| 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/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" |
| ) |
|
|
| |
| |
| 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" } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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) { |
| if opts.Alt == "responses/compact" { |
| return resp, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"} |
| } |
| 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") |
| |
| stream := from != to |
| originalPayloadSource := req.Payload |
| if len(opts.OriginalRequest) > 0 { |
| originalPayloadSource = opts.OriginalRequest |
| } |
| originalPayload := originalPayloadSource |
| originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, stream) |
| body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, stream) |
| body, _ = sjson.SetBytes(body, "model", baseModel) |
|
|
| body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) |
| if err != nil { |
| return resp, err |
| } |
|
|
| |
| |
| body = applyCloaking(ctx, e.cfg, auth, body, baseModel) |
|
|
| requestedModel := payloadRequestedModel(opts, req.Model) |
| body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) |
|
|
| |
| body = disableThinkingIfToolChoiceForced(body) |
|
|
| |
| if countCacheControls(body) == 0 { |
| body = ensureCacheControl(body) |
| } |
|
|
| |
| 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) |
| logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %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, |
| 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) { |
| if opts.Alt == "responses/compact" { |
| return nil, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"} |
| } |
| 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") |
| originalPayloadSource := req.Payload |
| if len(opts.OriginalRequest) > 0 { |
| originalPayloadSource = opts.OriginalRequest |
| } |
| originalPayload := originalPayloadSource |
| originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) |
| body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true) |
| body, _ = sjson.SetBytes(body, "model", baseModel) |
|
|
| body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) |
| if err != nil { |
| return nil, err |
| } |
|
|
| |
| |
| body = applyCloaking(ctx, e.cfg, auth, body, baseModel) |
|
|
| requestedModel := payloadRequestedModel(opts, req.Model) |
| body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) |
|
|
| |
| body = disableThinkingIfToolChoiceForced(body) |
|
|
| |
| if countCacheControls(body) == 0 { |
| body = ensureCacheControl(body) |
| } |
|
|
| |
| 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) |
| logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %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 { |
| scanner := bufio.NewScanner(decodedBody) |
| scanner.Buffer(nil, 52_428_800) |
| 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) |
| } |
| |
| 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 |
| } |
|
|
| |
| scanner := bufio.NewScanner(decodedBody) |
| scanner.Buffer(nil, 52_428_800) |
| 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, |
| 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") |
| |
| stream := from != to |
| body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, stream) |
| body, _ = sjson.SetBytes(body, "model", baseModel) |
|
|
| if !strings.HasPrefix(baseModel, "claude-3-5-haiku") { |
| body = checkSystemInstructions(body) |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| |
| 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 |
| } |
|
|
| |
| |
| |
| func disableThinkingIfToolChoiceForced(body []byte) []byte { |
| toolChoiceType := gjson.GetBytes(body, "tool_choice.type").String() |
| |
| if toolChoiceType == "any" || toolChoiceType == "tool" { |
| |
| body, _ = sjson.DeleteBytes(body, "thinking") |
| } |
| return body |
| } |
|
|
| 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 |
| } |
|
|
| promptCachingBeta := "prompt-caching-2024-07-31" |
| baseBetas := "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14," + promptCachingBeta |
| if val := strings.TrimSpace(ginHeaders.Get("Anthropic-Beta")); val != "" { |
| baseBetas = val |
| if !strings.Contains(val, "oauth") { |
| baseBetas += ",oauth-2025-04-20" |
| } |
| } |
| if !strings.Contains(baseBetas, promptCachingBeta) { |
| baseBetas += "," + promptCachingBeta |
| } |
|
|
| |
| 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 { |
| |
| |
| if tool.Get("type").Exists() && tool.Get("type").String() != "" { |
| return true |
| } |
| 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 |
| } |
|
|
| |
| func getClientUserAgent(ctx context.Context) string { |
| if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { |
| return ginCtx.GetHeader("User-Agent") |
| } |
| return "" |
| } |
|
|
| |
| |
| func getCloakConfigFromAuth(auth *cliproxyauth.Auth) (string, bool, []string) { |
| if auth == nil || auth.Attributes == nil { |
| return "auto", false, nil |
| } |
|
|
| cloakMode := auth.Attributes["cloak_mode"] |
| if cloakMode == "" { |
| cloakMode = "auto" |
| } |
|
|
| strictMode := strings.ToLower(auth.Attributes["cloak_strict_mode"]) == "true" |
|
|
| var sensitiveWords []string |
| if wordsStr := auth.Attributes["cloak_sensitive_words"]; wordsStr != "" { |
| sensitiveWords = strings.Split(wordsStr, ",") |
| for i := range sensitiveWords { |
| sensitiveWords[i] = strings.TrimSpace(sensitiveWords[i]) |
| } |
| } |
|
|
| return cloakMode, strictMode, sensitiveWords |
| } |
|
|
| |
| func resolveClaudeKeyCloakConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config.CloakConfig { |
| if cfg == nil || auth == nil { |
| return nil |
| } |
|
|
| apiKey, baseURL := claudeCreds(auth) |
| if apiKey == "" { |
| return nil |
| } |
|
|
| for i := range cfg.ClaudeKey { |
| entry := &cfg.ClaudeKey[i] |
| cfgKey := strings.TrimSpace(entry.APIKey) |
| cfgBase := strings.TrimSpace(entry.BaseURL) |
|
|
| |
| if strings.EqualFold(cfgKey, apiKey) { |
| |
| if baseURL != "" && cfgBase != "" && !strings.EqualFold(cfgBase, baseURL) { |
| continue |
| } |
| return entry.Cloak |
| } |
| } |
|
|
| return nil |
| } |
|
|
| |
| func injectFakeUserID(payload []byte) []byte { |
| metadata := gjson.GetBytes(payload, "metadata") |
| if !metadata.Exists() { |
| payload, _ = sjson.SetBytes(payload, "metadata.user_id", generateFakeUserID()) |
| return payload |
| } |
|
|
| existingUserID := gjson.GetBytes(payload, "metadata.user_id").String() |
| if existingUserID == "" || !isValidUserID(existingUserID) { |
| payload, _ = sjson.SetBytes(payload, "metadata.user_id", generateFakeUserID()) |
| } |
| return payload |
| } |
|
|
| |
| |
| |
| func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte { |
| system := gjson.GetBytes(payload, "system") |
| claudeCodeInstructions := `[{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude."}]` |
|
|
| if strictMode { |
| |
| payload, _ = sjson.SetRawBytes(payload, "system", []byte(claudeCodeInstructions)) |
| return payload |
| } |
|
|
| |
| 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 applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, payload []byte, model string) []byte { |
| clientUserAgent := getClientUserAgent(ctx) |
|
|
| |
| cloakCfg := resolveClaudeKeyCloakConfig(cfg, auth) |
|
|
| |
| var cloakMode string |
| var strictMode bool |
| var sensitiveWords []string |
|
|
| if cloakCfg != nil { |
| cloakMode = cloakCfg.Mode |
| strictMode = cloakCfg.StrictMode |
| sensitiveWords = cloakCfg.SensitiveWords |
| } |
|
|
| |
| if cloakMode == "" { |
| attrMode, attrStrict, attrWords := getCloakConfigFromAuth(auth) |
| cloakMode = attrMode |
| if !strictMode { |
| strictMode = attrStrict |
| } |
| if len(sensitiveWords) == 0 { |
| sensitiveWords = attrWords |
| } |
| } |
|
|
| |
| if !shouldCloak(cloakMode, clientUserAgent) { |
| return payload |
| } |
|
|
| |
| if !strings.HasPrefix(model, "claude-3-5-haiku") { |
| payload = checkSystemInstructionsWithMode(payload, strictMode) |
| } |
|
|
| |
| payload = injectFakeUserID(payload) |
|
|
| |
| if len(sensitiveWords) > 0 { |
| matcher := buildSensitiveWordMatcher(sensitiveWords) |
| payload = obfuscateSensitiveWords(payload, matcher) |
| } |
|
|
| return payload |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| func ensureCacheControl(payload []byte) []byte { |
| |
| |
| payload = injectToolsCacheControl(payload) |
|
|
| |
| |
| payload = injectSystemCacheControl(payload) |
|
|
| |
| |
| payload = injectMessagesCacheControl(payload) |
|
|
| return payload |
| } |
|
|
| func countCacheControls(payload []byte) int { |
| count := 0 |
|
|
| |
| system := gjson.GetBytes(payload, "system") |
| if system.IsArray() { |
| system.ForEach(func(_, item gjson.Result) bool { |
| if item.Get("cache_control").Exists() { |
| count++ |
| } |
| return true |
| }) |
| } |
|
|
| |
| tools := gjson.GetBytes(payload, "tools") |
| if tools.IsArray() { |
| tools.ForEach(func(_, item gjson.Result) bool { |
| if item.Get("cache_control").Exists() { |
| count++ |
| } |
| return true |
| }) |
| } |
|
|
| |
| messages := gjson.GetBytes(payload, "messages") |
| if messages.IsArray() { |
| messages.ForEach(func(_, msg gjson.Result) bool { |
| content := msg.Get("content") |
| if content.IsArray() { |
| content.ForEach(func(_, item gjson.Result) bool { |
| if item.Get("cache_control").Exists() { |
| count++ |
| } |
| return true |
| }) |
| } |
| return true |
| }) |
| } |
|
|
| return count |
| } |
|
|
| |
| |
| |
| |
| |
| |
| func injectMessagesCacheControl(payload []byte) []byte { |
| messages := gjson.GetBytes(payload, "messages") |
| if !messages.Exists() || !messages.IsArray() { |
| return payload |
| } |
|
|
| |
| hasCacheControlInMessages := false |
| messages.ForEach(func(_, msg gjson.Result) bool { |
| content := msg.Get("content") |
| if content.IsArray() { |
| content.ForEach(func(_, item gjson.Result) bool { |
| if item.Get("cache_control").Exists() { |
| hasCacheControlInMessages = true |
| return false |
| } |
| return true |
| }) |
| } |
| return !hasCacheControlInMessages |
| }) |
| if hasCacheControlInMessages { |
| return payload |
| } |
|
|
| |
| var userMsgIndices []int |
| messages.ForEach(func(index gjson.Result, msg gjson.Result) bool { |
| if msg.Get("role").String() == "user" { |
| userMsgIndices = append(userMsgIndices, int(index.Int())) |
| } |
| return true |
| }) |
|
|
| |
| if len(userMsgIndices) < 2 { |
| return payload |
| } |
|
|
| |
| secondToLastUserIdx := userMsgIndices[len(userMsgIndices)-2] |
|
|
| |
| contentPath := fmt.Sprintf("messages.%d.content", secondToLastUserIdx) |
| content := gjson.GetBytes(payload, contentPath) |
|
|
| if content.IsArray() { |
| |
| contentCount := int(content.Get("#").Int()) |
| if contentCount > 0 { |
| cacheControlPath := fmt.Sprintf("messages.%d.content.%d.cache_control", secondToLastUserIdx, contentCount-1) |
| result, err := sjson.SetBytes(payload, cacheControlPath, map[string]string{"type": "ephemeral"}) |
| if err != nil { |
| log.Warnf("failed to inject cache_control into messages: %v", err) |
| return payload |
| } |
| payload = result |
| } |
| } else if content.Type == gjson.String { |
| |
| text := content.String() |
| newContent := []map[string]interface{}{ |
| { |
| "type": "text", |
| "text": text, |
| "cache_control": map[string]string{ |
| "type": "ephemeral", |
| }, |
| }, |
| } |
| result, err := sjson.SetBytes(payload, contentPath, newContent) |
| if err != nil { |
| log.Warnf("failed to inject cache_control into message string content: %v", err) |
| return payload |
| } |
| payload = result |
| } |
|
|
| return payload |
| } |
|
|
| |
| |
| |
| func injectToolsCacheControl(payload []byte) []byte { |
| tools := gjson.GetBytes(payload, "tools") |
| if !tools.Exists() || !tools.IsArray() { |
| return payload |
| } |
|
|
| toolCount := int(tools.Get("#").Int()) |
| if toolCount == 0 { |
| return payload |
| } |
|
|
| |
| hasCacheControlInTools := false |
| tools.ForEach(func(_, tool gjson.Result) bool { |
| if tool.Get("cache_control").Exists() { |
| hasCacheControlInTools = true |
| return false |
| } |
| return true |
| }) |
| if hasCacheControlInTools { |
| return payload |
| } |
|
|
| |
| lastToolPath := fmt.Sprintf("tools.%d.cache_control", toolCount-1) |
| result, err := sjson.SetBytes(payload, lastToolPath, map[string]string{"type": "ephemeral"}) |
| if err != nil { |
| log.Warnf("failed to inject cache_control into tools array: %v", err) |
| return payload |
| } |
|
|
| return result |
| } |
|
|
| |
| |
| |
| func injectSystemCacheControl(payload []byte) []byte { |
| system := gjson.GetBytes(payload, "system") |
| if !system.Exists() { |
| return payload |
| } |
|
|
| if system.IsArray() { |
| count := int(system.Get("#").Int()) |
| if count == 0 { |
| return payload |
| } |
|
|
| |
| hasCacheControlInSystem := false |
| system.ForEach(func(_, item gjson.Result) bool { |
| if item.Get("cache_control").Exists() { |
| hasCacheControlInSystem = true |
| return false |
| } |
| return true |
| }) |
| if hasCacheControlInSystem { |
| return payload |
| } |
|
|
| |
| lastSystemPath := fmt.Sprintf("system.%d.cache_control", count-1) |
| result, err := sjson.SetBytes(payload, lastSystemPath, map[string]string{"type": "ephemeral"}) |
| if err != nil { |
| log.Warnf("failed to inject cache_control into system array: %v", err) |
| return payload |
| } |
| payload = result |
| } else if system.Type == gjson.String { |
| |
| |
| text := system.String() |
| newSystem := []map[string]interface{}{ |
| { |
| "type": "text", |
| "text": text, |
| "cache_control": map[string]string{ |
| "type": "ephemeral", |
| }, |
| }, |
| } |
| result, err := sjson.SetBytes(payload, "system", newSystem) |
| if err != nil { |
| log.Warnf("failed to inject cache_control into system string: %v", err) |
| return payload |
| } |
| payload = result |
| } |
|
|
| return payload |
| } |
|
|