mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-02 20:40:52 +08:00
904 lines
29 KiB
Go
904 lines
29 KiB
Go
package executor
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"compress/flate"
|
|
"compress/gzip"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/andybalholm/brotli"
|
|
"github.com/klauspost/compress/zstd"
|
|
claudeauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// ClaudeExecutor is a stateless executor for Anthropic Claude over the messages API.
|
|
// If api_key is unavailable on auth, it falls back to legacy via ClientAdapter.
|
|
type ClaudeExecutor struct {
|
|
cfg *config.Config
|
|
}
|
|
|
|
const claudeToolPrefix = "proxy_"
|
|
|
|
func NewClaudeExecutor(cfg *config.Config) *ClaudeExecutor { return &ClaudeExecutor{cfg: cfg} }
|
|
|
|
func (e *ClaudeExecutor) Identifier() string { return "claude" }
|
|
|
|
// PrepareRequest injects Claude credentials into the outgoing HTTP request.
|
|
func (e *ClaudeExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
|
if req == nil {
|
|
return nil
|
|
}
|
|
apiKey, _ := claudeCreds(auth)
|
|
if strings.TrimSpace(apiKey) == "" {
|
|
return nil
|
|
}
|
|
useAPIKey := auth != nil && auth.Attributes != nil && strings.TrimSpace(auth.Attributes["api_key"]) != ""
|
|
isAnthropicBase := req.URL != nil && strings.EqualFold(req.URL.Scheme, "https") && strings.EqualFold(req.URL.Host, "api.anthropic.com")
|
|
if isAnthropicBase && useAPIKey {
|
|
req.Header.Del("Authorization")
|
|
req.Header.Set("x-api-key", apiKey)
|
|
} else {
|
|
req.Header.Del("x-api-key")
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
}
|
|
var attrs map[string]string
|
|
if auth != nil {
|
|
attrs = auth.Attributes
|
|
}
|
|
util.ApplyCustomHeadersFromAttrs(req, attrs)
|
|
return nil
|
|
}
|
|
|
|
// HttpRequest injects Claude credentials into the request and executes it.
|
|
func (e *ClaudeExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
|
if req == nil {
|
|
return nil, fmt.Errorf("claude executor: request is nil")
|
|
}
|
|
if ctx == nil {
|
|
ctx = req.Context()
|
|
}
|
|
httpReq := req.WithContext(ctx)
|
|
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
|
return nil, err
|
|
}
|
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
|
return httpClient.Do(httpReq)
|
|
}
|
|
|
|
func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
|
|
|
apiKey, baseURL := claudeCreds(auth)
|
|
if baseURL == "" {
|
|
baseURL = "https://api.anthropic.com"
|
|
}
|
|
|
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
|
defer reporter.trackFailure(ctx, &err)
|
|
from := opts.SourceFormat
|
|
to := sdktranslator.FromString("claude")
|
|
// Use streaming translation to preserve function calling, except for claude.
|
|
stream := from != to
|
|
originalPayload := bytes.Clone(req.Payload)
|
|
if len(opts.OriginalRequest) > 0 {
|
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
|
}
|
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, stream)
|
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), stream)
|
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
|
|
|
body, err = thinking.ApplyThinking(body, req.Model, "claude")
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
|
|
if !strings.HasPrefix(baseModel, "claude-3-5-haiku") {
|
|
body = checkSystemInstructions(body)
|
|
}
|
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated)
|
|
|
|
// Disable thinking if tool_choice forces tool use (Anthropic API constraint)
|
|
body = disableThinkingIfToolChoiceForced(body)
|
|
|
|
// Ensure max_tokens > thinking.budget_tokens when thinking is enabled
|
|
body = ensureMaxTokensForThinking(baseModel, body)
|
|
|
|
// Extract betas from body and convert to header
|
|
var extraBetas []string
|
|
extraBetas, body = extractAndRemoveBetas(body)
|
|
bodyForTranslation := body
|
|
bodyForUpstream := body
|
|
if isClaudeOAuthToken(apiKey) {
|
|
bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream))
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
applyClaudeHeaders(httpReq, auth, apiKey, false, extraBetas)
|
|
var authID, authLabel, authType, authValue string
|
|
if auth != nil {
|
|
authID = auth.ID
|
|
authLabel = auth.Label
|
|
authType, authValue = auth.AccountInfo()
|
|
}
|
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
|
URL: url,
|
|
Method: http.MethodPost,
|
|
Headers: httpReq.Header.Clone(),
|
|
Body: bodyForUpstream,
|
|
Provider: e.Identifier(),
|
|
AuthID: authID,
|
|
AuthLabel: authLabel,
|
|
AuthType: authType,
|
|
AuthValue: authValue,
|
|
})
|
|
|
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
|
httpResp, err := httpClient.Do(httpReq)
|
|
if err != nil {
|
|
recordAPIResponseError(ctx, e.cfg, err)
|
|
return resp, err
|
|
}
|
|
recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
b, _ := io.ReadAll(httpResp.Body)
|
|
appendAPIResponseChunk(ctx, e.cfg, b)
|
|
log.Debugf("request error, error status: %d, error body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
|
|
err = statusErr{code: httpResp.StatusCode, msg: string(b)}
|
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
log.Errorf("response body close error: %v", errClose)
|
|
}
|
|
return resp, err
|
|
}
|
|
decodedBody, err := decodeResponseBody(httpResp.Body, httpResp.Header.Get("Content-Encoding"))
|
|
if err != nil {
|
|
recordAPIResponseError(ctx, e.cfg, err)
|
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
log.Errorf("response body close error: %v", errClose)
|
|
}
|
|
return resp, 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 {
|
|
recordAPIResponseError(ctx, e.cfg, err)
|
|
return resp, err
|
|
}
|
|
appendAPIResponseChunk(ctx, e.cfg, data)
|
|
if stream {
|
|
lines := bytes.Split(data, []byte("\n"))
|
|
for _, line := range lines {
|
|
if detail, ok := parseClaudeStreamUsage(line); ok {
|
|
reporter.publish(ctx, detail)
|
|
}
|
|
}
|
|
} else {
|
|
reporter.publish(ctx, parseClaudeUsage(data))
|
|
}
|
|
if isClaudeOAuthToken(apiKey) {
|
|
data = stripClaudeToolPrefixFromResponse(data, claudeToolPrefix)
|
|
}
|
|
var param any
|
|
out := sdktranslator.TranslateNonStream(
|
|
ctx,
|
|
to,
|
|
from,
|
|
req.Model,
|
|
bytes.Clone(opts.OriginalRequest),
|
|
bodyForTranslation,
|
|
data,
|
|
¶m,
|
|
)
|
|
resp = cliproxyexecutor.Response{Payload: []byte(out)}
|
|
return resp, nil
|
|
}
|
|
|
|
func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
|
|
|
apiKey, baseURL := claudeCreds(auth)
|
|
if baseURL == "" {
|
|
baseURL = "https://api.anthropic.com"
|
|
}
|
|
|
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
|
defer reporter.trackFailure(ctx, &err)
|
|
from := opts.SourceFormat
|
|
to := sdktranslator.FromString("claude")
|
|
originalPayload := bytes.Clone(req.Payload)
|
|
if len(opts.OriginalRequest) > 0 {
|
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
|
}
|
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
|
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
|
|
|
body, err = thinking.ApplyThinking(body, req.Model, "claude")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
body = checkSystemInstructions(body)
|
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated)
|
|
|
|
// Disable thinking if tool_choice forces tool use (Anthropic API constraint)
|
|
body = disableThinkingIfToolChoiceForced(body)
|
|
|
|
// Ensure max_tokens > thinking.budget_tokens when thinking is enabled
|
|
body = ensureMaxTokensForThinking(baseModel, body)
|
|
|
|
// Extract betas from body and convert to header
|
|
var extraBetas []string
|
|
extraBetas, body = extractAndRemoveBetas(body)
|
|
bodyForTranslation := body
|
|
bodyForUpstream := body
|
|
if isClaudeOAuthToken(apiKey) {
|
|
bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applyClaudeHeaders(httpReq, auth, apiKey, true, extraBetas)
|
|
var authID, authLabel, authType, authValue string
|
|
if auth != nil {
|
|
authID = auth.ID
|
|
authLabel = auth.Label
|
|
authType, authValue = auth.AccountInfo()
|
|
}
|
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
|
URL: url,
|
|
Method: http.MethodPost,
|
|
Headers: httpReq.Header.Clone(),
|
|
Body: bodyForUpstream,
|
|
Provider: e.Identifier(),
|
|
AuthID: authID,
|
|
AuthLabel: authLabel,
|
|
AuthType: authType,
|
|
AuthValue: authValue,
|
|
})
|
|
|
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
|
httpResp, err := httpClient.Do(httpReq)
|
|
if err != nil {
|
|
recordAPIResponseError(ctx, e.cfg, err)
|
|
return nil, err
|
|
}
|
|
recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
b, _ := io.ReadAll(httpResp.Body)
|
|
appendAPIResponseChunk(ctx, e.cfg, b)
|
|
log.Debugf("request error, error status: %d, error body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
|
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
log.Errorf("response body close error: %v", errClose)
|
|
}
|
|
err = statusErr{code: httpResp.StatusCode, msg: string(b)}
|
|
return nil, err
|
|
}
|
|
decodedBody, err := decodeResponseBody(httpResp.Body, httpResp.Header.Get("Content-Encoding"))
|
|
if err != nil {
|
|
recordAPIResponseError(ctx, e.cfg, err)
|
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
log.Errorf("response body close error: %v", errClose)
|
|
}
|
|
return nil, err
|
|
}
|
|
out := make(chan cliproxyexecutor.StreamChunk)
|
|
stream = out
|
|
go func() {
|
|
defer close(out)
|
|
defer func() {
|
|
if errClose := decodedBody.Close(); errClose != nil {
|
|
log.Errorf("response body close error: %v", errClose)
|
|
}
|
|
}()
|
|
|
|
// If from == to (Claude → Claude), directly forward the SSE stream without translation
|
|
if from == to {
|
|
scanner := bufio.NewScanner(decodedBody)
|
|
scanner.Buffer(nil, 52_428_800) // 50MB
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
appendAPIResponseChunk(ctx, e.cfg, line)
|
|
if detail, ok := parseClaudeStreamUsage(line); ok {
|
|
reporter.publish(ctx, detail)
|
|
}
|
|
if isClaudeOAuthToken(apiKey) {
|
|
line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix)
|
|
}
|
|
// Forward the line as-is to preserve SSE format
|
|
cloned := make([]byte, len(line)+1)
|
|
copy(cloned, line)
|
|
cloned[len(line)] = '\n'
|
|
out <- cliproxyexecutor.StreamChunk{Payload: cloned}
|
|
}
|
|
if errScan := scanner.Err(); errScan != nil {
|
|
recordAPIResponseError(ctx, e.cfg, errScan)
|
|
reporter.publishFailure(ctx)
|
|
out <- cliproxyexecutor.StreamChunk{Err: errScan}
|
|
}
|
|
return
|
|
}
|
|
|
|
// For other formats, use translation
|
|
scanner := bufio.NewScanner(decodedBody)
|
|
scanner.Buffer(nil, 52_428_800) // 50MB
|
|
var param any
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
appendAPIResponseChunk(ctx, e.cfg, line)
|
|
if detail, ok := parseClaudeStreamUsage(line); ok {
|
|
reporter.publish(ctx, detail)
|
|
}
|
|
if isClaudeOAuthToken(apiKey) {
|
|
line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix)
|
|
}
|
|
chunks := sdktranslator.TranslateStream(
|
|
ctx,
|
|
to,
|
|
from,
|
|
req.Model,
|
|
bytes.Clone(opts.OriginalRequest),
|
|
bodyForTranslation,
|
|
bytes.Clone(line),
|
|
¶m,
|
|
)
|
|
for i := range chunks {
|
|
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
|
|
}
|
|
}
|
|
if errScan := scanner.Err(); errScan != nil {
|
|
recordAPIResponseError(ctx, e.cfg, errScan)
|
|
reporter.publishFailure(ctx)
|
|
out <- cliproxyexecutor.StreamChunk{Err: errScan}
|
|
}
|
|
}()
|
|
return stream, nil
|
|
}
|
|
|
|
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
|
|
|
|
apiKey, baseURL := claudeCreds(auth)
|
|
if baseURL == "" {
|
|
baseURL = "https://api.anthropic.com"
|
|
}
|
|
|
|
from := opts.SourceFormat
|
|
to := sdktranslator.FromString("claude")
|
|
// Use streaming translation to preserve function calling, except for claude.
|
|
stream := from != to
|
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), stream)
|
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
|
|
|
if !strings.HasPrefix(baseModel, "claude-3-5-haiku") {
|
|
body = checkSystemInstructions(body)
|
|
}
|
|
|
|
// Extract betas from body and convert to header (for count_tokens too)
|
|
var extraBetas []string
|
|
extraBetas, body = extractAndRemoveBetas(body)
|
|
if isClaudeOAuthToken(apiKey) {
|
|
body = applyClaudeToolPrefix(body, claudeToolPrefix)
|
|
}
|
|
|
|
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
|
|
}
|
|
applyClaudeHeaders(httpReq, auth, apiKey, false, extraBetas)
|
|
var authID, authLabel, authType, authValue string
|
|
if auth != nil {
|
|
authID = auth.ID
|
|
authLabel = auth.Label
|
|
authType, authValue = auth.AccountInfo()
|
|
}
|
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
|
URL: url,
|
|
Method: http.MethodPost,
|
|
Headers: httpReq.Header.Clone(),
|
|
Body: body,
|
|
Provider: e.Identifier(),
|
|
AuthID: authID,
|
|
AuthLabel: authLabel,
|
|
AuthType: authType,
|
|
AuthValue: authValue,
|
|
})
|
|
|
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
|
resp, err := httpClient.Do(httpReq)
|
|
if err != nil {
|
|
recordAPIResponseError(ctx, e.cfg, err)
|
|
return cliproxyexecutor.Response{}, err
|
|
}
|
|
recordAPIResponseMetadata(ctx, e.cfg, resp.StatusCode, resp.Header.Clone())
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
appendAPIResponseChunk(ctx, e.cfg, b)
|
|
if errClose := resp.Body.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 {
|
|
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 {
|
|
recordAPIResponseError(ctx, e.cfg, err)
|
|
return cliproxyexecutor.Response{}, err
|
|
}
|
|
appendAPIResponseChunk(ctx, e.cfg, data)
|
|
count := gjson.GetBytes(data, "input_tokens").Int()
|
|
out := sdktranslator.TranslateTokenCount(ctx, to, from, count, data)
|
|
return cliproxyexecutor.Response{Payload: []byte(out)}, nil
|
|
}
|
|
|
|
func (e *ClaudeExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
|
|
log.Debugf("claude executor: refresh called")
|
|
if auth == nil {
|
|
return nil, fmt.Errorf("claude executor: auth is nil")
|
|
}
|
|
var refreshToken string
|
|
if auth.Metadata != nil {
|
|
if v, ok := auth.Metadata["refresh_token"].(string); ok && v != "" {
|
|
refreshToken = v
|
|
}
|
|
}
|
|
if refreshToken == "" {
|
|
return auth, nil
|
|
}
|
|
svc := claudeauth.NewClaudeAuth(e.cfg)
|
|
td, err := svc.RefreshTokens(ctx, refreshToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if auth.Metadata == nil {
|
|
auth.Metadata = make(map[string]any)
|
|
}
|
|
auth.Metadata["access_token"] = td.AccessToken
|
|
if td.RefreshToken != "" {
|
|
auth.Metadata["refresh_token"] = td.RefreshToken
|
|
}
|
|
auth.Metadata["email"] = td.Email
|
|
auth.Metadata["expired"] = td.Expire
|
|
auth.Metadata["type"] = "claude"
|
|
now := time.Now().Format(time.RFC3339)
|
|
auth.Metadata["last_refresh"] = now
|
|
return auth, nil
|
|
}
|
|
|
|
// extractAndRemoveBetas extracts the "betas" array from the body and removes it.
|
|
// Returns the extracted betas as a string slice and the modified body.
|
|
func extractAndRemoveBetas(body []byte) ([]string, []byte) {
|
|
betasResult := gjson.GetBytes(body, "betas")
|
|
if !betasResult.Exists() {
|
|
return nil, body
|
|
}
|
|
var betas []string
|
|
if betasResult.IsArray() {
|
|
for _, item := range betasResult.Array() {
|
|
if s := strings.TrimSpace(item.String()); s != "" {
|
|
betas = append(betas, s)
|
|
}
|
|
}
|
|
} else if s := strings.TrimSpace(betasResult.String()); s != "" {
|
|
betas = append(betas, s)
|
|
}
|
|
body, _ = sjson.DeleteBytes(body, "betas")
|
|
return betas, body
|
|
}
|
|
|
|
// disableThinkingIfToolChoiceForced checks if tool_choice forces tool use and disables thinking.
|
|
// Anthropic API does not allow thinking when tool_choice is set to "any" or a specific tool.
|
|
// See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations
|
|
func disableThinkingIfToolChoiceForced(body []byte) []byte {
|
|
toolChoiceType := gjson.GetBytes(body, "tool_choice.type").String()
|
|
// "auto" is allowed with thinking, but "any" or "tool" (specific tool) are not
|
|
if toolChoiceType == "any" || toolChoiceType == "tool" {
|
|
// Remove thinking configuration entirely to avoid API error
|
|
body, _ = sjson.DeleteBytes(body, "thinking")
|
|
}
|
|
return body
|
|
}
|
|
|
|
// ensureMaxTokensForThinking ensures max_tokens > thinking.budget_tokens when thinking is enabled.
|
|
// Anthropic API requires this constraint; violating it returns a 400 error.
|
|
// This function should be called after all thinking configuration is finalized.
|
|
// It looks up the model's MaxCompletionTokens from the registry to use as the cap.
|
|
func ensureMaxTokensForThinking(modelName string, body []byte) []byte {
|
|
thinkingType := gjson.GetBytes(body, "thinking.type").String()
|
|
if thinkingType != "enabled" {
|
|
return body
|
|
}
|
|
|
|
budgetTokens := gjson.GetBytes(body, "thinking.budget_tokens").Int()
|
|
if budgetTokens <= 0 {
|
|
return body
|
|
}
|
|
|
|
maxTokens := gjson.GetBytes(body, "max_tokens").Int()
|
|
|
|
// Look up the model's max completion tokens from the registry
|
|
maxCompletionTokens := 0
|
|
if modelInfo := registry.LookupModelInfo(modelName); modelInfo != nil {
|
|
maxCompletionTokens = modelInfo.MaxCompletionTokens
|
|
}
|
|
|
|
// Fall back to budget + buffer if registry lookup fails or returns 0
|
|
const fallbackBuffer = 4000
|
|
requiredMaxTokens := budgetTokens + fallbackBuffer
|
|
if maxCompletionTokens > 0 {
|
|
requiredMaxTokens = int64(maxCompletionTokens)
|
|
}
|
|
|
|
if maxTokens < requiredMaxTokens {
|
|
body, _ = sjson.SetBytes(body, "max_tokens", requiredMaxTokens)
|
|
}
|
|
return body
|
|
}
|
|
|
|
func (e *ClaudeExecutor) resolveClaudeConfig(auth *cliproxyauth.Auth) *config.ClaudeKey {
|
|
if auth == nil || e.cfg == nil {
|
|
return nil
|
|
}
|
|
var attrKey, attrBase string
|
|
if auth.Attributes != nil {
|
|
attrKey = strings.TrimSpace(auth.Attributes["api_key"])
|
|
attrBase = strings.TrimSpace(auth.Attributes["base_url"])
|
|
}
|
|
for i := range e.cfg.ClaudeKey {
|
|
entry := &e.cfg.ClaudeKey[i]
|
|
cfgKey := strings.TrimSpace(entry.APIKey)
|
|
cfgBase := strings.TrimSpace(entry.BaseURL)
|
|
if attrKey != "" && attrBase != "" {
|
|
if strings.EqualFold(cfgKey, attrKey) && strings.EqualFold(cfgBase, attrBase) {
|
|
return entry
|
|
}
|
|
continue
|
|
}
|
|
if attrKey != "" && strings.EqualFold(cfgKey, attrKey) {
|
|
if cfgBase == "" || strings.EqualFold(cfgBase, attrBase) {
|
|
return entry
|
|
}
|
|
}
|
|
if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) {
|
|
return entry
|
|
}
|
|
}
|
|
if attrKey != "" {
|
|
for i := range e.cfg.ClaudeKey {
|
|
entry := &e.cfg.ClaudeKey[i]
|
|
if strings.EqualFold(strings.TrimSpace(entry.APIKey), attrKey) {
|
|
return entry
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type compositeReadCloser struct {
|
|
io.Reader
|
|
closers []func() error
|
|
}
|
|
|
|
func (c *compositeReadCloser) Close() error {
|
|
var firstErr error
|
|
for i := range c.closers {
|
|
if c.closers[i] == nil {
|
|
continue
|
|
}
|
|
if err := c.closers[i](); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
func decodeResponseBody(body io.ReadCloser, contentEncoding string) (io.ReadCloser, error) {
|
|
if body == nil {
|
|
return nil, fmt.Errorf("response body is nil")
|
|
}
|
|
if contentEncoding == "" {
|
|
return body, nil
|
|
}
|
|
encodings := strings.Split(contentEncoding, ",")
|
|
for _, raw := range encodings {
|
|
encoding := strings.TrimSpace(strings.ToLower(raw))
|
|
switch encoding {
|
|
case "", "identity":
|
|
continue
|
|
case "gzip":
|
|
gzipReader, err := gzip.NewReader(body)
|
|
if err != nil {
|
|
_ = body.Close()
|
|
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
|
|
}
|
|
return &compositeReadCloser{
|
|
Reader: gzipReader,
|
|
closers: []func() error{
|
|
gzipReader.Close,
|
|
func() error { return body.Close() },
|
|
},
|
|
}, nil
|
|
case "deflate":
|
|
deflateReader := flate.NewReader(body)
|
|
return &compositeReadCloser{
|
|
Reader: deflateReader,
|
|
closers: []func() error{
|
|
deflateReader.Close,
|
|
func() error { return body.Close() },
|
|
},
|
|
}, nil
|
|
case "br":
|
|
return &compositeReadCloser{
|
|
Reader: brotli.NewReader(body),
|
|
closers: []func() error{
|
|
func() error { return body.Close() },
|
|
},
|
|
}, nil
|
|
case "zstd":
|
|
decoder, err := zstd.NewReader(body)
|
|
if err != nil {
|
|
_ = body.Close()
|
|
return nil, fmt.Errorf("failed to create zstd reader: %w", err)
|
|
}
|
|
return &compositeReadCloser{
|
|
Reader: decoder,
|
|
closers: []func() error{
|
|
func() error { decoder.Close(); return nil },
|
|
func() error { return body.Close() },
|
|
},
|
|
}, nil
|
|
default:
|
|
continue
|
|
}
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, stream bool, extraBetas []string) {
|
|
useAPIKey := auth != nil && auth.Attributes != nil && strings.TrimSpace(auth.Attributes["api_key"]) != ""
|
|
isAnthropicBase := r.URL != nil && strings.EqualFold(r.URL.Scheme, "https") && strings.EqualFold(r.URL.Host, "api.anthropic.com")
|
|
if isAnthropicBase && useAPIKey {
|
|
r.Header.Del("Authorization")
|
|
r.Header.Set("x-api-key", apiKey)
|
|
} else {
|
|
r.Header.Set("Authorization", "Bearer "+apiKey)
|
|
}
|
|
r.Header.Set("Content-Type", "application/json")
|
|
|
|
var ginHeaders http.Header
|
|
if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
|
|
ginHeaders = ginCtx.Request.Header
|
|
}
|
|
|
|
baseBetas := "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
|
|
if val := strings.TrimSpace(ginHeaders.Get("Anthropic-Beta")); val != "" {
|
|
baseBetas = val
|
|
if !strings.Contains(val, "oauth") {
|
|
baseBetas += ",oauth-2025-04-20"
|
|
}
|
|
}
|
|
|
|
// Merge extra betas from request body
|
|
if len(extraBetas) > 0 {
|
|
existingSet := make(map[string]bool)
|
|
for _, b := range strings.Split(baseBetas, ",") {
|
|
existingSet[strings.TrimSpace(b)] = true
|
|
}
|
|
for _, beta := range extraBetas {
|
|
beta = strings.TrimSpace(beta)
|
|
if beta != "" && !existingSet[beta] {
|
|
baseBetas += "," + beta
|
|
existingSet[beta] = true
|
|
}
|
|
}
|
|
}
|
|
r.Header.Set("Anthropic-Beta", baseBetas)
|
|
|
|
misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Version", "2023-06-01")
|
|
misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Dangerous-Direct-Browser-Access", "true")
|
|
misc.EnsureHeader(r.Header, ginHeaders, "X-App", "cli")
|
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Helper-Method", "stream")
|
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Retry-Count", "0")
|
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Runtime-Version", "v24.3.0")
|
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Package-Version", "0.55.1")
|
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Runtime", "node")
|
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Lang", "js")
|
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Arch", "arm64")
|
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Os", "MacOS")
|
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Timeout", "60")
|
|
misc.EnsureHeader(r.Header, ginHeaders, "User-Agent", "claude-cli/1.0.83 (external, cli)")
|
|
r.Header.Set("Connection", "keep-alive")
|
|
r.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
|
|
if stream {
|
|
r.Header.Set("Accept", "text/event-stream")
|
|
} else {
|
|
r.Header.Set("Accept", "application/json")
|
|
}
|
|
var attrs map[string]string
|
|
if auth != nil {
|
|
attrs = auth.Attributes
|
|
}
|
|
util.ApplyCustomHeadersFromAttrs(r, attrs)
|
|
}
|
|
|
|
func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
|
|
if a == nil {
|
|
return "", ""
|
|
}
|
|
if a.Attributes != nil {
|
|
apiKey = a.Attributes["api_key"]
|
|
baseURL = a.Attributes["base_url"]
|
|
}
|
|
if apiKey == "" && a.Metadata != nil {
|
|
if v, ok := a.Metadata["access_token"].(string); ok {
|
|
apiKey = v
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func checkSystemInstructions(payload []byte) []byte {
|
|
system := gjson.GetBytes(payload, "system")
|
|
claudeCodeInstructions := `[{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude."}]`
|
|
if system.IsArray() {
|
|
if gjson.GetBytes(payload, "system.0.text").String() != "You are Claude Code, Anthropic's official CLI for Claude." {
|
|
system.ForEach(func(_, part gjson.Result) bool {
|
|
if part.Get("type").String() == "text" {
|
|
claudeCodeInstructions, _ = sjson.SetRaw(claudeCodeInstructions, "-1", part.Raw)
|
|
}
|
|
return true
|
|
})
|
|
payload, _ = sjson.SetRawBytes(payload, "system", []byte(claudeCodeInstructions))
|
|
}
|
|
} else {
|
|
payload, _ = sjson.SetRawBytes(payload, "system", []byte(claudeCodeInstructions))
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func isClaudeOAuthToken(apiKey string) bool {
|
|
return strings.Contains(apiKey, "sk-ant-oat")
|
|
}
|
|
|
|
func applyClaudeToolPrefix(body []byte, prefix string) []byte {
|
|
if prefix == "" {
|
|
return body
|
|
}
|
|
|
|
if tools := gjson.GetBytes(body, "tools"); tools.Exists() && tools.IsArray() {
|
|
tools.ForEach(func(index, tool gjson.Result) bool {
|
|
name := tool.Get("name").String()
|
|
if name == "" || strings.HasPrefix(name, prefix) {
|
|
return true
|
|
}
|
|
path := fmt.Sprintf("tools.%d.name", index.Int())
|
|
body, _ = sjson.SetBytes(body, path, prefix+name)
|
|
return true
|
|
})
|
|
}
|
|
|
|
if gjson.GetBytes(body, "tool_choice.type").String() == "tool" {
|
|
name := gjson.GetBytes(body, "tool_choice.name").String()
|
|
if name != "" && !strings.HasPrefix(name, prefix) {
|
|
body, _ = sjson.SetBytes(body, "tool_choice.name", prefix+name)
|
|
}
|
|
}
|
|
|
|
if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
|
|
messages.ForEach(func(msgIndex, msg gjson.Result) bool {
|
|
content := msg.Get("content")
|
|
if !content.Exists() || !content.IsArray() {
|
|
return true
|
|
}
|
|
content.ForEach(func(contentIndex, part gjson.Result) bool {
|
|
if part.Get("type").String() != "tool_use" {
|
|
return true
|
|
}
|
|
name := part.Get("name").String()
|
|
if name == "" || strings.HasPrefix(name, prefix) {
|
|
return true
|
|
}
|
|
path := fmt.Sprintf("messages.%d.content.%d.name", msgIndex.Int(), contentIndex.Int())
|
|
body, _ = sjson.SetBytes(body, path, prefix+name)
|
|
return true
|
|
})
|
|
return true
|
|
})
|
|
}
|
|
|
|
return body
|
|
}
|
|
|
|
func stripClaudeToolPrefixFromResponse(body []byte, prefix string) []byte {
|
|
if prefix == "" {
|
|
return body
|
|
}
|
|
content := gjson.GetBytes(body, "content")
|
|
if !content.Exists() || !content.IsArray() {
|
|
return body
|
|
}
|
|
content.ForEach(func(index, part gjson.Result) bool {
|
|
if part.Get("type").String() != "tool_use" {
|
|
return true
|
|
}
|
|
name := part.Get("name").String()
|
|
if !strings.HasPrefix(name, prefix) {
|
|
return true
|
|
}
|
|
path := fmt.Sprintf("content.%d.name", index.Int())
|
|
body, _ = sjson.SetBytes(body, path, strings.TrimPrefix(name, prefix))
|
|
return true
|
|
})
|
|
return body
|
|
}
|
|
|
|
func stripClaudeToolPrefixFromStreamLine(line []byte, prefix string) []byte {
|
|
if prefix == "" {
|
|
return line
|
|
}
|
|
payload := jsonPayload(line)
|
|
if len(payload) == 0 || !gjson.ValidBytes(payload) {
|
|
return line
|
|
}
|
|
contentBlock := gjson.GetBytes(payload, "content_block")
|
|
if !contentBlock.Exists() || contentBlock.Get("type").String() != "tool_use" {
|
|
return line
|
|
}
|
|
name := contentBlock.Get("name").String()
|
|
if !strings.HasPrefix(name, prefix) {
|
|
return line
|
|
}
|
|
updated, err := sjson.SetBytes(payload, "content_block.name", strings.TrimPrefix(name, prefix))
|
|
if err != nil {
|
|
return line
|
|
}
|
|
|
|
trimmed := bytes.TrimSpace(line)
|
|
if bytes.HasPrefix(trimmed, []byte("data:")) {
|
|
return append([]byte("data: "), updated...)
|
|
}
|
|
return updated
|
|
}
|