import { feature } from 'bun:bundle' import memoize from 'lodash-es/memoize.js' import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE, getFeatureValue_CACHED_MAY_BE_STALE, } from 'src/services/analytics/growthbook.js' import { getIsNonInteractiveSession, getSdkBetas } from '../bootstrap/state.js' import { BEDROCK_EXTRA_PARAMS_HEADERS, CLAUDE_CODE_20250219_BETA_HEADER, CLI_INTERNAL_BETA_HEADER, CONTEXT_1M_BETA_HEADER, CONTEXT_MANAGEMENT_BETA_HEADER, INTERLEAVED_THINKING_BETA_HEADER, PROMPT_CACHING_SCOPE_BETA_HEADER, REDACT_THINKING_BETA_HEADER, STRUCTURED_OUTPUTS_BETA_HEADER, SUMMARIZE_CONNECTOR_TEXT_BETA_HEADER, TOKEN_EFFICIENT_TOOLS_BETA_HEADER, TOOL_SEARCH_BETA_HEADER_1P, TOOL_SEARCH_BETA_HEADER_3P, WEB_SEARCH_BETA_HEADER, } from '../constants/betas.js' import { OAUTH_BETA_HEADER } from '../constants/oauth.js' import { isClaudeAISubscriber } from './auth.js' import { has1mContext } from './context.js' import { isEnvDefinedFalsy, isEnvTruthy } from './envUtils.js' import { getCanonicalName } from './model/model.js' import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js' import { getAPIProvider } from './model/providers.js' import { getInitialSettings } from './settings/settings.js' /** * SDK-provided betas that are allowed for API key users. * Only betas in this list can be passed via SDK options. */ const ALLOWED_SDK_BETAS = [CONTEXT_1M_BETA_HEADER] /** * Filter betas to only include those in the allowlist. * Returns allowed and disallowed betas separately. */ function partitionBetasByAllowlist(betas: string[]): { allowed: string[] disallowed: string[] } { const allowed: string[] = [] const disallowed: string[] = [] for (const beta of betas) { if (ALLOWED_SDK_BETAS.includes(beta)) { allowed.push(beta) } else { disallowed.push(beta) } } return { allowed, disallowed } } /** * Filter SDK betas to only include allowed ones. * Warns about disallowed betas and subscriber restrictions. * Returns undefined if no valid betas remain or if user is a subscriber. */ export function filterAllowedSdkBetas( sdkBetas: string[] | undefined, ): string[] | undefined { if (!sdkBetas || sdkBetas.length === 0) { return undefined } if (isClaudeAISubscriber()) { // biome-ignore lint/suspicious/noConsole: intentional warning console.warn( 'Warning: Custom betas are only available for API key users. Ignoring provided betas.', ) return undefined } const { allowed, disallowed } = partitionBetasByAllowlist(sdkBetas) for (const beta of disallowed) { // biome-ignore lint/suspicious/noConsole: intentional warning console.warn( `Warning: Beta header '${beta}' is not allowed. Only the following betas are supported: ${ALLOWED_SDK_BETAS.join(', ')}`, ) } return allowed.length > 0 ? allowed : undefined } // Generally, foundry supports all 1P features; // however out of an abundance of caution, we do not enable any which are behind an experiment export function modelSupportsISP(model: string): boolean { const supported3P = get3PModelCapabilityOverride( model, 'interleaved_thinking', ) if (supported3P !== undefined) { return supported3P } const canonical = getCanonicalName(model) const provider = getAPIProvider() // Foundry supports interleaved thinking for all models if (provider === 'foundry') { return true } if (provider === 'firstParty') { return !canonical.includes('claude-3-') } return ( canonical.includes('claude-opus-4') || canonical.includes('claude-sonnet-4') ) } function vertexModelSupportsWebSearch(model: string): boolean { const canonical = getCanonicalName(model) // Web search only supported on Claude 4.0+ models on Vertex return ( canonical.includes('claude-opus-4') || canonical.includes('claude-sonnet-4') || canonical.includes('claude-haiku-4') ) } // Context management is supported on Claude 4+ models export function modelSupportsContextManagement(model: string): boolean { const canonical = getCanonicalName(model) const provider = getAPIProvider() if (provider === 'foundry') { return true } if (provider === 'firstParty') { return !canonical.includes('claude-3-') } return ( canonical.includes('claude-opus-4') || canonical.includes('claude-sonnet-4') || canonical.includes('claude-haiku-4') ) } // @[MODEL LAUNCH]: Add the new model ID to this list if it supports structured outputs. export function modelSupportsStructuredOutputs(model: string): boolean { const canonical = getCanonicalName(model) const provider = getAPIProvider() // Structured outputs only supported on firstParty and Foundry (not Bedrock/Vertex yet) if (provider !== 'firstParty' && provider !== 'foundry') { return false } return ( canonical.includes('claude-sonnet-4-6') || canonical.includes('claude-sonnet-4-5') || canonical.includes('claude-opus-4-1') || canonical.includes('claude-opus-4-5') || canonical.includes('claude-opus-4-6') || canonical.includes('claude-haiku-4-5') ) } // @[MODEL LAUNCH]: Add the new model if it supports auto mode (specifically PI probes) — ask in #proj-claude-code-safety-research. export function modelSupportsAutoMode(model: string): boolean { if (feature('TRANSCRIPT_CLASSIFIER')) { const m = getCanonicalName(model) // External: firstParty-only at launch (PI probes not wired for // Bedrock/Vertex/Foundry yet). Checked before allowModels so the GB // override can't enable auto mode on unsupported providers. if (process.env.USER_TYPE !== 'ant' && getAPIProvider() !== 'firstParty') { return false } // GrowthBook override: tengu_auto_mode_config.allowModels force-enables // auto mode for listed models, bypassing the denylist/allowlist below. // Exact model IDs (e.g. "claude-strudel-v6-p") match only that model; // canonical names (e.g. "claude-strudel") match the whole family. const config = getFeatureValue_CACHED_MAY_BE_STALE<{ allowModels?: string[] }>('tengu_auto_mode_config', {}) const rawLower = model.toLowerCase() if ( config?.allowModels?.some( am => am.toLowerCase() === rawLower || am.toLowerCase() === m, ) ) { return true } if (process.env.USER_TYPE === 'ant') { // Denylist: block known-unsupported claude models, allow everything else (ant-internal models etc.) if (m.includes('claude-3-')) return false // claude-*-4 not followed by -[6-9]: blocks bare -4, -4-YYYYMMDD, -4@, -4-0 thru -4-5 if (/claude-(opus|sonnet|haiku)-4(?!-[6-9])/.test(m)) return false return true } // External allowlist (firstParty already checked above). return /^claude-(opus|sonnet)-4-6/.test(m) } return false } /** * Get the correct tool search beta header for the current API provider. * - Claude API / Foundry: advanced-tool-use-2025-11-20 * - Vertex AI / Bedrock: tool-search-tool-2025-10-19 */ export function getToolSearchBetaHeader(): string { const provider = getAPIProvider() if (provider === 'vertex' || provider === 'bedrock') { return TOOL_SEARCH_BETA_HEADER_3P } return TOOL_SEARCH_BETA_HEADER_1P } /** * Check if experimental betas should be included. * These are betas that are only available on firstParty provider * and may not be supported by proxies or other providers. */ export function shouldIncludeFirstPartyOnlyBetas(): boolean { return ( (getAPIProvider() === 'firstParty' || getAPIProvider() === 'foundry') && !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS) ) } /** * Global-scope prompt caching is firstParty only. Foundry is excluded because * GrowthBook never bucketed Foundry users into the rollout experiment — the * treatment data is firstParty-only. */ export function shouldUseGlobalCacheScope(): boolean { return ( getAPIProvider() === 'firstParty' && !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS) ) } export const getAllModelBetas = memoize((model: string): string[] => { const betaHeaders = [] const isHaiku = getCanonicalName(model).includes('haiku') const provider = getAPIProvider() const includeFirstPartyOnlyBetas = shouldIncludeFirstPartyOnlyBetas() if (!isHaiku) { betaHeaders.push(CLAUDE_CODE_20250219_BETA_HEADER) if ( process.env.USER_TYPE === 'ant' && process.env.CLAUDE_CODE_ENTRYPOINT === 'cli' ) { if (CLI_INTERNAL_BETA_HEADER) { betaHeaders.push(CLI_INTERNAL_BETA_HEADER) } } } if (isClaudeAISubscriber()) { betaHeaders.push(OAUTH_BETA_HEADER) } if (has1mContext(model)) { betaHeaders.push(CONTEXT_1M_BETA_HEADER) } if ( !isEnvTruthy(process.env.DISABLE_INTERLEAVED_THINKING) && modelSupportsISP(model) ) { betaHeaders.push(INTERLEAVED_THINKING_BETA_HEADER) } // Skip the API-side Haiku thinking summarizer — the summary is only used // for ctrl+o display, which interactive users rarely open. The API returns // redacted_thinking blocks instead; AssistantRedactedThinkingMessage already // renders those as a stub. SDK / print-mode keep summaries because callers // may iterate over thinking content. Users can opt back in via settings.json // showThinkingSummaries. if ( includeFirstPartyOnlyBetas && modelSupportsISP(model) && !getIsNonInteractiveSession() && getInitialSettings().showThinkingSummaries !== true ) { betaHeaders.push(REDACT_THINKING_BETA_HEADER) } // POC: server-side connector-text summarization (anti-distillation). The // API buffers assistant text between tool calls, summarizes it, and returns // the summary with a signature so the original can be restored on subsequent // turns — same mechanism as thinking blocks. Ant-only while we measure // TTFT/TTLT/capacity; betas already flow to tengu_api_success for splitting. // Backend independently requires Capability.ANTHROPIC_INTERNAL_RESEARCH. // // USE_CONNECTOR_TEXT_SUMMARIZATION is tri-state: =1 forces on (opt-in even // if GB is off), =0 forces off (opt-out of a GB rollout you were bucketed // into), unset defers to GB. if ( SUMMARIZE_CONNECTOR_TEXT_BETA_HEADER && process.env.USER_TYPE === 'ant' && includeFirstPartyOnlyBetas && !isEnvDefinedFalsy(process.env.USE_CONNECTOR_TEXT_SUMMARIZATION) && (isEnvTruthy(process.env.USE_CONNECTOR_TEXT_SUMMARIZATION) || getFeatureValue_CACHED_MAY_BE_STALE('tengu_slate_prism', false)) ) { betaHeaders.push(SUMMARIZE_CONNECTOR_TEXT_BETA_HEADER) } // Add context management beta for tool clearing (ant opt-in) or thinking preservation const antOptedIntoToolClearing = isEnvTruthy(process.env.USE_API_CONTEXT_MANAGEMENT) && process.env.USER_TYPE === 'ant' const thinkingPreservationEnabled = modelSupportsContextManagement(model) if ( shouldIncludeFirstPartyOnlyBetas() && (antOptedIntoToolClearing || thinkingPreservationEnabled) ) { betaHeaders.push(CONTEXT_MANAGEMENT_BETA_HEADER) } // Add strict tool use beta if experiment is enabled. // Gate on includeFirstPartyOnlyBetas: CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS // already strips schema.strict from tool bodies at api.ts's choke point, but // this header was escaping that kill switch. Proxy gateways that look like // firstParty but forward to Vertex reject this header with 400. // github.com/deshaw/anthropic-issues/issues/5 const strictToolsEnabled = checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_tool_pear') // 3P default: false. API rejects strict + token-efficient-tools together // (tool_use.py:139), so these are mutually exclusive — strict wins. const tokenEfficientToolsEnabled = !strictToolsEnabled && getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_json_tools', false) if ( includeFirstPartyOnlyBetas && modelSupportsStructuredOutputs(model) && strictToolsEnabled ) { betaHeaders.push(STRUCTURED_OUTPUTS_BETA_HEADER) } // JSON tool_use format (FC v3) — ~4.5% output token reduction vs ANTML. // Sends the v2 header (2026-03-28) added in anthropics/anthropic#337072 to // isolate the CC A/B cohort from ~9.2M/week existing v1 senders. Ant-only // while the restored JsonToolUseOutputParser soaks. if ( process.env.USER_TYPE === 'ant' && includeFirstPartyOnlyBetas && tokenEfficientToolsEnabled ) { betaHeaders.push(TOKEN_EFFICIENT_TOOLS_BETA_HEADER) } // Add web search beta for Vertex Claude 4.0+ models only if (provider === 'vertex' && vertexModelSupportsWebSearch(model)) { betaHeaders.push(WEB_SEARCH_BETA_HEADER) } // Foundry only ships models that already support Web Search if (provider === 'foundry') { betaHeaders.push(WEB_SEARCH_BETA_HEADER) } // Always send the beta header for 1P. The header is a no-op without a scope field. if (includeFirstPartyOnlyBetas) { betaHeaders.push(PROMPT_CACHING_SCOPE_BETA_HEADER) } // If ANTHROPIC_BETAS is set, split it by commas and add to betaHeaders. // This is an explicit user opt-in, so honor it regardless of model. if (process.env.ANTHROPIC_BETAS) { betaHeaders.push( ...process.env.ANTHROPIC_BETAS.split(',') .map(_ => _.trim()) .filter(Boolean), ) } return betaHeaders }) export const getModelBetas = memoize((model: string): string[] => { const modelBetas = getAllModelBetas(model) if (getAPIProvider() === 'bedrock') { return modelBetas.filter(b => !BEDROCK_EXTRA_PARAMS_HEADERS.has(b)) } return modelBetas }) export const getBedrockExtraBodyParamsBetas = memoize( (model: string): string[] => { const modelBetas = getAllModelBetas(model) return modelBetas.filter(b => BEDROCK_EXTRA_PARAMS_HEADERS.has(b)) }, ) /** * Merge SDK-provided betas with auto-detected model betas. * SDK betas are read from global state (set via setSdkBetas in main.tsx). * The betas are pre-filtered by filterAllowedSdkBetas which handles * subscriber checks and allowlist validation with warnings. * * @param options.isAgenticQuery - When true, ensures the beta headers needed * for agentic queries are present. For non-Haiku models these are already * included by getAllModelBetas(); for Haiku they're excluded since * non-agentic calls (compaction, classifiers, token estimation) don't need them. */ export function getMergedBetas( model: string, options?: { isAgenticQuery?: boolean }, ): string[] { const baseBetas = [...getModelBetas(model)] // Agentic queries always need claude-code and cli-internal beta headers. // For non-Haiku models these are already in baseBetas; for Haiku they're // excluded by getAllModelBetas() since non-agentic Haiku calls don't need them. if (options?.isAgenticQuery) { if (!baseBetas.includes(CLAUDE_CODE_20250219_BETA_HEADER)) { baseBetas.push(CLAUDE_CODE_20250219_BETA_HEADER) } if ( process.env.USER_TYPE === 'ant' && process.env.CLAUDE_CODE_ENTRYPOINT === 'cli' && CLI_INTERNAL_BETA_HEADER && !baseBetas.includes(CLI_INTERNAL_BETA_HEADER) ) { baseBetas.push(CLI_INTERNAL_BETA_HEADER) } } const sdkBetas = getSdkBetas() if (!sdkBetas || sdkBetas.length === 0) { return baseBetas } // Merge SDK betas without duplicates (already filtered by filterAllowedSdkBetas) return [...baseBetas, ...sdkBetas.filter(b => !baseBetas.includes(b))] } export function clearBetasCaches(): void { getAllModelBetas.cache?.clear?.() getModelBetas.cache?.clear?.() getBedrockExtraBodyParamsBetas.cache?.clear?.() }