435 lines
15 KiB
TypeScript
435 lines
15 KiB
TypeScript
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?.()
|
|
}
|