Spaces:
Paused
Paused
| 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" | |
| ) | |
| // 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) { | |
| 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") | |
| // Use streaming translation to preserve function calling, except for 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 | |
| } | |
| // Apply cloaking (system prompt injection, fake user ID, sensitive word obfuscation) | |
| // based on client type and configuration. | |
| body = applyCloaking(ctx, e.cfg, auth, body, baseModel) | |
| requestedModel := payloadRequestedModel(opts, req.Model) | |
| body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) | |
| // Disable thinking if tool_choice forces tool use (Anthropic API constraint) | |
| body = disableThinkingIfToolChoiceForced(body) | |
| // Auto-inject cache_control if missing (optimization for ClawdBot/clients without caching support) | |
| if countCacheControls(body) == 0 { | |
| body = ensureCacheControl(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) | |
| 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 | |
| } | |
| // Apply cloaking (system prompt injection, fake user ID, sensitive word obfuscation) | |
| // based on client type and configuration. | |
| body = applyCloaking(ctx, e.cfg, auth, body, baseModel) | |
| requestedModel := payloadRequestedModel(opts, req.Model) | |
| body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) | |
| // Disable thinking if tool_choice forces tool use (Anthropic API constraint) | |
| body = disableThinkingIfToolChoiceForced(body) | |
| // Auto-inject cache_control if missing (optimization for ClawdBot/clients without caching support) | |
| if countCacheControls(body) == 0 { | |
| body = ensureCacheControl(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) | |
| 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 (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, | |
| 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, 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 | |
| } | |
| 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 | |
| } | |
| // 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 { | |
| // Skip built-in tools (web_search, code_execution, etc.) which have | |
| // a "type" field and require their name to remain unchanged. | |
| 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 | |
| } | |
| // getClientUserAgent extracts the client User-Agent from the gin context. | |
| 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 "" | |
| } | |
| // getCloakConfigFromAuth extracts cloak configuration from auth attributes. | |
| // Returns (cloakMode, strictMode, sensitiveWords). | |
| 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 | |
| } | |
| // resolveClaudeKeyCloakConfig finds the matching ClaudeKey config and returns its CloakConfig. | |
| 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) | |
| // Match by API key | |
| if strings.EqualFold(cfgKey, apiKey) { | |
| // If baseURL is specified, also check it | |
| if baseURL != "" && cfgBase != "" && !strings.EqualFold(cfgBase, baseURL) { | |
| continue | |
| } | |
| return entry.Cloak | |
| } | |
| } | |
| return nil | |
| } | |
| // injectFakeUserID generates and injects a fake user ID into the request metadata. | |
| 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 | |
| } | |
| // checkSystemInstructionsWithMode injects Claude Code system prompt. | |
| // In strict mode, it replaces all user system messages. | |
| // In non-strict mode (default), it prepends to existing system messages. | |
| 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 { | |
| // Strict mode: replace all system messages with Claude Code prompt only | |
| payload, _ = sjson.SetRawBytes(payload, "system", []byte(claudeCodeInstructions)) | |
| return payload | |
| } | |
| // Non-strict mode (default): prepend Claude Code prompt to existing system messages | |
| 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 | |
| } | |
| // applyCloaking applies cloaking transformations to the payload based on config and client. | |
| // Cloaking includes: system prompt injection, fake user ID, and sensitive word obfuscation. | |
| func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, payload []byte, model string) []byte { | |
| clientUserAgent := getClientUserAgent(ctx) | |
| // Get cloak config from ClaudeKey configuration | |
| cloakCfg := resolveClaudeKeyCloakConfig(cfg, auth) | |
| // Determine cloak settings | |
| var cloakMode string | |
| var strictMode bool | |
| var sensitiveWords []string | |
| if cloakCfg != nil { | |
| cloakMode = cloakCfg.Mode | |
| strictMode = cloakCfg.StrictMode | |
| sensitiveWords = cloakCfg.SensitiveWords | |
| } | |
| // Fallback to auth attributes if no config found | |
| if cloakMode == "" { | |
| attrMode, attrStrict, attrWords := getCloakConfigFromAuth(auth) | |
| cloakMode = attrMode | |
| if !strictMode { | |
| strictMode = attrStrict | |
| } | |
| if len(sensitiveWords) == 0 { | |
| sensitiveWords = attrWords | |
| } | |
| } | |
| // Determine if cloaking should be applied | |
| if !shouldCloak(cloakMode, clientUserAgent) { | |
| return payload | |
| } | |
| // Skip system instructions for claude-3-5-haiku models | |
| if !strings.HasPrefix(model, "claude-3-5-haiku") { | |
| payload = checkSystemInstructionsWithMode(payload, strictMode) | |
| } | |
| // Inject fake user ID | |
| payload = injectFakeUserID(payload) | |
| // Apply sensitive word obfuscation | |
| if len(sensitiveWords) > 0 { | |
| matcher := buildSensitiveWordMatcher(sensitiveWords) | |
| payload = obfuscateSensitiveWords(payload, matcher) | |
| } | |
| return payload | |
| } | |
| // ensureCacheControl injects cache_control breakpoints into the payload for optimal prompt caching. | |
| // According to Anthropic's documentation, cache prefixes are created in order: tools -> system -> messages. | |
| // This function adds cache_control to: | |
| // 1. The LAST tool in the tools array (caches all tool definitions) | |
| // 2. The LAST element in the system array (caches system prompt) | |
| // 3. The SECOND-TO-LAST user turn (caches conversation history for multi-turn) | |
| // | |
| // Up to 4 cache breakpoints are allowed per request. Tools, System, and Messages are INDEPENDENT breakpoints. | |
| // This enables up to 90% cost reduction on cached tokens (cache read = 0.1x base price). | |
| // See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching | |
| func ensureCacheControl(payload []byte) []byte { | |
| // 1. Inject cache_control into the LAST tool (caches all tool definitions) | |
| // Tools are cached first in the hierarchy, so this is the most important breakpoint. | |
| payload = injectToolsCacheControl(payload) | |
| // 2. Inject cache_control into the LAST system prompt element | |
| // System is the second level in the cache hierarchy. | |
| payload = injectSystemCacheControl(payload) | |
| // 3. Inject cache_control into messages for multi-turn conversation caching | |
| // This caches the conversation history up to the second-to-last user turn. | |
| payload = injectMessagesCacheControl(payload) | |
| return payload | |
| } | |
| func countCacheControls(payload []byte) int { | |
| count := 0 | |
| // Check system | |
| system := gjson.GetBytes(payload, "system") | |
| if system.IsArray() { | |
| system.ForEach(func(_, item gjson.Result) bool { | |
| if item.Get("cache_control").Exists() { | |
| count++ | |
| } | |
| return true | |
| }) | |
| } | |
| // Check tools | |
| tools := gjson.GetBytes(payload, "tools") | |
| if tools.IsArray() { | |
| tools.ForEach(func(_, item gjson.Result) bool { | |
| if item.Get("cache_control").Exists() { | |
| count++ | |
| } | |
| return true | |
| }) | |
| } | |
| // Check messages | |
| 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 | |
| } | |
| // injectMessagesCacheControl adds cache_control to the second-to-last user turn for multi-turn caching. | |
| // Per Anthropic docs: "Place cache_control on the second-to-last User message to let the model reuse the earlier cache." | |
| // This enables caching of conversation history, which is especially beneficial for long multi-turn conversations. | |
| // Only adds cache_control if: | |
| // - There are at least 2 user turns in the conversation | |
| // - No message content already has cache_control | |
| func injectMessagesCacheControl(payload []byte) []byte { | |
| messages := gjson.GetBytes(payload, "messages") | |
| if !messages.Exists() || !messages.IsArray() { | |
| return payload | |
| } | |
| // Check if ANY message content already has cache_control | |
| 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 | |
| } | |
| // Find all user message indices | |
| 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 | |
| }) | |
| // Need at least 2 user turns to cache the second-to-last | |
| if len(userMsgIndices) < 2 { | |
| return payload | |
| } | |
| // Get the second-to-last user message index | |
| secondToLastUserIdx := userMsgIndices[len(userMsgIndices)-2] | |
| // Get the content of this message | |
| contentPath := fmt.Sprintf("messages.%d.content", secondToLastUserIdx) | |
| content := gjson.GetBytes(payload, contentPath) | |
| if content.IsArray() { | |
| // Add cache_control to the last content block of this message | |
| 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 { | |
| // Convert string content to array with cache_control | |
| 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 | |
| } | |
| // injectToolsCacheControl adds cache_control to the last tool in the tools array. | |
| // Per Anthropic docs: "The cache_control parameter on the last tool definition caches all tool definitions." | |
| // This only adds cache_control if NO tool in the array already has it. | |
| 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 | |
| } | |
| // Check if ANY tool already has cache_control - if so, don't modify tools | |
| 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 | |
| } | |
| // Add cache_control to the last tool | |
| 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 | |
| } | |
| // injectSystemCacheControl adds cache_control to the last element in the system prompt. | |
| // Converts string system prompts to array format if needed. | |
| // This only adds cache_control if NO system element already has it. | |
| 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 | |
| } | |
| // Check if ANY system element already has cache_control | |
| 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 | |
| } | |
| // Add cache_control to the last system element | |
| 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 { | |
| // Convert string system prompt to array with cache_control | |
| // "system": "text" -> "system": [{"type": "text", "text": "text", "cache_control": {"type": "ephemeral"}}] | |
| 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 | |
| } | |