517 lines
19 KiB
Go
517 lines
19 KiB
Go
package helps
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
const (
|
|
// claudeAnthropicVersion is the only Anthropic-Version Claude Code sends.
|
|
claudeAnthropicVersion = "2023-06-01"
|
|
// claudeDefaultStainlessTimeout is the X-Stainless-Timeout every measured
|
|
// native helper sends. It is deliberately NOT read from
|
|
// claude-header-defaults.timeout: applyClaudeHeaders routes a confirmed client
|
|
// through misc.EnsureHeader, which prefers the incoming header and only falls
|
|
// back to the configured value when the caller sent none. A confirmed helper
|
|
// therefore always forwards its own 600, so comparing against the operator
|
|
// value would make any non-600 configuration reject every genuine helper.
|
|
claudeDefaultStainlessTimeout = "600"
|
|
)
|
|
|
|
var (
|
|
claudeCodeUserAgentPattern = regexp.MustCompile(`(?i)^claude-cli/`)
|
|
claudeCodeUserAgentDetailsPattern = regexp.MustCompile(`(?i)^claude-cli/\S+\s+\(external,\s*([^,)]+)(?:,\s*agent-sdk/([^,)]+))?`)
|
|
claudeCodeNativeUserAgentPattern = regexp.MustCompile(`(?i)^claude-cli/[0-9]+\.[0-9]+\.[0-9]+\s+\(external,\s*[^,)]+(?:,\s*agent-sdk/[0-9]+\.[0-9]+\.[0-9]+)?\)$`)
|
|
)
|
|
|
|
var claudeCodeSubclientByEntrypoint = map[string]string{
|
|
"cli": "claude-code-cli",
|
|
"mcp": "claude-code-mcp",
|
|
"bench": "claude-code-bench",
|
|
"sdk-cli": "claude-code-cli-sdk",
|
|
"sdk-ts": "claude-code-sdk-ts",
|
|
"sdk-py": "claude-code-sdk-py",
|
|
"claude-vscode": "claude-code-vscode",
|
|
"claude-code-github-action": "claude-code-gh-action",
|
|
"local-agent": "claude-local-agent",
|
|
"local_agent": "claude-local-agent",
|
|
"claude-desktop": "claude-desktop",
|
|
"claude-desktop-3p": "claude-desktop-3p",
|
|
"remote": "claude-remote",
|
|
"remote_baku": "claude-remote-baku",
|
|
"remote_cowork": "claude-remote-cowork",
|
|
"remote_trigger": "claude-remote-trigger",
|
|
"remote_desktop": "claude-remote-desktop",
|
|
"remote_mobile": "claude-remote-mobile",
|
|
"claude_in_slack": "claude-in-slack",
|
|
"claude-in-slack": "claude-in-slack",
|
|
"claude-in-teams": "claude-in-teams",
|
|
"claude-security": "claude-security",
|
|
"ssh-remote": "claude-ssh-remote",
|
|
"claude-coworker": "claude-coworker",
|
|
"claude-coworker-terminal": "claude-coworker-terminal",
|
|
}
|
|
|
|
// Only product surfaces with verified 2.1.220 wire behavior are eligible for
|
|
// pass-through. Other first-party-looking entrypoints are cloaked until their
|
|
// CPA-reachable request shape has been captured and reviewed.
|
|
var nativeClaudeEntrypoints = map[string]bool{
|
|
"cli": true,
|
|
"sdk-cli": true,
|
|
"claude-vscode": true,
|
|
}
|
|
|
|
type claudeCodeHelperShape uint8
|
|
|
|
const (
|
|
claudeCodeHelperShapeNone claudeCodeHelperShape = iota
|
|
claudeCodeHelperShapeMinimal
|
|
claudeCodeHelperShapeStructured
|
|
|
|
claudeCodeHelperModel = "claude-haiku-4-5-20251001"
|
|
)
|
|
|
|
// These are the six exact beta sequences observed across 14 markerless native
|
|
// Claude Code 2.1.220 Haiku helper requests. Keeping the allowlist exact avoids
|
|
// turning the helper exception into a generic no-claude-code-beta bypass.
|
|
var measuredClaudeCodeHelperBetaProfiles = map[string]claudeCodeHelperShape{
|
|
claudeCodeHelperBetaProfile(true): claudeCodeHelperShapeMinimal,
|
|
claudeCodeHelperBetaProfile(false): claudeCodeHelperShapeMinimal,
|
|
claudeCodeHelperBetaProfile(true,
|
|
"advisor-tool-2026-03-01",
|
|
"structured-outputs-2025-12-15",
|
|
"cache-diagnosis-2026-04-07",
|
|
): claudeCodeHelperShapeStructured,
|
|
claudeCodeHelperBetaProfile(true,
|
|
"structured-outputs-2025-12-15",
|
|
"fallback-credit-2026-06-01",
|
|
): claudeCodeHelperShapeStructured,
|
|
claudeCodeHelperBetaProfile(true,
|
|
"structured-outputs-2025-12-15",
|
|
): claudeCodeHelperShapeStructured,
|
|
claudeCodeHelperBetaProfile(false,
|
|
"structured-outputs-2025-12-15",
|
|
): claudeCodeHelperShapeStructured,
|
|
}
|
|
|
|
// ClaudeCodeRequestDetection records the strong signals and first-party
|
|
// subclient identity used to distinguish an official Claude Code request from
|
|
// a client that only copied its User-Agent.
|
|
type ClaudeCodeRequestDetection struct {
|
|
Confirmed bool
|
|
StrongSignals bool
|
|
NativeClient bool
|
|
XAppCLI bool
|
|
UserAgent bool
|
|
BetasPresent bool
|
|
MetadataUserID bool
|
|
HelperProfile bool
|
|
Entrypoint string
|
|
Subclient string
|
|
AgentSDKVersion string
|
|
}
|
|
|
|
// DetectClaudeCodeRequest first mirrors CCH's strong-signal contract, then
|
|
// applies CPA's native-client policy. Standard Messages requests require all
|
|
// four strong signals; count_tokens omits metadata.user_id. A separate narrow
|
|
// profile recognizes measured native Haiku helper requests that intentionally
|
|
// omit claude-code-20250219. Generic sdk-ts/sdk-py Agent SDK entrypoints remain
|
|
// unconfirmed and receive CLI cloaking.
|
|
func DetectClaudeCodeRequest(headers http.Header, payload []byte, countTokens bool, configs ...*config.Config) ClaudeCodeRequestDetection {
|
|
var cfg *config.Config
|
|
if len(configs) > 0 {
|
|
cfg = configs[0]
|
|
}
|
|
userAgent := headerValue(headers, "User-Agent")
|
|
entrypoint, agentSDKVersion := parseClaudeCodeUserAgentDetails(userAgent)
|
|
detection := ClaudeCodeRequestDetection{
|
|
XAppCLI: headerValue(headers, "X-App") == "cli",
|
|
UserAgent: plausibleClaudeCodeUserAgent(userAgent, cfg),
|
|
BetasPresent: headerContainsClaudeCodeBeta(headers),
|
|
Entrypoint: entrypoint,
|
|
Subclient: claudeCodeSubclientByEntrypoint[entrypoint],
|
|
AgentSDKVersion: agentSDKVersion,
|
|
}
|
|
|
|
metadataUserID := gjson.GetBytes(payload, "metadata.user_id")
|
|
detection.MetadataUserID = metadataUserID.Exists() && metadataUserID.Type == gjson.String && isValidUserID(metadataUserID.String())
|
|
detection.NativeClient = nativeClaudeEntrypoints[entrypoint]
|
|
standardSignals := detection.XAppCLI && detection.UserAgent && detection.BetasPresent && (countTokens || detection.MetadataUserID)
|
|
detection.HelperProfile = detection.NativeClient && matchesMeasuredClaudeCodeHelperProfile(headers, payload, countTokens, detection, cfg)
|
|
detection.StrongSignals = standardSignals || detection.HelperProfile
|
|
detection.Confirmed = detection.StrongSignals && detection.NativeClient
|
|
return detection
|
|
}
|
|
|
|
func claudeCodeHelperBetaProfile(redactThinking bool, trailing ...string) string {
|
|
betas := []string{"oauth-2025-04-20", "interleaved-thinking-2025-05-14"}
|
|
if redactThinking {
|
|
betas = append(betas, "redact-thinking-2026-02-12")
|
|
}
|
|
betas = append(betas,
|
|
"thinking-token-count-2026-05-13",
|
|
"context-management-2025-06-27",
|
|
"prompt-caching-scope-2026-01-05",
|
|
)
|
|
betas = append(betas, trailing...)
|
|
return strings.Join(betas, ",")
|
|
}
|
|
|
|
func matchesMeasuredClaudeCodeHelperProfile(
|
|
headers http.Header,
|
|
payload []byte,
|
|
countTokens bool,
|
|
detection ClaudeCodeRequestDetection,
|
|
cfg *config.Config,
|
|
) bool {
|
|
if countTokens ||
|
|
detection.Entrypoint != "cli" ||
|
|
detection.BetasPresent ||
|
|
!detection.XAppCLI ||
|
|
!detection.UserAgent ||
|
|
!detection.MetadataUserID {
|
|
return false
|
|
}
|
|
|
|
shape := measuredClaudeCodeHelperBetaProfiles[normalizedClaudeBetaHeader(headers)]
|
|
if shape == claudeCodeHelperShapeNone || measuredClaudeCodeHelperBodyShape(payload) != shape {
|
|
return false
|
|
}
|
|
if !measuredClaudeCodeHelperHeadersMatch(headers, cfg, shape) {
|
|
return false
|
|
}
|
|
return measuredClaudeCodeHelperSessionMatches(headers, payload)
|
|
}
|
|
|
|
// normalizedClaudeBetaHeader joins every Anthropic-Beta value in wire order.
|
|
// Values() is tried first so canonical headers keep a deterministic order; the
|
|
// case-insensitive fallback only exists for hand-built header maps that store a
|
|
// non-canonical key, where ranging the map alone would be order-dependent.
|
|
func normalizedClaudeBetaHeader(headers http.Header) string {
|
|
if headers == nil {
|
|
return ""
|
|
}
|
|
values := headers.Values("Anthropic-Beta")
|
|
if len(values) == 0 {
|
|
keys := make([]string, 0, 2)
|
|
for key := range headers {
|
|
if strings.EqualFold(key, "Anthropic-Beta") {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
values = append(values, headers[key]...)
|
|
}
|
|
}
|
|
betas := make([]string, 0, 12)
|
|
for _, value := range values {
|
|
for _, beta := range strings.Split(value, ",") {
|
|
if beta = strings.TrimSpace(beta); beta != "" {
|
|
betas = append(betas, beta)
|
|
}
|
|
}
|
|
}
|
|
return strings.Join(betas, ",")
|
|
}
|
|
|
|
// measuredClaudeCodeHelperHeadersMatch validates the helper transport envelope.
|
|
//
|
|
// Platform and software-version headers are deliberately NOT compared for
|
|
// equality. The device-profile pipeline this detector feeds already pins OS/Arch
|
|
// to the configured baseline and replaces a non-baseline software tuple instead
|
|
// of rejecting it, so demanding equality here would classify a genuine Claude
|
|
// Code helper from Windows/Linux, or from a different Node or SDK build, as a
|
|
// foreign client and cloak it. Values that carry real discriminating power - the
|
|
// exact beta allowlist, the body shape, the billing CCH and the session binding -
|
|
// stay strict.
|
|
func measuredClaudeCodeHelperHeadersMatch(headers http.Header, cfg *config.Config, shape claudeCodeHelperShape) bool {
|
|
profile := defaultClaudeDeviceProfile(cfg)
|
|
expected := map[string]string{
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
"X-Stainless-Lang": "js",
|
|
"X-Stainless-Runtime": "node",
|
|
"X-Stainless-Retry-Count": "0",
|
|
"X-Stainless-Timeout": claudeDefaultStainlessTimeout,
|
|
"Anthropic-Version": claudeAnthropicVersion,
|
|
"Anthropic-Dangerous-Direct-Browser-Access": "true",
|
|
}
|
|
for name, want := range expected {
|
|
if headerValue(headers, name) != want {
|
|
return false
|
|
}
|
|
}
|
|
// Presence is still required: the native SDK always sends these.
|
|
for _, name := range []string{
|
|
"X-Stainless-Package-Version",
|
|
"X-Stainless-Runtime-Version",
|
|
"X-Stainless-OS",
|
|
"X-Stainless-Arch",
|
|
} {
|
|
if headerValue(headers, name) == "" {
|
|
return false
|
|
}
|
|
}
|
|
candidate := ClaudeDeviceProfile{
|
|
UserAgent: headerValue(headers, "User-Agent"),
|
|
PackageVersion: headerValue(headers, "X-Stainless-Package-Version"),
|
|
RuntimeVersion: headerValue(headers, "X-Stainless-Runtime-Version"),
|
|
}
|
|
if version, ok := parseClaudeCLIVersion(candidate.UserAgent); ok {
|
|
candidate.version = version
|
|
candidate.hasVersion = true
|
|
}
|
|
if !meetsClaudeDeviceProfileBaseline(candidate, profile) {
|
|
return false
|
|
}
|
|
if async := headerValue(headers, "X-Stainless-Async"); (shape == claudeCodeHelperShapeStructured && async != "async") ||
|
|
(shape == claudeCodeHelperShapeMinimal && async != "") {
|
|
return false
|
|
}
|
|
compression := headerValue(headers, "Accept-Encoding")
|
|
if (shape == claudeCodeHelperShapeStructured && compression != "gzip, deflate, br, zstd") ||
|
|
(shape == claudeCodeHelperShapeMinimal && compression != "gzip") {
|
|
return false
|
|
}
|
|
requestID := headerValue(headers, "X-Client-Request-Id")
|
|
_, errRequestID := uuid.Parse(requestID)
|
|
return errRequestID == nil
|
|
}
|
|
|
|
func measuredClaudeCodeHelperSessionMatches(headers http.Header, payload []byte) bool {
|
|
metadata := gjson.GetBytes(payload, "metadata")
|
|
if !metadata.IsObject() || !claudeJSONObjectHasKeys([]byte(metadata.Raw), []string{"user_id"}) {
|
|
return false
|
|
}
|
|
userID := metadata.Get("user_id")
|
|
if userID.Type != gjson.String || !isValidUserID(userID.String()) {
|
|
return false
|
|
}
|
|
// The native metadata builder is
|
|
// {...extraMetadata, device_id, account_uuid, session_id, ...parentSessionId && {parent_session_id}}
|
|
// in 2.1.220, 2.1.221 and 2.1.227 alike, so parent_session_id is a legitimate
|
|
// optional trailing key for sub-agent and forked sessions. Rejecting it would
|
|
// cloak the helper requests those sessions issue.
|
|
identityRaw := []byte(userID.String())
|
|
if !claudeJSONObjectHasKeys(identityRaw, []string{"device_id", "account_uuid", "session_id"}) &&
|
|
!claudeJSONObjectHasKeys(identityRaw, []string{"device_id", "account_uuid", "session_id", "parent_session_id"}) {
|
|
return false
|
|
}
|
|
return headerValue(headers, ClaudeCodeSessionHeader) == gjson.GetBytes(identityRaw, "session_id").String()
|
|
}
|
|
|
|
func measuredClaudeCodeHelperBodyShape(payload []byte) claudeCodeHelperShape {
|
|
minimalKeys := []string{"model", "max_tokens", "messages", "metadata"}
|
|
structuredKeys := []string{"model", "messages", "system", "tools", "metadata", "max_tokens", "thinking", "temperature", "output_config", "stream"}
|
|
shape := claudeCodeHelperShapeNone
|
|
switch {
|
|
case claudeJSONObjectHasKeys(payload, minimalKeys):
|
|
shape = claudeCodeHelperShapeMinimal
|
|
case claudeJSONObjectHasKeys(payload, structuredKeys):
|
|
shape = claudeCodeHelperShapeStructured
|
|
default:
|
|
return claudeCodeHelperShapeNone
|
|
}
|
|
|
|
maxTokens := gjson.GetBytes(payload, "max_tokens")
|
|
if gjson.GetBytes(payload, "model").String() != claudeCodeHelperModel ||
|
|
maxTokens.Type != gjson.Number {
|
|
return claudeCodeHelperShapeNone
|
|
}
|
|
messages := gjson.GetBytes(payload, "messages")
|
|
if !messages.IsArray() || len(messages.Array()) != 1 {
|
|
return claudeCodeHelperShapeNone
|
|
}
|
|
message := messages.Get("0")
|
|
if !claudeJSONObjectHasKeys([]byte(message.Raw), []string{"role", "content"}) ||
|
|
message.Get("role").String() != "user" {
|
|
return claudeCodeHelperShapeNone
|
|
}
|
|
|
|
if shape == claudeCodeHelperShapeMinimal {
|
|
if maxTokens.Raw != "1" || message.Get("content").Type != gjson.String {
|
|
return claudeCodeHelperShapeNone
|
|
}
|
|
return shape
|
|
}
|
|
|
|
content := message.Get("content")
|
|
if !content.IsArray() || len(content.Array()) != 1 {
|
|
return claudeCodeHelperShapeNone
|
|
}
|
|
contentBlock := content.Get("0")
|
|
if !claudeJSONObjectHasKeys([]byte(contentBlock.Raw), []string{"type", "text"}) ||
|
|
contentBlock.Get("type").String() != "text" {
|
|
return claudeCodeHelperShapeNone
|
|
}
|
|
if !measuredClaudeCodeHelperSystemMatches(gjson.GetBytes(payload, "system")) {
|
|
return claudeCodeHelperShapeNone
|
|
}
|
|
if tools := gjson.GetBytes(payload, "tools"); !tools.IsArray() || len(tools.Array()) != 0 {
|
|
return claudeCodeHelperShapeNone
|
|
}
|
|
thinking := gjson.GetBytes(payload, "thinking")
|
|
outputConfig := gjson.GetBytes(payload, "output_config")
|
|
if !claudeJSONObjectHasKeys([]byte(thinking.Raw), []string{"type"}) ||
|
|
thinking.Get("type").String() != "disabled" {
|
|
return claudeCodeHelperShapeNone
|
|
}
|
|
format := outputConfig.Get("format")
|
|
schema := format.Get("schema")
|
|
properties := schema.Get("properties")
|
|
titleProperty := properties.Get("title")
|
|
required := schema.Get("required")
|
|
additionalProperties := schema.Get("additionalProperties")
|
|
if !claudeJSONObjectHasKeys([]byte(outputConfig.Raw), []string{"format"}) ||
|
|
!claudeJSONObjectHasKeys([]byte(format.Raw), []string{"type", "schema"}) ||
|
|
format.Get("type").String() != "json_schema" ||
|
|
!claudeJSONObjectHasKeys([]byte(schema.Raw), []string{"type", "properties", "required", "additionalProperties"}) ||
|
|
schema.Get("type").String() != "object" ||
|
|
!claudeJSONObjectHasKeys([]byte(properties.Raw), []string{"title"}) ||
|
|
!claudeJSONObjectHasKeys([]byte(titleProperty.Raw), []string{"type"}) ||
|
|
titleProperty.Get("type").String() != "string" ||
|
|
!required.IsArray() || len(required.Array()) != 1 || required.Get("0").String() != "title" ||
|
|
additionalProperties.Type != gjson.False {
|
|
return claudeCodeHelperShapeNone
|
|
}
|
|
temperature := gjson.GetBytes(payload, "temperature")
|
|
if maxTokens.Raw != "32000" ||
|
|
temperature.Raw != "1" ||
|
|
gjson.GetBytes(payload, "stream").Type != gjson.True {
|
|
return claudeCodeHelperShapeNone
|
|
}
|
|
return shape
|
|
}
|
|
|
|
func measuredClaudeCodeHelperSystemMatches(system gjson.Result) bool {
|
|
if !system.IsArray() || len(system.Array()) != 3 {
|
|
return false
|
|
}
|
|
for _, block := range system.Array() {
|
|
if !claudeJSONObjectHasKeys([]byte(block.Raw), []string{"type", "text"}) || block.Get("type").String() != "text" {
|
|
return false
|
|
}
|
|
}
|
|
billing := system.Get("0.text").String()
|
|
identity := system.Get("1.text").String()
|
|
return strings.HasPrefix(billing, "x-anthropic-billing-header:") && measuredClaudeBillingCCH(billing) && strings.HasPrefix(identity, "You are Claude Code")
|
|
}
|
|
|
|
// measuredClaudeBillingCCH validates the five lowercase hexadecimal characters the
|
|
// native billing header carries. It duplicates isLowerHex in
|
|
// internal/runtime/executor/claude_signing.go because the signing side lives in the
|
|
// package that imports this one; keep the two definitions in step.
|
|
func measuredClaudeBillingCCH(billing string) bool {
|
|
marker := strings.Index(billing, " cch=")
|
|
if marker < 0 {
|
|
return false
|
|
}
|
|
valueStart := marker + len(" cch=")
|
|
valueEnd := valueStart + 5
|
|
if valueEnd >= len(billing) || billing[valueEnd] != ';' {
|
|
return false
|
|
}
|
|
for _, character := range billing[valueStart:valueEnd] {
|
|
decimal := character >= '0' && character <= '9'
|
|
lowerHex := character >= 'a' && character <= 'f'
|
|
if !decimal && !lowerHex {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func claudeJSONObjectHasKeys(raw []byte, want []string) bool {
|
|
if !json.Valid(raw) {
|
|
return false
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
opening, errOpening := decoder.Token()
|
|
if errOpening != nil || opening != json.Delim('{') {
|
|
return false
|
|
}
|
|
keyIndex := 0
|
|
for decoder.More() {
|
|
token, errToken := decoder.Token()
|
|
if errToken != nil {
|
|
return false
|
|
}
|
|
key, okKey := token.(string)
|
|
if !okKey || keyIndex >= len(want) || key != want[keyIndex] {
|
|
return false
|
|
}
|
|
keyIndex++
|
|
var value json.RawMessage
|
|
if errValue := decoder.Decode(&value); errValue != nil {
|
|
return false
|
|
}
|
|
}
|
|
closing, errClosing := decoder.Token()
|
|
return errClosing == nil && closing == json.Delim('}') && keyIndex == len(want)
|
|
}
|
|
|
|
func plausibleClaudeCodeUserAgent(userAgent string, cfg *config.Config) bool {
|
|
userAgent = strings.TrimSpace(userAgent)
|
|
if !claudeCodeUserAgentPattern.MatchString(userAgent) || !claudeCodeNativeUserAgentPattern.MatchString(userAgent) {
|
|
return false
|
|
}
|
|
candidate, okCandidate := parseClaudeCLIVersion(userAgent)
|
|
baseline, okBaseline := parseClaudeCLIVersion(defaultClaudeDeviceProfile(cfg).UserAgent)
|
|
return okCandidate && okBaseline && plausibleClaudeCLIVersion(candidate, baseline)
|
|
}
|
|
|
|
func parseClaudeCodeUserAgentDetails(userAgent string) (entrypoint, agentSDKVersion string) {
|
|
matches := claudeCodeUserAgentDetailsPattern.FindStringSubmatch(strings.TrimSpace(userAgent))
|
|
if len(matches) < 2 {
|
|
return "", ""
|
|
}
|
|
entrypoint = strings.ToLower(strings.TrimSpace(matches[1]))
|
|
if len(matches) >= 3 {
|
|
agentSDKVersion = strings.TrimSpace(matches[2])
|
|
}
|
|
return entrypoint, agentSDKVersion
|
|
}
|
|
|
|
func headerValue(headers http.Header, name string) string {
|
|
if headers == nil {
|
|
return ""
|
|
}
|
|
if value := headers.Get(name); value != "" {
|
|
return value
|
|
}
|
|
for key, values := range headers {
|
|
if !strings.EqualFold(key, name) || len(values) == 0 {
|
|
continue
|
|
}
|
|
return values[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func headerContainsClaudeCodeBeta(headers http.Header) bool {
|
|
if headers == nil {
|
|
return false
|
|
}
|
|
for key, values := range headers {
|
|
if !strings.EqualFold(key, "Anthropic-Beta") {
|
|
continue
|
|
}
|
|
for _, value := range values {
|
|
for _, beta := range strings.Split(value, ",") {
|
|
if strings.TrimSpace(beta) == "claude-code-20250219" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|