mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3af5443b22 | ||
|
|
7ba3e1ac52 | ||
|
|
9fb260439f | ||
|
|
07931bbb18 | ||
|
|
2307aa5ca7 | ||
|
|
1563db3f82 | ||
|
|
e440ac48c7 | ||
|
|
03e8ab4134 | ||
|
|
19da0f240e |
@@ -1 +1 @@
|
||||
0.1.179
|
||||
0.1.180
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user