From 5c52fa93d5fbfa4fd15b5fe145c38754ef9496cf Mon Sep 17 00:00:00 2001 From: Rain <94058511+Pluviobyte@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:49:25 +0800 Subject: [PATCH 1/3] fix grok missing usage billing bypass --- .../openai_gateway_chat_completions.go | 11 ++++++ .../openai_gateway_chat_completions_raw.go | 14 ++++++- ...penai_gateway_chat_completions_raw_test.go | 2 +- .../openai_gateway_grok_chat_bridge_test.go | 39 +++++++++++++++++++ .../service/openai_gateway_grok_test.go | 35 +++++++++++++++++ .../service/openai_gateway_usage_integrity.go | 16 ++++++++ 6 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 backend/internal/service/openai_gateway_usage_integrity.go diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 171a6c8805..a66489da00 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -463,6 +463,17 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( // When the terminal event has an empty output array, reconstruct from // accumulated delta events so the client receives the full content. acc.SupplementResponseOutput(finalResponse) + if account != nil && account.Platform == PlatformGrok && !hasBillableOpenAIUsage(usage) { + return nil, s.newOpenAIStreamFailoverError( + c, + account, + false, + firstNonEmpty(requestID, resp.Header.Get("xai-request-id")), + nil, + grokMissingUsageMessage, + resp.Header, + ) + } chatResp := apicompat.ResponsesToChatCompletions(finalResponse, originalModel) diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 49cd591829..0aee0fa5f5 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -214,7 +214,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( if clientStream { result, forwardErr = s.streamRawChatCompletions(c, resp, account, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime, len(body)) } else { - result, forwardErr = s.bufferRawChatCompletions(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) + result, forwardErr = s.bufferRawChatCompletions(c, resp, account, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } if result != nil { addOpenAIUsage(&result.Usage, bridgeUsage) @@ -409,6 +409,7 @@ func extractCCStreamUsage(payload string) *OpenAIUsage { func (s *OpenAIGatewayService) bufferRawChatCompletions( c *gin.Context, resp *http.Response, + account *Account, originalModel string, billingModel string, upstreamModel string, @@ -430,6 +431,17 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions( if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(respBody); ok { usage = parsedUsage } + if account != nil && account.Platform == PlatformGrok && !hasBillableOpenAIUsage(usage) { + return nil, s.newOpenAIStreamFailoverError( + c, + account, + false, + firstNonEmpty(requestID, resp.Header.Get("xai-request-id")), + nil, + grokMissingUsageMessage, + resp.Header, + ) + } if s.responseHeaderFilter != nil { responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) diff --git a/backend/internal/service/openai_gateway_chat_completions_raw_test.go b/backend/internal/service/openai_gateway_chat_completions_raw_test.go index 61663e636a..c19066f3fc 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw_test.go @@ -638,7 +638,7 @@ func TestBufferRawChatCompletions_RejectsOversizedResponse(t *testing.T) { svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig()} svc.cfg.Gateway.UpstreamResponseReadMaxBytes = 3 - result, err := svc.bufferRawChatCompletions(c, resp, "gpt-5.4", "gpt-5.4", "gpt-5.4", nil, nil, time.Now()) + result, err := svc.bufferRawChatCompletions(c, resp, rawChatCompletionsTestAccount(), "gpt-5.4", "gpt-5.4", "gpt-5.4", nil, nil, time.Now()) require.ErrorIs(t, err, ErrUpstreamResponseBodyTooLarge) require.Nil(t, result) require.Equal(t, http.StatusBadGateway, rec.Code) diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go index 06f434341a..abb21906aa 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go @@ -273,6 +273,45 @@ func TestForwardGrokChatViaResponsesNonStreamingCachesAndReturnsChat(t *testing. require.NotNil(t, repo.updates[account.ID][grokQuotaSnapshotExtraKey]) } +func TestForwardGrokChatViaResponsesNonStreamingRejectsCompletedResponseWithoutUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"grok","messages":[{"role":"user","content":"hi"}],"stream":false,"prompt_cache_key":"stable-session"}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, grokChatRawEndpoint, bytes.NewReader(body)) + c.Set("api_key", &APIKey{ID: 7102}) + + account := grokChatBridgeTestAccount(72) + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }} + upstreamBody := strings.Join([]string{ + `data: {"type":"response.output_text.delta","sequence_number":0,"delta":"ok"}`, + "", + `data: {"type":"response.completed","sequence_number":1,"response":{"id":"resp_missing_usage","object":"response","model":"grok-4.5","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}]}}`, + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + accountRepo: repo, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.False(t, c.Writer.Written(), "an unbillable response must not be committed to the client") + require.Empty(t, recorder.Body.String()) +} + func TestForwardGrokChatViaResponsesCodeBuddyUsesStableConversationHeader(t *testing.T) { gin.SetMode(gin.TestMode) const conversationID = "codebuddy-session-42" diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index d087bd5184..24f87fed40 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -1937,6 +1937,41 @@ func TestForwardAsChatCompletionsForGrokAPIKeyUsesConfiguredRawEndpointWithoutOA require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent")) } +func TestForwardAsChatCompletionsForGrokAPIKeyRejectsNonStreamingResponseWithoutUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + body := []byte(`{"model":"grok","messages":[{"role":"user","content":"hi"}],"stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + account := &Account{ + ID: 707, + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "third-party-key", + "base_url": "https://grok.example.test/v1", + }, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_missing_usage","object":"chat.completion","model":"grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`, + )), + }} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.False(t, c.Writer.Written(), "an unbillable response must not be committed to the client") + require.Empty(t, recorder.Body.String()) +} + func TestAccountTestServiceGrokAPIKeyUsesXAIResponses(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_gateway_usage_integrity.go b/backend/internal/service/openai_gateway_usage_integrity.go new file mode 100644 index 0000000000..c1b9e1523b --- /dev/null +++ b/backend/internal/service/openai_gateway_usage_integrity.go @@ -0,0 +1,16 @@ +package service + +const grokMissingUsageMessage = "Grok upstream returned a successful response without billable usage" + +// hasBillableOpenAIUsage distinguishes a usable accounting result from the +// zero value produced when an upstream omits usage entirely. A successful Grok +// completion without any positive usage cannot be safely charged, so callers +// must fail over before committing the response to the client. +func hasBillableOpenAIUsage(usage OpenAIUsage) bool { + return usage.InputTokens > 0 || + usage.ImageInputTokens > 0 || + usage.OutputTokens > 0 || + usage.CacheCreationInputTokens > 0 || + usage.CacheReadInputTokens > 0 || + usage.ImageOutputTokens > 0 +} From ba92d704225256aa6975967bee6480f8abc4c8bb Mon Sep 17 00:00:00 2001 From: Rain <94058511+Pluviobyte@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:59:20 +0800 Subject: [PATCH 2/3] fix grok usage guard for compatible accounts --- .../openai_gateway_chat_completions.go | 2 +- .../openai_gateway_chat_completions_raw.go | 3 +- ...penai_gateway_chat_completions_raw_test.go | 36 ++++++++++++ .../service/openai_gateway_usage_integrity.go | 16 +++++ .../openai_gateway_usage_integrity_test.go | 58 +++++++++++++++++++ 5 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 backend/internal/service/openai_gateway_usage_integrity_test.go diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index a66489da00..ddf9a288a8 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -463,7 +463,7 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( // When the terminal event has an empty output array, reconstruct from // accumulated delta events so the client receives the full content. acc.SupplementResponseOutput(finalResponse) - if account != nil && account.Platform == PlatformGrok && !hasBillableOpenAIUsage(usage) { + if requiresBillableGrokChatUsage(account, originalModel, billingModel, upstreamModel, finalResponse.Model) && !hasBillableOpenAIUsage(usage) { return nil, s.newOpenAIStreamFailoverError( c, account, diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 0aee0fa5f5..cdafc30d1b 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -431,7 +431,8 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions( if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(respBody); ok { usage = parsedUsage } - if account != nil && account.Platform == PlatformGrok && !hasBillableOpenAIUsage(usage) { + responseModel := gjson.GetBytes(respBody, "model").String() + if requiresBillableGrokChatUsage(account, originalModel, billingModel, upstreamModel, responseModel) && !hasBillableOpenAIUsage(usage) { return nil, s.newOpenAIStreamFailoverError( c, account, diff --git a/backend/internal/service/openai_gateway_chat_completions_raw_test.go b/backend/internal/service/openai_gateway_chat_completions_raw_test.go index c19066f3fc..0a9741889c 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" @@ -122,6 +123,41 @@ func TestForwardAsRawChatCompletions_ForcesStreamUsageUpstreamAndPassesUsageDown require.Contains(t, rec.Body.String(), "data: [DONE]") } +func TestForwardAsChatCompletions_OpenAICompatibleGrokRawMissingUsageFailsBeforeWrite(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"grok-4.5","messages":[{"role":"user","content":"hello"}],"stream":false}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Request-Id": []string{"rid-openai-compat-grok-no-usage"}, + }, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_missing_usage","object":"chat.completion","model":"grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`, + )), + }} + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + account := rawChatCompletionsTestAccount() + account.Name = "openai-compatible-grok" + account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: false} + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.False(t, c.Writer.Written(), "unbilled Grok content must not be returned by an OpenAI-compatible account") + require.Empty(t, recorder.Body.String()) +} + func TestForwardAsRawChatCompletions_PreservesMappedGPT56MaxEffort(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_gateway_usage_integrity.go b/backend/internal/service/openai_gateway_usage_integrity.go index c1b9e1523b..a70357cc6f 100644 --- a/backend/internal/service/openai_gateway_usage_integrity.go +++ b/backend/internal/service/openai_gateway_usage_integrity.go @@ -14,3 +14,19 @@ func hasBillableOpenAIUsage(usage OpenAIUsage) bool { usage.CacheReadInputTokens > 0 || usage.ImageOutputTokens > 0 } + +// requiresBillableGrokChatUsage identifies Grok traffic by both account +// platform and model identity. Grok models may be served through generic +// OpenAI-compatible accounts, so account.Platform alone is not a safe billing +// boundary. +func requiresBillableGrokChatUsage(account *Account, models ...string) bool { + if account != nil && account.Platform == PlatformGrok { + return true + } + for _, model := range models { + if platform, ok := DetectModelPlatform(model); ok && platform == PlatformGrok { + return true + } + } + return false +} diff --git a/backend/internal/service/openai_gateway_usage_integrity_test.go b/backend/internal/service/openai_gateway_usage_integrity_test.go new file mode 100644 index 0000000000..3a1dc441f9 --- /dev/null +++ b/backend/internal/service/openai_gateway_usage_integrity_test.go @@ -0,0 +1,58 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRequiresBillableGrokChatUsage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + account *Account + models []string + want bool + }{ + { + name: "grok platform", + account: &Account{Platform: PlatformGrok}, + models: []string{"alias"}, + want: true, + }, + { + name: "OpenAI-compatible requested Grok model", + account: &Account{Platform: PlatformOpenAI}, + models: []string{"grok-4.5"}, + want: true, + }, + { + name: "OpenAI-compatible mapped Grok model", + account: &Account{Platform: PlatformOpenAI}, + models: []string{"alias", "grok-4.5"}, + want: true, + }, + { + name: "xAI-qualified Grok model", + account: &Account{Platform: PlatformOpenAI}, + models: []string{"xai/grok-4.5"}, + want: true, + }, + { + name: "ordinary OpenAI model", + account: &Account{Platform: PlatformOpenAI}, + models: []string{"gpt-5.4"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, requiresBillableGrokChatUsage(tt.account, tt.models...)) + }) + } +} From 8ea68bd689664ede8e6f658dfb49296e9784c884 Mon Sep 17 00:00:00 2001 From: Rain <94058511+Pluviobyte@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:36:34 +0800 Subject: [PATCH 3/3] fix grok chat usage guard parity --- .../openai_gateway_chat_completions.go | 16 ++-- .../openai_gateway_chat_completions_raw.go | 13 +-- ...penai_gateway_chat_completions_raw_test.go | 86 +++++++++++++++++++ .../openai_gateway_grok_chat_bridge_test.go | 2 + .../service/openai_gateway_grok_test.go | 2 + .../service/openai_gateway_usage_integrity.go | 69 ++++++++++++--- 6 files changed, 157 insertions(+), 31 deletions(-) diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index ddf9a288a8..d2533e486d 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -460,20 +460,14 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( return nil, fmt.Errorf("upstream response failed: %s", message) } + if requiresBillableGrokChatUsage(account, billingModel, upstreamModel, finalResponse.Model) && !hasBillableGrokChatUsage(usage) { + upstreamRequestID := firstNonEmpty(requestID, resp.Header.Get("xai-request-id")) + return nil, newGrokMissingUsageFailoverError(c, account, upstreamRequestID) + } + // When the terminal event has an empty output array, reconstruct from // accumulated delta events so the client receives the full content. acc.SupplementResponseOutput(finalResponse) - if requiresBillableGrokChatUsage(account, originalModel, billingModel, upstreamModel, finalResponse.Model) && !hasBillableOpenAIUsage(usage) { - return nil, s.newOpenAIStreamFailoverError( - c, - account, - false, - firstNonEmpty(requestID, resp.Header.Get("xai-request-id")), - nil, - grokMissingUsageMessage, - resp.Header, - ) - } chatResp := apicompat.ResponsesToChatCompletions(finalResponse, originalModel) diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index cdafc30d1b..81ef3ac39a 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -432,16 +432,9 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions( usage = parsedUsage } responseModel := gjson.GetBytes(respBody, "model").String() - if requiresBillableGrokChatUsage(account, originalModel, billingModel, upstreamModel, responseModel) && !hasBillableOpenAIUsage(usage) { - return nil, s.newOpenAIStreamFailoverError( - c, - account, - false, - firstNonEmpty(requestID, resp.Header.Get("xai-request-id")), - nil, - grokMissingUsageMessage, - resp.Header, - ) + if requiresBillableGrokChatUsage(account, billingModel, upstreamModel, responseModel) && !hasBillableGrokChatUsage(usage) { + upstreamRequestID := firstNonEmpty(requestID, resp.Header.Get("xai-request-id")) + return nil, newGrokMissingUsageFailoverError(c, account, upstreamRequestID) } if s.responseHeaderFilter != nil { diff --git a/backend/internal/service/openai_gateway_chat_completions_raw_test.go b/backend/internal/service/openai_gateway_chat_completions_raw_test.go index 0a9741889c..a80234913a 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw_test.go @@ -158,6 +158,92 @@ func TestForwardAsChatCompletions_OpenAICompatibleGrokRawMissingUsageFailsBefore require.Empty(t, recorder.Body.String()) } +func TestForwardAsChatCompletions_OpenAICompatibleRawUsageGuard(t *testing.T) { + tests := []struct { + name string + model string + upstreamResponse string + modelMapping map[string]any + wantGuarded bool + }{ + { + name: "Grok response without usage", + model: "grok-4.5", + upstreamResponse: `{"id":"resp_missing","object":"chat.completion","model":"grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`, + wantGuarded: true, + }, + { + name: "namespaced Grok response without usage", + model: "x-ai/grok-4.5", + upstreamResponse: `{"id":"resp_namespaced","object":"chat.completion","model":"x-ai/grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`, + wantGuarded: true, + }, + { + name: "Grok response with aggregate usage passes", + model: "grok-4.5", + upstreamResponse: `{"id":"resp_usage","object":"chat.completion","model":"grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":3,"total_tokens":12}}`, + wantGuarded: false, + }, + { + name: "Grok alias mapped to non-Grok remains unchanged", + model: "grok-alias", + upstreamResponse: `{"id":"resp_mapped","object":"chat.completion","model":"gpt-5.4","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`, + modelMapping: map[string]any{"grok-alias": "gpt-5.4"}, + wantGuarded: false, + }, + { + name: "Grok response with detail-only usage", + model: "grok-4.5", + upstreamResponse: `{"id":"resp_detail_only","object":"chat.completion","model":"grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}],"usage":{"input_tokens_details":{"text_tokens":9,"image_tokens":2},"output_tokens_details":{"image_tokens":1}}}`, + wantGuarded: true, + }, + { + name: "non-Grok response without usage remains unchanged", + model: "gpt-5.4", + upstreamResponse: `{"id":"resp_openai","object":"chat.completion","model":"gpt-5.4","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`, + wantGuarded: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"` + tt.model + `","messages":[{"role":"user","content":"hello"}],"stream":false}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "X-Request-Id": []string{"rid-openai-compatible"}}, + Body: io.NopCloser(strings.NewReader(tt.upstreamResponse)), + }} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := rawChatCompletionsTestAccount() + account.Name = "openai-compatible" + account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: false} + if tt.modelMapping != nil { + account.Credentials["model_mapping"] = tt.modelMapping + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + + if !tt.wantGuarded { + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, c.Writer.Written()) + return + } + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + require.Equal(t, "grok_missing_usage", gjson.GetBytes(failoverErr.ResponseBody, "error.code").String()) + require.False(t, c.Writer.Written(), "unbilled Grok content must not be returned") + }) + } +} + func TestForwardAsRawChatCompletions_PreservesMappedGPT56MaxEffort(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go index abb21906aa..6c4137d9d8 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go @@ -308,6 +308,8 @@ func TestForwardGrokChatViaResponsesNonStreamingRejectsCompletedResponseWithoutU require.Nil(t, result) var failoverErr *UpstreamFailoverError require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + require.Equal(t, grokMissingUsageErrorCode, gjson.GetBytes(failoverErr.ResponseBody, "error.code").String()) require.False(t, c.Writer.Written(), "an unbillable response must not be committed to the client") require.Empty(t, recorder.Body.String()) } diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 24f87fed40..8bfeeb9edb 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -1968,6 +1968,8 @@ func TestForwardAsChatCompletionsForGrokAPIKeyRejectsNonStreamingResponseWithout require.Nil(t, result) var failoverErr *UpstreamFailoverError require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + require.Equal(t, grokMissingUsageErrorCode, gjson.GetBytes(failoverErr.ResponseBody, "error.code").String()) require.False(t, c.Writer.Written(), "an unbillable response must not be committed to the client") require.Empty(t, recorder.Body.String()) } diff --git a/backend/internal/service/openai_gateway_usage_integrity.go b/backend/internal/service/openai_gateway_usage_integrity.go index a70357cc6f..e0344d4f44 100644 --- a/backend/internal/service/openai_gateway_usage_integrity.go +++ b/backend/internal/service/openai_gateway_usage_integrity.go @@ -1,18 +1,26 @@ package service -const grokMissingUsageMessage = "Grok upstream returned a successful response without billable usage" +import ( + "encoding/json" + "net/http" + "strings" -// hasBillableOpenAIUsage distinguishes a usable accounting result from the -// zero value produced when an upstream omits usage entirely. A successful Grok -// completion without any positive usage cannot be safely charged, so callers -// must fail over before committing the response to the client. -func hasBillableOpenAIUsage(usage OpenAIUsage) bool { + "github.com/gin-gonic/gin" +) + +const ( + grokMissingUsageErrorCode = "grok_missing_usage" + grokMissingUsageMessage = "xAI upstream returned a successful chat completion without billable usage" +) + +// hasBillableGrokChatUsage stays aligned with the aggregate token buckets used +// to account for chat completions. Detail fields alone do not prove that the +// successful response can be settled safely. +func hasBillableGrokChatUsage(usage OpenAIUsage) bool { return usage.InputTokens > 0 || - usage.ImageInputTokens > 0 || usage.OutputTokens > 0 || usage.CacheCreationInputTokens > 0 || - usage.CacheReadInputTokens > 0 || - usage.ImageOutputTokens > 0 + usage.CacheReadInputTokens > 0 } // requiresBillableGrokChatUsage identifies Grok traffic by both account @@ -24,9 +32,50 @@ func requiresBillableGrokChatUsage(account *Account, models ...string) bool { return true } for _, model := range models { - if platform, ok := DetectModelPlatform(model); ok && platform == PlatformGrok { + normalized := strings.ToLower(strings.TrimSpace(model)) + if separator := strings.LastIndex(normalized, "/"); separator >= 0 { + normalized = strings.TrimSpace(normalized[separator+1:]) + } + if normalized == "grok" || strings.HasPrefix(normalized, "grok-") { return true } } return false } + +func newGrokMissingUsageFailoverError(c *gin.Context, account *Account, upstreamRequestID string) *UpstreamFailoverError { + accountID := int64(0) + accountName := "" + if account != nil { + accountID = account.ID + accountName = account.Name + } + + setOpsUpstreamError(c, http.StatusBadGateway, grokMissingUsageMessage, "") + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: PlatformGrok, + AccountID: accountID, + AccountName: accountName, + UpstreamStatusCode: http.StatusBadGateway, + UpstreamRequestID: strings.TrimSpace(upstreamRequestID), + Kind: "failover", + Message: grokMissingUsageMessage, + }) + + body, _ := json.Marshal(gin.H{ + "error": gin.H{ + "type": "upstream_error", + "code": grokMissingUsageErrorCode, + "message": grokMissingUsageMessage, + }, + }) + headers := http.Header{} + if requestID := strings.TrimSpace(upstreamRequestID); requestID != "" { + headers.Set("x-request-id", requestID) + } + return &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + ResponseBody: body, + ResponseHeaders: headers, + } +}