Compare commits

..
9 Commits
Author SHA1 Message Date
Wesley Liddick 3af5443b22 Merge pull request #6116 from wucm667/fix/issue-6110-gemini-tool-schema
fix(gemini): sanitize unsupported tool schema fields
2026-08-24 22:19:07 +08:00
Wesley Liddick 7ba3e1ac52 Merge pull request #6150 from Wei-Shaw/fix/grok-upstream-user-agent
fix(grok): use official CLI user agent
2026-08-24 22:18:02 +08:00
shaw 9fb260439f fix(grok): use official CLI user agent 2026-08-24 22:14:45 +08:00
Wesley Liddick 07931bbb18 Merge pull request #6143 from akihitohyh/fix/rejected-status-strip-all
fix(openai): clear the rejected input status for the whole item type
2026-08-24 22:01:14 +08:00
Wesley Liddick 2307aa5ca7 Merge pull request #6148 from 759502416/fix/responses-lite-parallel-tool-calls
fix(openai): keep parallel_tool_calls for Responses Lite additional_tools
2026-08-24 21:49:01 +08:00
759502416andClaude Opus 5 1563db3f82 fix(openai): keep parallel_tool_calls for Responses Lite additional_tools
normalizeOpenAIParallelToolCallsWithoutTools only looked at the top-level
"tools" array, but normalizeOpenAIResponsesLiteTools moves namespace tools
into an input item of type "additional_tools" and deletes the top-level key.
A Responses Lite request that carries tools therefore looks like it has none,
and the parallel_tool_calls:false that ensureOpenAIResponsesLiteParallelToolCalls
had just pinned gets deleted on the way out.

OpenAI then applies its default of true and rejects the request:

  400 unsupported_value: "X-OpenAI-Internal-Codex-Responses-Lite requires
  `parallel_tool_calls` to be false."

Note the field cannot simply be pinned to false unconditionally: without tools
OpenAI rejects it with "'parallel_tool_calls' is only allowed when 'tools' are
specified", so the two constraints have to be honoured together.

Reuse the same tool-detection standard the Lite path already uses by adding
openAIRequestBodyHasTools, the []byte counterpart of openAIResponsesLiteHasTools,
so both sides of the repo agree on what "has tools" means.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 21:15:39 +08:00
akihitohyhandClaude Opus 5 e440ac48c7 fix(openai): clear the rejected input status for the whole item type
The upstream names one offending index per response, but a replayed
conversation routinely carries dozens of items of the same type, each with a
status the upstream schema does not accept. Clearing a single index per round
trip needs one retry per item, so a conversation with more than
maxOpenAIResponsesRejectedFieldRetries such items exhausts the bounded budget
and the 400 reaches the client. Reported against tool_search_output items,
where the rejection surfaced as "Unknown parameter: 'input[60].status'".

Clear the status of every input item sharing the rejected item's type in the
same pass. Items of other types keep theirs: the rejection only proves that
the rejected item's type has no status field. When the rejected item carries
no type to match on, fall back to clearing the named index alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 17:03:44 +08:00
github-actions[bot] 03e8ab4134 chore: sync VERSION to 0.1.180 [skip ci] 2026-08-24 07:30:34 +00:00
wucm667 19da0f240e fix(gemini): sanitize unsupported tool schema fields 2026-08-23 18:38:11 +08:00
16 changed files with 245 additions and 58 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.179
0.1.180
+1 -1
View File
@@ -19,7 +19,7 @@ const (
// repository and service layers build their own client identity from it, so
// one bump here covers OAuth traffic and billing probes together.
// Keep in sync with https://x.ai/cli/stable.
CLIClientVersion = "0.2.114"
CLIClientVersion = "0.2.120"
// billingCLIUserAgent is the legacy pager/shell UA used by billing probes.
// Distinct from CLIUserAgent() in cli_identity.go (workspace-style UA).
billingCLIUserAgent = "grok-pager/" + CLIClientVersion + " grok-shell/" + CLIClientVersion + " (macos; aarch64)"
@@ -41,7 +41,7 @@ func TestApplyCLIProxyHeaders(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
require.NoError(t, err)
req.Header.Set("User-Agent", "sub2api-grok/1.0")
req.Header.Set("User-Agent", "legacy-client/1.0")
ApplyCLIProxyHeaders(req)
@@ -56,12 +56,12 @@ func TestApplyCLIProxyHeadersLeavesAPIHostUnchanged(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil)
require.NoError(t, err)
req.Header.Set("User-Agent", "sub2api-grok/1.0")
req.Header.Set("User-Agent", "direct-api-client/1.0")
ApplyCLIProxyHeaders(req)
require.Empty(t, req.Header.Get("x-grok-client-version"))
require.Empty(t, req.Header.Get("x-grok-client-identifier"))
require.Empty(t, req.Header.Get("X-XAI-Token-Auth"))
require.Equal(t, "sub2api-grok/1.0", req.Header.Get("User-Agent"))
require.Equal(t, "direct-api-client/1.0", req.Header.Get("User-Agent"))
}
@@ -17,6 +17,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
@@ -228,16 +229,16 @@ func TestHTTPUpstreamDoAppliesGrokCLIIdentityBeforeOAuthRoundTrip(t *testing.T)
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/"+endpoint, nil)
require.NoError(t, err)
req.Header.Set("User-Agent", "sub2api-grok/1.0")
req.Header.Set("User-Agent", "legacy-client/1.0")
resp, err := svc.Do(req, "", accountID, 1)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NoError(t, resp.Body.Close())
require.Equal(t, "0.2.114", capturedHeaders.Get("x-grok-client-version"))
require.Equal(t, xai.CLIClientVersion, capturedHeaders.Get("x-grok-client-version"))
require.Equal(t, "xai-grok-cli", capturedHeaders.Get("X-XAI-Token-Auth"))
require.Equal(t, "xai-grok-workspace/0.2.114", capturedHeaders.Get("User-Agent"))
require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), capturedHeaders.Get("User-Agent"))
})
}
}
@@ -454,67 +455,67 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", "")
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
require.NoError(t, err)
req.Header.Set("User-Agent", "sub2api-grok/1.0")
req.Header.Set("User-Agent", "legacy-client/1.0")
applyGrokCLIProxyHeaders(req)
require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version"))
require.Equal(t, "xai-grok-cli", req.Header.Get("X-XAI-Token-Auth"))
require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent"))
require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent"))
})
t.Run("accepts a valid operator override", func(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.115-alpha.1")
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.121-alpha.1")
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/chat/completions", nil)
require.NoError(t, err)
applyGrokCLIProxyHeaders(req)
require.Equal(t, "0.2.115-alpha.1", req.Header.Get("x-grok-client-version"))
require.Equal(t, "xai-grok-workspace/0.2.115-alpha.1", req.Header.Get("User-Agent"))
require.Equal(t, "0.2.121-alpha.1", req.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIUserAgent("0.2.121-alpha.1"), req.Header.Get("User-Agent"))
})
t.Run("rejects an unsafe override", func(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.115\r\nX-Injected: true")
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.121\r\nX-Injected: true")
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
require.NoError(t, err)
applyGrokCLIProxyHeaders(req)
require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version"))
require.Empty(t, req.Header.Get("X-Injected"))
})
t.Run("rejects an override below the supported minimum", func(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.113")
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.119")
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
require.NoError(t, err)
applyGrokCLIProxyHeaders(req)
require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version"))
require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent"))
require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent"))
})
t.Run("rejects a prerelease override at the minimum version", func(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.114-beta.1")
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.120-beta.1")
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
require.NoError(t, err)
applyGrokCLIProxyHeaders(req)
require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version"))
require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent"))
require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent"))
})
// Every entry sits above the pinned minimum, so a rejection here can only be
// caused by the malformed semver and never by the version being too old.
for _, version := range []string{
"0.2.0115",
"0.2.115-alpha..1",
"0.2.0121",
"0.2.121-alpha..1",
"0.3",
"1",
"0.2.115+build.1",
"0.2.121+build.1",
} {
t.Run("rejects invalid semver "+version, func(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", version)
@@ -523,8 +524,8 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) {
applyGrokCLIProxyHeaders(req)
require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version"))
require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent"))
require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent"))
})
}
@@ -532,13 +533,13 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95")
req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil)
require.NoError(t, err)
req.Header.Set("User-Agent", "sub2api-grok/1.0")
req.Header.Set("User-Agent", "direct-api-client/1.0")
applyGrokCLIProxyHeaders(req)
require.Empty(t, req.Header.Get("x-grok-client-version"))
require.Empty(t, req.Header.Get("X-XAI-Token-Auth"))
require.Equal(t, "sub2api-grok/1.0", req.Header.Get("User-Agent"))
require.Equal(t, "direct-api-client/1.0", req.Header.Get("User-Agent"))
})
}
@@ -3587,12 +3587,20 @@ func cleanToolSchema(schema any) any {
if key == "$schema" || key == "$id" || key == "$ref" ||
key == "$defs" || key == "definitions" ||
key == "additionalProperties" || key == "patternProperties" || key == "minLength" ||
key == "maxLength" || key == "minItems" || key == "maxItems" || key == "exclusiveMinimum" {
key == "maxLength" || key == "minItems" || key == "maxItems" || key == "exclusiveMinimum" ||
key == "deprecated" {
continue
}
// 递归清理嵌套对象
cleaned[key] = cleanToolSchema(value)
}
if enum, ok := cleaned["enum"].([]any); ok {
if normalized, ok := normalizeGeminiEnum(enum); ok {
cleaned["enum"] = normalized
} else {
delete(cleaned, "enum")
}
}
// 规范化 type 字段为大写
if typeVal, ok := cleaned["type"].(string); ok {
cleaned["type"] = strings.ToUpper(typeVal)
@@ -3627,6 +3635,28 @@ func cleanToolSchema(schema any) any {
}
}
func normalizeGeminiEnum(values []any) ([]any, bool) {
normalized := make([]any, len(values))
for i, value := range values {
if stringValue, ok := value.(string); ok {
normalized[i] = stringValue
continue
}
switch value.(type) {
case nil, bool, float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, json.Number:
encoded, err := json.Marshal(value)
if err != nil {
return nil, false
}
normalized[i] = string(encoded)
default:
return nil, false
}
}
return normalized, true
}
func incrementIntegralSchemaBound(value any) (any, bool) {
switch v := value.(type) {
case float64:
@@ -471,6 +471,44 @@ func TestCleanToolSchema_DropsAmbiguousExclusiveMinimumWithoutConversion(t *test
}
}
func TestCleanToolSchema_RemovesNestedDeprecatedAndNormalizesMixedScalarEnum(t *testing.T) {
schema := map[string]any{
"anyOf": []any{
map[string]any{
"type": "string",
"deprecated": true,
},
map[string]any{
"enum": []any{"enabled", false, float64(1), nil},
},
},
}
cleaned, ok := cleanToolSchema(schema).(map[string]any)
require.True(t, ok)
anyOf, ok := cleaned["anyOf"].([]any)
require.True(t, ok)
require.Len(t, anyOf, 2)
deprecatedSchema, ok := anyOf[0].(map[string]any)
require.True(t, ok)
require.NotContains(t, deprecatedSchema, "deprecated")
enumSchema, ok := anyOf[1].(map[string]any)
require.True(t, ok)
require.Equal(t, []any{"enabled", "false", "1", "null"}, enumSchema["enum"])
}
func TestCleanToolSchema_DropsEnumWithNonScalarValue(t *testing.T) {
schema := map[string]any{
"enum": []any{"valid", map[string]any{"invalid": true}},
}
cleaned, ok := cleanToolSchema(schema).(map[string]any)
require.True(t, ok)
require.NotContains(t, cleaned, "enum")
}
func TestConvertClaudeToolsToGeminiTools_PreservesWebSearchAlongsideFunctions(t *testing.T) {
tools := []any{
map[string]any{
@@ -92,7 +92,7 @@ func (s *GrokQuotaService) syncGrokObservedModels(ctx context.Context, account *
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", grokUpstreamUserAgent)
req.Header.Set("User-Agent", defaultGrokUpstreamUserAgent())
if account.IsGrokOAuth() {
applyGrokCLIHeaders(req.Header)
if isGrokCLIProxyTarget(req.URL.String()) {
@@ -133,8 +133,9 @@ func TestSyncGrokObservedModelsUsesCLIIdentityAndAccountHeaders(t *testing.T) {
require.NoError(t, svc.syncGrokObservedModels(context.Background(), account))
require.Equal(t, xai.DefaultCLIBaseURL+"/models", upstream.lastReq.URL.String())
require.NotEmpty(t, upstream.lastReq.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIClientVersion, upstream.lastReq.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIClientIdentifier, upstream.lastReq.Header.Get("x-grok-client-identifier"))
require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "interactive", upstream.lastReq.Header.Get("X-Grok-Client-Mode"))
require.Equal(t, "user-902", upstream.lastReq.Header.Get("X-UserID"))
require.Equal(t, "user902@example.test", upstream.lastReq.Header.Get("X-Email"))
@@ -10,10 +10,6 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
)
// grokUpstreamUserAgent is kept for compatibility with older Grok request
// tests. Current requests use the pinned default UA from this package.
const grokUpstreamUserAgent = "sub2api-grok/1.0"
// Fixed CLI identity aliases — single source of truth is internal/pkg/xai.
const (
grokClientVersionHeader = xai.CLIStableVersion
@@ -175,7 +175,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions(
SetActualOpenAIUpstreamEndpoint(c, grokChatRawEndpoint)
customUA := account.GetOpenAIUserAgent()
if customUA == "" && account.IsGrokOAuth() {
customUA = "sub2api-grok/1.0"
customUA = defaultGrokUpstreamUserAgent()
}
resp, err := s.sendCCUpstreamRequest(ctx, c, account, targetURL, upstreamBody, clientStream, token, customUA, grokCacheIdentity)
if err != nil {
@@ -22,14 +22,13 @@ import (
const (
grokComposerImageBridgeVisionModel = "grok-build-0.1"
grokComposerImageBridgeMaxOutputTokens = 512
// grokUpstreamUserAgent lives in grok_upstream_headers.go (shared with TLS header helpers).
grokCLIVersion = xai.CLIClientVersion
grokDefaultResponsesModel = "grok-4.5"
grokRateLimitFallbackCooldown = 2 * time.Minute
grokRateLimitRepeatCooldown = 10 * time.Minute
grokRateLimitSustainedCooldown = 30 * time.Minute
grokRateLimitMaxAdaptiveCooldown = time.Hour
grokRateLimitBackoffQuietPeriod = time.Hour
grokCLIVersion = xai.CLIClientVersion
grokDefaultResponsesModel = "grok-4.5"
grokRateLimitFallbackCooldown = 2 * time.Minute
grokRateLimitRepeatCooldown = 10 * time.Minute
grokRateLimitSustainedCooldown = 30 * time.Minute
grokRateLimitMaxAdaptiveCooldown = time.Hour
grokRateLimitBackoffQuietPeriod = time.Hour
)
func (s *OpenAIGatewayService) forwardGrokResponses(
@@ -828,7 +828,7 @@ func TestBuildGrokResponsesRequestAllowsPublicAPIKeyBaseURLByDefault(t *testing.
require.Equal(t, "https://grok.example.test/v1/responses", req.URL.String())
require.Equal(t, "Bearer api-key", req.Header.Get("Authorization"))
require.Empty(t, req.Header.Get("X-Grok-Client-Version"))
require.NotEqual(t, grokUpstreamUserAgent, req.Header.Get("User-Agent"))
require.NotEqual(t, defaultGrokUpstreamUserAgent(), req.Header.Get("User-Agent"))
}
func TestBuildGrokResponsesRequestHonorsOAuthOfficialEndpointSwitch(t *testing.T) {
@@ -1132,7 +1132,7 @@ func TestForwardGrokMediaImagesGenerationNormalizesImagineAlias(t *testing.T) {
require.Equal(t, "Bearer api-key", upstream.lastReq.Header.Get("Authorization"))
require.Equal(t, "application/json", upstream.lastReq.Header.Get("Content-Type"))
require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version"))
require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent"))
require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent"))
require.JSONEq(t, `{"model":"grok-imagine-image-quality","prompt":"draw a cat"}`, string(upstream.lastBody))
require.Equal(t, http.StatusOK, recorder.Code)
require.JSONEq(t, `{"data":[{"url":"https://images.test/cat.png"}]}`, recorder.Body.String())
@@ -1646,7 +1646,7 @@ func TestForwardGrokMediaVideoStatusUsesGETWithoutBody(t *testing.T) {
require.Equal(t, http.MethodGet, upstream.lastReq.Method)
require.Equal(t, "Bearer api-key", upstream.lastReq.Header.Get("Authorization"))
require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version"))
require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent"))
require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent"))
require.Empty(t, upstream.lastReq.Header.Get("Content-Type"))
require.Empty(t, upstream.lastBody)
require.Equal(t, http.StatusOK, recorder.Code)
@@ -1982,7 +1982,7 @@ func TestForwardGrokResponsesAPIKeyUsesXAIResponses(t *testing.T) {
require.Equal(t, "https://api.x.ai/v1/responses", upstream.lastReq.URL.String())
require.Equal(t, "Bearer xai-test-key", upstream.lastReq.Header.Get("Authorization"))
require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version"))
require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent"))
require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "resp_grok_api_key", result.ResponseID)
require.Equal(t, 2, result.Usage.InputTokens)
@@ -2259,7 +2259,7 @@ func TestForwardAsChatCompletionsForGrokAPIKeyUsesConfiguredRawEndpointWithoutOA
require.Equal(t, "https://grok.example.test/v1/chat/completions", upstream.lastReq.URL.String())
require.Equal(t, "Bearer third-party-key", upstream.lastReq.Header.Get("Authorization"))
require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version"))
require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent"))
require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent"))
}
func TestForwardAsChatCompletionsForGrokAPIKeyRejectsNonStreamingResponseWithoutUsage(t *testing.T) {
@@ -366,8 +366,7 @@ func normalizeOpenAIParallelToolCallsWithoutTools(body []byte) ([]byte, bool, er
if !parallel.Exists() {
return body, false, nil
}
tools := gjson.GetBytes(body, "tools")
if tools.IsArray() && len(tools.Array()) > 0 {
if openAIRequestBodyHasTools(body) {
return body, false, nil
}
normalized, err := sjson.DeleteBytes(body, "parallel_tool_calls")
@@ -377,6 +376,31 @@ func normalizeOpenAIParallelToolCallsWithoutTools(body []byte) ([]byte, bool, er
return normalized, true, nil
}
// openAIRequestBodyHasTools is the []byte counterpart of openAIResponsesLiteHasTools:
// besides the top-level "tools" array it also recognizes the Responses Lite carrier.
// normalizeOpenAIResponsesLiteTools moves namespace tools into an input item of type
// "additional_tools" and drops the top-level "tools" key; the request still carries
// tools at that point. Looking only at the top level therefore misreads such a body as
// "no tools" and deletes the parallel_tool_calls:false that
// ensureOpenAIResponsesLiteParallelToolCalls had just pinned, and OpenAI falls back to
// its default of true and rejects the request with
// 400 unsupported_value: "X-OpenAI-Internal-Codex-Responses-Lite requires
// `parallel_tool_calls` to be false."
func openAIRequestBodyHasTools(body []byte) bool {
if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 {
return true
}
for _, item := range gjson.GetBytes(body, "input").Array() {
if strings.TrimSpace(item.Get("type").String()) != "additional_tools" {
continue
}
if tools := item.Get("tools"); tools.IsArray() && len(tools.Array()) > 0 {
return true
}
}
return false
}
func normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body []byte, knownStoreFalse bool) ([]byte, bool, error) {
if !knownStoreFalse && gjson.GetBytes(body, "store").Type != gjson.False {
return body, false, nil
@@ -270,3 +270,23 @@ func TestNormalizeOpenAIParallelToolCallsWithoutTools(t *testing.T) {
require.True(t, changed)
require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists())
}
// A Responses Lite body that has already been through normalizeOpenAIResponsesLiteTools
// carries its tools in an input item of type "additional_tools" and no longer has a
// top-level "tools" key. It still has tools, so the parallel_tool_calls:false that
// ensureOpenAIResponsesLiteParallelToolCalls pinned must survive this normalization —
// otherwise OpenAI applies its default of true and rejects the request.
func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAdditionalTools(t *testing.T) {
liteBody := []byte(`{"input":[{"type":"message","role":"user","content":"hi"},{"type":"additional_tools","tools":[{"type":"function","name":"spawn_agent"}]}],"parallel_tool_calls":false}`)
normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(liteBody)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, gjson.False, gjson.GetBytes(normalized, "parallel_tool_calls").Type)
// An empty additional_tools item carries no tools, so the field is still dropped.
emptyLiteBody := []byte(`{"input":[{"type":"additional_tools","tools":[]}],"parallel_tool_calls":true}`)
normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(emptyLiteBody)
require.NoError(t, err)
require.True(t, changed)
require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists())
}
@@ -271,18 +271,56 @@ func openAIResponsesRejectedInputIndex(pattern *regexp.Regexp, param string) (in
return 0, false
}
// removeOpenAIResponsesRejectedStatusAtIndex drops the status field the
// upstream rejected, and the status of every other input item sharing the
// rejected item's type.
//
// The upstream names one offending index per response, but a replayed
// conversation routinely carries dozens of items of the same type, each with a
// status its schema does not accept. Clearing one index per round trip would
// need one retry per item and exhaust the bounded retry budget long before the
// request could succeed. Items of other types keep their status: the rejection
// only proves that this type has no status field.
func removeOpenAIResponsesRejectedStatusAtIndex(body []byte, index int) ([]byte, string, bool, error) {
itemPath := fmt.Sprintf("input.%d", index)
if !gjson.GetBytes(body, itemPath).IsObject() {
rejected := gjson.GetBytes(body, itemPath)
if !rejected.IsObject() {
return nil, "", false, nil
}
statusPath := itemPath + ".status"
if !gjson.GetBytes(body, statusPath).Exists() {
if !gjson.GetBytes(body, itemPath+".status").Exists() {
return nil, "", false, nil
}
retryBody, err := sjson.DeleteBytes(body, statusPath)
if err != nil {
return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", index, err)
retryBody := body
cleared := 0
rejectedType := strings.TrimSpace(rejected.Get("type").String())
if input := gjson.GetBytes(body, "input"); rejectedType != "" && input.IsArray() {
// Deleting a field never shifts array indexes, so positions read from
// the original body stay valid against the rewritten one.
for itemIndex, item := range input.Array() {
if !item.IsObject() || strings.TrimSpace(item.Get("type").String()) != rejectedType {
continue
}
statusPath := fmt.Sprintf("input.%d.status", itemIndex)
if !gjson.GetBytes(retryBody, statusPath).Exists() {
continue
}
next, err := sjson.DeleteBytes(retryBody, statusPath)
if err != nil {
return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", itemIndex, err)
}
retryBody = next
cleared++
}
}
if cleared == 0 {
// The rejected item carries no type to match on; fall back to clearing
// just the index the upstream named.
next, err := sjson.DeleteBytes(retryBody, itemPath+".status")
if err != nil {
return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", index, err)
}
retryBody = next
}
return retryBody, "indexed status parameter rejection", true, nil
}
@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
@@ -681,3 +682,42 @@ func newOpenAIRejectedFieldTestResponse(status int, body string) *http.Response
Body: io.NopCloser(strings.NewReader(body)),
}
}
// A replayed conversation carries many items of the same type, each with a
// status the upstream schema rejects. One rejection must clear all of them:
// clearing one index per round trip exhausts the bounded retry budget.
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyClearsStatusForWholeType(t *testing.T) {
input := make([]string, 0, 12)
for i := 0; i < 10; i++ {
input = append(input, `{"type":"tool_search_output","status":"completed","call_id":"call_`+strconv.Itoa(i)+`","tools":[]}`)
}
input = append(input, `{"type":"message","role":"user","status":"completed","content":"hi"}`)
body := []byte(`{"input":[` + strings.Join(input, ",") + `]}`)
responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[7].status'.","param":"input[7].status"}}`)
retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
require.NoError(t, err)
require.True(t, changed)
require.NotEmpty(t, reason)
for i := 0; i < 10; i++ {
require.False(t, gjson.GetBytes(retryBody, "input."+strconv.Itoa(i)+".status").Exists(),
"every tool_search_output must lose its status in a single retry, index %d did not", i)
require.Equal(t, "call_"+strconv.Itoa(i), gjson.GetBytes(retryBody, "input."+strconv.Itoa(i)+".call_id").String(),
"unrelated fields must survive")
}
require.Equal(t, "completed", gjson.GetBytes(retryBody, "input.10.status").String(),
"a different item type keeps its status: the rejection only proves this type has none")
}
// The rejected item may carry no type to match on.
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyClearsUntypedStatusAtIndexOnly(t *testing.T) {
body := []byte(`{"input":[{"status":"keep_a"},{"status":"remove"}]}`)
responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].status'.","param":"input[1].status"}}`)
retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "keep_a", gjson.GetBytes(retryBody, "input.0.status").String())
require.False(t, gjson.GetBytes(retryBody, "input.1.status").Exists())
}