Merge pull request #4590 from superman2003/fix/grok-client-cache-codex-trae
fix(grok): cache client tools from Codex, Trae, and Claude Desktop
This commit is contained in:
12 files changed
+1584
-92
No files matched your search
@@ -52,18 +52,21 @@ func (s *OpenAIGatewayService) forwardGrokResponses(
|
||||
if isGrokImageGenerationModel(upstreamModel) {
|
||||
return nil, fmt.Errorf("model %s is an image model and is not available on the Responses endpoint; use /v1/images/generations instead", upstreamModel)
|
||||
}
|
||||
cacheIdentity := resolveGrokCacheIdentity(c, body, "", upstreamModel)
|
||||
patchedBody, err := patchGrokResponsesBody(body, upstreamModel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Derive the identity from the request xAI will actually see. This makes
|
||||
// Codex Responses Lite additional_tools part of the stable tool prefix.
|
||||
cacheIdentity := resolveGrokCacheIdentity(c, patchedBody, "", upstreamModel)
|
||||
mixedCacheIntentBody := append([]byte(nil), patchedBody...)
|
||||
patchedBody, err = applyGrokResponsesCacheIdentity(patchedBody, body, cacheIdentity, account.IsGrokOAuth())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apply grok prompt cache identity: %w", err)
|
||||
}
|
||||
// Free OAuth + client function tools: reuse Messages mixed-tools cache route
|
||||
// (append web_search/x_search so xAI does not force non-cacheable build-free).
|
||||
patchedBody, err = applyGrokFreeMessagesFunctionToolCacheRoute(patchedBody, body, account, cacheIdentity)
|
||||
patchedBody, err = applyGrokFreeRequestToolCacheRoute(c, patchedBody, mixedCacheIntentBody, account, cacheIdentity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apply grok Free function-tool cache route: %w", err)
|
||||
}
|
||||
@@ -374,10 +377,9 @@ func deleteJSONFields(value any, fields map[string]struct{}) bool {
|
||||
}
|
||||
|
||||
// additional_tools is a Codex/Responses Lite private input carrier. xAI's
|
||||
// Responses schema accepts ordinary message/function-call input items but
|
||||
// rejects this carrier before inference with a ModelInput deserialization
|
||||
// error. Top-level supported tools remain available through the separate
|
||||
// sanitizeGrokResponsesTools path.
|
||||
// Responses schema rejects the carrier itself, but accepts supported tools at
|
||||
// the top level. Preserve top-level order, append newly discovered tools in
|
||||
// carrier order, then let sanitizeGrokResponsesTools filter unsupported types.
|
||||
func sanitizeGrokResponsesInput(body []byte) ([]byte, error) {
|
||||
if !bytes.Contains(body, []byte(`"additional_tools"`)) {
|
||||
return body, nil
|
||||
@@ -389,8 +391,36 @@ func sanitizeGrokResponsesInput(body []byte) ([]byte, error) {
|
||||
|
||||
rawItems := input.Array()
|
||||
filtered := make([]json.RawMessage, 0, len(rawItems))
|
||||
topLevelTools := gjson.GetBytes(body, "tools")
|
||||
mergedTools := make([]json.RawMessage, 0)
|
||||
seenTools := make(map[string]struct{})
|
||||
appendTool := func(tool gjson.Result) bool {
|
||||
key := grokResponsesToolDedupKey(tool)
|
||||
if _, exists := seenTools[key]; exists {
|
||||
return false
|
||||
}
|
||||
seenTools[key] = struct{}{}
|
||||
mergedTools = append(mergedTools, json.RawMessage(tool.Raw))
|
||||
return true
|
||||
}
|
||||
if topLevelTools.IsArray() {
|
||||
for _, tool := range topLevelTools.Array() {
|
||||
seenTools[grokResponsesToolDedupKey(tool)] = struct{}{}
|
||||
mergedTools = append(mergedTools, json.RawMessage(tool.Raw))
|
||||
}
|
||||
}
|
||||
|
||||
promoted := false
|
||||
for _, item := range rawItems {
|
||||
if strings.TrimSpace(item.Get("type").String()) == "additional_tools" {
|
||||
tools := item.Get("tools")
|
||||
if tools.IsArray() {
|
||||
for _, tool := range tools.Array() {
|
||||
if appendTool(tool) {
|
||||
promoted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, json.RawMessage(item.Raw))
|
||||
@@ -402,7 +432,30 @@ func sanitizeGrokResponsesInput(body []byte) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sjson.SetRawBytes(body, "input", encoded)
|
||||
body, err = sjson.SetRawBytes(body, "input", encoded)
|
||||
if err != nil || !promoted {
|
||||
return body, err
|
||||
}
|
||||
encodedTools, err := json.Marshal(mergedTools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sjson.SetRawBytes(body, "tools", encodedTools)
|
||||
}
|
||||
|
||||
func grokResponsesToolDedupKey(tool gjson.Result) string {
|
||||
toolType := strings.TrimSpace(tool.Get("type").String())
|
||||
if toolType != "" {
|
||||
if name := strings.TrimSpace(tool.Get("name").String()); name != "" {
|
||||
return "type:" + toolType + "\x00name:" + name
|
||||
}
|
||||
if toolType == "mcp" {
|
||||
if label := strings.TrimSpace(tool.Get("server_label").String()); label != "" {
|
||||
return "type:mcp\x00server_label:" + label
|
||||
}
|
||||
}
|
||||
}
|
||||
return "json:" + normalizeCompatSeedJSON(json.RawMessage(tool.Raw))
|
||||
}
|
||||
|
||||
// sanitizeGrokReasoningNullContent 删除 reasoning 项中的 "content": null。
|
||||
|
||||
@@ -12,10 +12,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
grokConversationIDHeader = "X-Grok-Conv-Id"
|
||||
grokFreeCacheNativeToolsJSON = `[{"type":"web_search"},{"type":"x_search"}]`
|
||||
grokFreeCacheDisabledToolChoice = "none"
|
||||
grokFreeRolling24hTokenLimit = int64(2_000_000)
|
||||
grokConversationIDHeader = "X-Grok-Conv-Id"
|
||||
grokClientToolCacheOptInHeader = "X-Sub2API-Grok-Client-Tool-Cache"
|
||||
grokFreeCacheNativeToolsJSON = `[{"type":"web_search"},{"type":"x_search"}]`
|
||||
grokFreeCacheDisabledToolChoice = "none"
|
||||
grokClientToolCacheOptInExtraKey = "grok_client_tool_cache_enabled"
|
||||
grokFreeRolling24hTokenLimit = int64(2_000_000)
|
||||
)
|
||||
|
||||
// resolveGrokCacheIdentity derives one stable, tenant-isolated routing identity
|
||||
@@ -122,7 +124,7 @@ func applyGrokResponsesCacheIdentity(body, intentSourceBody []byte, identity str
|
||||
// Inspect the pre-sanitization source. patchGrokResponsesBody may remove an
|
||||
// unsupported client tool and its tool_choice; that must not turn an
|
||||
// explicit client tool intent into an eligible native-tool request.
|
||||
if gjson.GetBytes(intentSourceBody, "tools").Exists() || gjson.GetBytes(intentSourceBody, "tool_choice").Exists() {
|
||||
if hasGrokResponsesToolIntent(intentSourceBody) {
|
||||
return out, nil
|
||||
}
|
||||
out, err = sjson.SetRawBytes(out, "tools", []byte(grokFreeCacheNativeToolsJSON))
|
||||
@@ -132,12 +134,113 @@ func applyGrokResponsesCacheIdentity(body, intentSourceBody []byte, identity str
|
||||
return sjson.SetBytes(out, "tool_choice", grokFreeCacheDisabledToolChoice)
|
||||
}
|
||||
|
||||
func hasGrokResponsesToolIntent(body []byte) bool {
|
||||
if gjson.GetBytes(body, "tools").Exists() || gjson.GetBytes(body, "tool_choice").Exists() {
|
||||
return true
|
||||
}
|
||||
input := gjson.GetBytes(body, "input")
|
||||
if !input.IsArray() {
|
||||
return false
|
||||
}
|
||||
for _, item := range input.Array() {
|
||||
if strings.TrimSpace(item.Get("type").String()) != "additional_tools" {
|
||||
continue
|
||||
}
|
||||
tools := item.Get("tools")
|
||||
if !tools.Exists() || !tools.IsArray() || len(tools.Array()) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// applyGrokFreeMessagesFunctionToolCacheRoute enables xAI's cache-capable
|
||||
// mixed-tools route only for the Anthropic Messages bridge and only when the
|
||||
// selected account is known to be Free. Native tools become eligible under
|
||||
// auto selection, so callers must not apply this policy to paid accounts or
|
||||
// other ingress protocols implicitly.
|
||||
// mixed-tools route only for known Free accounts. Pure client tools default to
|
||||
// the cache-capable route so an intermediate sub2api does not need to preserve
|
||||
// client-specific opt-in headers. Operators can explicitly disable this per
|
||||
// account when native search tools would change the desired behavior (#4486).
|
||||
func applyGrokFreeMessagesFunctionToolCacheRoute(body, intentSourceBody []byte, account *Account, cacheIdentity string) ([]byte, error) {
|
||||
allowPureClientTools, _ := grokClientToolCacheAccountPolicy(account)
|
||||
return applyGrokFreeToolCacheRoute(body, intentSourceBody, account, cacheIdentity, allowPureClientTools, true)
|
||||
}
|
||||
|
||||
// applyGrokFreeRequestToolCacheRoute also accepts a request-scoped opt-in. The
|
||||
// sub2api header is consumed locally because buildGrokResponsesRequest only
|
||||
// forwards the explicitly supported OpenAI-Beta header from downstream.
|
||||
func applyGrokFreeRequestToolCacheRoute(c *gin.Context, body, intentSourceBody []byte, account *Account, cacheIdentity string) ([]byte, error) {
|
||||
allowPureClientTools, accountPolicyExplicit := grokClientToolCacheAccountPolicy(account)
|
||||
requestOptOut := false
|
||||
if c != nil {
|
||||
switch strings.ToLower(strings.TrimSpace(c.GetHeader(grokClientToolCacheOptInHeader))) {
|
||||
case "1", "true", "yes", "on", "prefer-cache":
|
||||
allowPureClientTools = true
|
||||
case "0", "false", "no", "off":
|
||||
allowPureClientTools = false
|
||||
requestOptOut = true
|
||||
}
|
||||
}
|
||||
if !allowPureClientTools && !accountPolicyExplicit && !requestOptOut && isGrokClaudeDesktopResponsesCacheRequest(c) {
|
||||
allowPureClientTools = true
|
||||
}
|
||||
// A function merely named web_search/x_search is still a client function.
|
||||
// Known Free OAuth accounts use the cache route by default; a request-scoped
|
||||
// opt-in may override an account opt-out, while an explicit request opt-out
|
||||
// always wins. The legacy Claude fingerprint remains only as a compatibility
|
||||
// fallback when no account policy has been recorded (#4486).
|
||||
return applyGrokFreeToolCacheRoute(body, intentSourceBody, account, cacheIdentity, allowPureClientTools, allowPureClientTools)
|
||||
}
|
||||
|
||||
// grokClientToolCacheAccountPolicy is intentionally strict for configured
|
||||
// values: only a JSON boolean is accepted. A missing key defaults on solely for
|
||||
// accounts positively identified as Grok Free OAuth; paid, API-key, and unknown
|
||||
// accounts remain fail-closed.
|
||||
func grokClientToolCacheAccountPolicy(account *Account) (enabled, explicit bool) {
|
||||
if !isKnownGrokFreeAccount(account) {
|
||||
return false, false
|
||||
}
|
||||
if account.Extra == nil {
|
||||
return true, false
|
||||
}
|
||||
value, exists := account.Extra[grokClientToolCacheOptInExtraKey]
|
||||
if !exists {
|
||||
return true, false
|
||||
}
|
||||
enabled, valid := value.(bool)
|
||||
if !valid {
|
||||
return false, true
|
||||
}
|
||||
return enabled, true
|
||||
}
|
||||
|
||||
// isGrokClaudeDesktopResponsesCacheRequest recognizes the strict wire
|
||||
// fingerprint emitted when Claude Desktop's local agent is translated by
|
||||
// CC Switch into an OpenAI Responses request. Requiring every independent
|
||||
// signal prevents a generic Claude-compatible client (or the Chat bridge)
|
||||
// from silently opting into the mixed native/client tool route.
|
||||
func isGrokClaudeDesktopResponsesCacheRequest(c *gin.Context) bool {
|
||||
if c == nil || c.Request == nil || c.Request.URL == nil || isOpenAIResponsesCompactPath(c) {
|
||||
return false
|
||||
}
|
||||
path := strings.TrimRight(strings.TrimSpace(c.Request.URL.Path), "/")
|
||||
if !strings.HasSuffix(path, "/responses") {
|
||||
return false
|
||||
}
|
||||
|
||||
if !claudeCodeUAPattern.MatchString(strings.TrimSpace(c.GetHeader("User-Agent"))) {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(c.GetHeader("X-App"))) {
|
||||
case "cli", "cli-bg":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(c.GetHeader("anthropic-client-platform")), "desktop_app") {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(c.GetHeader("X-Claude-Code-Session-Id")) != ""
|
||||
}
|
||||
|
||||
func applyGrokFreeToolCacheRoute(body, intentSourceBody []byte, account *Account, cacheIdentity string, allowPureClientTools, allowFunctionSearch bool) ([]byte, error) {
|
||||
if strings.TrimSpace(cacheIdentity) == "" || !isKnownGrokFreeAccount(account) {
|
||||
return body, nil
|
||||
}
|
||||
@@ -146,7 +249,12 @@ func applyGrokFreeMessagesFunctionToolCacheRoute(body, intentSourceBody []byte,
|
||||
if !isGrokFreeCacheFunctionToolIntent(intentTools, intentToolChoice) {
|
||||
return body, nil
|
||||
}
|
||||
return appendMissingGrokFreeCacheNativeTools(body)
|
||||
if intentToolChoice.Type == gjson.String && strings.TrimSpace(intentToolChoice.String()) == grokFreeCacheDisabledToolChoice {
|
||||
// Adding native cache-routing tools cannot change behavior when the
|
||||
// client has explicitly disabled all tool execution.
|
||||
return appendGrokFreeCacheNativeToolsWithPolicy(body, true, false)
|
||||
}
|
||||
return appendGrokFreeCacheNativeToolsWithPolicy(body, allowPureClientTools, allowFunctionSearch)
|
||||
}
|
||||
|
||||
func isKnownGrokFreeAccount(account *Account) bool {
|
||||
@@ -231,22 +339,44 @@ func isGrokFreeCacheFunctionToolIntent(tools, toolChoice gjson.Result) bool {
|
||||
return false
|
||||
}
|
||||
for _, tool := range items {
|
||||
if !tool.IsObject() || strings.TrimSpace(tool.Get("type").String()) != "function" {
|
||||
if !tool.IsObject() {
|
||||
return false
|
||||
}
|
||||
// Responses function declarations keep name at the top level. Reject
|
||||
// Chat Completions' nested function shape and incomplete declarations.
|
||||
if strings.TrimSpace(tool.Get("name").String()) == "" || tool.Get("function").Exists() {
|
||||
toolType := strings.TrimSpace(tool.Get("type").String())
|
||||
if _, ok := grokResponsesSupportedToolTypes[toolType]; !ok {
|
||||
return false
|
||||
}
|
||||
if toolType == "function" {
|
||||
// Responses function declarations keep name at the top level. Reject
|
||||
// Chat Completions' nested function shape and incomplete declarations.
|
||||
if strings.TrimSpace(tool.Get("name").String()) == "" || tool.Get("function").Exists() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if !toolChoice.Exists() {
|
||||
return true
|
||||
}
|
||||
return toolChoice.Type == gjson.String && strings.TrimSpace(toolChoice.String()) == "auto"
|
||||
if toolChoice.Type != gjson.String {
|
||||
return false
|
||||
}
|
||||
switch strings.TrimSpace(toolChoice.String()) {
|
||||
case "auto", grokFreeCacheDisabledToolChoice:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) {
|
||||
return appendGrokFreeCacheNativeTools(body, false)
|
||||
}
|
||||
|
||||
func appendGrokFreeCacheNativeTools(body []byte, allowPureClientTools bool) ([]byte, error) {
|
||||
return appendGrokFreeCacheNativeToolsWithPolicy(body, allowPureClientTools, true)
|
||||
}
|
||||
|
||||
func appendGrokFreeCacheNativeToolsWithPolicy(body []byte, allowPureClientTools, allowFunctionSearch bool) ([]byte, error) {
|
||||
tools := gjson.GetBytes(body, "tools")
|
||||
if !tools.Exists() || !tools.IsArray() {
|
||||
return body, nil
|
||||
@@ -256,9 +386,19 @@ func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) {
|
||||
if len(items) == 0 {
|
||||
return body, nil
|
||||
}
|
||||
hasNativeSearch := false
|
||||
for _, tool := range items {
|
||||
switch strings.TrimSpace(tool.Get("type").String()) {
|
||||
case "web_search", "x_search":
|
||||
hasNativeSearch = true
|
||||
}
|
||||
}
|
||||
if !allowPureClientTools && !allowFunctionSearch && !hasNativeSearch {
|
||||
return body, nil
|
||||
}
|
||||
merged := make([]json.RawMessage, 0, len(items)+2)
|
||||
present := make(map[string]bool, 2)
|
||||
hasFunction := false
|
||||
hasCompanionTool := false
|
||||
for _, tool := range items {
|
||||
toolType := strings.TrimSpace(tool.Get("type").String())
|
||||
switch toolType {
|
||||
@@ -269,7 +409,7 @@ func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) {
|
||||
}
|
||||
// Grok Build may declare search as function tools. Convert to native
|
||||
// entries so Free OAuth stays cache-capable without duplicate names.
|
||||
if name == "web_search" || name == "x_search" {
|
||||
if (name == "web_search" || name == "x_search") && allowFunctionSearch {
|
||||
if present[name] {
|
||||
continue
|
||||
}
|
||||
@@ -279,9 +419,17 @@ func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) {
|
||||
}
|
||||
merged = append(merged, raw)
|
||||
present[name] = true
|
||||
if allowPureClientTools {
|
||||
hasCompanionTool = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
hasFunction = true
|
||||
if name == "web_search" || name == "x_search" {
|
||||
// Keep the client function intact and avoid adding a same-named
|
||||
// native tool unless conversion was explicitly enabled.
|
||||
present[name] = true
|
||||
}
|
||||
hasCompanionTool = true
|
||||
merged = append(merged, json.RawMessage(tool.Raw))
|
||||
case "web_search", "x_search":
|
||||
if present[toolType] {
|
||||
@@ -290,17 +438,21 @@ func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) {
|
||||
merged = append(merged, json.RawMessage(tool.Raw))
|
||||
present[toolType] = true
|
||||
default:
|
||||
return body, nil
|
||||
if _, ok := grokResponsesSupportedToolTypes[toolType]; !ok {
|
||||
return body, nil
|
||||
}
|
||||
hasCompanionTool = true
|
||||
merged = append(merged, json.RawMessage(tool.Raw))
|
||||
}
|
||||
}
|
||||
if !hasFunction {
|
||||
if !hasCompanionTool {
|
||||
return body, nil
|
||||
}
|
||||
// Only complement missing native search tools when the request already contains
|
||||
// at least one search tool (native or function-form). Pure client function tools
|
||||
// (e.g. view_image) must not trigger injection to avoid biasing model tool
|
||||
// selection (#4486).
|
||||
if !present["web_search"] && !present["x_search"] {
|
||||
if !allowPureClientTools && !present["web_search"] && !present["x_search"] {
|
||||
return body, nil
|
||||
}
|
||||
for _, toolType := range []string{"web_search", "x_search"} {
|
||||
|
||||
@@ -197,7 +197,7 @@ func TestApplyGrokCacheIdentityWritesResponsesBodyAndHeader(t *testing.T) {
|
||||
require.False(t, gjson.GetBytes(unscopedBody, "tool_choice").Exists())
|
||||
}
|
||||
|
||||
func TestApplyGrokCacheIdentityAppendsNativeToolsToResponseFunctions(t *testing.T) {
|
||||
func TestGrokFreeMessagesClientToolCacheDefaultsOnForKnownFree(t *testing.T) {
|
||||
account := healthyGrokOAuthGatewayTestAccount(901, "access-token")
|
||||
account.Credentials["subscription_tier"] = " FREE "
|
||||
tests := []struct {
|
||||
@@ -211,7 +211,6 @@ func TestApplyGrokCacheIdentityAppendsNativeToolsToResponseFunctions(t *testing.
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Pure client function tools without search → no native injection (#4486).
|
||||
intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"lookup","description":"look up a value","parameters":{"type":"object"}},{"type":"function","name":"save","parameters":{"type":"object"}}]` + tt.toolChoiceJSON + `}`)
|
||||
body, err := applyGrokResponsesCacheIdentity(intentBody, intentBody, "isolated-id", true)
|
||||
require.NoError(t, err)
|
||||
@@ -220,16 +219,46 @@ func TestApplyGrokCacheIdentityAppendsNativeToolsToResponseFunctions(t *testing.
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "isolated-id", gjson.GetBytes(body, "prompt_cache_key").String())
|
||||
tools := gjson.GetBytes(body, "tools").Array()
|
||||
require.Len(t, tools, 2, "pure client functions should not get native search injected")
|
||||
require.Len(t, tools, 4)
|
||||
require.Equal(t, "function", tools[0].Get("type").String())
|
||||
require.Equal(t, "lookup", tools[0].Get("name").String())
|
||||
require.Equal(t, "function", tools[1].Get("type").String())
|
||||
require.Equal(t, "save", tools[1].Get("name").String())
|
||||
require.Equal(t, "web_search", tools[2].Get("type").String())
|
||||
require.Equal(t, "x_search", tools[3].Get("type").String())
|
||||
require.Equal(t, tt.wantChoice, gjson.GetBytes(body, "tool_choice").Exists())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokFreeMessagesClientToolCacheDefaultsWithMissingAccountSetting(t *testing.T) {
|
||||
body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
|
||||
tests := []struct {
|
||||
name string
|
||||
extra map[string]any
|
||||
}{
|
||||
{name: "nil extra"},
|
||||
{name: "empty extra", extra: map[string]any{}},
|
||||
{name: "unrelated extra", extra: map[string]any{"other_setting": true}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
account := healthyGrokOAuthGatewayTestAccount(90101, "access-token")
|
||||
account.Credentials["subscription_tier"] = "free"
|
||||
account.Extra = tt.extra
|
||||
|
||||
patched, err := applyGrokFreeMessagesFunctionToolCacheRoute(body, body, account, "isolated-id")
|
||||
|
||||
require.NoError(t, err)
|
||||
tools := gjson.GetBytes(patched, "tools").Array()
|
||||
require.Len(t, tools, 3)
|
||||
require.Equal(t, "web_search", tools[1].Get("type").String())
|
||||
require.Equal(t, "x_search", tools[2].Get("type").String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyGrokCacheIdentityAppendsNativeToolsWhenSearchPresent(t *testing.T) {
|
||||
account := healthyGrokOAuthGatewayTestAccount(901, "access-token")
|
||||
account.Credentials["subscription_tier"] = " FREE "
|
||||
@@ -249,6 +278,330 @@ func TestApplyGrokCacheIdentityAppendsNativeToolsWhenSearchPresent(t *testing.T)
|
||||
require.Equal(t, "x_search", tools[2].Get("type").String())
|
||||
}
|
||||
|
||||
func TestGrokFreeClientToolCacheAccountOptIn(t *testing.T) {
|
||||
account := healthyGrokOAuthGatewayTestAccount(9011, "access-token")
|
||||
account.Credentials["subscription_tier"] = "free"
|
||||
account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: true}
|
||||
intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}},{"type":"function","name":"read_file","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
|
||||
|
||||
body, err := applyGrokResponsesCacheIdentity(intentBody, intentBody, "isolated-id", true)
|
||||
require.NoError(t, err)
|
||||
body, err = applyGrokFreeMessagesFunctionToolCacheRoute(body, intentBody, account, "isolated-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
tools := gjson.GetBytes(body, "tools").Array()
|
||||
require.Len(t, tools, 4)
|
||||
require.Equal(t, "view_image", tools[0].Get("name").String())
|
||||
require.Equal(t, "read_file", tools[1].Get("name").String())
|
||||
require.Equal(t, "web_search", tools[2].Get("type").String())
|
||||
require.Equal(t, "x_search", tools[3].Get("type").String())
|
||||
}
|
||||
|
||||
func TestGrokFreeMessagesClientToolCacheAccountOptOut(t *testing.T) {
|
||||
body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
}{
|
||||
{name: "explicit false", value: false},
|
||||
{name: "string false is malformed", value: "false"},
|
||||
{name: "numeric value is malformed", value: 1},
|
||||
{name: "null value is malformed", value: nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
account := healthyGrokOAuthGatewayTestAccount(90111, "access-token")
|
||||
account.Credentials["subscription_tier"] = "free"
|
||||
account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: tt.value}
|
||||
|
||||
patched, err := applyGrokFreeMessagesFunctionToolCacheRoute(body, body, account, "isolated-id")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, string(body), string(patched))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokFreeClientToolCacheRequestOptInOverridesAccountOptOut(t *testing.T) {
|
||||
account := healthyGrokOAuthGatewayTestAccount(9014, "access-token")
|
||||
account.Credentials["subscription_tier"] = "free"
|
||||
account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: false}
|
||||
c := newGrokCacheTestContext(9014)
|
||||
c.Request.Header.Set(grokClientToolCacheOptInHeader, "prefer-cache")
|
||||
intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
|
||||
|
||||
body, err := applyGrokResponsesCacheIdentity(intentBody, intentBody, "isolated-id", true)
|
||||
require.NoError(t, err)
|
||||
body, err = applyGrokFreeRequestToolCacheRoute(c, body, intentBody, account, "isolated-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
tools := gjson.GetBytes(body, "tools").Array()
|
||||
require.Len(t, tools, 3)
|
||||
require.Equal(t, "view_image", tools[0].Get("name").String())
|
||||
require.Equal(t, "web_search", tools[1].Get("type").String())
|
||||
require.Equal(t, "x_search", tools[2].Get("type").String())
|
||||
}
|
||||
|
||||
func TestGrokFreeChatRequestClientToolCacheDefaultsOnWithoutClientFingerprint(t *testing.T) {
|
||||
account := healthyGrokOAuthGatewayTestAccount(90140, "access-token")
|
||||
account.Credentials["subscription_tier"] = "free"
|
||||
c := newGrokCacheTestContext(90140)
|
||||
c.Request.URL.Path = "/v1/chat/completions"
|
||||
body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
|
||||
|
||||
patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id")
|
||||
|
||||
require.NoError(t, err)
|
||||
tools := gjson.GetBytes(patched, "tools").Array()
|
||||
require.Len(t, tools, 3)
|
||||
require.Equal(t, "view_image", tools[0].Get("name").String())
|
||||
require.Equal(t, "web_search", tools[1].Get("type").String())
|
||||
require.Equal(t, "x_search", tools[2].Get("type").String())
|
||||
}
|
||||
|
||||
func TestGrokFreeClientToolCacheClaudeDesktopResponsesAutoOptIn(t *testing.T) {
|
||||
account := healthyGrokOAuthGatewayTestAccount(90141, "access-token")
|
||||
account.Credentials["subscription_tier"] = "free"
|
||||
body := []byte(`{"model":"grok","tools":[{"type":"function","name":"Read","parameters":{"type":"object"}},{"type":"function","name":"Edit","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
|
||||
|
||||
for _, xApp := range []string{"cli", "cli-bg"} {
|
||||
t.Run(xApp, func(t *testing.T) {
|
||||
c := newGrokCacheTestContext(90141)
|
||||
// The desktop marker text is intentionally not required; CC Switch and
|
||||
// Claude Desktop may change the descriptive User-Agent suffix.
|
||||
c.Request.Header.Set("User-Agent", "claude-cli/2.1.215 (external, future-desktop, agent-sdk/0.3.215)")
|
||||
c.Request.Header.Set("X-App", xApp)
|
||||
c.Request.Header.Set("anthropic-client-platform", "desktop_app")
|
||||
c.Request.Header.Set("X-Claude-Code-Session-Id", "desktop-session-1")
|
||||
|
||||
patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id")
|
||||
|
||||
require.NoError(t, err)
|
||||
tools := gjson.GetBytes(patched, "tools").Array()
|
||||
require.Len(t, tools, 4)
|
||||
require.Equal(t, "Read", tools[0].Get("name").String())
|
||||
require.Equal(t, "Edit", tools[1].Get("name").String())
|
||||
require.Equal(t, "web_search", tools[2].Get("type").String())
|
||||
require.Equal(t, "x_search", tools[3].Get("type").String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokFreeClientToolCacheClaudeDesktopFingerprintRequiresAllSignals(t *testing.T) {
|
||||
account := healthyGrokOAuthGatewayTestAccount(90142, "access-token")
|
||||
account.Credentials["subscription_tier"] = "free"
|
||||
account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: false}
|
||||
body := []byte(`{"model":"grok","tools":[{"type":"function","name":"Read","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
ua string
|
||||
xApp string
|
||||
platform string
|
||||
session string
|
||||
}{
|
||||
{
|
||||
name: "chat path",
|
||||
path: "/v1/chat/completions",
|
||||
ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)",
|
||||
xApp: "cli",
|
||||
platform: "desktop_app",
|
||||
session: "desktop-session-1",
|
||||
},
|
||||
{
|
||||
name: "compact responses path",
|
||||
path: "/v1/responses/compact",
|
||||
ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)",
|
||||
xApp: "cli",
|
||||
platform: "desktop_app",
|
||||
session: "desktop-session-1",
|
||||
},
|
||||
{
|
||||
name: "non claude cli user agent",
|
||||
path: "/v1/responses",
|
||||
ua: "Mozilla/5.0 (claude-desktop-3p)",
|
||||
xApp: "cli",
|
||||
platform: "desktop_app",
|
||||
session: "desktop-session-1",
|
||||
},
|
||||
{
|
||||
name: "missing x app",
|
||||
path: "/v1/responses",
|
||||
ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)",
|
||||
platform: "desktop_app",
|
||||
session: "desktop-session-1",
|
||||
},
|
||||
{
|
||||
name: "wrong x app",
|
||||
path: "/v1/responses",
|
||||
ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)",
|
||||
xApp: "desktop",
|
||||
platform: "desktop_app",
|
||||
session: "desktop-session-1",
|
||||
},
|
||||
{
|
||||
name: "missing client platform",
|
||||
path: "/v1/responses",
|
||||
ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)",
|
||||
xApp: "cli",
|
||||
session: "desktop-session-1",
|
||||
},
|
||||
{
|
||||
name: "wrong client platform",
|
||||
path: "/v1/responses",
|
||||
ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)",
|
||||
xApp: "cli",
|
||||
platform: "web",
|
||||
session: "desktop-session-1",
|
||||
},
|
||||
{
|
||||
name: "missing session header",
|
||||
path: "/v1/responses",
|
||||
ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)",
|
||||
xApp: "cli",
|
||||
platform: "desktop_app",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := newGrokCacheTestContext(90142)
|
||||
c.Request.URL.Path = tt.path
|
||||
if tt.ua != "" {
|
||||
c.Request.Header.Set("User-Agent", tt.ua)
|
||||
}
|
||||
if tt.xApp != "" {
|
||||
c.Request.Header.Set("X-App", tt.xApp)
|
||||
}
|
||||
if tt.platform != "" {
|
||||
c.Request.Header.Set("anthropic-client-platform", tt.platform)
|
||||
}
|
||||
if tt.session != "" {
|
||||
c.Request.Header.Set("X-Claude-Code-Session-Id", tt.session)
|
||||
}
|
||||
|
||||
patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, string(body), string(patched))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokFreeClientToolCacheExplicitRequestOptOut(t *testing.T) {
|
||||
account := healthyGrokOAuthGatewayTestAccount(90144, "access-token")
|
||||
account.Credentials["subscription_tier"] = "free"
|
||||
body := []byte(`{"model":"grok","tools":[{"type":"function","name":"Read","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
|
||||
|
||||
for _, value := range []string{"0", "false", "no", "off"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
c := newGrokCacheTestContext(90144)
|
||||
c.Request.URL.Path = "/v1/chat/completions"
|
||||
c.Request.Header.Set(grokClientToolCacheOptInHeader, value)
|
||||
|
||||
patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, string(body), string(patched))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokFreeClientToolCacheClaudeDesktopAutoOptInDoesNotOverridePaidTier(t *testing.T) {
|
||||
account := healthyGrokOAuthGatewayTestAccount(90143, "access-token")
|
||||
account.Credentials["subscription_tier"] = "supergrok"
|
||||
c := newGrokCacheTestContext(90143)
|
||||
c.Request.Header.Set("User-Agent", "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)")
|
||||
c.Request.Header.Set("X-App", "cli")
|
||||
c.Request.Header.Set("anthropic-client-platform", "desktop_app")
|
||||
c.Request.Header.Set("X-Claude-Code-Session-Id", "desktop-session-1")
|
||||
body := []byte(`{"model":"grok","tools":[{"type":"function","name":"Read","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
|
||||
|
||||
patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, string(body), string(patched))
|
||||
}
|
||||
|
||||
func TestGrokFreeRequestClientSearchFunctionUsesDefaultAccountPolicy(t *testing.T) {
|
||||
account := healthyGrokOAuthGatewayTestAccount(9015, "access-token")
|
||||
account.Credentials["subscription_tier"] = "free"
|
||||
c := newGrokCacheTestContext(9015)
|
||||
body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}},{"type":"function","name":"web_search","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
|
||||
|
||||
patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id")
|
||||
|
||||
require.NoError(t, err)
|
||||
tools := gjson.GetBytes(patched, "tools").Array()
|
||||
require.Len(t, tools, 3)
|
||||
require.Equal(t, "view_image", tools[0].Get("name").String())
|
||||
require.Equal(t, "web_search", tools[1].Get("type").String())
|
||||
require.Equal(t, "x_search", tools[2].Get("type").String())
|
||||
}
|
||||
|
||||
func TestGrokFreeRequestToolChoiceNoneUsesSafeCacheRoute(t *testing.T) {
|
||||
account := healthyGrokOAuthGatewayTestAccount(9016, "access-token")
|
||||
account.Credentials["subscription_tier"] = "free"
|
||||
c := newGrokCacheTestContext(9016)
|
||||
body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"none"}`)
|
||||
|
||||
patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id")
|
||||
|
||||
require.NoError(t, err)
|
||||
tools := gjson.GetBytes(patched, "tools").Array()
|
||||
require.Len(t, tools, 3)
|
||||
require.Equal(t, "view_image", tools[0].Get("name").String())
|
||||
require.Equal(t, "web_search", tools[1].Get("type").String())
|
||||
require.Equal(t, "x_search", tools[2].Get("type").String())
|
||||
require.Equal(t, "none", gjson.GetBytes(patched, "tool_choice").String())
|
||||
}
|
||||
|
||||
func TestApplyGrokCacheIdentityRecognizesResponsesLiteAdditionalTools(t *testing.T) {
|
||||
intentBody := []byte(`{"model":"grok","input":[{"type":"additional_tools","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]},{"type":"message","role":"user","content":"hello"}]}`)
|
||||
patchedBody := []byte(`{"model":"grok-4.5","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],"input":[{"type":"message","role":"user","content":"hello"}]}`)
|
||||
|
||||
patched, err := applyGrokResponsesCacheIdentity(patchedBody, intentBody, "isolated-id", true)
|
||||
|
||||
require.NoError(t, err)
|
||||
tools := gjson.GetBytes(patched, "tools").Array()
|
||||
require.Len(t, tools, 1)
|
||||
require.Equal(t, "lookup", tools[0].Get("name").String())
|
||||
require.False(t, gjson.GetBytes(patched, "tool_choice").Exists())
|
||||
require.Equal(t, "isolated-id", gjson.GetBytes(patched, "prompt_cache_key").String())
|
||||
}
|
||||
|
||||
func TestGrokFreeCacheRoutePreservesMixedSupportedToolsWithSearchIntent(t *testing.T) {
|
||||
account := healthyGrokOAuthGatewayTestAccount(9012, "access-token")
|
||||
account.Credentials["subscription_tier"] = "free"
|
||||
intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}},{"type":"shell"},{"type":"web_search"}],"tool_choice":"auto"}`)
|
||||
|
||||
body, err := applyGrokResponsesCacheIdentity(intentBody, intentBody, "isolated-id", true)
|
||||
require.NoError(t, err)
|
||||
body, err = applyGrokFreeMessagesFunctionToolCacheRoute(body, intentBody, account, "isolated-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
tools := gjson.GetBytes(body, "tools").Array()
|
||||
require.Len(t, tools, 4)
|
||||
require.Equal(t, "function", tools[0].Get("type").String())
|
||||
require.Equal(t, "shell", tools[1].Get("type").String())
|
||||
require.Equal(t, "web_search", tools[2].Get("type").String())
|
||||
require.Equal(t, "x_search", tools[3].Get("type").String())
|
||||
}
|
||||
|
||||
func TestGrokClientToolCacheOptInDoesNotOverridePaidTier(t *testing.T) {
|
||||
account := healthyGrokOAuthGatewayTestAccount(9013, "access-token")
|
||||
account.Credentials["subscription_tier"] = "supergrok"
|
||||
account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: true}
|
||||
body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
|
||||
|
||||
patched, err := applyGrokFreeMessagesFunctionToolCacheRoute(body, body, account, "isolated-id")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, string(body), string(patched))
|
||||
}
|
||||
|
||||
func TestApplyGrokCacheIdentityRequiresPatchedFunctionTools(t *testing.T) {
|
||||
account := healthyGrokOAuthGatewayTestAccount(902, "access-token")
|
||||
account.Credentials["subscription_tier"] = "free"
|
||||
@@ -260,7 +613,7 @@ func TestApplyGrokCacheIdentityRequiresPatchedFunctionTools(t *testing.T) {
|
||||
{name: "missing tools", patchedBody: `{"model":"grok-4.5"}`},
|
||||
{name: "empty tools", patchedBody: `{"model":"grok-4.5","tools":[]}`},
|
||||
{name: "native tools only", patchedBody: `{"model":"grok-4.5","tools":[{"type":"web_search"}]}`},
|
||||
{name: "unexpected patched tool", patchedBody: `{"model":"grok-4.5","tools":[{"type":"function","name":"lookup"},{"type":"mcp","name":"server"}]}`},
|
||||
{name: "unexpected patched tool", patchedBody: `{"model":"grok-4.5","tools":[{"type":"function","name":"lookup"},{"type":"namespace","name":"server"}]}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -280,8 +633,7 @@ func TestApplyGrokCacheIdentityRequiresPatchedFunctionTools(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGrokFreeMessagesFunctionToolCacheRouteRequiresKnownFreeTier(t *testing.T) {
|
||||
// Include web_search as function to trigger native tool injection (pure client
|
||||
// functions no longer trigger injection after #4486 fix).
|
||||
// Include web_search as a function to also cover its conversion to a native tool.
|
||||
intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"lookup"},{"type":"function","name":"web_search"}],"tool_choice":"auto"}`)
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -22,17 +22,23 @@ const (
|
||||
var grokChatResponsesBridgeTopLevelFields = map[string]struct{}{
|
||||
"model": {},
|
||||
"messages": {},
|
||||
"instructions": {},
|
||||
"stream": {},
|
||||
"stream_options": {},
|
||||
"max_tokens": {},
|
||||
"max_completion_tokens": {},
|
||||
"temperature": {},
|
||||
"top_p": {},
|
||||
"stop": {},
|
||||
"reasoning_effort": {},
|
||||
"prompt_cache_key": {},
|
||||
"tools": {},
|
||||
"tool_choice": {},
|
||||
"functions": {},
|
||||
"function_call": {},
|
||||
"parallel_tool_calls": {},
|
||||
"response_format": {},
|
||||
"service_tier": {},
|
||||
}
|
||||
|
||||
// grokChatResponsesBridgeEligibility deliberately accepts only request shapes
|
||||
@@ -45,18 +51,49 @@ func grokChatResponsesBridgeEligibility(body []byte) (bool, string) {
|
||||
return false, "invalid_json"
|
||||
}
|
||||
|
||||
// These fields have no effect when explicitly set to JSON null. Accepting
|
||||
// that common SDK representation keeps the request on the bridge path,
|
||||
// while non-null values remain unsupported because the Responses converter
|
||||
// cannot preserve their Chat Completions semantics.
|
||||
for _, field := range []string{"stop", "reasoning_effort"} {
|
||||
if _, exists := root[field]; exists {
|
||||
if raw, exists := root[field]; exists && !grokChatJSONNull(raw) {
|
||||
return false, "unsupported_" + field
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"tools", "functions"} {
|
||||
if raw, exists := root[field]; exists && !grokChatNullOrEmptyArray(raw) {
|
||||
return false, "unsupported_" + field
|
||||
if raw, exists := root["instructions"]; exists {
|
||||
var instructions string
|
||||
if !grokChatJSONNull(raw) && json.Unmarshal(raw, &instructions) != nil {
|
||||
return false, "invalid_instructions"
|
||||
}
|
||||
}
|
||||
if raw, exists := root["tool_choice"]; exists && !grokChatNullOrNone(raw) {
|
||||
return false, "unsupported_tool_choice"
|
||||
if raw, exists := root["response_format"]; exists {
|
||||
var responseFormat map[string]json.RawMessage
|
||||
if !grokChatJSONNull(raw) && (json.Unmarshal(raw, &responseFormat) != nil || responseFormat == nil) {
|
||||
return false, "invalid_response_format"
|
||||
}
|
||||
}
|
||||
if raw, exists := root["service_tier"]; exists {
|
||||
var serviceTier string
|
||||
if !grokChatJSONNull(raw) && json.Unmarshal(raw, &serviceTier) != nil {
|
||||
return false, "invalid_service_tier"
|
||||
}
|
||||
}
|
||||
if raw, exists := root["tools"]; exists {
|
||||
if ok, reason := grokChatFunctionDeclarationsBridgeable(raw); !ok {
|
||||
return false, reason
|
||||
}
|
||||
}
|
||||
if raw, exists := root["functions"]; exists && !grokChatNullOrEmptyArray(raw) {
|
||||
return false, "unsupported_functions"
|
||||
}
|
||||
if raw, exists := root["tool_choice"]; exists {
|
||||
if ok, reason := grokChatToolChoiceBridgeable(raw); !ok {
|
||||
return false, reason
|
||||
}
|
||||
var choice string
|
||||
if json.Unmarshal(raw, &choice) == nil && choice == "required" && !grokChatHasFunctionDeclarations(root) {
|
||||
return false, "required_tool_choice_without_tools"
|
||||
}
|
||||
}
|
||||
if raw, exists := root["function_call"]; exists && !grokChatNullOrNone(raw) {
|
||||
return false, "unsupported_function_call"
|
||||
@@ -78,6 +115,12 @@ func grokChatResponsesBridgeEligibility(body []byte) (bool, string) {
|
||||
return false, "invalid_stream"
|
||||
}
|
||||
}
|
||||
if raw, ok := root["parallel_tool_calls"]; ok {
|
||||
var parallelToolCalls *bool
|
||||
if json.Unmarshal(raw, ¶llelToolCalls) != nil || parallelToolCalls == nil {
|
||||
return false, "invalid_parallel_tool_calls"
|
||||
}
|
||||
}
|
||||
if raw, ok := root["stream_options"]; ok {
|
||||
var options map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &options) != nil || options == nil {
|
||||
@@ -128,42 +171,256 @@ func grokChatResponsesBridgeEligibility(body []byte) (bool, string) {
|
||||
return false, "invalid_messages"
|
||||
}
|
||||
for _, message := range messages {
|
||||
for field := range message {
|
||||
if field != "role" && field != "content" {
|
||||
return false, "unsafe_message_field_" + field
|
||||
}
|
||||
}
|
||||
var role string
|
||||
if raw, exists := message["role"]; !exists || json.Unmarshal(raw, &role) != nil {
|
||||
return false, "invalid_message_role"
|
||||
}
|
||||
switch role {
|
||||
case "system", "user", "assistant":
|
||||
case "system", "user":
|
||||
if ok, reason := grokChatMessageFieldsBridgeable(message, "role", "content"); !ok {
|
||||
return false, reason
|
||||
}
|
||||
raw, exists := message["content"]
|
||||
if !exists {
|
||||
return false, "non_text_message_content"
|
||||
}
|
||||
if ok, reason := grokChatRequiredMessageContentBridgeable(raw); !ok {
|
||||
return false, reason
|
||||
}
|
||||
case "assistant":
|
||||
if ok, reason := grokChatMessageFieldsBridgeable(message, "role", "content", "reasoning_content", "tool_calls"); !ok {
|
||||
return false, reason
|
||||
}
|
||||
reasoningContent := ""
|
||||
if raw, exists := message["reasoning_content"]; exists {
|
||||
if !grokChatJSONNull(raw) && json.Unmarshal(raw, &reasoningContent) != nil {
|
||||
return false, "invalid_reasoning_content"
|
||||
}
|
||||
}
|
||||
hasReasoningContent := strings.TrimSpace(reasoningContent) != ""
|
||||
toolCallCount := 0
|
||||
if raw, exists := message["tool_calls"]; exists {
|
||||
var reason string
|
||||
toolCallCount, reason = grokChatAssistantToolCallsBridgeable(raw)
|
||||
if reason != "" {
|
||||
return false, reason
|
||||
}
|
||||
}
|
||||
raw, hasContent := message["content"]
|
||||
if !hasContent || strings.TrimSpace(string(raw)) == "null" {
|
||||
if toolCallCount == 0 && !hasReasoningContent {
|
||||
return false, "non_text_message_content"
|
||||
}
|
||||
continue
|
||||
}
|
||||
var content string
|
||||
if json.Unmarshal(raw, &content) == nil {
|
||||
if strings.TrimSpace(content) == "" && toolCallCount == 0 && !hasReasoningContent {
|
||||
return false, "empty_message_content"
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ok, reason := grokChatStructuredContentBridgeable(raw); !ok {
|
||||
// The converter can still emit a standalone reasoning part when
|
||||
// an otherwise empty content array accompanies reasoning_content.
|
||||
// Do not broaden this exception to unsupported/malformed parts.
|
||||
if !hasReasoningContent || reason != "empty_message_content" {
|
||||
return false, reason
|
||||
}
|
||||
}
|
||||
case "tool":
|
||||
if ok, reason := grokChatMessageFieldsBridgeable(message, "role", "content", "tool_call_id"); !ok {
|
||||
return false, reason
|
||||
}
|
||||
var callID string
|
||||
if raw, exists := message["tool_call_id"]; !exists || json.Unmarshal(raw, &callID) != nil || strings.TrimSpace(callID) == "" {
|
||||
return false, "invalid_tool_call_id"
|
||||
}
|
||||
var output string
|
||||
if raw, exists := message["content"]; !exists || json.Unmarshal(raw, &output) != nil || output == "" {
|
||||
return false, "invalid_tool_message_content"
|
||||
}
|
||||
default:
|
||||
return false, "unsupported_message_role_" + role
|
||||
}
|
||||
raw, exists := message["content"]
|
||||
if !exists {
|
||||
return false, "non_text_message_content"
|
||||
}
|
||||
var content string
|
||||
if json.Unmarshal(raw, &content) == nil {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return false, "empty_message_content"
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Structured content: only allow arrays whose parts are text or
|
||||
// image_url. These are losslessly convertible to Responses input_text/
|
||||
// input_image parts, so the bridge preserves Chat Completions semantics.
|
||||
if ok, reason := grokChatStructuredContentBridgeable(raw); !ok {
|
||||
return false, reason
|
||||
}
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func grokChatFunctionDeclarationsBridgeable(raw json.RawMessage) (bool, string) {
|
||||
if strings.TrimSpace(string(raw)) == "null" {
|
||||
return true, ""
|
||||
}
|
||||
var declarations []json.RawMessage
|
||||
if json.Unmarshal(raw, &declarations) != nil {
|
||||
return false, "invalid_tools"
|
||||
}
|
||||
for _, declaration := range declarations {
|
||||
var tool map[string]json.RawMessage
|
||||
if json.Unmarshal(declaration, &tool) != nil || tool == nil {
|
||||
return false, "invalid_tool"
|
||||
}
|
||||
for field := range tool {
|
||||
if field != "type" && field != "function" {
|
||||
return false, "unsafe_tool_field_" + field
|
||||
}
|
||||
}
|
||||
var toolType string
|
||||
if rawType, exists := tool["type"]; !exists || json.Unmarshal(rawType, &toolType) != nil || toolType != "function" {
|
||||
return false, "unsupported_tool_type"
|
||||
}
|
||||
functionRaw, exists := tool["function"]
|
||||
if !exists {
|
||||
return false, "invalid_tool_function"
|
||||
}
|
||||
|
||||
var function map[string]json.RawMessage
|
||||
if json.Unmarshal(functionRaw, &function) != nil || function == nil {
|
||||
return false, "invalid_tool_function"
|
||||
}
|
||||
for field := range function {
|
||||
switch field {
|
||||
case "name", "description", "parameters", "strict":
|
||||
default:
|
||||
return false, "unsafe_tool_function_field_" + field
|
||||
}
|
||||
}
|
||||
var name string
|
||||
if rawName, exists := function["name"]; !exists || json.Unmarshal(rawName, &name) != nil || strings.TrimSpace(name) == "" {
|
||||
return false, "invalid_tool_function_name"
|
||||
}
|
||||
if rawDescription, exists := function["description"]; exists {
|
||||
var description string
|
||||
if json.Unmarshal(rawDescription, &description) != nil {
|
||||
return false, "invalid_tool_function_description"
|
||||
}
|
||||
}
|
||||
var parameters map[string]json.RawMessage
|
||||
if rawParameters, exists := function["parameters"]; !exists || json.Unmarshal(rawParameters, ¶meters) != nil || parameters == nil {
|
||||
return false, "invalid_tool_function_parameters"
|
||||
}
|
||||
if rawStrict, exists := function["strict"]; exists {
|
||||
var strict bool
|
||||
if json.Unmarshal(rawStrict, &strict) != nil {
|
||||
return false, "invalid_tool_function_strict"
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func grokChatToolChoiceBridgeable(raw json.RawMessage) (bool, string) {
|
||||
if strings.TrimSpace(string(raw)) == "null" {
|
||||
return true, ""
|
||||
}
|
||||
var choice string
|
||||
if json.Unmarshal(raw, &choice) != nil {
|
||||
return false, "unsupported_tool_choice"
|
||||
}
|
||||
switch choice {
|
||||
case "auto", "none", "required":
|
||||
return true, ""
|
||||
default:
|
||||
return false, "unsupported_tool_choice"
|
||||
}
|
||||
}
|
||||
|
||||
func grokChatHasFunctionDeclarations(root map[string]json.RawMessage) bool {
|
||||
for _, field := range []string{"tools", "functions"} {
|
||||
raw, exists := root[field]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
var declarations []json.RawMessage
|
||||
if json.Unmarshal(raw, &declarations) == nil && len(declarations) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func grokChatMessageFieldsBridgeable(message map[string]json.RawMessage, allowedFields ...string) (bool, string) {
|
||||
allowed := make(map[string]struct{}, len(allowedFields))
|
||||
for _, field := range allowedFields {
|
||||
allowed[field] = struct{}{}
|
||||
}
|
||||
for field := range message {
|
||||
if _, ok := allowed[field]; !ok {
|
||||
return false, "unsafe_message_field_" + field
|
||||
}
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func grokChatRequiredMessageContentBridgeable(raw json.RawMessage) (bool, string) {
|
||||
var content string
|
||||
if json.Unmarshal(raw, &content) == nil {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return false, "empty_message_content"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
// Structured content: only allow arrays whose parts are text or
|
||||
// image_url. These are losslessly convertible to Responses input_text/
|
||||
// input_image parts, so the bridge preserves Chat Completions semantics.
|
||||
return grokChatStructuredContentBridgeable(raw)
|
||||
}
|
||||
|
||||
func grokChatAssistantToolCallsBridgeable(raw json.RawMessage) (int, string) {
|
||||
if strings.TrimSpace(string(raw)) == "null" {
|
||||
return 0, ""
|
||||
}
|
||||
var calls []map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &calls) != nil {
|
||||
return 0, "invalid_tool_calls"
|
||||
}
|
||||
for _, call := range calls {
|
||||
if call == nil {
|
||||
return 0, "invalid_tool_call"
|
||||
}
|
||||
for field := range call {
|
||||
switch field {
|
||||
case "id", "type", "function", "index":
|
||||
default:
|
||||
return 0, "unsafe_tool_call_field_" + field
|
||||
}
|
||||
}
|
||||
if rawIndex, exists := call["index"]; exists {
|
||||
var index *int
|
||||
if json.Unmarshal(rawIndex, &index) != nil || (index != nil && *index < 0) {
|
||||
return 0, "invalid_tool_call_index"
|
||||
}
|
||||
}
|
||||
var callID string
|
||||
if rawID, exists := call["id"]; !exists || json.Unmarshal(rawID, &callID) != nil || strings.TrimSpace(callID) == "" {
|
||||
return 0, "invalid_tool_call_id"
|
||||
}
|
||||
var callType string
|
||||
if rawType, exists := call["type"]; !exists || json.Unmarshal(rawType, &callType) != nil || callType != "function" {
|
||||
return 0, "unsupported_tool_call_type"
|
||||
}
|
||||
var function map[string]json.RawMessage
|
||||
if rawFunction, exists := call["function"]; !exists || json.Unmarshal(rawFunction, &function) != nil || function == nil {
|
||||
return 0, "invalid_tool_call_function"
|
||||
}
|
||||
for field := range function {
|
||||
if field != "name" && field != "arguments" {
|
||||
return 0, "unsafe_tool_call_function_field_" + field
|
||||
}
|
||||
}
|
||||
var name string
|
||||
if rawName, exists := function["name"]; !exists || json.Unmarshal(rawName, &name) != nil || strings.TrimSpace(name) == "" {
|
||||
return 0, "invalid_tool_call_function_name"
|
||||
}
|
||||
var arguments string
|
||||
if rawArguments, exists := function["arguments"]; !exists || json.Unmarshal(rawArguments, &arguments) != nil || !json.Valid([]byte(arguments)) {
|
||||
return 0, "invalid_tool_call_arguments"
|
||||
}
|
||||
}
|
||||
return len(calls), ""
|
||||
}
|
||||
|
||||
func grokChatStructuredContentBridgeable(raw json.RawMessage) (bool, string) {
|
||||
var parts []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &parts); err != nil {
|
||||
@@ -199,14 +456,6 @@ func grokChatStructuredContentBridgeable(raw json.RawMessage) (bool, string) {
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func grokChatNullOrEmptyArray(raw json.RawMessage) bool {
|
||||
if strings.TrimSpace(string(raw)) == "null" {
|
||||
return true
|
||||
}
|
||||
var values []json.RawMessage
|
||||
return json.Unmarshal(raw, &values) == nil && len(values) == 0
|
||||
}
|
||||
|
||||
func grokChatNullOrNone(raw json.RawMessage) bool {
|
||||
if strings.TrimSpace(string(raw)) == "null" {
|
||||
return true
|
||||
@@ -215,14 +464,34 @@ func grokChatNullOrNone(raw json.RawMessage) bool {
|
||||
return json.Unmarshal(raw, &value) == nil && strings.EqualFold(strings.TrimSpace(value), "none")
|
||||
}
|
||||
|
||||
func grokChatCacheIntentBody(body []byte) ([]byte, error) {
|
||||
func grokChatJSONNull(raw json.RawMessage) bool {
|
||||
return strings.TrimSpace(string(raw)) == "null"
|
||||
}
|
||||
|
||||
func grokChatNullOrEmptyArray(raw json.RawMessage) bool {
|
||||
if strings.TrimSpace(string(raw)) == "null" {
|
||||
return true
|
||||
}
|
||||
var values []json.RawMessage
|
||||
return json.Unmarshal(raw, &values) == nil && len(values) == 0
|
||||
}
|
||||
|
||||
func grokChatResponsesCacheIntentBody(body []byte) ([]byte, error) {
|
||||
// An empty Chat tools array is omitted by the Responses converter. In that
|
||||
// case auto/none is also a semantic no-op and must not suppress the normal
|
||||
// tool-free cache route. Non-empty converted tools are always kept intact.
|
||||
if gjson.GetBytes(body, "tools").Exists() {
|
||||
return append([]byte(nil), body...), nil
|
||||
}
|
||||
choice := gjson.GetBytes(body, "tool_choice")
|
||||
if !choice.Exists() || choice.Type != gjson.String || (choice.String() != "auto" && choice.String() != "none") {
|
||||
return append([]byte(nil), body...), nil
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, field := range []string{"tools", "tool_choice", "functions", "function_call"} {
|
||||
delete(root, field)
|
||||
}
|
||||
delete(root, "tool_choice")
|
||||
return json.Marshal(root)
|
||||
}
|
||||
|
||||
@@ -268,6 +537,10 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses(
|
||||
}
|
||||
responsesReq.Model = upstreamModel
|
||||
responsesReq.Stream = true
|
||||
// Keep Chat and native Responses paths aligned for OpenAI-compatible
|
||||
// service_tier aliases (for example, "fast" -> "priority"). Unknown
|
||||
// values are omitted by the shared normalizer instead of reaching xAI.
|
||||
normalizeResponsesRequestServiceTier(responsesReq)
|
||||
// These fields are useful to Codex but are not needed by the Grok CLI
|
||||
// protocol. Keep the bridge request as close as possible to native Grok.
|
||||
responsesReq.Include = nil
|
||||
@@ -277,18 +550,25 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal grok responses bridge request: %w", err)
|
||||
}
|
||||
// Preserve the converted Responses intent before Grok capability
|
||||
// sanitization. Cache routing must see the actual client function tools,
|
||||
// not the nested Chat Completions declarations and not a tool-free copy.
|
||||
intentBody, err := grokChatResponsesCacheIntentBody(responsesBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("normalize grok responses bridge cache intent: %w", err)
|
||||
}
|
||||
responsesBody, err = patchGrokResponsesBody(responsesBody, upstreamModel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("patch grok responses bridge request: %w", err)
|
||||
}
|
||||
intentBody, err := grokChatCacheIntentBody(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("normalize grok responses bridge tool intent: %w", err)
|
||||
}
|
||||
responsesBody, err = applyGrokResponsesCacheIdentity(responsesBody, intentBody, cacheIdentity, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apply grok responses bridge cache identity: %w", err)
|
||||
}
|
||||
responsesBody, err = applyGrokFreeRequestToolCacheRoute(c, responsesBody, intentBody, account, cacheIdentity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apply grok responses bridge function-tool cache route: %w", err)
|
||||
}
|
||||
|
||||
updatedBody, policyErr := s.applyOpenAIFastPolicyToBody(ctx, account, upstreamModel, responsesBody)
|
||||
if policyErr != nil {
|
||||
|
||||
@@ -39,6 +39,31 @@ func TestGrokChatResponsesBridgeEligibility(t *testing.T) {
|
||||
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"stream":true,"stream_options":{"include_usage":true},"max_completion_tokens":256,"temperature":0.2,"top_p":0.9,"prompt_cache_key":"session","tools":[],"functions":null,"tool_choice":"none"}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Trae compatible fields",
|
||||
body: `{"model":"grok","messages":[{"role":"user","content":"return json"}],"instructions":"Be concise","response_format":{"type":"json_object"},"service_tier":"fast","stop":null,"reasoning_effort":null}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Trae nullable SDK fields",
|
||||
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"instructions":null,"response_format":null,"service_tier":null,"stop":null,"reasoning_effort":null}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "invalid instructions fall back",
|
||||
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"instructions":{"text":"invalid"}}`,
|
||||
reason: "invalid_instructions",
|
||||
},
|
||||
{
|
||||
name: "invalid response format falls back",
|
||||
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"response_format":"json_object"}`,
|
||||
reason: "invalid_response_format",
|
||||
},
|
||||
{
|
||||
name: "invalid service tier falls back",
|
||||
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"service_tier":1}`,
|
||||
reason: "invalid_service_tier",
|
||||
},
|
||||
{
|
||||
name: "stop falls back",
|
||||
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"stop":"done"}`,
|
||||
@@ -75,15 +100,80 @@ func TestGrokChatResponsesBridgeEligibility(t *testing.T) {
|
||||
reason: "empty_message_content",
|
||||
},
|
||||
{
|
||||
name: "function tools fall back",
|
||||
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"lookup"}}]}`,
|
||||
reason: "unsupported_tools",
|
||||
name: "function tools bridge",
|
||||
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"},"strict":false}}]}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "automatic tool choice falls back",
|
||||
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[],"tool_choice":"auto"}`,
|
||||
name: "legacy functions fall back",
|
||||
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"functions":[{"name":"lookup","parameters":{"type":"object"}}]}`,
|
||||
reason: "unsupported_functions",
|
||||
},
|
||||
{
|
||||
name: "automatic tool choice bridges",
|
||||
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[],"tool_choice":"auto"}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "required tool choice without tools falls back",
|
||||
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[],"tool_choice":"required"}`,
|
||||
reason: "required_tool_choice_without_tools",
|
||||
},
|
||||
{
|
||||
name: "tool history bridges",
|
||||
body: `{"model":"grok","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"alpha\"}"}}]},{"role":"tool","tool_call_id":"call_lookup","content":"{\"value\":\"ok\"}"},{"role":"user","content":"summarize"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}],"tool_choice":"auto","parallel_tool_calls":true}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Trae reasoning and indexed tool history bridges",
|
||||
body: `{"model":"grok","messages":[{"role":"assistant","content":null,"reasoning_content":"I should call lookup","tool_calls":[{"index":0,"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"alpha\"}"}}]},{"role":"tool","tool_call_id":"call_lookup","content":"{\"value\":\"ok\"}"},{"role":"user","content":"summarize"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}]}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "reasoning only assistant history bridges",
|
||||
body: `{"model":"grok","messages":[{"role":"assistant","reasoning_content":"Prior reasoning"},{"role":"user","content":"continue"}]}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "invalid reasoning content falls back",
|
||||
body: `{"model":"grok","messages":[{"role":"assistant","reasoning_content":{"text":"invalid"}},{"role":"user","content":"continue"}]}`,
|
||||
reason: "invalid_reasoning_content",
|
||||
},
|
||||
{
|
||||
name: "negative tool call index falls back",
|
||||
body: `{"model":"grok","messages":[{"role":"assistant","content":null,"tool_calls":[{"index":-1,"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{}"}}]}]}`,
|
||||
reason: "invalid_tool_call_index",
|
||||
},
|
||||
{
|
||||
name: "unknown tool type falls back",
|
||||
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"web_search","function":{"name":"lookup","parameters":{"type":"object"}}}]}`,
|
||||
reason: "unsupported_tool_type",
|
||||
},
|
||||
{
|
||||
name: "missing tool schema falls back",
|
||||
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"lookup"}}]}`,
|
||||
reason: "invalid_tool_function_parameters",
|
||||
},
|
||||
{
|
||||
name: "named tool choice falls back",
|
||||
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}],"tool_choice":{"type":"function","function":{"name":"lookup"}}}`,
|
||||
reason: "unsupported_tool_choice",
|
||||
},
|
||||
{
|
||||
name: "invalid tool call arguments fall back",
|
||||
body: `{"model":"grok","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{"}}]}]}`,
|
||||
reason: "invalid_tool_call_arguments",
|
||||
},
|
||||
{
|
||||
name: "tool result without call id falls back",
|
||||
body: `{"model":"grok","messages":[{"role":"tool","content":"ok"}]}`,
|
||||
reason: "invalid_tool_call_id",
|
||||
},
|
||||
{
|
||||
name: "non boolean parallel tool calls falls back",
|
||||
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"parallel_tool_calls":"true"}`,
|
||||
reason: "invalid_parallel_tool_calls",
|
||||
},
|
||||
{
|
||||
name: "reasoning effort falls back because conversion adds summary",
|
||||
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"high"}`,
|
||||
@@ -100,9 +190,9 @@ func TestGrokChatResponsesBridgeEligibility(t *testing.T) {
|
||||
reason: "empty_message_content",
|
||||
},
|
||||
{
|
||||
name: "tool history falls back",
|
||||
name: "empty tool history falls back",
|
||||
body: `{"model":"grok","messages":[{"role":"assistant","content":"","tool_calls":[]}]}`,
|
||||
reason: "unsafe_message_field_tool_calls",
|
||||
reason: "empty_message_content",
|
||||
},
|
||||
{
|
||||
name: "unknown field falls back",
|
||||
@@ -183,6 +273,116 @@ func TestForwardGrokChatViaResponsesNonStreamingCachesAndReturnsChat(t *testing.
|
||||
require.NotNil(t, repo.updates[account.ID][grokQuotaSnapshotExtraKey])
|
||||
}
|
||||
|
||||
func TestForwardGrokChatViaResponsesTraeToolHistoryKeepsCacheRoute(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
firstTurnBody := []byte(`{"model":"grok","messages":[{"role":"system","content":"Be concise"},{"role":"user","content":"Find alpha"}],"stream":false,"prompt_cache_key":"trae-session","tools":[{"type":"function","function":{"name":"lookup","description":"Lookup a value","parameters":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]},"strict":false}}],"tool_choice":"auto","parallel_tool_calls":true}`)
|
||||
body := []byte(`{"model":"grok","messages":[{"role":"system","content":"Be concise"},{"role":"user","content":"Find alpha"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"alpha\"}"}}]},{"role":"tool","tool_call_id":"call_lookup","content":"{\"value\":\"ok\"}"},{"role":"user","content":"Summarize"}],"stream":false,"prompt_cache_key":"trae-session","tools":[{"type":"function","function":{"name":"lookup","description":"Lookup a value","parameters":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]},"strict":false}}],"tool_choice":"auto","parallel_tool_calls":true}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, grokChatRawEndpoint, bytes.NewReader(body))
|
||||
c.Request.Header.Set(grokClientToolCacheOptInHeader, "prefer-cache")
|
||||
c.Set("api_key", &APIKey{ID: 7151})
|
||||
|
||||
account := grokChatBridgeTestAccount(715)
|
||||
account.Credentials["subscription_tier"] = "free"
|
||||
repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
|
||||
accountsByID: map[int64]*Account{account.ID: account},
|
||||
}}
|
||||
upstream := &httpUpstreamRecorder{resp: grokChatBridgeCompletedResponse("resp_grok_chat_trae", 8192)}
|
||||
svc := &OpenAIGatewayService{
|
||||
httpUpstream: upstream,
|
||||
grokTokenProvider: NewGrokTokenProvider(repo, nil),
|
||||
accountRepo: repo,
|
||||
}
|
||||
|
||||
firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.5")
|
||||
extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.5")
|
||||
require.NotEmpty(t, firstTurnIdentity)
|
||||
require.Equal(t, firstTurnIdentity, extendedTurnIdentity)
|
||||
|
||||
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, grokChatResponsesEndpoint, result.UpstreamEndpoint)
|
||||
require.Equal(t, extendedTurnIdentity, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String())
|
||||
require.Equal(t, extendedTurnIdentity, upstream.lastReq.Header.Get(grokConversationIDHeader))
|
||||
|
||||
tools := gjson.GetBytes(upstream.lastBody, "tools").Array()
|
||||
require.Len(t, tools, 3)
|
||||
require.Equal(t, "function", tools[0].Get("type").String())
|
||||
require.Equal(t, "lookup", tools[0].Get("name").String())
|
||||
require.Equal(t, "Lookup a value", tools[0].Get("description").String())
|
||||
require.Equal(t, "string", tools[0].Get("parameters.properties.key.type").String())
|
||||
require.True(t, tools[0].Get("strict").Exists())
|
||||
require.False(t, tools[0].Get("strict").Bool())
|
||||
require.Equal(t, "web_search", tools[1].Get("type").String())
|
||||
require.Equal(t, "x_search", tools[2].Get("type").String())
|
||||
require.Equal(t, "auto", gjson.GetBytes(upstream.lastBody, "tool_choice").String())
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool())
|
||||
|
||||
require.Equal(t, "function_call", gjson.GetBytes(upstream.lastBody, "input.2.type").String())
|
||||
require.Equal(t, "call_lookup", gjson.GetBytes(upstream.lastBody, "input.2.call_id").String())
|
||||
require.Equal(t, "lookup", gjson.GetBytes(upstream.lastBody, "input.2.name").String())
|
||||
require.Equal(t, `{"key":"alpha"}`, gjson.GetBytes(upstream.lastBody, "input.2.arguments").String())
|
||||
require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.3.type").String())
|
||||
require.Equal(t, "call_lookup", gjson.GetBytes(upstream.lastBody, "input.3.call_id").String())
|
||||
require.Equal(t, `{"value":"ok"}`, gjson.GetBytes(upstream.lastBody, "input.3.output").String())
|
||||
}
|
||||
|
||||
func TestForwardGrokChatViaResponsesTraeCompatibilityFieldsKeepCacheRoute(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
firstTurnBody := []byte(`{"model":"grok","messages":[{"role":"user","content":"Find alpha"}],"instructions":"Return concise JSON","stream":false,"response_format":{"type":"json_object"},"service_tier":"fast","stop":null,"reasoning_effort":null,"tools":[{"type":"function","function":{"name":"lookup","description":"Lookup a value","parameters":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]}}}],"tool_choice":"auto","parallel_tool_calls":true}`)
|
||||
body := []byte(`{"model":"grok","messages":[{"role":"user","content":"Find alpha"},{"role":"assistant","content":null,"reasoning_content":"I should use lookup","tool_calls":[{"index":0,"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"alpha\"}"}}]},{"role":"tool","tool_call_id":"call_lookup","content":"{\"value\":\"ok\"}"},{"role":"user","content":"Summarize"}],"instructions":"Return concise JSON","stream":false,"response_format":{"type":"json_object"},"service_tier":"fast","stop":null,"reasoning_effort":null,"tools":[{"type":"function","function":{"name":"lookup","description":"Lookup a value","parameters":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]}}}],"tool_choice":"auto","parallel_tool_calls":true}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, grokChatRawEndpoint, bytes.NewReader(body))
|
||||
c.Set("api_key", &APIKey{ID: 7161})
|
||||
|
||||
account := grokChatBridgeTestAccount(716)
|
||||
account.Credentials["subscription_tier"] = "free"
|
||||
repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
|
||||
accountsByID: map[int64]*Account{account.ID: account},
|
||||
}}
|
||||
upstream := &httpUpstreamRecorder{resp: grokChatBridgeCompletedResponse("resp_grok_chat_trae_compat", 12288)}
|
||||
svc := &OpenAIGatewayService{
|
||||
httpUpstream: upstream,
|
||||
grokTokenProvider: NewGrokTokenProvider(repo, nil),
|
||||
accountRepo: repo,
|
||||
}
|
||||
|
||||
firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.5")
|
||||
extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.5")
|
||||
require.NotEmpty(t, firstTurnIdentity)
|
||||
require.Equal(t, firstTurnIdentity, extendedTurnIdentity)
|
||||
|
||||
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, grokChatResponsesEndpoint, result.UpstreamEndpoint)
|
||||
require.Equal(t, 12288, result.Usage.CacheReadInputTokens)
|
||||
require.Equal(t, extendedTurnIdentity, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String())
|
||||
require.Equal(t, extendedTurnIdentity, upstream.lastReq.Header.Get(grokConversationIDHeader))
|
||||
require.Equal(t, "Return concise JSON", gjson.GetBytes(upstream.lastBody, "instructions").String())
|
||||
require.Equal(t, "json_object", gjson.GetBytes(upstream.lastBody, "text.format.type").String())
|
||||
require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "stop").Exists())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "reasoning").Exists())
|
||||
require.Contains(t, gjson.GetBytes(upstream.lastBody, "input.1.content.0.text").String(), "<thinking>I should use lookup</thinking>")
|
||||
require.Equal(t, "function_call", gjson.GetBytes(upstream.lastBody, "input.2.type").String())
|
||||
require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.3.type").String())
|
||||
|
||||
tools := gjson.GetBytes(upstream.lastBody, "tools").Array()
|
||||
require.Len(t, tools, 3)
|
||||
require.Equal(t, "lookup", tools[0].Get("name").String())
|
||||
require.Equal(t, "web_search", tools[1].Get("type").String())
|
||||
require.Equal(t, "x_search", tools[2].Get("type").String())
|
||||
require.Equal(t, int64(12288), gjson.Get(recorder.Body.String(), "usage.prompt_tokens_details.cached_tokens").Int())
|
||||
}
|
||||
|
||||
func TestForwardGrokChatViaResponsesStreamingPropagatesCachedUsage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -207,18 +207,27 @@ func TestPatchGrokResponsesBodyDropsToolChoiceWhenNoSupportedToolsRemain(t *test
|
||||
require.False(t, gjson.GetBytes(patched, "tool_choice").Exists())
|
||||
}
|
||||
|
||||
func TestPatchGrokResponsesBodyDropsCodexAdditionalToolsInputItems(t *testing.T) {
|
||||
func TestPatchGrokResponsesBodyPromotesCodexAdditionalTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := []byte(`{
|
||||
"model": "grok",
|
||||
"tools": [
|
||||
{"type": "function", "name": "existing", "description": "top-level wins"},
|
||||
{"type": "web_search"}
|
||||
],
|
||||
"tool_choice": "auto",
|
||||
"input": [
|
||||
{
|
||||
"type": "additional_tools",
|
||||
"role": "developer",
|
||||
"tools": [
|
||||
{"type": "namespace", "name": "image_gen"},
|
||||
{"type": "function", "name": "wait"}
|
||||
{"type": "function", "name": "existing", "description": "duplicate carrier definition"},
|
||||
{"type": "function", "name": "wait"},
|
||||
{"type": "web_search"},
|
||||
{"type": "shell"},
|
||||
{"type": "custom", "name": "apply_patch"},
|
||||
{"type": "namespace", "name": "collaboration"}
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -240,12 +249,235 @@ func TestPatchGrokResponsesBodyDropsCodexAdditionalToolsInputItems(t *testing.T)
|
||||
require.Equal(t, "grok-4.5", gjson.GetBytes(patched, "model").String())
|
||||
require.Equal(t, 2, len(gjson.GetBytes(patched, "input").Array()))
|
||||
require.False(t, gjson.GetBytes(patched, `input.#(type=="additional_tools")`).Exists())
|
||||
tools := gjson.GetBytes(patched, "tools").Array()
|
||||
require.Len(t, tools, 4)
|
||||
require.Equal(t, "existing", tools[0].Get("name").String())
|
||||
require.Equal(t, "top-level wins", tools[0].Get("description").String())
|
||||
require.Equal(t, "web_search", tools[1].Get("type").String())
|
||||
require.Equal(t, "wait", tools[2].Get("name").String())
|
||||
require.Equal(t, "shell", tools[3].Get("type").String())
|
||||
require.False(t, gjson.GetBytes(patched, `tools.#(type=="custom")`).Exists())
|
||||
require.False(t, gjson.GetBytes(patched, `tools.#(type=="namespace")`).Exists())
|
||||
require.Equal(t, "auto", gjson.GetBytes(patched, "tool_choice").String())
|
||||
require.Equal(t, "developer", gjson.GetBytes(patched, "input.0.role").String())
|
||||
require.Equal(t, "system prompt", gjson.GetBytes(patched, "input.0.content.0.text").String())
|
||||
require.Equal(t, "user", gjson.GetBytes(patched, "input.1.role").String())
|
||||
require.Equal(t, "hello", gjson.GetBytes(patched, "input.1.content.0.text").String())
|
||||
}
|
||||
|
||||
func TestForwardGrokResponsesCodexAdditionalToolsUsesMixedCacheIntent(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
body := []byte(`{
|
||||
"model":"grok",
|
||||
"stream":false,
|
||||
"prompt_cache_key":"codex-session",
|
||||
"input":[
|
||||
{"type":"additional_tools","role":"developer","tools":[
|
||||
{"type":"function","name":"lookup","description":"look up a key","parameters":{"type":"object"}},
|
||||
{"type":"function","name":"web_search","description":"search","parameters":{"type":"object"}},
|
||||
{"type":"custom","name":"apply_patch"},
|
||||
{"type":"namespace","name":"collaboration"}
|
||||
]},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}
|
||||
]
|
||||
}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
|
||||
c.Request.Header.Set(grokClientToolCacheOptInHeader, "prefer-cache")
|
||||
c.Set("api_key", &APIKey{ID: 4501})
|
||||
|
||||
account := healthyGrokOAuthGatewayTestAccount(4501, "access-token")
|
||||
account.Credentials["subscription_tier"] = "free"
|
||||
repo := &grokQuotaAccountRepo{
|
||||
mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
|
||||
accountsByID: map[int64]*Account{account.ID: account},
|
||||
},
|
||||
}
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"id":"resp_codex_lite","object":"response","model":"grok-4.5","status":"completed",
|
||||
"output":[],"usage":{"input_tokens":10,"output_tokens":1}
|
||||
}`)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{
|
||||
httpUpstream: upstream,
|
||||
grokTokenProvider: NewGrokTokenProvider(repo, nil),
|
||||
accountRepo: repo,
|
||||
}
|
||||
|
||||
result, err := svc.forwardGrokResponses(context.Background(), c, account, body, "grok", false, time.Now())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "resp_codex_lite", result.ResponseID)
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, `input.#(type=="additional_tools")`).Exists())
|
||||
tools := gjson.GetBytes(upstream.lastBody, "tools").Array()
|
||||
require.Len(t, tools, 3)
|
||||
require.Equal(t, "function", tools[0].Get("type").String())
|
||||
require.Equal(t, "lookup", tools[0].Get("name").String())
|
||||
require.Equal(t, "web_search", tools[1].Get("type").String())
|
||||
require.Equal(t, "x_search", tools[2].Get("type").String())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "tool_choice").Exists())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="custom")`).Exists())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="namespace")`).Exists())
|
||||
identity := gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()
|
||||
require.NotEmpty(t, identity)
|
||||
require.Equal(t, identity, upstream.lastReq.Header.Get(grokConversationIDHeader))
|
||||
require.Empty(t, upstream.lastReq.Header.Get(grokClientToolCacheOptInHeader))
|
||||
}
|
||||
|
||||
func TestForwardGrokResponsesClaudeDesktopClientToolsUseCacheRoute(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
firstBody := []byte(`{
|
||||
"model":"grok","stream":false,"instructions":"You are Claude Desktop.",
|
||||
"tools":[
|
||||
{"type":"function","name":"Read","parameters":{"type":"object"}},
|
||||
{"type":"function","name":"Edit","parameters":{"type":"object"}},
|
||||
{"type":"function","name":"WebSearch","parameters":{"type":"object"}},
|
||||
{"type":"function","name":"mcp__workspace__bash","parameters":{"type":"object"}}
|
||||
],
|
||||
"input":[{"role":"user","content":[{"type":"input_text","text":"first turn"}]}]
|
||||
}`)
|
||||
secondBody := []byte(`{
|
||||
"model":"grok","stream":false,"instructions":"You are Claude Desktop.",
|
||||
"tools":[
|
||||
{"type":"function","name":"Read","parameters":{"type":"object"}},
|
||||
{"type":"function","name":"Edit","parameters":{"type":"object"}},
|
||||
{"type":"function","name":"WebSearch","parameters":{"type":"object"}},
|
||||
{"type":"function","name":"mcp__workspace__bash","parameters":{"type":"object"}}
|
||||
],
|
||||
"input":[
|
||||
{"role":"user","content":[{"type":"input_text","text":"first turn"}]},
|
||||
{"role":"assistant","content":[{"type":"output_text","text":"first answer"}]},
|
||||
{"role":"user","content":[{"type":"input_text","text":"second turn"}]}
|
||||
]
|
||||
}`)
|
||||
|
||||
account := healthyGrokOAuthGatewayTestAccount(4504, "access-token")
|
||||
account.Credentials["subscription_tier"] = "free"
|
||||
repo := &grokQuotaAccountRepo{
|
||||
mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
|
||||
accountsByID: map[int64]*Account{account.ID: account},
|
||||
},
|
||||
}
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"id":"resp_claude_desktop_1","object":"response","model":"grok-4.5","status":"completed",
|
||||
"output":[],"usage":{"input_tokens":30000,"output_tokens":10,"input_tokens_details":{"cached_tokens":0}}
|
||||
}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"id":"resp_claude_desktop_2","object":"response","model":"grok-4.5","status":"completed",
|
||||
"output":[],"usage":{"input_tokens":30100,"output_tokens":12,"input_tokens_details":{"cached_tokens":28672}}
|
||||
}`)),
|
||||
},
|
||||
}}
|
||||
svc := &OpenAIGatewayService{
|
||||
httpUpstream: upstream,
|
||||
grokTokenProvider: NewGrokTokenProvider(repo, nil),
|
||||
accountRepo: repo,
|
||||
}
|
||||
|
||||
newContext := func(body []byte) *gin.Context {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
|
||||
c.Request.Header.Set("User-Agent", "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)")
|
||||
c.Request.Header.Set("X-App", "cli")
|
||||
c.Request.Header.Set("anthropic-client-platform", "desktop_app")
|
||||
c.Request.Header.Set("X-Claude-Code-Session-Id", "claude-desktop-session")
|
||||
c.Set("api_key", &APIKey{ID: 4504})
|
||||
return c
|
||||
}
|
||||
|
||||
first, err := svc.forwardGrokResponses(context.Background(), newContext(firstBody), account, firstBody, "grok", false, time.Now())
|
||||
require.NoError(t, err)
|
||||
second, err := svc.forwardGrokResponses(context.Background(), newContext(secondBody), account, secondBody, "grok", false, time.Now())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 0, first.Usage.CacheReadInputTokens)
|
||||
require.Equal(t, 28672, second.Usage.CacheReadInputTokens)
|
||||
require.Len(t, upstream.bodies, 2)
|
||||
require.Len(t, upstream.requests, 2)
|
||||
for i := range upstream.bodies {
|
||||
tools := gjson.GetBytes(upstream.bodies[i], "tools").Array()
|
||||
require.Len(t, tools, 6)
|
||||
require.Equal(t, "Read", tools[0].Get("name").String())
|
||||
require.Equal(t, "Edit", tools[1].Get("name").String())
|
||||
require.Equal(t, "WebSearch", tools[2].Get("name").String())
|
||||
require.Equal(t, "mcp__workspace__bash", tools[3].Get("name").String())
|
||||
require.Equal(t, "web_search", tools[4].Get("type").String())
|
||||
require.Equal(t, "x_search", tools[5].Get("type").String())
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[i], "tool_choice").Exists())
|
||||
require.Empty(t, upstream.requests[i].Header.Get("X-App"))
|
||||
require.Empty(t, upstream.requests[i].Header.Get("anthropic-client-platform"))
|
||||
require.Empty(t, upstream.requests[i].Header.Get("X-Claude-Code-Session-Id"))
|
||||
}
|
||||
firstIdentity := gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").String()
|
||||
secondIdentity := gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String()
|
||||
require.NotEmpty(t, firstIdentity)
|
||||
require.Equal(t, firstIdentity, secondIdentity)
|
||||
require.Equal(t, firstIdentity, upstream.requests[0].Header.Get(grokConversationIDHeader))
|
||||
require.Equal(t, secondIdentity, upstream.requests[1].Header.Get(grokConversationIDHeader))
|
||||
}
|
||||
|
||||
func TestGrokResponsesCacheIdentityIncludesPromotedCodexTools(t *testing.T) {
|
||||
c := newGrokCacheTestContext(4503)
|
||||
lookupBody := []byte(`{"model":"grok","input":[{"type":"additional_tools","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]},{"type":"message","role":"user","content":"same prompt"}]}`)
|
||||
readBody := []byte(`{"model":"grok","input":[{"type":"additional_tools","tools":[{"type":"function","name":"read_file","parameters":{"type":"object"}}]},{"type":"message","role":"user","content":"same prompt"}]}`)
|
||||
|
||||
patchedLookup, err := patchGrokResponsesBody(lookupBody, "grok-4.5")
|
||||
require.NoError(t, err)
|
||||
patchedRead, err := patchGrokResponsesBody(readBody, "grok-4.5")
|
||||
require.NoError(t, err)
|
||||
|
||||
lookupIdentity := resolveGrokCacheIdentity(c, patchedLookup, "", "grok-4.5")
|
||||
readIdentity := resolveGrokCacheIdentity(c, patchedRead, "", "grok-4.5")
|
||||
require.NotEmpty(t, lookupIdentity)
|
||||
require.NotEmpty(t, readIdentity)
|
||||
require.NotEqual(t, lookupIdentity, readIdentity)
|
||||
}
|
||||
|
||||
func TestCodexUnsupportedAdditionalToolsDoNotBecomeToolFreeCacheIntent(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"model":"grok","tool_choice":"auto",
|
||||
"input":[
|
||||
{"type":"additional_tools","role":"developer","tools":[
|
||||
{"type":"custom","name":"apply_patch"},
|
||||
{"type":"namespace","name":"collaboration"}
|
||||
]},
|
||||
{"type":"message","role":"user","content":"hello"}
|
||||
]
|
||||
}`)
|
||||
patched, err := patchGrokResponsesBody(body, "grok-4.5")
|
||||
require.NoError(t, err)
|
||||
require.False(t, gjson.GetBytes(patched, "tools").Exists())
|
||||
require.False(t, gjson.GetBytes(patched, "tool_choice").Exists())
|
||||
|
||||
mixedCacheIntent := patched
|
||||
patched, err = applyGrokResponsesCacheIdentity(patched, body, "isolated-id", true)
|
||||
require.NoError(t, err)
|
||||
account := healthyGrokOAuthGatewayTestAccount(4502, "access-token")
|
||||
account.Credentials["subscription_tier"] = "free"
|
||||
patched, err = applyGrokFreeRequestToolCacheRoute(nil, patched, mixedCacheIntent, account, "isolated-id")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, gjson.GetBytes(patched, "tools").Exists())
|
||||
require.False(t, gjson.GetBytes(patched, "tool_choice").Exists())
|
||||
require.Equal(t, "isolated-id", gjson.GetBytes(patched, "prompt_cache_key").String())
|
||||
}
|
||||
|
||||
func TestBuildGrokResponsesRequestUsesAccountBaseURLAndBearerToken(t *testing.T) {
|
||||
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true")
|
||||
|
||||
|
||||
@@ -196,12 +196,13 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
releaseUpstreamCtx()
|
||||
return nil, err
|
||||
}
|
||||
grokMixedCacheIntentBody := append([]byte(nil), body...)
|
||||
body, err = applyGrokResponsesCacheIdentity(body, grokIntentSourceBody, grokCacheIdentity, account.IsGrokOAuth())
|
||||
if err != nil {
|
||||
releaseUpstreamCtx()
|
||||
return nil, fmt.Errorf("apply grok prompt cache identity: %w", err)
|
||||
}
|
||||
body, err = applyGrokFreeMessagesFunctionToolCacheRoute(body, grokIntentSourceBody, account, grokCacheIdentity)
|
||||
body, err = applyGrokFreeRequestToolCacheRoute(c, body, grokMixedCacheIntentBody, account, grokCacheIdentity)
|
||||
if err != nil {
|
||||
releaseUpstreamCtx()
|
||||
return nil, fmt.Errorf("apply grok Free function-tool cache route: %w", err)
|
||||
@@ -482,6 +483,10 @@ func resolveGrokWSCacheIdentity(c *gin.Context, account *Account, payload []byte
|
||||
return "", err
|
||||
}
|
||||
upstreamModel := resolveGrokWSUpstreamModel(account, body, originalModel)
|
||||
body, err = patchGrokResponsesBody(body, upstreamModel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolveGrokCacheIdentity(c, body, "", upstreamModel), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -448,6 +448,78 @@ func TestProxyOpenAIWSHTTPBridgeTurnForGrokDefaultsEmptyModelTo45(t *testing.T)
|
||||
require.Len(t, events, 2)
|
||||
}
|
||||
|
||||
func TestProxyOpenAIWSHTTPBridgeTurnPromotesCodexAdditionalToolsForMixedCache(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(strings.Join([]string{
|
||||
`data: {"type":"response.created","response":{"id":"resp_grok_codex_lite","model":"grok-4.5"}}`,
|
||||
"",
|
||||
`data: {"type":"response.completed","response":{"id":"resp_grok_codex_lite","model":"grok-4.5","usage":{"input_tokens":4,"output_tokens":1}}}`,
|
||||
"",
|
||||
}, "\n"))),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
account := &Account{
|
||||
ID: 73,
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"base_url": xai.DefaultCLIBaseURL,
|
||||
"subscription_tier": "free",
|
||||
},
|
||||
}
|
||||
payload := []byte(`{
|
||||
"type":"response.create","generate":true,"model":"grok","stream":true,
|
||||
"input":[
|
||||
{"type":"additional_tools","role":"developer","tools":[
|
||||
{"type":"function","name":"lookup","parameters":{"type":"object"}},
|
||||
{"type":"function","name":"web_search","parameters":{"type":"object"}},
|
||||
{"type":"custom","name":"apply_patch"},
|
||||
{"type":"namespace","name":"collaboration"}
|
||||
]},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}
|
||||
]
|
||||
}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
|
||||
c.Request.Header.Set(grokClientToolCacheOptInHeader, "prefer-cache")
|
||||
var events [][]byte
|
||||
|
||||
result, err := svc.proxyOpenAIWSHTTPBridgeTurn(
|
||||
context.Background(), c, account, "access-token", payload, len(payload),
|
||||
"grok", "", "", "", "isolated-ws-cache-id", 1,
|
||||
func(message []byte) error {
|
||||
events = append(events, append([]byte(nil), message...))
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, events, 2)
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, `input.#(type=="additional_tools")`).Exists())
|
||||
tools := gjson.GetBytes(upstream.lastBody, "tools").Array()
|
||||
require.Len(t, tools, 3)
|
||||
require.Equal(t, "function", tools[0].Get("type").String())
|
||||
require.Equal(t, "lookup", tools[0].Get("name").String())
|
||||
require.Equal(t, "web_search", tools[1].Get("type").String())
|
||||
require.Equal(t, "x_search", tools[2].Get("type").String())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "tool_choice").Exists())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="custom")`).Exists())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="namespace")`).Exists())
|
||||
require.Equal(t, "isolated-ws-cache-id", gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String())
|
||||
require.Equal(t, "isolated-ws-cache-id", upstream.lastReq.Header.Get(grokConversationIDHeader))
|
||||
require.Empty(t, upstream.lastReq.Header.Get(grokClientToolCacheOptInHeader))
|
||||
}
|
||||
|
||||
func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridge(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -428,6 +428,26 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Grok OAuth client-tool prompt cache opt-in -->
|
||||
<div
|
||||
v-if="account.platform === 'grok' && account.type === 'oauth'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.grokClientToolCache.title') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.grokClientToolCache.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="grokClientToolCacheEnabled"
|
||||
data-testid="grok-client-tool-cache-toggle"
|
||||
:aria-label="t('admin.accounts.grokClientToolCache.title')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grok OAuth Custom Upstream URL (仅改写转发端点,OAuth 授权/刷新不受影响) -->
|
||||
<div
|
||||
v-if="account.platform === 'grok' && account.type === 'oauth'"
|
||||
@@ -2699,6 +2719,7 @@ const allowedModels = ref<string[]>([])
|
||||
const DEFAULT_POOL_MODE_RETRY_COUNT = 3
|
||||
const MAX_POOL_MODE_RETRY_COUNT = 10
|
||||
const DEFAULT_POOL_MODE_RETRY_STATUS_CODES = [401, 403, 429]
|
||||
const GROK_CLIENT_TOOL_CACHE_EXTRA_KEY = 'grok_client_tool_cache_enabled'
|
||||
const poolModeEnabled = ref(false)
|
||||
const poolModeRetryCount = ref(DEFAULT_POOL_MODE_RETRY_COUNT)
|
||||
const poolModeRetryStatusCodesInput = ref('')
|
||||
@@ -2747,6 +2768,9 @@ const headerOverrideCapable = computed(
|
||||
// Grok OAuth 自定义上游地址(仅转发端点;OAuth 授权/令牌刷新不受影响)
|
||||
const grokOAuthCustomBaseUrlEnabled = ref(false)
|
||||
const grokOAuthBaseUrl = ref('')
|
||||
// Grok Free OAuth accounts use client-tool prompt caching by default. Keep an
|
||||
// explicit false in the account extra as the opt-out signal.
|
||||
const grokClientToolCacheEnabled = ref(true)
|
||||
|
||||
const interceptWarmupRequests = ref(false)
|
||||
const autoPauseOnExpired = ref(false)
|
||||
@@ -3402,6 +3426,14 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
// Load Grok OAuth custom upstream URL state(存储的官方地址视同未定制)
|
||||
grokOAuthCustomBaseUrlEnabled.value = false
|
||||
grokOAuthBaseUrl.value = ''
|
||||
const grokClientToolCacheSetting =
|
||||
newAccount.platform === 'grok' && newAccount.type === 'oauth'
|
||||
? newAccount.extra?.[GROK_CLIENT_TOOL_CACHE_EXTRA_KEY]
|
||||
: undefined
|
||||
grokClientToolCacheEnabled.value =
|
||||
newAccount.platform === 'grok' &&
|
||||
newAccount.type === 'oauth' &&
|
||||
(grokClientToolCacheSetting === undefined || grokClientToolCacheSetting === true)
|
||||
if (newAccount.platform === 'grok' && newAccount.type === 'oauth' && newAccount.credentials) {
|
||||
const grokCreds = newAccount.credentials as Record<string, unknown>
|
||||
if (isCustomGrokBaseUrl(grokCreds.base_url)) {
|
||||
@@ -4288,6 +4320,14 @@ const handleSubmit = async () => {
|
||||
applyHeaderOverride(newCredentials, headerOverrideEnabled.value, headerOverrideRows.value, 'edit')
|
||||
|
||||
updatePayload.credentials = newCredentials
|
||||
|
||||
const newExtra: Record<string, unknown> = {
|
||||
...((props.account.extra as Record<string, unknown>) || {})
|
||||
}
|
||||
// Persist both states so a disabled account remains opted out when the
|
||||
// backend applies the default-enabled policy to missing values.
|
||||
newExtra[GROK_CLIENT_TOOL_CACHE_EXTRA_KEY] = grokClientToolCacheEnabled.value
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
// OpenAI: 手动覆盖订阅档位 plan_type(Plus/Pro/Free)。仅 OAuth 非影子账号:
|
||||
|
||||
@@ -67,7 +67,10 @@ const BaseDialogStub = defineComponent({
|
||||
template: '<div v-if="show"><slot /><slot name="footer" /></div>'
|
||||
})
|
||||
|
||||
function buildGrokOAuthAccount(credentials: Record<string, unknown> = {}) {
|
||||
function buildGrokOAuthAccount(
|
||||
credentials: Record<string, unknown> = {},
|
||||
extra: Record<string, unknown> = {}
|
||||
) {
|
||||
return {
|
||||
id: 5,
|
||||
name: 'Grok OAuth',
|
||||
@@ -80,7 +83,7 @@ function buildGrokOAuthAccount(credentials: Record<string, unknown> = {}) {
|
||||
...credentials
|
||||
},
|
||||
credentials_status: { has_access_token: true, has_refresh_token: true },
|
||||
extra: {},
|
||||
extra,
|
||||
proxy_id: null,
|
||||
concurrency: 1,
|
||||
priority: 1,
|
||||
@@ -225,4 +228,99 @@ describe('EditAccountModal Grok OAuth upstream config', () => {
|
||||
'x-xai-token-auth': 'xai-grok-cli'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the client-tool cache switch only for Grok OAuth accounts', () => {
|
||||
const grokOAuthWrapper = mountModal(buildGrokOAuthAccount())
|
||||
expect(grokOAuthWrapper.find('[data-testid="grok-client-tool-cache-toggle"]').exists()).toBe(true)
|
||||
|
||||
const grokAPIKeyWrapper = mountModal({
|
||||
...buildGrokOAuthAccount(),
|
||||
type: 'apikey',
|
||||
credentials: { api_key: 'xai-test', base_url: 'https://api.x.ai/v1' }
|
||||
})
|
||||
expect(grokAPIKeyWrapper.find('[data-testid="grok-client-tool-cache-toggle"]').exists()).toBe(false)
|
||||
|
||||
const openAIOAuthWrapper = mountModal({
|
||||
...buildGrokOAuthAccount(),
|
||||
platform: 'openai'
|
||||
})
|
||||
expect(openAIOAuthWrapper.find('[data-testid="grok-client-tool-cache-toggle"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('loads and disables client-tool caching while preserving unrelated extra fields', async () => {
|
||||
const account = buildGrokOAuthAccount({}, {
|
||||
grok_client_tool_cache_enabled: true,
|
||||
custom_setting: 'keep-me'
|
||||
})
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
const toggle = wrapper.get('[data-testid="grok-client-tool-cache-toggle"]')
|
||||
expect(toggle.attributes('aria-checked')).toBe('true')
|
||||
|
||||
await toggle.trigger('click')
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
const payload = updateAccountMock.mock.calls[0]?.[1]
|
||||
expect(payload?.extra?.custom_setting).toBe('keep-me')
|
||||
expect(payload?.extra?.grok_client_tool_cache_enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults client-tool caching on when the setting is missing and persists explicit true', async () => {
|
||||
const account = buildGrokOAuthAccount({}, { custom_setting: 'keep-me' })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
const toggle = wrapper.get('[data-testid="grok-client-tool-cache-toggle"]')
|
||||
expect(toggle.attributes('aria-checked')).toBe('true')
|
||||
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
const payload = updateAccountMock.mock.calls[0]?.[1]
|
||||
expect(payload?.extra).toMatchObject({
|
||||
grok_client_tool_cache_enabled: true,
|
||||
custom_setting: 'keep-me'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an explicit false opt-out when saving an untouched account', async () => {
|
||||
const account = buildGrokOAuthAccount(
|
||||
{},
|
||||
{
|
||||
grok_client_tool_cache_enabled: false,
|
||||
custom_setting: 'keep-me'
|
||||
}
|
||||
)
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
const toggle = wrapper.get('[data-testid="grok-client-tool-cache-toggle"]')
|
||||
expect(toggle.attributes('aria-checked')).toBe('false')
|
||||
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
const payload = updateAccountMock.mock.calls[0]?.[1]
|
||||
expect(payload?.extra).toMatchObject({
|
||||
grok_client_tool_cache_enabled: false,
|
||||
custom_setting: 'keep-me'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows malformed cache settings as disabled so the UI matches the fail-closed backend', () => {
|
||||
const account = buildGrokOAuthAccount(
|
||||
{},
|
||||
{
|
||||
grok_client_tool_cache_enabled: 'true',
|
||||
custom_setting: 'keep-me'
|
||||
}
|
||||
)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
expect(
|
||||
wrapper.get('[data-testid="grok-client-tool-cache-toggle"]').attributes('aria-checked')
|
||||
).toBe('false')
|
||||
})
|
||||
})
|
||||
@@ -641,6 +641,10 @@ export default {
|
||||
official: 'Official API'
|
||||
}
|
||||
},
|
||||
grokClientToolCache: {
|
||||
title: 'Client Tool Cache (May Change Automatic Tool Selection)',
|
||||
hint: 'For detected Grok Free OAuth accounts, this is enabled by default for client function tools such as Codex and Trae. Turn it off to opt out if the automatic tool-selection behavior is not acceptable.'
|
||||
},
|
||||
autoPauseOnExpired: 'Auto Pause On Expired',
|
||||
autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires',
|
||||
autoPause5hThreshold: '5h Usage Threshold (%)',
|
||||
|
||||
@@ -734,6 +734,10 @@ export default {
|
||||
official: '官方 API'
|
||||
}
|
||||
},
|
||||
grokClientToolCache: {
|
||||
title: '客户端工具缓存(可能改变自动工具选择)',
|
||||
hint: '仅对已识别为 Free 的 Grok OAuth 账号生效,默认会为 Codex、Trae 等客户端函数工具请求启用上游提示缓存;如不接受自动工具选择行为,可关闭此开关退出。'
|
||||
},
|
||||
autoPauseOnExpired: '过期自动暂停调度',
|
||||
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
|
||||
autoPause5hThreshold: '5h 用量阈值(%)',
|
||||
|
||||
Reference in new issue
Block a user