mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-06 16:15:50 +08:00
- Replaced instances of `thinking.ApplyThinking` with `helps.ApplyRequestThinking` across all executors for consistency. - Updated `applyGeminiInteractionsThinking` to accept `cliproxyexecutor.Request` and `Options`. - Centralized logic for request thinking application to `helps` package for improved maintainability. Closes: #4618
226 lines
8.8 KiB
Go
226 lines
8.8 KiB
Go
package executor
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
|
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"
|
|
)
|
|
|
|
func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
|
from := opts.SourceFormat
|
|
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
|
|
to := sdktranslator.FromString("claude")
|
|
|
|
// Use streaming translation to preserve function calling, except for claude.
|
|
stream := from != to
|
|
body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream)
|
|
var errThinking error
|
|
body, errThinking = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
|
|
if errThinking != nil {
|
|
return cliproxyexecutor.Response{}, errThinking
|
|
}
|
|
if rebuildMidSystemMessageEnabled(e.cfg, auth) {
|
|
body = rebuildMidSystemMessagesToTopLevel(body)
|
|
}
|
|
body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel)
|
|
if errValidate := validateClaudeTokenCountRequest(body); errValidate != nil {
|
|
return cliproxyexecutor.Response{}, errValidate
|
|
}
|
|
|
|
// Count locally so generation-only Claude Code system instructions are never
|
|
// injected into the payload being measured and OAuth does not require an
|
|
// additional upstream count_tokens request.
|
|
count, err := helps.CountClaudeInputTokens(body)
|
|
if err != nil {
|
|
return cliproxyexecutor.Response{}, fmt.Errorf("claude executor: token counting failed: %w", err)
|
|
}
|
|
|
|
usageJSON := []byte(fmt.Sprintf(`{"input_tokens":%d}`, count))
|
|
out := sdktranslator.TranslateTokenCount(ctx, to, responseFormat, count, usageJSON)
|
|
return cliproxyexecutor.Response{Payload: out}, nil
|
|
}
|
|
|
|
type claudeTokenCountValidationError struct {
|
|
statusErr
|
|
}
|
|
|
|
func (claudeTokenCountValidationError) IsRequestScoped() bool {
|
|
return true
|
|
}
|
|
|
|
func newClaudeTokenCountValidationError(message string) error {
|
|
return claudeTokenCountValidationError{statusErr{code: http.StatusBadRequest, msg: message}}
|
|
}
|
|
|
|
func validateClaudeTokenCountRequest(body []byte) error {
|
|
if !gjson.ValidBytes(body) {
|
|
return newClaudeTokenCountValidationError("invalid Claude token count request JSON")
|
|
}
|
|
root := gjson.ParseBytes(body)
|
|
if !root.IsObject() {
|
|
return newClaudeTokenCountValidationError("Claude token count request must be a JSON object")
|
|
}
|
|
messages := root.Get("messages")
|
|
if !messages.IsArray() || len(messages.Array()) == 0 {
|
|
return newClaudeTokenCountValidationError("Claude token count request messages must be a non-empty array")
|
|
}
|
|
for _, message := range messages.Array() {
|
|
if !message.IsObject() {
|
|
return newClaudeTokenCountValidationError("Claude token count request messages must contain objects")
|
|
}
|
|
role := message.Get("role").String()
|
|
if role != "user" && role != "assistant" {
|
|
return newClaudeTokenCountValidationError("Claude token count request message role must be user or assistant")
|
|
}
|
|
content := message.Get("content")
|
|
if content.Type == gjson.String {
|
|
continue
|
|
}
|
|
if !content.IsArray() {
|
|
return newClaudeTokenCountValidationError("Claude token count request message content must be a string or array")
|
|
}
|
|
for _, block := range content.Array() {
|
|
if !block.IsObject() || block.Get("type").Type != gjson.String || block.Get("type").String() == "" {
|
|
return newClaudeTokenCountValidationError("Claude token count request content blocks must be typed objects")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// countTokensUpstream preserves native token counting for Claude-compatible
|
|
// providers that expose their own count_tokens endpoint.
|
|
func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
|
upstreamModel := e.upstreamModel(baseModel)
|
|
|
|
apiKey, baseURL := claudeCreds(auth)
|
|
if baseURL == "" {
|
|
baseURL = "https://api.anthropic.com"
|
|
}
|
|
|
|
from := opts.SourceFormat
|
|
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
|
|
to := sdktranslator.FromString("claude")
|
|
// Use streaming translation to preserve function calling, except for claude.
|
|
stream := from != to
|
|
body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream)
|
|
body = helps.SetStringIfDifferent(body, "model", upstreamModel)
|
|
var errThinking error
|
|
body, errThinking = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
|
|
if errThinking != nil {
|
|
return cliproxyexecutor.Response{}, errThinking
|
|
}
|
|
if rebuildMidSystemMessageEnabled(e.cfg, auth) {
|
|
body = rebuildMidSystemMessagesToTopLevel(body)
|
|
}
|
|
|
|
if !strings.HasPrefix(baseModel, "claude-3-5-haiku") {
|
|
body = checkSystemInstructions(body)
|
|
}
|
|
|
|
// Keep count_tokens requests compatible with Anthropic cache-control constraints too.
|
|
body = enforceCacheControlLimit(body, 4)
|
|
body = normalizeCacheControlTTL(body)
|
|
|
|
// Extract betas from body and convert to header (for count_tokens too)
|
|
var extraBetas []string
|
|
extraBetas, body = extractAndRemoveBetas(body)
|
|
if isClaudeOAuthToken(apiKey) {
|
|
body, _ = prepareClaudeOAuthToolNamesForUpstream(body, claudeToolPrefix, auth.ToolPrefixDisabled())
|
|
}
|
|
body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel)
|
|
|
|
url := fmt.Sprintf("%s/v1/messages/count_tokens?beta=true", baseURL)
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return cliproxyexecutor.Response{}, err
|
|
}
|
|
if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, false, extraBetas, e.cfg, opts.Headers); errHeaders != nil {
|
|
return cliproxyexecutor.Response{}, errHeaders
|
|
}
|
|
var authID, authLabel, authType, authValue string
|
|
if auth != nil {
|
|
authID = auth.ID
|
|
authLabel = auth.Label
|
|
authType, authValue = auth.AccountInfo()
|
|
}
|
|
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
|
|
URL: url,
|
|
Method: http.MethodPost,
|
|
Headers: httpReq.Header.Clone(),
|
|
Body: body,
|
|
Provider: e.upstreamRequestLogProvider(),
|
|
AuthID: authID,
|
|
AuthLabel: authLabel,
|
|
AuthType: authType,
|
|
AuthValue: authValue,
|
|
})
|
|
|
|
httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0)
|
|
resp, err := httpClient.Do(httpReq)
|
|
if err != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
|
return cliproxyexecutor.Response{}, err
|
|
}
|
|
helps.RecordAPIResponseMetadata(ctx, e.cfg, resp.StatusCode, resp.Header.Clone())
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
// Decompress error responses — pass the Content-Encoding value (may be empty)
|
|
// and let decodeResponseBody handle both header-declared and magic-byte-detected
|
|
// compression. This keeps error-path behaviour consistent with the success path.
|
|
errBody, decErr := decodeResponseBody(resp.Body, resp.Header.Get("Content-Encoding"))
|
|
if decErr != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, decErr)
|
|
msg := fmt.Sprintf("failed to decode error response body: %v", decErr)
|
|
helps.LogWithRequestID(ctx).Warn(msg)
|
|
return cliproxyexecutor.Response{}, statusErr{code: resp.StatusCode, msg: msg}
|
|
}
|
|
b, readErr := io.ReadAll(errBody)
|
|
if readErr != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, readErr)
|
|
msg := fmt.Sprintf("failed to read error response body: %v", readErr)
|
|
helps.LogWithRequestID(ctx).Warn(msg)
|
|
b = []byte(msg)
|
|
}
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
|
|
if errClose := errBody.Close(); errClose != nil {
|
|
log.Errorf("response body close error: %v", errClose)
|
|
}
|
|
return cliproxyexecutor.Response{}, statusErr{code: resp.StatusCode, msg: string(b)}
|
|
}
|
|
decodedBody, err := decodeResponseBody(resp.Body, resp.Header.Get("Content-Encoding"))
|
|
if err != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
|
if errClose := resp.Body.Close(); errClose != nil {
|
|
log.Errorf("response body close error: %v", errClose)
|
|
}
|
|
return cliproxyexecutor.Response{}, err
|
|
}
|
|
defer func() {
|
|
if errClose := decodedBody.Close(); errClose != nil {
|
|
log.Errorf("response body close error: %v", errClose)
|
|
}
|
|
}()
|
|
data, err := io.ReadAll(decodedBody)
|
|
if err != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
|
return cliproxyexecutor.Response{}, err
|
|
}
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
|
|
count := gjson.GetBytes(data, "input_tokens").Int()
|
|
out := sdktranslator.TranslateTokenCount(ctx, to, responseFormat, count, data)
|
|
return cliproxyexecutor.Response{Payload: out, Headers: resp.Header.Clone()}, nil
|
|
}
|