mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Merge pull request #5277 from Pluviobyte/agent/fix-grok-missing-usage-billing
fix: reject Grok responses without billable usage
This commit is contained in:
@@ -467,6 +467,11 @@ 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)
|
||||
|
||||
@@ -218,7 +218,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)
|
||||
@@ -420,6 +420,7 @@ func extractCCStreamUsage(payload string) *OpenAIUsage {
|
||||
func (s *OpenAIGatewayService) bufferRawChatCompletions(
|
||||
c *gin.Context,
|
||||
resp *http.Response,
|
||||
account *Account,
|
||||
originalModel string,
|
||||
billingModel string,
|
||||
upstreamModel string,
|
||||
@@ -446,6 +447,11 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions(
|
||||
if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(respBody); ok {
|
||||
usage = parsedUsage
|
||||
}
|
||||
responseModel := gjson.GetBytes(respBody, "model").String()
|
||||
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 {
|
||||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||||
|
||||
@@ -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,127 @@ 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 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)
|
||||
|
||||
@@ -638,7 +760,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)
|
||||
|
||||
@@ -273,6 +273,47 @@ 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.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())
|
||||
}
|
||||
|
||||
func TestForwardGrokChatViaResponsesCodeBuddyUsesStableConversationHeader(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const conversationID = "codebuddy-session-42"
|
||||
|
||||
@@ -2065,6 +2065,43 @@ 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.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())
|
||||
}
|
||||
|
||||
func TestAccountTestServiceGrokAPIKeyUsesXAIResponses(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"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.OutputTokens > 0 ||
|
||||
usage.CacheCreationInputTokens > 0 ||
|
||||
usage.CacheReadInputTokens > 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 {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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...))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user