feat(executor): add Claude input token state handling and token estimation logic

- Introduced `ClaudeInputTokenState` to track and estimate input token usage for Claude message_start events.
- Added `TranslateStreamWithClaudeInputTokens` for token-aware stream translation.
- Updated executors (XAI, Kimi, Gemini) to leverage the new logic.
- Included robust test cases for token counting, JSON validation, and concurrent tokenization scenarios.
This commit is contained in:
Luis Pater
2026-07-22 05:21:14 +08:00
parent cb110ad4fa
commit f3e36f19c0
14 changed files with 858 additions and 27 deletions
+2 -2
View File
@@ -23,7 +23,7 @@ require (
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/tiktoken-go/tokenizer v0.7.0
github.com/tiktoken-go/tokenizer v0.8.1
golang.org/x/crypto v0.54.0
golang.org/x/net v0.57.0
golang.org/x/oauth2 v0.30.0
@@ -35,6 +35,7 @@ require (
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dlclark/regexp2/v2 v2.5.1 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
)
@@ -56,7 +57,6 @@ require (
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+4 -4
View File
@@ -53,8 +53,8 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.5.1 h1:E5Ug7Dh264W1ymdySmiHNcDG7fmsR307APCE5R07a20=
github.com/dlclark/regexp2/v2 v2.5.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
@@ -197,8 +197,8 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tiktoken-go/tokenizer v0.7.0 h1:VMu6MPT0bXFDHr7UPh9uii7CNItVt3X9K90omxL54vw=
github.com/tiktoken-go/tokenizer v0.7.0/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w=
github.com/tiktoken-go/tokenizer v0.8.1 h1:4obDoB6/dhdBt9xMweX4nww5cjdOq/nYF4ecwPq2+mg=
github.com/tiktoken-go/tokenizer v0.8.1/go.mod h1:eLA0t6nGvn9mDc7gt90qt7pMat+gE9ViqwQ6l9B+tA4=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
@@ -291,6 +291,11 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
go func(first wsrelay.StreamEvent) {
defer close(out)
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
originalRequest := opts.OriginalRequest
if len(originalRequest) == 0 {
originalRequest = req.Payload
}
claudeInputTokens := helps.NewClaudeInputTokenState(opts.SourceFormat, body.toFormat, responseFormat, originalRequest)
var param any
metadataLogged := false
processEvent := func(event wsrelay.StreamEvent) bool {
@@ -318,7 +323,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
if detail, ok := helps.ParseGeminiStreamUsage(filtered); ok {
reporter.Publish(ctx, detail)
}
lines := sdktranslator.TranslateStream(ctx, body.toFormat, responseFormat, req.Model, opts.OriginalRequest, translatedReq, filtered, &param)
lines := helps.TranslateStreamWithClaudeInputTokens(ctx, body.toFormat, responseFormat, req.Model, opts.OriginalRequest, translatedReq, filtered, &param, claudeInputTokens)
for i := range lines {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])}:
@@ -340,7 +345,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
reporter.MarkFirstResponseByte()
helps.AppendAPIResponseChunk(ctx, e.cfg, event.Payload)
}
lines := sdktranslator.TranslateStream(ctx, body.toFormat, responseFormat, req.Model, opts.OriginalRequest, translatedReq, event.Payload, &param)
lines := helps.TranslateStreamWithClaudeInputTokens(ctx, body.toFormat, responseFormat, req.Model, opts.OriginalRequest, translatedReq, event.Payload, &param, claudeInputTokens)
for i := range lines {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])}:
@@ -1533,6 +1533,7 @@ attemptLoop:
}()
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(nil, streamScannerBuffer)
claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload)
var param any
for scanner.Scan() {
line := scanner.Bytes()
@@ -1555,7 +1556,7 @@ attemptLoop:
}
payload = e.resolveWebSearchGroundingURLs(ctx, auth, from, originalPayload, translated, payload)
chunks := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, bytes.Clone(payload), &param)
chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, bytes.Clone(payload), &param, claudeInputTokens)
for i := range chunks {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
@@ -1564,7 +1565,7 @@ attemptLoop:
}
}
}
tail := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, []byte("[DONE]"), &param)
tail := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, []byte("[DONE]"), &param, claudeInputTokens)
for i := range tail {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: tail[i]}:
+2 -1
View File
@@ -1487,6 +1487,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
}()
scanner := bufio.NewScanner(httpResp.Body)
scanner.Buffer(nil, 52_428_800) // 50MB
claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload)
var param any
outputItemsByIndex := make(map[int64][]byte)
var outputItemsFallback [][]byte
@@ -1535,7 +1536,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
}
translatedLine = applyCodexIdentityExposeResponsePayload(translatedLine, identityState)
chunks := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, originalPayload, body, translatedLine, &param)
chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, originalPayload, body, translatedLine, &param, claudeInputTokens)
for i := range chunks {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
@@ -761,6 +761,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
}
}
claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload)
var param any
outputItemsByIndex := make(map[int64][]byte)
var outputItemsFallback [][]byte
@@ -880,7 +881,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
eventType = gjson.GetBytes(payload, "type").String()
clientPayload = applyCodexIdentityExposeResponsePayload(payload, identityState)
line := encodeCodexWebsocketAsSSE(clientPayload)
chunks := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, originalPayload, clientBody, line, &param)
chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, originalPayload, clientBody, line, &param, claudeInputTokens)
for i := range chunks {
if !send(cliproxyexecutor.StreamChunk{Payload: chunks[i]}) {
terminateReason = "context_done"
+9 -3
View File
@@ -339,6 +339,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
}()
scanner := bufio.NewScanner(httpResp.Body)
scanner.Buffer(nil, streamScannerBuffer)
claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload)
var param any
for scanner.Scan() {
line := scanner.Bytes()
@@ -351,7 +352,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if detail, ok := helps.ParseGeminiStreamUsage(payload); ok {
reporter.Publish(ctx, detail)
}
lines := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, bytes.Clone(payload), &param)
lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, bytes.Clone(payload), &param, claudeInputTokens)
for i := range lines {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
@@ -360,7 +361,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
}
}
}
lines := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), &param)
lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), &param, claudeInputTokens)
for i := range lines {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
@@ -530,6 +531,11 @@ func (e *GeminiExecutor) executeInteractionsStream(ctx context.Context, auth *cl
}()
scanner := bufio.NewScanner(httpResp.Body)
scanner.Buffer(nil, streamScannerBuffer)
originalRequest := opts.OriginalRequest
if len(originalRequest) == 0 {
originalRequest = req.Payload
}
claudeInputTokens := helps.NewClaudeInputTokenState(opts.SourceFormat, sdktranslator.FormatInteractions, responseFormat, originalRequest)
var param any
var frame []byte
emitFrame := func() bool {
@@ -564,7 +570,7 @@ func (e *GeminiExecutor) executeInteractionsStream(ctx context.Context, auth *cl
return true
}
var lines [][]byte
lines = sdktranslator.TranslateStream(ctx, sdktranslator.FormatInteractions, responseFormat, req.Model, opts.OriginalRequest, body, payload, &param)
lines = helps.TranslateStreamWithClaudeInputTokens(ctx, sdktranslator.FormatInteractions, responseFormat, req.Model, opts.OriginalRequest, body, payload, &param, claudeInputTokens)
for i := range lines {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
@@ -661,6 +661,7 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
}()
scanner := bufio.NewScanner(httpResp.Body)
scanner.Buffer(nil, streamScannerBuffer)
claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload)
var param any
for scanner.Scan() {
line := scanner.Bytes()
@@ -668,7 +669,7 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
if detail, ok := helps.ParseGeminiStreamUsage(line); ok {
reporter.Publish(ctx, detail)
}
lines := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, bytes.Clone(line), &param)
lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, bytes.Clone(line), &param, claudeInputTokens)
for i := range lines {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
@@ -677,7 +678,7 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
}
}
}
lines := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), &param)
lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), &param, claudeInputTokens)
for i := range lines {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
@@ -806,6 +807,7 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
}()
scanner := bufio.NewScanner(httpResp.Body)
scanner.Buffer(nil, streamScannerBuffer)
claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload)
var param any
for scanner.Scan() {
line := scanner.Bytes()
@@ -813,7 +815,7 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
if detail, ok := helps.ParseGeminiStreamUsage(line); ok {
reporter.Publish(ctx, detail)
}
lines := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, bytes.Clone(line), &param)
lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, bytes.Clone(line), &param, claudeInputTokens)
for i := range lines {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
@@ -822,7 +824,7 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
}
}
}
lines := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), &param)
lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), &param, claudeInputTokens)
for i := range lines {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
@@ -0,0 +1,369 @@
package helps
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"sync"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"github.com/tiktoken-go/tokenizer"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)
var (
claudeInputTokenizerOnce sync.Once
claudeInputTokenizerCodec tokenizer.Codec
claudeInputTokenizerErr error
)
// ClaudeInputTokenState tracks the one-time input token update for a translated Claude stream.
type ClaudeInputTokenState struct {
upstreamFormat sdktranslator.Format
responseFormat sdktranslator.Format
originalRequest []byte
codec tokenizer.Codec
handled bool
}
// NewClaudeInputTokenState creates request-scoped state for translated Claude input token usage.
func NewClaudeInputTokenState(sourceFormat, upstreamFormat, responseFormat sdktranslator.Format, originalRequest []byte) *ClaudeInputTokenState {
enabled := sourceFormat == sdktranslator.FormatClaude &&
upstreamFormat != sdktranslator.FormatClaude &&
responseFormat == sdktranslator.FormatClaude
return &ClaudeInputTokenState{
upstreamFormat: upstreamFormat,
responseFormat: responseFormat,
originalRequest: originalRequest,
handled: !enabled,
}
}
// TranslateStreamWithClaudeInputTokens translates a stream chunk and estimates Claude message_start input usage once.
func TranslateStreamWithClaudeInputTokens(
ctx context.Context,
upstreamFormat, responseFormat sdktranslator.Format,
model string,
originalRequestRawJSON, requestRawJSON, rawJSON []byte,
param *any,
state *ClaudeInputTokenState,
) [][]byte {
chunks := sdktranslator.TranslateStream(
ctx,
upstreamFormat,
responseFormat,
model,
originalRequestRawJSON,
requestRawJSON,
rawJSON,
param,
)
if state == nil {
return chunks
}
return state.apply(ctx, chunks)
}
func claudeInputTokenizer() (tokenizer.Codec, error) {
claudeInputTokenizerOnce.Do(func() {
claudeInputTokenizerCodec, claudeInputTokenizerErr = tokenizer.Get(tokenizer.O200kBase)
})
return claudeInputTokenizerCodec, claudeInputTokenizerErr
}
func countClaudeInputTokens(enc tokenizer.Codec, payload []byte) (int64, error) {
if enc == nil {
return 0, fmt.Errorf("encoder is nil")
}
segments, err := collectClaudeInputTokenSegments(payload)
if err != nil {
return 0, err
}
if len(segments) == 0 {
return 0, nil
}
count, err := enc.Count(strings.Join(segments, "\n"))
if err != nil {
return 0, err
}
return int64(count), nil
}
func collectClaudeInputTokenSegments(payload []byte) ([]string, error) {
if len(bytes.TrimSpace(payload)) == 0 {
return nil, nil
}
if !gjson.ValidBytes(payload) {
return nil, fmt.Errorf("invalid Claude request JSON")
}
root := gjson.ParseBytes(payload)
segments := make([]string, 0, 32)
collectClaudeSystemTokenSegments(root.Get("system"), &segments)
collectClaudeMessageTokenSegments(root.Get("messages"), &segments)
collectClaudeToolTokenSegments(root.Get("tools"), &segments)
collectClaudeToolChoiceTokenSegments(root.Get("tool_choice"), &segments)
return segments, nil
}
func collectClaudeSystemTokenSegments(system gjson.Result, segments *[]string) {
if system.Type == gjson.String {
appendClaudeTokenString(segments, system.String())
return
}
if !system.IsArray() {
return
}
system.ForEach(func(_, part gjson.Result) bool {
if part.Type == gjson.String {
appendClaudeTokenString(segments, part.String())
} else if part.Get("type").String() == "text" {
appendClaudeTokenString(segments, part.Get("text").String())
}
return true
})
}
func collectClaudeMessageTokenSegments(messages gjson.Result, segments *[]string) {
if !messages.IsArray() {
return
}
messages.ForEach(func(_, message gjson.Result) bool {
appendClaudeTokenString(segments, message.Get("role").String())
collectClaudeContentTokenSegments(message.Get("content"), segments)
return true
})
}
func collectClaudeContentTokenSegments(content gjson.Result, segments *[]string) {
if !content.Exists() {
return
}
if content.Type == gjson.String {
appendClaudeTokenString(segments, content.String())
return
}
if content.IsArray() {
content.ForEach(func(_, part gjson.Result) bool {
collectClaudeContentTokenSegments(part, segments)
return true
})
return
}
if !content.IsObject() {
return
}
switch content.Get("type").String() {
case "text":
appendClaudeTokenString(segments, content.Get("text").String())
case "thinking":
appendClaudeTokenString(segments, content.Get("thinking").String())
case "document":
collectClaudeDocumentTokenSegments(content, segments)
case "tool_use", "server_tool_use", "mcp_tool_use":
appendClaudeTokenString(segments, content.Get("id").String())
appendClaudeTokenString(segments, content.Get("name").String())
appendClaudeTokenJSON(segments, content.Get("input"))
case "tool_result", "mcp_tool_result", "web_search_tool_result", "web_fetch_tool_result", "code_execution_tool_result", "bash_code_execution_tool_result", "text_editor_code_execution_tool_result":
appendClaudeTokenString(segments, content.Get("tool_use_id").String())
appendClaudeTokenString(segments, content.Get("tool_call_id").String())
collectClaudeContentTokenSegments(content.Get("content"), segments)
case "web_search_result", "search_result":
if source := content.Get("source"); source.Type == gjson.String {
appendClaudeTokenString(segments, source.String())
}
appendClaudeTokenString(segments, content.Get("title").String())
appendClaudeTokenString(segments, content.Get("url").String())
appendClaudeTokenString(segments, content.Get("page_age").String())
collectClaudeContentTokenSegments(content.Get("content"), segments)
case "web_fetch_result":
appendClaudeTokenString(segments, content.Get("url").String())
appendClaudeTokenString(segments, content.Get("retrieved_at").String())
collectClaudeContentTokenSegments(content.Get("content"), segments)
case "code_execution_result", "bash_code_execution_result", "text_editor_code_execution_result":
appendClaudeTokenString(segments, content.Get("stdout").String())
appendClaudeTokenString(segments, content.Get("stderr").String())
appendClaudeTokenString(segments, content.Get("return_code").String())
collectClaudeContentTokenSegments(content.Get("content"), segments)
collectClaudeContentTokenSegments(content.Get("output"), segments)
case "tool_reference":
appendClaudeTokenString(segments, content.Get("tool_name").String())
case "image", "input_audio", "audio", "video", "redacted_thinking":
return
case "":
appendClaudeTokenJSON(segments, content)
default:
appendClaudeTokenString(segments, content.Get("text").String())
}
}
func collectClaudeDocumentTokenSegments(document gjson.Result, segments *[]string) {
source := document.Get("source")
if source.Get("type").String() != "text" {
return
}
appendClaudeTokenString(segments, document.Get("title").String())
appendClaudeTokenString(segments, document.Get("context").String())
appendClaudeTokenString(segments, source.Get("data").String())
appendClaudeTokenString(segments, source.Get("content").String())
}
func collectClaudeToolTokenSegments(tools gjson.Result, segments *[]string) {
if !tools.IsArray() {
return
}
tools.ForEach(func(_, tool gjson.Result) bool {
appendClaudeTokenString(segments, tool.Get("type").String())
appendClaudeTokenString(segments, tool.Get("name").String())
appendClaudeTokenString(segments, tool.Get("description").String())
appendClaudeTokenJSON(segments, tool.Get("input_schema"))
return true
})
}
func collectClaudeToolChoiceTokenSegments(toolChoice gjson.Result, segments *[]string) {
if !toolChoice.Exists() {
return
}
if toolChoice.Type == gjson.String {
appendClaudeTokenString(segments, toolChoice.String())
return
}
appendClaudeTokenString(segments, toolChoice.Get("type").String())
appendClaudeTokenString(segments, toolChoice.Get("name").String())
}
func appendClaudeTokenString(segments *[]string, value string) {
if segments == nil {
return
}
if trimmed := strings.TrimSpace(value); trimmed != "" {
*segments = append(*segments, trimmed)
}
}
func appendClaudeTokenJSON(segments *[]string, value gjson.Result) {
if !value.Exists() {
return
}
if value.Type == gjson.String {
appendClaudeTokenString(segments, value.String())
return
}
raw := strings.TrimSpace(value.Raw)
if raw == "" {
return
}
var compact bytes.Buffer
if err := json.Compact(&compact, []byte(raw)); err == nil {
appendClaudeTokenString(segments, compact.String())
return
}
appendClaudeTokenString(segments, raw)
}
func (state *ClaudeInputTokenState) apply(ctx context.Context, chunks [][]byte) [][]byte {
if state == nil || state.handled {
return chunks
}
for i := range chunks {
updated, found := state.applyChunk(ctx, chunks[i])
if !found {
continue
}
state.handled = true
chunks[i] = updated
break
}
return chunks
}
func (state *ClaudeInputTokenState) applyChunk(ctx context.Context, chunk []byte) ([]byte, bool) {
for lineStart := 0; lineStart < len(chunk); {
lineEnd := bytes.IndexByte(chunk[lineStart:], '\n')
if lineEnd < 0 {
lineEnd = len(chunk)
} else {
lineEnd += lineStart
}
contentEnd := lineEnd
if contentEnd > lineStart && chunk[contentEnd-1] == '\r' {
contentEnd--
}
line := chunk[lineStart:contentEnd]
trimmedLeft := bytes.TrimLeft(line, " \t")
if bytes.HasPrefix(trimmedLeft, []byte("data:")) {
payloadOffset := len(line) - len(trimmedLeft) + len("data:")
for payloadOffset < len(line) && (line[payloadOffset] == ' ' || line[payloadOffset] == '\t') {
payloadOffset++
}
payloadEnd := len(line)
for payloadEnd > payloadOffset && (line[payloadEnd-1] == ' ' || line[payloadEnd-1] == '\t') {
payloadEnd--
}
payload := line[payloadOffset:payloadEnd]
if gjson.GetBytes(payload, "type").String() == "message_start" {
inputTokens := gjson.GetBytes(payload, "message.usage.input_tokens")
if inputTokens.Exists() && inputTokens.Int() != 0 {
return chunk, true
}
count, err := state.estimate()
if err != nil {
state.logEstimateError(ctx, err)
return chunk, true
}
if count == 0 {
return chunk, true
}
updatedPayload, errSet := sjson.SetBytes(payload, "message.usage.input_tokens", count)
if errSet != nil {
state.logEstimateError(ctx, fmt.Errorf("set message_start usage: %w", errSet))
return chunk, true
}
payloadStart := lineStart + payloadOffset
payloadStop := lineStart + payloadEnd
updated := make([]byte, 0, len(chunk)+len(updatedPayload)-len(payload))
updated = append(updated, chunk[:payloadStart]...)
updated = append(updated, updatedPayload...)
updated = append(updated, chunk[payloadStop:]...)
return updated, true
}
}
if lineEnd == len(chunk) {
break
}
lineStart = lineEnd + 1
}
return chunk, false
}
func (state *ClaudeInputTokenState) estimate() (int64, error) {
enc := state.codec
if enc == nil {
var err error
enc, err = claudeInputTokenizer()
if err != nil {
return 0, fmt.Errorf("initialize O200kBase tokenizer: %w", err)
}
}
count, err := countClaudeInputTokens(enc, state.originalRequest)
if err != nil {
return 0, fmt.Errorf("count Claude input tokens: %w", err)
}
return count, nil
}
func (state *ClaudeInputTokenState) logEstimateError(ctx context.Context, err error) {
LogWithRequestID(ctx).WithFields(log.Fields{
"upstream_format": state.upstreamFormat.String(),
"response_format": state.responseFormat.String(),
}).WithError(err).Warn("failed to estimate Claude input tokens")
}
@@ -0,0 +1,443 @@
package helps
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"sync"
"testing"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tiktoken-go/tokenizer"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)
type failingClaudeInputCodec struct{}
func (failingClaudeInputCodec) GetName() string {
return "failing"
}
func (failingClaudeInputCodec) Count(string) (int, error) {
return 0, errors.New("count failed")
}
func (failingClaudeInputCodec) Encode(string) ([]uint, []string, error) {
return nil, nil, errors.New("encode failed")
}
func (failingClaudeInputCodec) Decode([]uint) (string, error) {
return "", errors.New("decode failed")
}
func TestCollectClaudeInputTokenSegments(t *testing.T) {
payload := []byte(`{
"model":"claude-test",
"system":[
{"type":"text","text":"Follow repository rules.","cache_control":{"type":"ephemeral"}},
{"type":"image","source":{"type":"base64","media_type":"image/png","data":"ignored-system-image"}}
],
"messages":[
{"role":"user","content":[
{"type":"text","text":"Review the implementation."},
{"type":"document","source":{"type":"text","data":"Reference document text."}},
{"type":"image","source":{"type":"base64","media_type":"image/png","data":"ignored-image"}}
]},
{"role":"assistant","content":[
{"type":"thinking","thinking":"Inspect the relevant files.","signature":"ignored-signature"},
{"type":"tool_use","id":"toolu_1","name":"read_file","input":{"path":"main.go"}}
]},
{"role":"user","content":[
{"type":"tool_result","tool_use_id":"toolu_1","content":[
{"type":"text","text":"package main"},
{"type":"image","source":{"type":"base64","data":"ignored-tool-image"}}
]}
]}
],
"tools":[{
"name":"read_file",
"description":"Reads a repository file.",
"input_schema":{"type":"object","properties":{"path":{"type":"string"}}},
"cache_control":{"type":"ephemeral"}
}],
"tool_choice":{"type":"tool","name":"read_file"},
"metadata":{"user_id":"ignored-metadata"},
"max_tokens":4096,
"stream":true
}`)
got, err := collectClaudeInputTokenSegments(payload)
if err != nil {
t.Fatalf("collectClaudeInputTokenSegments() error = %v", err)
}
want := []string{
"Follow repository rules.",
"user",
"Review the implementation.",
"Reference document text.",
"assistant",
"Inspect the relevant files.",
"toolu_1",
"read_file",
`{"path":"main.go"}`,
"user",
"toolu_1",
"package main",
"read_file",
"Reads a repository file.",
`{"type":"object","properties":{"path":{"type":"string"}}}`,
"tool",
"read_file",
}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("segments = %#v, want %#v", got, want)
}
}
func TestCollectClaudeInputTokenSegmentsIncludesKnownToolResults(t *testing.T) {
payload := []byte(`{
"messages":[{"role":"user","content":[
{"type":"web_search_tool_result","tool_use_id":"ws_tool_1","content":[
{"type":"web_search_result","source":"Search source","title":"Search result title","url":"https://search.example/result","page_age":"1 day","encrypted_content":"ignored-secret"}
]},
{"type":"web_fetch_tool_result","tool_use_id":"fetch_tool_1","content":{
"type":"web_fetch_result","url":"https://docs.example/page","retrieved_at":"2026-07-22T00:00:00Z","content":{
"type":"document","title":"Fetched document","source":{"type":"text","data":"Fetched body"}
}
}},
{"type":"bash_code_execution_tool_result","tool_use_id":"bash_tool_1","content":{
"type":"bash_code_execution_result","stdout":"command output","stderr":"command error","return_code":1,
"content":[{"type":"text","text":"additional output"}]
}},
{"type":"tool_result","tool_use_id":"toolu_1","content":[
{"type":"tool_reference","tool_name":"proxy_mcp__nia__manage_resource"}
]}
]}]
}`)
segments, err := collectClaudeInputTokenSegments(payload)
if err != nil {
t.Fatalf("collectClaudeInputTokenSegments() error = %v", err)
}
joined := "\n" + strings.Join(segments, "\n") + "\n"
for _, want := range []string{
"ws_tool_1",
"Search source",
"Search result title",
"https://search.example/result",
"1 day",
"fetch_tool_1",
"https://docs.example/page",
"2026-07-22T00:00:00Z",
"Fetched document",
"Fetched body",
"bash_tool_1",
"command output",
"command error",
"1",
"additional output",
"toolu_1",
"proxy_mcp__nia__manage_resource",
} {
if !strings.Contains(joined, "\n"+want+"\n") {
t.Errorf("segments do not contain %q: %#v", want, segments)
}
}
if strings.Contains(joined, "ignored-secret") {
t.Fatalf("segments contain encrypted content: %#v", segments)
}
}
func TestCountClaudeInputTokensExcludesMultimediaAndControlFields(t *testing.T) {
enc, err := tokenizer.Get(tokenizer.O200kBase)
if err != nil {
t.Fatalf("tokenizer.Get() error = %v", err)
}
base := []byte(`{
"system":"System text.",
"messages":[{"role":"user","content":[{"type":"text","text":"User text."}]}],
"tools":[{"name":"lookup","description":"Looks up data.","input_schema":{"type":"object"}}]
}`)
withExcludedFields := []byte(`{
"model":"claude-test",
"system":"System text.",
"messages":[{"role":"user","content":[
{"type":"text","text":"User text."},
{"type":"image","source":{"type":"base64","media_type":"image/png","data":"very-large-image-data"}},
{"type":"input_audio","source":{"type":"base64","data":"very-large-audio-data"}},
{"type":"video","source":{"type":"url","url":"https://example.com/video.mp4"}},
{"type":"document","source":{"type":"base64","media_type":"application/pdf","data":"very-large-pdf-data"}}
]}],
"tools":[{"name":"lookup","description":"Looks up data.","input_schema":{"type":"object"},"cache_control":{"type":"ephemeral"}}],
"metadata":{"large_wrapper":"ignored"},
"max_tokens":8192,
"temperature":0.8,
"top_p":0.9,
"thinking":{"type":"enabled","budget_tokens":4096},
"stream":true
}`)
baseCount, errBase := countClaudeInputTokens(enc, base)
if errBase != nil {
t.Fatalf("countClaudeInputTokens(base) error = %v", errBase)
}
excludedCount, errExcluded := countClaudeInputTokens(enc, withExcludedFields)
if errExcluded != nil {
t.Fatalf("countClaudeInputTokens(withExcludedFields) error = %v", errExcluded)
}
if excludedCount != baseCount {
t.Fatalf("count with excluded fields = %d, want %d", excludedCount, baseCount)
}
}
func TestTranslateStreamWithClaudeInputTokensPatchesMessageStartOnce(t *testing.T) {
upstreamFormat := sdktranslator.Format("claude-input-token-test-upstream")
sdktranslator.Register(sdktranslator.FormatClaude, upstreamFormat, nil, sdktranslator.ResponseTransform{
Stream: func(_ context.Context, _ string, _, _, rawJSON []byte, _ *any) [][]byte {
return [][]byte{rawJSON}
},
})
originalRequest := []byte(`{"system":"System text.","messages":[{"role":"user","content":"Hello."}]}`)
state := NewClaudeInputTokenState(sdktranslator.FormatClaude, upstreamFormat, sdktranslator.FormatClaude, originalRequest)
var param any
combined := []byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}}\n\n" +
"event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0}\n\n")
got := TranslateStreamWithClaudeInputTokens(
context.Background(),
upstreamFormat,
sdktranslator.FormatClaude,
"claude-test",
originalRequest,
nil,
combined,
&param,
state,
)
if tokens := messageStartInputTokens(got); tokens <= 0 {
t.Fatalf("message_start input_tokens = %d, want positive estimate; output = %q", tokens, joinClaudeInputChunks(got))
}
if !state.handled {
t.Fatal("state.handled = false, want true after message_start")
}
if !strings.Contains(joinClaudeInputChunks(got), `"type":"content_block_start"`) {
t.Fatalf("combined non-target event was not preserved: %q", joinClaudeInputChunks(got))
}
secondStart := []byte(`event: message_start
data: {"type":"message_start","message":{"usage":{"input_tokens":0}}}
`)
gotSecond := TranslateStreamWithClaudeInputTokens(
context.Background(),
upstreamFormat,
sdktranslator.FormatClaude,
"claude-test",
originalRequest,
nil,
secondStart,
&param,
state,
)
if tokens := messageStartInputTokens(gotSecond); tokens != 0 {
t.Fatalf("second message_start input_tokens = %d, want 0 after state handled", tokens)
}
}
func TestClaudeInputTokenStatePreservesCRLFAndNonTargetEvents(t *testing.T) {
originalRequest := []byte(`{"messages":[{"role":"user","content":"Hello."}]}`)
state := NewClaudeInputTokenState(sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, originalRequest)
chunk := []byte("event: message_start\r\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}} \r\n\r\n" +
"event: ping\r\ndata: {\"type\":\"ping\",\"value\":\"keep\"}\r\n\r\n")
got := state.apply(context.Background(), [][]byte{chunk})
tokens := messageStartInputTokens(got)
if tokens <= 0 {
t.Fatalf("input_tokens = %d, want positive estimate", tokens)
}
want := fmt.Sprintf("event: message_start\r\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":%d,\"output_tokens\":0}}} \r\n\r\n"+
"event: ping\r\ndata: {\"type\":\"ping\",\"value\":\"keep\"}\r\n\r\n", tokens)
if joined := joinClaudeInputChunks(got); joined != want {
t.Fatalf("output bytes changed unexpectedly:\n got: %q\nwant: %q", joined, want)
}
}
func TestClaudeInputTokenStatePatchesMissingAndPreservesNonZero(t *testing.T) {
originalRequest := []byte(`{"messages":[{"role":"user","content":"Hello."}]}`)
t.Run("missing", func(t *testing.T) {
state := NewClaudeInputTokenState(sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, originalRequest)
chunks := [][]byte{[]byte("data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"output_tokens\":0}}}\n\n")}
got := state.apply(context.Background(), chunks)
if tokens := messageStartInputTokens(got); tokens <= 0 {
t.Fatalf("input_tokens = %d, want positive estimate", tokens)
}
})
t.Run("non-zero", func(t *testing.T) {
state := NewClaudeInputTokenState(sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, []byte(`not valid json`))
chunks := [][]byte{[]byte("data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":73}}}\n\n")}
got := state.apply(context.Background(), chunks)
if tokens := messageStartInputTokens(got); tokens != 73 {
t.Fatalf("input_tokens = %d, want preserved value 73", tokens)
}
if !state.handled {
t.Fatal("state.handled = false, want true")
}
})
}
func TestClaudeInputTokenStateSkipsUnsupportedFlows(t *testing.T) {
originalRequest := []byte(`{"messages":[{"role":"user","content":"Hello."}]}`)
testCases := []struct {
name string
sourceFormat sdktranslator.Format
upstreamFormat sdktranslator.Format
responseFormat sdktranslator.Format
}{
{name: "non-Claude source", sourceFormat: sdktranslator.FormatOpenAI, upstreamFormat: sdktranslator.FormatGemini, responseFormat: sdktranslator.FormatClaude},
{name: "Claude passthrough", sourceFormat: sdktranslator.FormatClaude, upstreamFormat: sdktranslator.FormatClaude, responseFormat: sdktranslator.FormatClaude},
{name: "non-Claude response", sourceFormat: sdktranslator.FormatClaude, upstreamFormat: sdktranslator.FormatOpenAI, responseFormat: sdktranslator.FormatOpenAI},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
state := NewClaudeInputTokenState(tc.sourceFormat, tc.upstreamFormat, tc.responseFormat, originalRequest)
chunks := [][]byte{[]byte("data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":0}}}\n\n")}
got := state.apply(context.Background(), chunks)
if tokens := messageStartInputTokens(got); tokens != 0 {
t.Fatalf("input_tokens = %d, want unchanged 0", tokens)
}
if !state.handled {
t.Fatal("state.handled = false, want disabled flow handled at initialization")
}
})
}
}
func TestClaudeInputTokenStateCountErrorKeepsZero(t *testing.T) {
originalLogOutput := log.StandardLogger().Out
log.SetOutput(io.Discard)
defer log.SetOutput(originalLogOutput)
state := NewClaudeInputTokenState(
sdktranslator.FormatClaude,
sdktranslator.FormatOpenAI,
sdktranslator.FormatClaude,
[]byte(`{"messages":[{"role":"user","content":"Hello."}]}`),
)
state.codec = failingClaudeInputCodec{}
chunks := [][]byte{[]byte("data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":0}}}\n\n")}
got := state.apply(context.Background(), chunks)
if tokens := messageStartInputTokens(got); tokens != 0 {
t.Fatalf("input_tokens = %d, want fallback 0", tokens)
}
if !state.handled {
t.Fatal("state.handled = false, want true after failed estimate")
}
}
func TestClaudeInputTokenStateInvalidJSONKeepsZeroWithoutLoggingRequest(t *testing.T) {
originalLogOutput := log.StandardLogger().Out
var logOutput bytes.Buffer
log.SetOutput(&logOutput)
defer log.SetOutput(originalLogOutput)
const sensitiveRequest = `{"messages":["sensitive-original-request"`
state := NewClaudeInputTokenState(
sdktranslator.FormatClaude,
sdktranslator.FormatOpenAI,
sdktranslator.FormatClaude,
[]byte(sensitiveRequest),
)
chunks := [][]byte{[]byte("data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":0}}}\n\n")}
got := state.apply(context.Background(), chunks)
if tokens := messageStartInputTokens(got); tokens != 0 {
t.Fatalf("input_tokens = %d, want fallback 0", tokens)
}
if !state.handled {
t.Fatal("state.handled = false, want true after invalid JSON")
}
if !strings.Contains(logOutput.String(), "failed to estimate Claude input tokens") {
t.Fatalf("warning not logged: %q", logOutput.String())
}
if strings.Contains(logOutput.String(), "sensitive-original-request") {
t.Fatalf("warning leaked original request: %q", logOutput.String())
}
}
func TestClaudeInputTokenizerConcurrentCount(t *testing.T) {
first, errFirst := claudeInputTokenizer()
if errFirst != nil {
t.Fatalf("claudeInputTokenizer() error = %v", errFirst)
}
second, errSecond := claudeInputTokenizer()
if errSecond != nil {
t.Fatalf("claudeInputTokenizer() second error = %v", errSecond)
}
if first != second {
t.Fatal("claudeInputTokenizer() returned different codec instances")
}
const workers = 32
const iterations = 50
var wg sync.WaitGroup
errs := make(chan error, workers)
for worker := 0; worker < workers; worker++ {
worker := worker
wg.Add(1)
go func() {
defer wg.Done()
for iteration := 0; iteration < iterations; iteration++ {
payload := []byte(fmt.Sprintf(`{"messages":[{"role":"user","content":"worker %d iteration %d 你好"}]}`, worker, iteration))
count, errCount := countClaudeInputTokens(first, payload)
if errCount != nil {
errs <- errCount
return
}
if count <= 0 {
errs <- fmt.Errorf("non-positive count: %d", count)
return
}
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
t.Error(err)
}
}
func messageStartInputTokens(chunks [][]byte) int64 {
for _, chunk := range chunks {
for _, line := range strings.Split(string(chunk), "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
if gjson.Get(payload, "type").String() == "message_start" {
return gjson.Get(payload, "message.usage.input_tokens").Int()
}
}
}
return 0
}
func joinClaudeInputChunks(chunks [][]byte) string {
var builder strings.Builder
for _, chunk := range chunks {
builder.Write(chunk)
}
return builder.String()
}
+3 -2
View File
@@ -297,6 +297,7 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
}()
scanner := bufio.NewScanner(httpResp.Body)
scanner.Buffer(nil, 1_048_576) // 1MB
claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload)
var param any
var streamUsage helps.StreamUsageBuffer
defer streamUsage.Publish(ctx, reporter)
@@ -304,7 +305,7 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
line := scanner.Bytes()
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
streamUsage.ObserveOpenAIStream(line)
chunks := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, bytes.Clone(line), &param)
chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, bytes.Clone(line), &param, claudeInputTokens)
for i := range chunks {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
@@ -313,7 +314,7 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
}
}
}
doneChunks := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), &param)
doneChunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), &param, claudeInputTokens)
for i := range doneChunks {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: doneChunks[i]}:
@@ -392,6 +392,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
}()
scanner := bufio.NewScanner(httpResp.Body)
scanner.Buffer(nil, 52_428_800) // 50MB
claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload)
var param any
var streamUsage helps.StreamUsageBuffer
defer streamUsage.Publish(ctx, reporter)
@@ -423,7 +424,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
}
// OpenAI-compatible streams must use SSE data lines.
chunks := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, bytes.Clone(trimmedLine), &param)
chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, bytes.Clone(trimmedLine), &param, claudeInputTokens)
for i := range chunks {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
@@ -443,7 +444,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
// In case the upstream close the stream without a terminal [DONE] marker.
// Feed a synthetic done marker through the translator so pending
// response.completed events are still emitted exactly once.
chunks := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, []byte("data: [DONE]"), &param)
chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, []byte("data: [DONE]"), &param, claudeInputTokens)
for i := range chunks {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
+2 -1
View File
@@ -669,13 +669,14 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth
}()
scanner := bufio.NewScanner(httpResp.Body)
scanner.Buffer(nil, 52_428_800)
claudeInputTokens := helps.NewClaudeInputTokenState(prepared.from, prepared.to, prepared.responseFormat, prepared.originalPayload)
var param any
outputItemsByIndex := make(map[int64][]byte)
var outputItemsFallback [][]byte
responseFilter := newXAIInternalXSearchResponseFilter(prepared.filterInternalXSearch, prepared.clientDeclaredTools)
var pendingEventLine []byte
emitTranslatedLine := func(translatedLine []byte) bool {
chunks := sdktranslator.TranslateStream(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, translatedLine, &param)
chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, translatedLine, &param, claudeInputTokens)
for i := range chunks {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
@@ -21,7 +21,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -586,6 +585,7 @@ func (e *XAIWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliprox
}
}
claudeInputTokens := helps.NewClaudeInputTokenState(prepared.from, prepared.to, prepared.responseFormat, prepared.originalPayload)
var param any
outputItemsByIndex := make(map[int64][]byte)
var outputItemsFallback [][]byte
@@ -719,7 +719,7 @@ func (e *XAIWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliprox
payload = normalizeCodexWebsocketCompletion(payload)
line := encodeCodexWebsocketAsSSE(payload)
chunks := sdktranslator.TranslateStream(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, line, &param)
chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, line, &param, claudeInputTokens)
for i := range chunks {
if !send(cliproxyexecutor.StreamChunk{Payload: chunks[i]}) {
terminateReason = "context_done"
@@ -729,7 +729,7 @@ func (e *XAIWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliprox
}
if len(warmupCompletedPayload) > 0 {
line = encodeCodexWebsocketAsSSE(warmupCompletedPayload)
chunks = sdktranslator.TranslateStream(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, line, &param)
chunks = helps.TranslateStreamWithClaudeInputTokens(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, line, &param, claudeInputTokens)
for i := range chunks {
if !send(cliproxyexecutor.StreamChunk{Payload: chunks[i]}) {
terminateReason = "context_done"