fix(grok): estimate messages count tokens locally

This commit is contained in:
superman2003
2026-07-19 12:17:23 +08:00
parent d4b9797ff7
commit 1433fbc44d
5 changed files with 155 additions and 27 deletions
@@ -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.
+14 -20
View File
@@ -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)
{
+21 -7
View File
@@ -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",
@@ -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(
@@ -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