feat(relay):新增token估算接口

This commit is contained in:
chengqian committed 2026-09-09 14:33:59 +08:00
1 parent bc42e00ac9
commit 755868a331
4 files changed
+345

No files matched your search

+1
View File
@@ -531,6 +531,7 @@ func registerRelayRoutes(server *ghttp.Server) {
group.POST("/responses/{id}/cancel", relay.HandleResponsesCancel)
group.DELETE("/responses/{id}", relay.HandleResponsesDelete)
group.POST("/messages", relay.HandleMessages)
group.POST("/messages/count_tokens", relay.HandleCountTokens)
group.POST("/audio/speech", relay.HandleAudioSpeech)
group.POST("/audio/transcriptions", relay.HandleAudioTranscription)
group.POST("/audio/translations", relay.HandleAudioTranslation)
+32
View File
@@ -236,6 +236,38 @@ func HandleMessages(r *ghttp.Request) {
recordAudit(r, rc, capture, body, "/messages", latencyMs, firstTokenMs(billingResult))
}
// HandleCountTokens 处理 /v1/messages/count_tokens(Claude 原生格式)
// 本地估算输入 token 数:不请求上游、不计费,仅返回 {"input_tokens": N} 供客户端做上下文窗口管理参考。
func HandleCountTokens(r *ghttp.Request) {
body := r.GetBody()
if len(body) == 0 {
writeClaudeCountTokensError(r, http.StatusBadRequest, "request body is empty")
return
}
monitor.RecordBytesIn(len(body))
tokens, err := handler.CountClaudeTokens(body)
if err != nil {
writeClaudeCountTokensError(r, http.StatusBadRequest, err.Error())
return
}
r.Response.WriteJsonExit(g.Map{"input_tokens": tokens})
}
// writeClaudeCountTokensError 以 Claude 原生错误格式回写 count_tokens 的校验错误。
func writeClaudeCountTokensError(r *ghttp.Request, status int, message string) {
r.Response.WriteHeader(status)
r.Response.WriteJsonExit(g.Map{
"type": "error",
"error": g.Map{
"type": "invalid_request_error",
"message": message,
},
})
}
// HandleGeminiGenerateContent 处理 /v1beta/models/{model}:generateContent(Gemini 原生格式)
func HandleGeminiGenerateContent(r *ghttp.Request) {
g.Log().Debugf(r.Context(), "[HandleGeminiGenerateContent] Entry: method=%s, path=%s, uri=%s",
+221
View File
@@ -0,0 +1,221 @@
package handler
import (
"encoding/json"
"errors"
"math"
"strings"
"github.com/qianfree/team-api/relay/dto"
)
// count_tokens 端点的本地估算参数。
//
// 说明:本平台不内置精确分词器(tiktoken 等),POST /v1/messages/count_tokens
// 采用「本地粗略估算」策略——不请求上游、不计费,仅供客户端做上下文窗口管理的近似参考,
// 返回值不保证与上游精确计数一致。估算按字符类别加权:CJK 表意文字 token 密度远高于拉丁字母。
const (
latinTokenWeight = 0.25 // 拉丁字母/数字/空白/标点:约 4 字符/token
cjkTokenWeight = 0.6 // CJK 表意文字:约 1.67 字符/token
claudeImageTokens = 1600 // 单张图片/文档的粗略 token 估值(无法本地解码,按固定值计)
claudeMessageOverhead = 3 // 每条消息的结构化开销
claudeToolOverhead = 8 // 每个工具定义的结构化开销
claudeRequestOverhead = 8 // 请求整体固定开销(system 包装、消息分隔等)
)
// errCountTokensInvalid 表示 count_tokens 请求体校验失败,调用方统一映射为 400 invalid_request_error。
var errCountTokensInvalid = errors.New("invalid count_tokens request")
// ctRequest count_tokens 的宽松解析结构:content / system / input_schema 保留为 RawMessage,
// 便于区分「字符串」与「内容块数组」两种形态后分别估算。
type ctRequest struct {
Model string `json:"model"`
System json.RawMessage `json:"system"`
Messages []ctMessage `json:"messages"`
Tools []ctTool `json:"tools"`
}
type ctMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}
type ctTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
}
// CountClaudeTokens 本地估算 Claude Messages 请求的输入 token 数。
// 返回 (input_tokens, error);error 非 nil 时均为请求体校验错误。
func CountClaudeTokens(body []byte) (int, error) {
var req ctRequest
if err := json.Unmarshal(body, &req); err != nil {
return 0, errWithMessage("invalid request body")
}
if strings.TrimSpace(req.Model) == "" {
return 0, errWithMessage("model is required")
}
if len(req.Messages) == 0 {
return 0, errWithMessage("messages is required")
}
tokens := claudeRequestOverhead
// system:string 或 []ClaudeContentBlock
tokens += estimateContentTokens(req.System)
// messages
for _, msg := range req.Messages {
tokens += estimateContentTokens(msg.Content)
tokens += claudeMessageOverhead
}
// tools:名称 + 描述 + input_schema(JSON 全文计入)
for _, tool := range req.Tools {
tokens += estimateTextTokens(tool.Name)
tokens += estimateTextTokens(tool.Description)
tokens += estimateRawTokens(tool.InputSchema)
tokens += claudeToolOverhead
}
return tokens, nil
}
// estimateContentTokens 估算 message.content / system 的 token 数,兼容字符串与内容块数组两种形态。
func estimateContentTokens(raw json.RawMessage) int {
trimmed := bytesTrimSpace(raw)
if len(trimmed) == 0 || string(trimmed) == "null" {
return 0
}
// 形态一:纯字符串
var s string
if json.Unmarshal(trimmed, &s) == nil {
return estimateTextTokens(s)
}
// 形态二:内容块数组
var blocks []dto.ClaudeContentBlock
if json.Unmarshal(trimmed, &blocks) == nil {
total := 0
for i := range blocks {
total += estimateBlockTokens(&blocks[i])
}
return total
}
// 兜底:无法结构化解析时,按原始 JSON 文本估算,避免漏计
return estimateRawTokens(trimmed)
}
// estimateBlockTokens 估算单个内容块的 token 数(text / thinking / tool_use / tool_result / image 等)。
func estimateBlockTokens(b *dto.ClaudeContentBlock) int {
total := 0
if b.Text != nil {
total += estimateTextTokens(*b.Text)
}
if b.Thinking != nil {
total += estimateTextTokens(*b.Thinking)
}
if b.Name != "" {
total += estimateTextTokens(b.Name)
}
if b.Input != nil {
total += estimateAnyTokens(b.Input) // tool_use 的入参
}
if b.Content != nil {
total += estimateAnyContent(b.Content) // tool_result 的嵌套内容(string 或 []block)
}
if b.Source != nil {
total += claudeImageTokens // 图片/文档:固定估值
}
return total
}
// estimateAnyContent 估算 tool_result.content:可能是字符串,也可能是内容块数组。
func estimateAnyContent(v any) int {
if v == nil {
return 0
}
if s, ok := v.(string); ok {
return estimateTextTokens(s)
}
raw, err := json.Marshal(v)
if err != nil {
return 0
}
return estimateContentTokens(raw)
}
// estimateAnyTokens 将任意结构(tool_use 入参等)序列化为 JSON 后按文本估算。
func estimateAnyTokens(v any) int {
if v == nil {
return 0
}
raw, err := json.Marshal(v)
if err != nil {
return 0
}
return estimateTextTokens(string(raw))
}
// estimateRawTokens 按原始 JSON 字节的文本估算(input_schema 等已是 JSON 的场景)。
func estimateRawTokens(raw json.RawMessage) int {
if len(raw) == 0 {
return 0
}
return estimateTextTokens(string(raw))
}
// estimateTextTokens 按字符类别粗略估算文本 token 数:CJK 表意文字权重高,其余字符权重低。
func estimateTextTokens(text string) int {
if text == "" {
return 0
}
var weighted float64
for _, r := range text {
if isCJK(r) {
weighted += cjkTokenWeight
} else {
weighted += latinTokenWeight
}
}
return int(math.Ceil(weighted))
}
// isCJK 判断字符是否属于常见 CJK 表意文字区段(中日韩统一表意文字及扩展、假名、谚文等)。
func isCJK(r rune) bool {
switch {
case r >= 0x4E00 && r <= 0x9FFF, // CJK 统一表意文字
r >= 0x3400 && r <= 0x4DBF, // CJK 扩展 A
r >= 0x20000 && r <= 0x2A6DF, // CJK 扩展 B
r >= 0xF900 && r <= 0xFAFF, // CJK 兼容表意文字
r >= 0x3040 && r <= 0x30FF, // 平假名 + 片假名
r >= 0xAC00 && r <= 0xD7AF, // 谚文音节
r >= 0x3000 && r <= 0x303F: // CJK 符号和标点
return true
}
return false
}
// bytesTrimSpace 去除 RawMessage 两端的 JSON 空白字符。
func bytesTrimSpace(b []byte) []byte {
return []byte(strings.TrimSpace(string(b)))
}
// errWithMessage 构造 count_tokens 校验错误,消息文本即返回给客户端的错误信息。
func errWithMessage(msg string) error {
return &countTokensError{message: msg}
}
// countTokensError 携带面向客户端的错误信息,供 handler 以 Claude 原生格式回写。
type countTokensError struct {
message string
}
func (e *countTokensError) Error() string { return e.message }
func (e *countTokensError) Is(target error) bool {
return target == errCountTokensInvalid
}
+91
View File
@@ -0,0 +1,91 @@
package handler
import (
"errors"
"testing"
)
func TestCountClaudeTokens_Basic(t *testing.T) {
body := []byte(`{
"model": "claude-3-5-sonnet",
"messages": [{"role": "user", "content": "count this prompt please"}],
"tools": [{"name": "lookup", "description": "Look up a value", "input_schema": {"type": "object", "properties": {"query": {"type": "string"}}}}]
}`)
tokens, err := CountClaudeTokens(body)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tokens <= 0 {
t.Fatalf("expected positive input_tokens, got %d", tokens)
}
}
func TestCountClaudeTokens_MissingMessages(t *testing.T) {
body := []byte(`{"model": "claude-3-5-sonnet"}`)
_, err := CountClaudeTokens(body)
if err == nil {
t.Fatal("expected error for missing messages")
}
if !errors.Is(err, errCountTokensInvalid) {
t.Fatalf("expected errCountTokensInvalid, got %v", err)
}
if err.Error() != "messages is required" {
t.Fatalf("unexpected message: %q", err.Error())
}
}
func TestCountClaudeTokens_MissingModel(t *testing.T) {
body := []byte(`{"messages": [{"role": "user", "content": "hi"}]}`)
_, err := CountClaudeTokens(body)
if err == nil || err.Error() != "model is required" {
t.Fatalf("expected model required error, got %v", err)
}
}
func TestCountClaudeTokens_InvalidBody(t *testing.T) {
_, err := CountClaudeTokens([]byte(`not json`))
if err == nil || !errors.Is(err, errCountTokensInvalid) {
t.Fatalf("expected invalid request error, got %v", err)
}
}
func TestCountClaudeTokens_ContentBlocks(t *testing.T) {
// 内容块数组(含 text + image)应比同长纯文本计入更多 token(图片固定估值)
body := []byte(`{
"model": "claude-3-5-sonnet",
"messages": [{"role": "user", "content": [
{"type": "text", "text": "describe this"},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "aGVsbG8="}}
]}]
}`)
tokens, err := CountClaudeTokens(body)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tokens < claudeImageTokens {
t.Fatalf("expected image tokens to dominate, got %d", tokens)
}
}
func TestCountClaudeTokens_SystemAndCJK(t *testing.T) {
// system 文本应计入;CJK 文本 token 密度高于等长拉丁文本
latin := []byte(`{"model":"m","system":"aaaaaaaaaa","messages":[{"role":"user","content":"x"}]}`)
cjk := []byte(`{"model":"m","system":"你好世界你好世界你好","messages":[{"role":"user","content":"x"}]}`)
lt, err := CountClaudeTokens(latin)
if err != nil {
t.Fatalf("latin err: %v", err)
}
ct, err := CountClaudeTokens(cjk)
if err != nil {
t.Fatalf("cjk err: %v", err)
}
if ct <= lt {
t.Fatalf("expected CJK (%d) to exceed latin (%d) for equal char count", ct, lt)
}
}
func TestEstimateTextTokens_Empty(t *testing.T) {
if got := estimateTextTokens(""); got != 0 {
t.Fatalf("expected 0 for empty string, got %d", got)
}
}