mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-06 08:02:22 +08:00
- Removed deprecated interceptor and executor-related methods, including `callRequestInterceptor`, `callResponseInterceptor`, and `callStreamChunkInterceptor`. - Consolidated unused logic and pruned redundant imports to streamline `adapters.go`. - No functional changes.
909 lines
30 KiB
Go
909 lines
30 KiB
Go
package executor
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"compress/flate"
|
|
"compress/gzip"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/andybalholm/brotli"
|
|
"github.com/google/uuid"
|
|
"github.com/klauspost/compress/zstd"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// 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")
|
|
// Adaptive thinking may also set output_config.effort; remove it to avoid
|
|
// leaking thinking controls when tool_choice forces tool use.
|
|
body, _ = sjson.DeleteBytes(body, "output_config.effort")
|
|
if oc := gjson.GetBytes(body, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
|
body, _ = sjson.DeleteBytes(body, "output_config")
|
|
}
|
|
}
|
|
return body
|
|
}
|
|
|
|
// normalizeClaudeSamplingForUpstream keeps Anthropic message requests valid.
|
|
func normalizeClaudeSamplingForUpstream(body []byte) []byte {
|
|
body, _ = sjson.DeleteBytes(body, "temperature")
|
|
body, _ = sjson.DeleteBytes(body, "top_p")
|
|
|
|
thinkingType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String()))
|
|
switch thinkingType {
|
|
case "enabled", "adaptive", "auto":
|
|
body, _ = sjson.DeleteBytes(body, "top_p")
|
|
body, _ = sjson.DeleteBytes(body, "top_k")
|
|
}
|
|
return body
|
|
}
|
|
|
|
// ensureClaudeThinkingDisplay defaults thinking.display to "summarized" when thinking
|
|
// is active and the client did not set display. Without this, Claude backends that
|
|
// enable redact-thinking return signature-only thinking blocks (empty thinking text).
|
|
// Explicit client values such as "omitted" are preserved.
|
|
func ensureClaudeThinkingDisplay(body []byte) []byte {
|
|
thinkingType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String()))
|
|
switch thinkingType {
|
|
case "enabled", "adaptive", "auto":
|
|
default:
|
|
return body
|
|
}
|
|
if display := strings.TrimSpace(gjson.GetBytes(body, "thinking.display").String()); display != "" {
|
|
return body
|
|
}
|
|
out, err := sjson.SetBytes(body, "thinking.display", "summarized")
|
|
if err != nil {
|
|
return body
|
|
}
|
|
return out
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// peekableBody wraps a bufio.Reader around the original ReadCloser so that
|
|
// magic bytes can be inspected without consuming them from the stream.
|
|
type peekableBody struct {
|
|
*bufio.Reader
|
|
closer io.Closer
|
|
}
|
|
|
|
func (p *peekableBody) Close() error {
|
|
return p.closer.Close()
|
|
}
|
|
|
|
func decodeResponseBody(body io.ReadCloser, contentEncoding string) (io.ReadCloser, error) {
|
|
if body == nil {
|
|
return nil, fmt.Errorf("response body is nil")
|
|
}
|
|
if contentEncoding == "" {
|
|
// No Content-Encoding header. Attempt best-effort magic-byte detection to
|
|
// handle misbehaving upstreams that compress without setting the header.
|
|
// Only gzip (1f 8b) and zstd (28 b5 2f fd) have reliable magic sequences;
|
|
// br and deflate have none and are left as-is.
|
|
// The bufio wrapper preserves unread bytes so callers always see the full
|
|
// stream regardless of whether decompression was applied.
|
|
pb := &peekableBody{Reader: bufio.NewReader(body), closer: body}
|
|
magic, peekErr := pb.Peek(4)
|
|
if peekErr == nil || (peekErr == io.EOF && len(magic) >= 2) {
|
|
switch {
|
|
case len(magic) >= 2 && magic[0] == 0x1f && magic[1] == 0x8b:
|
|
gzipReader, gzErr := gzip.NewReader(pb)
|
|
if gzErr != nil {
|
|
_ = pb.Close()
|
|
return nil, fmt.Errorf("magic-byte gzip: failed to create reader: %w", gzErr)
|
|
}
|
|
return &compositeReadCloser{
|
|
Reader: gzipReader,
|
|
closers: []func() error{
|
|
gzipReader.Close,
|
|
pb.Close,
|
|
},
|
|
}, nil
|
|
case len(magic) >= 4 && magic[0] == 0x28 && magic[1] == 0xb5 && magic[2] == 0x2f && magic[3] == 0xfd:
|
|
decoder, zdErr := zstd.NewReader(pb)
|
|
if zdErr != nil {
|
|
_ = pb.Close()
|
|
return nil, fmt.Errorf("magic-byte zstd: failed to create reader: %w", zdErr)
|
|
}
|
|
return &compositeReadCloser{
|
|
Reader: decoder,
|
|
closers: []func() error{
|
|
func() error { decoder.Close(); return nil },
|
|
pb.Close,
|
|
},
|
|
}, nil
|
|
}
|
|
}
|
|
return pb, 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, cfg *config.Config, incomingHeaders http.Header) error {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
hdrDefault := func(cfgVal, fallback string) string {
|
|
if cfgVal != "" {
|
|
return cfgVal
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
var hd config.ClaudeHeaderDefaults
|
|
if cfg != nil {
|
|
hd = cfg.ClaudeHeaderDefaults
|
|
}
|
|
|
|
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")
|
|
|
|
if incomingHeaders == nil {
|
|
if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
|
|
incomingHeaders = ginCtx.Request.Header
|
|
}
|
|
}
|
|
stabilizeDeviceProfile := helps.ClaudeDeviceProfileStabilizationEnabled(cfg)
|
|
var deviceProfile helps.ClaudeDeviceProfile
|
|
if stabilizeDeviceProfile {
|
|
var errDeviceProfile error
|
|
deviceProfile, errDeviceProfile = helps.ResolveClaudeDeviceProfileRequired(r.Context(), auth, apiKey, incomingHeaders, cfg)
|
|
if errDeviceProfile != nil {
|
|
return errDeviceProfile
|
|
}
|
|
}
|
|
|
|
baseBetas := "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28"
|
|
if val := strings.TrimSpace(strings.Join(incomingHeaders.Values("Anthropic-Beta"), ",")); val != "" {
|
|
baseBetas = val
|
|
if !strings.Contains(val, "oauth") {
|
|
baseBetas += ",oauth-2025-04-20"
|
|
}
|
|
}
|
|
if !strings.Contains(baseBetas, "interleaved-thinking") {
|
|
baseBetas += ",interleaved-thinking-2025-05-14"
|
|
}
|
|
|
|
// Merge extra betas from request body and request flags.
|
|
if len(extraBetas) > 0 {
|
|
existingSet := make(map[string]bool)
|
|
for _, b := range strings.Split(baseBetas, ",") {
|
|
betaName := strings.TrimSpace(b)
|
|
if betaName != "" {
|
|
existingSet[betaName] = 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, incomingHeaders, "Anthropic-Version", "2023-06-01")
|
|
// Only set browser access header for API key mode; real Claude Code CLI does not send it.
|
|
if useAPIKey {
|
|
misc.EnsureHeader(r.Header, incomingHeaders, "Anthropic-Dangerous-Direct-Browser-Access", "true")
|
|
}
|
|
misc.EnsureHeader(r.Header, incomingHeaders, "X-App", "cli")
|
|
// Values below match Claude Code 2.1.63 / @anthropic-ai/sdk 0.74.0 (updated 2026-02-28).
|
|
misc.EnsureHeader(r.Header, incomingHeaders, "X-Stainless-Retry-Count", "0")
|
|
misc.EnsureHeader(r.Header, incomingHeaders, "X-Stainless-Runtime", "node")
|
|
misc.EnsureHeader(r.Header, incomingHeaders, "X-Stainless-Lang", "js")
|
|
misc.EnsureHeader(r.Header, incomingHeaders, "X-Stainless-Timeout", hdrDefault(hd.Timeout, "600"))
|
|
// Session ID: stable per auth/apiKey, matches Claude Code's X-Claude-Code-Session-Id header.
|
|
sessionID, errSessionID := helps.CachedSessionIDRequired(r.Context(), apiKey)
|
|
if errSessionID != nil {
|
|
return errSessionID
|
|
}
|
|
misc.EnsureHeader(r.Header, incomingHeaders, "X-Claude-Code-Session-Id", sessionID)
|
|
// Per-request UUID, matches Claude Code's x-client-request-id for first-party API.
|
|
if isAnthropicBase {
|
|
misc.EnsureHeader(r.Header, incomingHeaders, "x-client-request-id", uuid.New().String())
|
|
}
|
|
r.Header.Set("Connection", "keep-alive")
|
|
if stream {
|
|
r.Header.Set("Accept", "text/event-stream")
|
|
// SSE streams must not be compressed: the downstream scanner reads
|
|
// line-delimited text and cannot parse compressed bytes. Using
|
|
// "identity" tells the upstream to send an uncompressed stream.
|
|
r.Header.Set("Accept-Encoding", "identity")
|
|
} else {
|
|
r.Header.Set("Accept", "application/json")
|
|
r.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
|
|
}
|
|
// Legacy mode keeps OS/Arch runtime-derived; stabilized mode pins OS/Arch
|
|
// to the configured baseline while still allowing newer official
|
|
// User-Agent/package/runtime tuples to upgrade the software fingerprint.
|
|
if stabilizeDeviceProfile {
|
|
helps.ApplyClaudeDeviceProfileHeaders(r, deviceProfile)
|
|
} else {
|
|
helps.ApplyClaudeLegacyDeviceHeaders(r, incomingHeaders, cfg)
|
|
}
|
|
var attrs map[string]string
|
|
if auth != nil {
|
|
attrs = auth.Attributes
|
|
}
|
|
util.ApplyCustomHeadersFromAttrs(r, attrs)
|
|
// Re-enforce Accept-Encoding: identity after ApplyCustomHeadersFromAttrs, which
|
|
// may override it with a user-configured value. Compressed SSE breaks the line
|
|
// scanner regardless of user preference, so this is non-negotiable for streams.
|
|
if stream {
|
|
r.Header.Set("Accept-Encoding", "identity")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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 {
|
|
return checkSystemInstructionsWithSigningMode(payload, false, false, false, "2.1.63", "", "")
|
|
}
|
|
|
|
func rebuildMidSystemMessagesToTopLevel(payload []byte) []byte {
|
|
messages := gjson.GetBytes(payload, "messages")
|
|
if !messages.IsArray() {
|
|
return payload
|
|
}
|
|
|
|
var movedSystemParts []string
|
|
keptMessages := make([]string, 0, int(messages.Get("#").Int()))
|
|
messages.ForEach(func(_, message gjson.Result) bool {
|
|
if strings.EqualFold(strings.TrimSpace(message.Get("role").String()), "system") {
|
|
movedSystemParts = append(movedSystemParts, claudeSystemTextParts(message.Get("content"))...)
|
|
return true
|
|
}
|
|
keptMessages = append(keptMessages, message.Raw)
|
|
return true
|
|
})
|
|
if len(movedSystemParts) == 0 {
|
|
return payload
|
|
}
|
|
|
|
systemParts := claudeSystemTextParts(gjson.GetBytes(payload, "system"))
|
|
systemParts = append(systemParts, movedSystemParts...)
|
|
if len(systemParts) > 0 {
|
|
if updated, errSetSystem := sjson.SetRawBytes(payload, "system", rawJSONArray(systemParts)); errSetSystem == nil {
|
|
payload = updated
|
|
}
|
|
}
|
|
if updated, errSetMessages := sjson.SetRawBytes(payload, "messages", rawJSONArray(keptMessages)); errSetMessages == nil {
|
|
payload = updated
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func claudeSystemTextParts(content gjson.Result) []string {
|
|
if !content.Exists() {
|
|
return nil
|
|
}
|
|
if content.Type == gjson.String {
|
|
text := content.String()
|
|
if strings.TrimSpace(text) == "" {
|
|
return nil
|
|
}
|
|
block := []byte(`{"type":"text","text":""}`)
|
|
block, _ = sjson.SetBytes(block, "text", text)
|
|
return []string{string(block)}
|
|
}
|
|
if !content.IsArray() {
|
|
return nil
|
|
}
|
|
|
|
var parts []string
|
|
content.ForEach(func(_, item gjson.Result) bool {
|
|
if item.Type == gjson.String {
|
|
text := item.String()
|
|
if strings.TrimSpace(text) != "" {
|
|
block := []byte(`{"type":"text","text":""}`)
|
|
block, _ = sjson.SetBytes(block, "text", text)
|
|
parts = append(parts, string(block))
|
|
}
|
|
return true
|
|
}
|
|
if item.IsObject() && item.Get("type").String() == "text" && strings.TrimSpace(item.Get("text").String()) != "" {
|
|
parts = append(parts, item.Raw)
|
|
}
|
|
return true
|
|
})
|
|
return parts
|
|
}
|
|
|
|
func rawJSONArray(items []string) []byte {
|
|
if len(items) == 0 {
|
|
return []byte("[]")
|
|
}
|
|
var builder strings.Builder
|
|
builder.WriteByte('[')
|
|
for i, item := range items {
|
|
if i > 0 {
|
|
builder.WriteByte(',')
|
|
}
|
|
builder.WriteString(item)
|
|
}
|
|
builder.WriteByte(']')
|
|
return []byte(builder.String())
|
|
}
|
|
|
|
func isClaudeOAuthToken(apiKey string) bool {
|
|
return strings.Contains(apiKey, "sk-ant-oat")
|
|
}
|
|
|
|
// prepareClaudeOAuthToolNamesForUpstream applies the Claude OAuth tool-name
|
|
// transforms in the same order across request paths. Remap runs before prefixing
|
|
// so any future non-empty prefix still composes correctly with the per-request
|
|
// reverse map.
|
|
func prepareClaudeOAuthToolNamesForUpstream(body []byte, prefix string, prefixDisabled bool) ([]byte, map[string]string) {
|
|
body, reverseMap := remapOAuthToolNames(body)
|
|
if !prefixDisabled {
|
|
body = applyClaudeToolPrefix(body, prefix)
|
|
}
|
|
return body, reverseMap
|
|
}
|
|
|
|
// restoreClaudeOAuthToolNamesFromResponse undoes the Claude OAuth tool-name
|
|
// transforms for non-stream responses in reverse order.
|
|
func restoreClaudeOAuthToolNamesFromResponse(body []byte, prefix string, prefixDisabled bool, reverseMap map[string]string) []byte {
|
|
if !prefixDisabled {
|
|
body = stripClaudeToolPrefixFromResponse(body, prefix)
|
|
}
|
|
return reverseRemapOAuthToolNames(body, reverseMap)
|
|
}
|
|
|
|
// restoreClaudeOAuthToolNamesFromStreamLine undoes the Claude OAuth tool-name
|
|
// transforms for SSE lines in reverse order.
|
|
func restoreClaudeOAuthToolNamesFromStreamLine(line []byte, prefix string, prefixDisabled bool, reverseMap map[string]string) []byte {
|
|
if !prefixDisabled {
|
|
line = stripClaudeToolPrefixFromStreamLine(line, prefix)
|
|
}
|
|
return reverseRemapOAuthToolNamesFromStreamLine(line, reverseMap)
|
|
}
|
|
|
|
// remapOAuthToolNames renames third-party tool names to Claude Code equivalents
|
|
// and removes tools without an official counterpart. This prevents Anthropic from
|
|
// fingerprinting the request as a third-party client via tool naming patterns.
|
|
//
|
|
// It operates on: tools[].name, tool_choice.name, and all tool_use/tool_reference
|
|
// references in messages. Removed tools' corresponding tool_result blocks are preserved
|
|
// (they just become orphaned, which is safe for Claude).
|
|
//
|
|
// The returned map is keyed on the upstream (TitleCase) name and maps to the
|
|
// client-supplied original name. Callers MUST pass this map to the reverse
|
|
// functions so only names the client actually caused us to rewrite are restored
|
|
// on the response. A global reverse map (the previous implementation) incorrectly
|
|
// rewrote names the client originally sent in TitleCase (e.g. `Bash`)
|
|
// when any OTHER tool in the same request triggered a forward rename (e.g.
|
|
// `glob` -> `Glob`), because the global reverse map contained `Bash` -> `bash`
|
|
// regardless of what the client originally sent.
|
|
func remapOAuthToolNames(body []byte) ([]byte, map[string]string) {
|
|
reverseMap := make(map[string]string, len(oauthToolRenameMap))
|
|
recordRename := func(original, renamed string) {
|
|
// Preserve the first-seen original name if the same upstream name is
|
|
// produced from multiple call sites; they all map back identically.
|
|
if _, exists := reverseMap[renamed]; !exists {
|
|
reverseMap[renamed] = original
|
|
}
|
|
}
|
|
|
|
// 1. Rewrite tools array in a single pass (if present).
|
|
// IMPORTANT: do not mutate names first and then rebuild from an older gjson
|
|
// snapshot. gjson results are snapshots of the original bytes; rebuilding from a
|
|
// stale snapshot will preserve removals but overwrite renamed names back to their
|
|
// original lowercase values.
|
|
tools := gjson.GetBytes(body, "tools")
|
|
toolsNeedRewrite := false
|
|
if tools.Exists() && tools.IsArray() {
|
|
tools.ForEach(func(_, tool gjson.Result) bool {
|
|
if tool.Get("type").Exists() && tool.Get("type").String() != "" {
|
|
return true
|
|
}
|
|
name := tool.Get("name").String()
|
|
toolsNeedRewrite = oauthToolsToRemove[name]
|
|
if !toolsNeedRewrite {
|
|
newName, ok := oauthToolRenameMap[name]
|
|
toolsNeedRewrite = ok && newName != name
|
|
}
|
|
return !toolsNeedRewrite
|
|
})
|
|
}
|
|
if toolsNeedRewrite {
|
|
var toolsJSON strings.Builder
|
|
toolsJSON.WriteByte('[')
|
|
toolCount := 0
|
|
tools.ForEach(func(_, tool gjson.Result) bool {
|
|
// Keep Anthropic built-in tools (web_search, code_execution, etc.) unchanged.
|
|
if tool.Get("type").Exists() && tool.Get("type").String() != "" {
|
|
if toolCount > 0 {
|
|
toolsJSON.WriteByte(',')
|
|
}
|
|
toolsJSON.WriteString(tool.Raw)
|
|
toolCount++
|
|
return true
|
|
}
|
|
|
|
name := tool.Get("name").String()
|
|
if oauthToolsToRemove[name] {
|
|
return true
|
|
}
|
|
|
|
toolJSON := tool.Raw
|
|
if newName, ok := oauthToolRenameMap[name]; ok && newName != name {
|
|
updatedTool, err := sjson.Set(toolJSON, "name", newName)
|
|
if err == nil {
|
|
toolJSON = updatedTool
|
|
recordRename(name, newName)
|
|
}
|
|
}
|
|
|
|
if toolCount > 0 {
|
|
toolsJSON.WriteByte(',')
|
|
}
|
|
toolsJSON.WriteString(toolJSON)
|
|
toolCount++
|
|
return true
|
|
})
|
|
toolsJSON.WriteByte(']')
|
|
body, _ = sjson.SetRawBytes(body, "tools", []byte(toolsJSON.String()))
|
|
}
|
|
|
|
// 2. Rename tool_choice if it references a known tool
|
|
toolChoiceType := gjson.GetBytes(body, "tool_choice.type").String()
|
|
if toolChoiceType == "tool" {
|
|
tcName := gjson.GetBytes(body, "tool_choice.name").String()
|
|
if oauthToolsToRemove[tcName] {
|
|
// The chosen tool was removed from the tools array, so drop tool_choice to
|
|
// keep the payload internally consistent and fall back to normal auto tool use.
|
|
body, _ = sjson.DeleteBytes(body, "tool_choice")
|
|
} else if newName, ok := oauthToolRenameMap[tcName]; ok && newName != tcName {
|
|
body, _ = sjson.SetBytes(body, "tool_choice.name", newName)
|
|
recordRename(tcName, newName)
|
|
}
|
|
}
|
|
|
|
// 3. Rename tool references in messages
|
|
messages := gjson.GetBytes(body, "messages")
|
|
if 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 {
|
|
partType := part.Get("type").String()
|
|
switch partType {
|
|
case "tool_use":
|
|
name := part.Get("name").String()
|
|
if newName, ok := oauthToolRenameMap[name]; ok && newName != name {
|
|
path := fmt.Sprintf("messages.%d.content.%d.name", msgIndex.Int(), contentIndex.Int())
|
|
body, _ = sjson.SetBytes(body, path, newName)
|
|
recordRename(name, newName)
|
|
}
|
|
case "tool_reference":
|
|
toolName := part.Get("tool_name").String()
|
|
if newName, ok := oauthToolRenameMap[toolName]; ok && newName != toolName {
|
|
path := fmt.Sprintf("messages.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int())
|
|
body, _ = sjson.SetBytes(body, path, newName)
|
|
recordRename(toolName, newName)
|
|
}
|
|
case "tool_result":
|
|
// Handle nested tool_reference blocks inside tool_result.content[]
|
|
toolID := part.Get("tool_use_id").String()
|
|
_ = toolID // tool_use_id stays as-is
|
|
nestedContent := part.Get("content")
|
|
if nestedContent.Exists() && nestedContent.IsArray() {
|
|
nestedContent.ForEach(func(nestedIndex, nestedPart gjson.Result) bool {
|
|
if nestedPart.Get("type").String() == "tool_reference" {
|
|
nestedToolName := nestedPart.Get("tool_name").String()
|
|
if newName, ok := oauthToolRenameMap[nestedToolName]; ok && newName != nestedToolName {
|
|
nestedPath := fmt.Sprintf("messages.%d.content.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int(), nestedIndex.Int())
|
|
body, _ = sjson.SetBytes(body, nestedPath, newName)
|
|
recordRename(nestedToolName, newName)
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
return true
|
|
})
|
|
}
|
|
|
|
return body, reverseMap
|
|
}
|
|
|
|
// reverseRemapOAuthToolNames reverses the tool name mapping for non-stream responses
|
|
// using the per-request map produced by remapOAuthToolNames. Names the client sent
|
|
// that were NOT forward-renamed are passed through unchanged.
|
|
func reverseRemapOAuthToolNames(body []byte, reverseMap map[string]string) []byte {
|
|
if len(reverseMap) == 0 {
|
|
return body
|
|
}
|
|
content := gjson.GetBytes(body, "content")
|
|
if !content.Exists() || !content.IsArray() {
|
|
return body
|
|
}
|
|
content.ForEach(func(index, part gjson.Result) bool {
|
|
partType := part.Get("type").String()
|
|
switch partType {
|
|
case "tool_use":
|
|
name := part.Get("name").String()
|
|
if origName, ok := reverseMap[name]; ok {
|
|
path := fmt.Sprintf("content.%d.name", index.Int())
|
|
body, _ = sjson.SetBytes(body, path, origName)
|
|
}
|
|
case "tool_reference":
|
|
toolName := part.Get("tool_name").String()
|
|
if origName, ok := reverseMap[toolName]; ok {
|
|
path := fmt.Sprintf("content.%d.tool_name", index.Int())
|
|
body, _ = sjson.SetBytes(body, path, origName)
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
return body
|
|
}
|
|
|
|
// reverseRemapOAuthToolNamesFromStreamLine reverses the tool name mapping for SSE
|
|
// stream lines, using the per-request reverseMap produced by remapOAuthToolNames.
|
|
func reverseRemapOAuthToolNamesFromStreamLine(line []byte, reverseMap map[string]string) []byte {
|
|
if len(reverseMap) == 0 {
|
|
return line
|
|
}
|
|
payload := helps.JSONPayload(line)
|
|
if len(payload) == 0 || !gjson.ValidBytes(payload) {
|
|
return line
|
|
}
|
|
|
|
contentBlock := gjson.GetBytes(payload, "content_block")
|
|
if !contentBlock.Exists() {
|
|
return line
|
|
}
|
|
|
|
blockType := contentBlock.Get("type").String()
|
|
var updated []byte
|
|
var err error
|
|
|
|
switch blockType {
|
|
case "tool_use":
|
|
name := contentBlock.Get("name").String()
|
|
if origName, ok := reverseMap[name]; ok {
|
|
updated, err = sjson.SetBytes(payload, "content_block.name", origName)
|
|
if err != nil {
|
|
return line
|
|
}
|
|
} else {
|
|
return line
|
|
}
|
|
case "tool_reference":
|
|
toolName := contentBlock.Get("tool_name").String()
|
|
if origName, ok := reverseMap[toolName]; ok {
|
|
updated, err = sjson.SetBytes(payload, "content_block.tool_name", origName)
|
|
if err != nil {
|
|
return line
|
|
}
|
|
} else {
|
|
return line
|
|
}
|
|
default:
|
|
return line
|
|
}
|
|
|
|
trimmed := bytes.TrimSpace(line)
|
|
if bytes.HasPrefix(trimmed, []byte("data:")) {
|
|
return append([]byte("data: "), updated...)
|
|
}
|
|
return updated
|
|
}
|
|
|
|
func applyClaudeToolPrefix(body []byte, prefix string) []byte {
|
|
if prefix == "" {
|
|
return body
|
|
}
|
|
|
|
// Collect built-in tool names from the authoritative fallback seed list and
|
|
// augment it with any typed built-ins present in the current request body.
|
|
builtinTools := helps.AugmentClaudeBuiltinToolRegistry(body, nil)
|
|
|
|
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() != "" {
|
|
if n := tool.Get("name").String(); n != "" {
|
|
builtinTools[n] = true
|
|
}
|
|
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) && !builtinTools[name] {
|
|
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 {
|
|
partType := part.Get("type").String()
|
|
switch partType {
|
|
case "tool_use":
|
|
name := part.Get("name").String()
|
|
if name == "" || strings.HasPrefix(name, prefix) || builtinTools[name] {
|
|
return true
|
|
}
|
|
path := fmt.Sprintf("messages.%d.content.%d.name", msgIndex.Int(), contentIndex.Int())
|
|
body, _ = sjson.SetBytes(body, path, prefix+name)
|
|
case "tool_reference":
|
|
toolName := part.Get("tool_name").String()
|
|
if toolName == "" || strings.HasPrefix(toolName, prefix) || builtinTools[toolName] {
|
|
return true
|
|
}
|
|
path := fmt.Sprintf("messages.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int())
|
|
body, _ = sjson.SetBytes(body, path, prefix+toolName)
|
|
case "tool_result":
|
|
// Handle nested tool_reference blocks inside tool_result.content[]
|
|
nestedContent := part.Get("content")
|
|
if nestedContent.Exists() && nestedContent.IsArray() {
|
|
nestedContent.ForEach(func(nestedIndex, nestedPart gjson.Result) bool {
|
|
if nestedPart.Get("type").String() == "tool_reference" {
|
|
nestedToolName := nestedPart.Get("tool_name").String()
|
|
if nestedToolName != "" && !strings.HasPrefix(nestedToolName, prefix) && !builtinTools[nestedToolName] {
|
|
nestedPath := fmt.Sprintf("messages.%d.content.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int(), nestedIndex.Int())
|
|
body, _ = sjson.SetBytes(body, nestedPath, prefix+nestedToolName)
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
}
|
|
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 {
|
|
partType := part.Get("type").String()
|
|
switch partType {
|
|
case "tool_use":
|
|
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))
|
|
case "tool_reference":
|
|
toolName := part.Get("tool_name").String()
|
|
if !strings.HasPrefix(toolName, prefix) {
|
|
return true
|
|
}
|
|
path := fmt.Sprintf("content.%d.tool_name", index.Int())
|
|
body, _ = sjson.SetBytes(body, path, strings.TrimPrefix(toolName, prefix))
|
|
case "tool_result":
|
|
// Handle nested tool_reference blocks inside tool_result.content[]
|
|
nestedContent := part.Get("content")
|
|
if nestedContent.Exists() && nestedContent.IsArray() {
|
|
nestedContent.ForEach(func(nestedIndex, nestedPart gjson.Result) bool {
|
|
if nestedPart.Get("type").String() == "tool_reference" {
|
|
nestedToolName := nestedPart.Get("tool_name").String()
|
|
if strings.HasPrefix(nestedToolName, prefix) {
|
|
nestedPath := fmt.Sprintf("content.%d.content.%d.tool_name", index.Int(), nestedIndex.Int())
|
|
body, _ = sjson.SetBytes(body, nestedPath, strings.TrimPrefix(nestedToolName, prefix))
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
return body
|
|
}
|
|
|
|
func stripClaudeToolPrefixFromStreamLine(line []byte, prefix string) []byte {
|
|
if prefix == "" {
|
|
return line
|
|
}
|
|
payload := helps.JSONPayload(line)
|
|
if len(payload) == 0 || !gjson.ValidBytes(payload) {
|
|
return line
|
|
}
|
|
contentBlock := gjson.GetBytes(payload, "content_block")
|
|
if !contentBlock.Exists() {
|
|
return line
|
|
}
|
|
|
|
blockType := contentBlock.Get("type").String()
|
|
var updated []byte
|
|
var err error
|
|
|
|
switch blockType {
|
|
case "tool_use":
|
|
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
|
|
}
|
|
case "tool_reference":
|
|
toolName := contentBlock.Get("tool_name").String()
|
|
if !strings.HasPrefix(toolName, prefix) {
|
|
return line
|
|
}
|
|
updated, err = sjson.SetBytes(payload, "content_block.tool_name", strings.TrimPrefix(toolName, prefix))
|
|
if err != nil {
|
|
return line
|
|
}
|
|
default:
|
|
return line
|
|
}
|
|
|
|
trimmed := bytes.TrimSpace(line)
|
|
if bytes.HasPrefix(trimmed, []byte("data:")) {
|
|
return append([]byte("data: "), updated...)
|
|
}
|
|
return updated
|
|
}
|