From 1433fbc44d378cef3d4757d8f9b40137de44ecee Mon Sep 17 00:00:00 2001 From: superman2003 <2112076433zcr@gmail.com> Date: Sun, 19 Jul 2026 12:17:23 +0800 Subject: [PATCH] fix(grok): estimate messages count tokens locally --- .../handler/openai_gateway_count_tokens.go | 42 +++++++++++++++++ backend/internal/server/routes/gateway.go | 34 ++++++-------- .../internal/server/routes/gateway_test.go | 28 +++++++++--- .../service/openai_gateway_count_tokens.go | 33 ++++++++++++++ .../openai_gateway_count_tokens_test.go | 45 +++++++++++++++++++ 5 files changed, 155 insertions(+), 27 deletions(-) diff --git a/backend/internal/handler/openai_gateway_count_tokens.go b/backend/internal/handler/openai_gateway_count_tokens.go index a9c2710b9..bfbd2d72a 100644 --- a/backend/internal/handler/openai_gateway_count_tokens.go +++ b/backend/internal/handler/openai_gateway_count_tokens.go @@ -13,6 +13,48 @@ import ( "go.uber.org/zap" ) +// GrokCountTokens handles Anthropic-compatible count_tokens requests locally. +// The route middleware already authenticates the API key and resolves the +// group; this handler intentionally does not select an account or check billing. +func (h *OpenAIGatewayHandler) GrokCountTokens(c *gin.Context) { + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) + if err != nil { + if maxErr, ok := extractMaxBytesError(err); ok { + h.anthropicErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) + return + } + h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + return + } + if len(body) == 0 { + h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty") + return + } + + bodyRef := service.NewRequestBodyRef(body) + parsedReq, err := service.ParseGatewayRequest(bodyRef, domain.PlatformAnthropic) + if err != nil { + logRequestBodyParseFailure(requestLogger(c, "handler.openai_gateway.grok_count_tokens"), body, err) + h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") + return + } + if parsedReq.Model == "" { + h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required") + return + } + + estimated, err := service.EstimateGrokCountTokens(parsedReq.Body.Bytes()) + if err != nil { + requestLogger(c, "handler.openai_gateway.grok_count_tokens").Warn("grok_count_tokens.local_estimate_failed", zap.Error(err)) + h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") + return + } + + setOpsRequestContext(c, parsedReq.Model, false) + setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(false, false))) + c.JSON(http.StatusOK, gin.H{"input_tokens": estimated}) +} + // CountTokens handles Anthropic-compatible POST /v1/messages/count_tokens for OpenAI groups. // It validates billing and routes to an OpenAI token-count bridge without taking concurrency slots // or recording usage. diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 07b8f1ebd..8e14f1d5a 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -43,6 +43,16 @@ func RegisterGatewayRoutes( isOpenAIGatewayPlatform := func(c *gin.Context) bool { return getGroupPlatform(c) == service.PlatformOpenAI } + countTokensHandler := func(c *gin.Context) { + switch getGroupPlatform(c) { + case service.PlatformOpenAI: + h.OpenAIGateway.CountTokens(c) + case service.PlatformGrok: + h.OpenAIGateway.GrokCountTokens(c) + default: + h.Gateway.CountTokens(c) + } + } modelsHandler := func(c *gin.Context) { if isOpenAIGatewayPlatform(c) && c.Query("client_version") != "" { h.OpenAIGateway.CodexModels(c) @@ -139,26 +149,9 @@ func RegisterGatewayRoutes( } h.Gateway.Messages(c) }) - // /v1/messages/count_tokens: OpenAI uses Anthropic-compat bridge; other - // OpenAI-compatible platforms keep the prior unsupported response. - gateway.POST("/messages/count_tokens", func(c *gin.Context) { - if isOpenAIGatewayPlatform(c) { - h.OpenAIGateway.CountTokens(c) - return - } - if isOpenAIResponsesCompatibleGatewayPlatform(c) { - service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) - c.JSON(http.StatusNotFound, gin.H{ - "type": "error", - "error": gin.H{ - "type": "not_found_error", - "message": "Token counting is not supported for this platform", - }, - }) - return - } - h.Gateway.CountTokens(c) - }) + // /v1/messages/count_tokens: OpenAI bridges upstream, Grok estimates + // locally, and Anthropic-compatible platforms retain their existing path. + gateway.POST("/messages/count_tokens", countTokensHandler) // Codex CLI / Codex app refresh their model picker from the provider's // /models endpoint with a client_version query and expect the ChatGPT // Codex manifest format; other clients keep the OpenAI-style list. @@ -256,6 +249,7 @@ func RegisterGatewayRoutes( h.OpenAIGateway.ResponsesWebSocket(c) }) r.GET("/models", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, modelsHandler) + r.POST("/messages/count_tokens", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, countTokensHandler) codexDirect := r.Group("/backend-api/codex") codexDirect.Use(bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic) { diff --git a/backend/internal/server/routes/gateway_test.go b/backend/internal/server/routes/gateway_test.go index 80e319db7..43a43ff78 100644 --- a/backend/internal/server/routes/gateway_test.go +++ b/backend/internal/server/routes/gateway_test.go @@ -1,6 +1,7 @@ package routes import ( + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -15,6 +16,10 @@ import ( ) func newGatewayRoutesTestRouter(platform ...string) *gin.Engine { + return newGatewayRoutesTestRouterWithConfig(&config.Config{}, platform...) +} + +func newGatewayRoutesTestRouterWithConfig(cfg *config.Config, platform ...string) *gin.Engine { gin.SetMode(gin.TestMode) router := gin.New() @@ -42,7 +47,7 @@ func newGatewayRoutesTestRouter(platform ...string) *gin.Engine { nil, nil, nil, - &config.Config{}, + cfg, ) return router @@ -224,13 +229,22 @@ func TestGatewayRoutesGrokAllowsCLICompatibilityEntrypoints(t *testing.T) { require.NotContains(t, w.Body.String(), "not supported for Grok groups") } - req := httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", strings.NewReader(`{"model":"grok","messages":[{"role":"user","content":"hi"}]}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() + countTokensRouter := newGatewayRoutesTestRouterWithConfig(&config.Config{ + Gateway: config.GatewayConfig{MaxBodySize: 1024 * 1024}, + }, service.PlatformGrok) + for _, path := range []string{"/v1/messages/count_tokens", "/messages/count_tokens"} { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"grok","messages":[{"role":"user","content":"hi"}]}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() - router.ServeHTTP(w, req) - require.Equal(t, http.StatusNotFound, w.Code) - require.Contains(t, w.Body.String(), "Token counting is not supported for this platform") + countTokensRouter.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, "path=%s", path) + var response struct { + InputTokens int `json:"input_tokens"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response), "path=%s", path) + require.Positive(t, response.InputTokens, "path=%s", path) + } for _, path := range []string{ "/v1/responses", diff --git a/backend/internal/service/openai_gateway_count_tokens.go b/backend/internal/service/openai_gateway_count_tokens.go index 46b5a3843..eccd1c414 100644 --- a/backend/internal/service/openai_gateway_count_tokens.go +++ b/backend/internal/service/openai_gateway_count_tokens.go @@ -39,6 +39,39 @@ type openAIInputTokensCountPrepared struct { UpstreamModel string } +// EstimateGrokCountTokens estimates an Anthropic-compatible count_tokens request +// locally. Grok does not expose a compatible token-counting endpoint, so this +// path deliberately avoids account selection, credentials, and upstream calls. +func EstimateGrokCountTokens(body []byte) (int, error) { + var anthropicReq apicompat.AnthropicRequest + if err := json.Unmarshal(body, &anthropicReq); err != nil { + return 0, fmt.Errorf("parse anthropic count_tokens request: %w", err) + } + if strings.TrimSpace(anthropicReq.Model) == "" { + return 0, fmt.Errorf("parse anthropic count_tokens request: model is required") + } + + responsesReq, err := apicompat.AnthropicToResponses(&anthropicReq) + if err != nil { + return 0, fmt.Errorf("convert anthropic request to responses: %w", err) + } + + estimated, err := estimateOpenAIInputTokens(openAIInputTokensCountRequest{ + Model: anthropicReq.Model, + Instructions: responsesReq.Instructions, + Input: responsesReq.Input, + Tools: responsesReq.Tools, + ToolChoice: responsesReq.ToolChoice, + }) + if err != nil { + return 0, fmt.Errorf("estimate grok input tokens: %w", err) + } + if estimated < openAIInputTokensFallbackMinimum { + estimated = openAIInputTokensFallbackMinimum + } + return estimated, nil +} + // ForwardCountTokensAsAnthropic bridges Anthropic /v1/messages/count_tokens to // OpenAI POST /v1/responses/input_tokens and returns Anthropic-compatible output. func (s *OpenAIGatewayService) ForwardCountTokensAsAnthropic( diff --git a/backend/internal/service/openai_gateway_count_tokens_test.go b/backend/internal/service/openai_gateway_count_tokens_test.go index 3ab0178a0..9719c9b3f 100644 --- a/backend/internal/service/openai_gateway_count_tokens_test.go +++ b/backend/internal/service/openai_gateway_count_tokens_test.go @@ -236,6 +236,51 @@ func TestEstimateOpenAIInputTokens_RequestSamples(t *testing.T) { } } +func TestEstimateGrokCountTokens_AnthropicRequests(t *testing.T) { + cases := []struct { + name string + body string + }{ + { + name: "simple message", + body: `{"model":"grok-4","messages":[{"role":"user","content":"hello world"}]}`, + }, + { + name: "system blocks and tools", + body: `{ + "model":"grok-4", + "system":[{"type":"text","text":"You are helpful."}], + "messages":[{"role":"user","content":[{"type":"text","text":"look up the weather"}]}], + "tools":[{"name":"lookup_weather","description":"Look up weather","input_schema":{"type":"object","properties":{"city":{"type":"string"}}}}], + "tool_choice":{"type":"auto"} + }`, + }, + { + name: "empty conversation uses positive minimum", + body: `{"model":"grok-4","messages":[]}`, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + got, err := EstimateGrokCountTokens([]byte(tt.body)) + require.NoError(t, err) + require.Positive(t, got) + }) + } +} + +func TestEstimateGrokCountTokens_RejectsInvalidRequests(t *testing.T) { + for _, body := range []string{ + `{`, + `{"messages":[{"role":"user","content":"hello"}]}`, + `{"model":"grok-4","messages":[{"role":"user","content":{"unexpected":true}}]}`, + } { + _, err := EstimateGrokCountTokens([]byte(body)) + require.Error(t, err, "body=%s", body) + } +} + func TestOpenAIInputTokensEncodingForModel(t *testing.T) { cases := []struct { model string