mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
fix(grok): estimate messages count tokens locally
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user