openai_gateway_grok_inline_image_tool_test.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. //go:build unit
  2. package service
  3. import (
  4. "bytes"
  5. "context"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. "github.com/tidwall/gjson"
  15. )
  16. func TestForwardGrokChatViaResponsesDropsRedundantViewImage(t *testing.T) {
  17. gin.SetMode(gin.TestMode)
  18. body := grokChatInlineImageRequest()
  19. recorder := httptest.NewRecorder()
  20. c, _ := gin.CreateTestContext(recorder)
  21. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  22. c.Set("api_key", &APIKey{ID: 7991})
  23. account := grokChatBridgeTestAccount(799)
  24. repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
  25. accountsByID: map[int64]*Account{account.ID: account},
  26. }}
  27. upstream := &httpUpstreamRecorder{resp: grokChatBridgeCompletedResponse("resp_chat_image", 0)}
  28. svc := &OpenAIGatewayService{
  29. httpUpstream: upstream,
  30. grokTokenProvider: NewGrokTokenProvider(repo, nil),
  31. accountRepo: repo,
  32. }
  33. result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
  34. require.NoError(t, err)
  35. require.NotNil(t, result)
  36. require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String())
  37. require.Equal(t, "input_image", gjson.GetBytes(upstream.lastBody, "input.0.content.1.type").String())
  38. assertGrokUpstreamKeepsOtherToolAndDropsViewImage(t, upstream.lastBody, "tools.#(name==\"%s\")")
  39. }
  40. func TestForwardGrokRawChatDropsRedundantViewImage(t *testing.T) {
  41. gin.SetMode(gin.TestMode)
  42. body := grokChatInlineImageRequest()
  43. recorder := httptest.NewRecorder()
  44. c, _ := gin.CreateTestContext(recorder)
  45. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  46. account := &Account{
  47. ID: 800, Platform: PlatformGrok, Type: AccountTypeAPIKey, Concurrency: 1,
  48. Credentials: map[string]any{"api_key": "test-key", "base_url": "https://grok.example.test/v1"},
  49. }
  50. upstream := &httpUpstreamRecorder{resp: &http.Response{
  51. StatusCode: http.StatusOK,
  52. Header: http.Header{"Content-Type": []string{"application/json"}},
  53. Body: io.NopCloser(strings.NewReader(
  54. `{"id":"chatcmpl","object":"chat.completion","model":"grok-4.6","choices":[],"usage":{"prompt_tokens":1,"completion_tokens":1}}`,
  55. )),
  56. }}
  57. svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
  58. result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
  59. require.NoError(t, err)
  60. require.NotNil(t, result)
  61. require.Equal(t, "https://grok.example.test/v1/chat/completions", upstream.lastReq.URL.String())
  62. require.Equal(t, "image_url", gjson.GetBytes(upstream.lastBody, "messages.0.content.1.type").String())
  63. assertGrokUpstreamKeepsOtherToolAndDropsViewImage(t, upstream.lastBody, "tools.#(function.name==\"%s\")")
  64. }
  65. func TestForwardGrokMessagesDropsRedundantViewImage(t *testing.T) {
  66. gin.SetMode(gin.TestMode)
  67. body := []byte(`{
  68. "model":"grok-4.6","max_tokens":32,"stream":false,
  69. "messages":[{"role":"user","content":[
  70. {"type":"text","text":"What text is in this image?"},
  71. {"type":"image","source":{"type":"base64","media_type":"image/png","data":"AA=="}}
  72. ]}],
  73. "tools":[
  74. {"name":"view_image","input_schema":{"type":"object","properties":{"path":{"type":"string"}}}},
  75. {"name":"shell_command","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}}}}
  76. ],
  77. "tool_choice":{"type":"auto"}
  78. }`)
  79. recorder := httptest.NewRecorder()
  80. c, _ := gin.CreateTestContext(recorder)
  81. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  82. c.Set("api_key", &APIKey{ID: 7992})
  83. account := healthyGrokOAuthGatewayTestAccount(801, "access-token")
  84. repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
  85. accountsByID: map[int64]*Account{account.ID: account},
  86. }}
  87. upstream := &httpUpstreamRecorder{resp: grokMessagesSSECompletedResponse("resp_messages_image", 0)}
  88. svc := &OpenAIGatewayService{
  89. httpUpstream: upstream,
  90. grokTokenProvider: NewGrokTokenProvider(repo, nil),
  91. accountRepo: repo,
  92. }
  93. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
  94. require.NoError(t, err)
  95. require.NotNil(t, result)
  96. require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String())
  97. require.Equal(t, "input_image", gjson.GetBytes(upstream.lastBody, "input.0.content.1.type").String())
  98. assertGrokUpstreamKeepsOtherToolAndDropsViewImage(t, upstream.lastBody, "tools.#(name==\"%s\")")
  99. }
  100. func TestStripRedundantGrokChatViewImageToolLeavesNonTargetRequestsByteExact(t *testing.T) {
  101. t.Parallel()
  102. tests := []struct {
  103. name string
  104. body string
  105. }{
  106. {
  107. name: "current turn has no inline image",
  108. body: `{"messages":[{"role":"user","content":"Inspect a local image"}],"tools":[{"type":"function","function":{"name":"view_image"}}]}`,
  109. },
  110. {
  111. name: "inline image is only historical",
  112. 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"}}]}`,
  113. },
  114. {
  115. name: "view image is explicitly selected",
  116. 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"}}}`,
  117. },
  118. {
  119. name: "required with view image as the only tool",
  120. 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"}`,
  121. },
  122. }
  123. for _, tt := range tests {
  124. t.Run(tt.name, func(t *testing.T) {
  125. t.Parallel()
  126. body := []byte(tt.body)
  127. patched, err := stripRedundantGrokChatViewImageTool(body)
  128. require.NoError(t, err)
  129. require.Equal(t, body, patched)
  130. })
  131. }
  132. }
  133. func TestStripRedundantGrokChatViewImageToolDropsOnlyToolMetadata(t *testing.T) {
  134. t.Parallel()
  135. body := []byte(`{
  136. "messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}]}],
  137. "tools":[{"type":"function","function":{"name":"view_image"}}],
  138. "tool_choice":"auto",
  139. "parallel_tool_calls":true
  140. }`)
  141. patched, err := stripRedundantGrokChatViewImageTool(body)
  142. require.NoError(t, err)
  143. require.False(t, gjson.GetBytes(patched, "tools").Exists())
  144. require.False(t, gjson.GetBytes(patched, "tool_choice").Exists())
  145. require.False(t, gjson.GetBytes(patched, "parallel_tool_calls").Exists())
  146. }
  147. func grokChatInlineImageRequest() []byte {
  148. return []byte(`{
  149. "model":"grok-4.6",
  150. "messages":[{"role":"user","content":[
  151. {"type":"text","text":"What text is in this image?"},
  152. {"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}
  153. ]}],
  154. "stream":false,
  155. "tools":[
  156. {"type":"function","function":{"name":"view_image","parameters":{"type":"object","properties":{"path":{"type":"string"}}}}},
  157. {"type":"function","function":{"name":"shell_command","parameters":{"type":"object","properties":{"cmd":{"type":"string"}}}}}
  158. ],
  159. "tool_choice":"auto"
  160. }`)
  161. }
  162. func assertGrokUpstreamKeepsOtherToolAndDropsViewImage(t *testing.T, body []byte, pathTemplate string) {
  163. t.Helper()
  164. require.False(t, gjson.GetBytes(body, strings.Replace(pathTemplate, "%s", "view_image", 1)).Exists(), string(body))
  165. require.True(t, gjson.GetBytes(body, strings.Replace(pathTemplate, "%s", "shell_command", 1)).Exists(), string(body))
  166. }