* fix(agent): provider-aware fallback for haiku/sonnet aliases Explore agent fails on custom providers (Z.AI GLM, Alibaba Anthropic-compatible, local OpenAI endpoints) because 'haiku' alias resolves to a non-existent model. Changes: - Add isClaudeNativeProvider check (Bedrock, Vertex, Foundry, official Anthropic) - For non-Claude-native providers, haiku/sonnet aliases inherit parent model - Add 8 tests for provider-aware fallback behavior Fixes Explore agent "model not found" errors on custom Anthropic-compatible APIs. * test(agent): use Bun mock.module() for provider tests Replace env manipulation with proper Bun mock.module() to reliably mock getAPIProvider() and isFirstPartyAnthropicBaseUrl() functions. This ensures tests work correctly on CI where module caching caused false negatives. --------- Co-authored-by: Ali Alakbarli <ali.alakbarli@users.noreply.github.com>
194 lines
7.0 KiB
TypeScript
194 lines
7.0 KiB
TypeScript
import type { PermissionMode } from '../permissions/PermissionMode.js'
|
|
import { capitalize } from '../stringUtils.js'
|
|
import { MODEL_ALIASES, type ModelAlias } from './aliases.js'
|
|
import { applyBedrockRegionPrefix, getBedrockRegionPrefix } from './bedrock.js'
|
|
import {
|
|
getCanonicalName,
|
|
getRuntimeMainLoopModel,
|
|
parseUserSpecifiedModel,
|
|
} from './model.js'
|
|
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from './providers.js'
|
|
|
|
export const AGENT_MODEL_OPTIONS = [...MODEL_ALIASES, 'inherit'] as const
|
|
export type AgentModelAlias = (typeof AGENT_MODEL_OPTIONS)[number]
|
|
|
|
export type AgentModelOption = {
|
|
value: AgentModelAlias
|
|
label: string
|
|
description: string
|
|
}
|
|
|
|
/**
|
|
* Get the default subagent model. Returns 'inherit' so subagents inherit
|
|
* the model from the parent thread.
|
|
*/
|
|
export function getDefaultSubagentModel(): string {
|
|
return 'inherit'
|
|
}
|
|
|
|
/**
|
|
* Get the effective model string for an agent.
|
|
*
|
|
* For Bedrock, if the parent model uses a cross-region inference prefix (e.g., "eu.", "us."),
|
|
* that prefix is inherited by subagents using alias models (e.g., "sonnet", "haiku", "opus").
|
|
* This ensures subagents use the same region as the parent, which is necessary when
|
|
* IAM permissions are scoped to specific cross-region inference profiles.
|
|
*/
|
|
export function getAgentModel(
|
|
agentModel: string | undefined,
|
|
parentModel: string,
|
|
toolSpecifiedModel?: ModelAlias,
|
|
permissionMode?: PermissionMode,
|
|
): string {
|
|
if (process.env.CLAUDE_CODE_SUBAGENT_MODEL) {
|
|
return parseUserSpecifiedModel(process.env.CLAUDE_CODE_SUBAGENT_MODEL)
|
|
}
|
|
|
|
// Extract Bedrock region prefix from parent model to inherit for subagents.
|
|
// This ensures subagents use the same cross-region inference profile (e.g., "eu.", "us.")
|
|
// as the parent, which is required when IAM permissions only allow specific regions.
|
|
const parentRegionPrefix = getBedrockRegionPrefix(parentModel)
|
|
|
|
// Helper to apply parent region prefix for Bedrock models.
|
|
// `originalSpec` is the raw model string before resolution (alias or full ID).
|
|
// If the user explicitly specified a full model ID that already carries its own
|
|
// region prefix (e.g., "eu.anthropic.…"), we preserve it instead of overwriting
|
|
// with the parent's prefix. This prevents silent data-residency violations when
|
|
// an agent config intentionally pins to a different region than the parent.
|
|
const applyParentRegionPrefix = (
|
|
resolvedModel: string,
|
|
originalSpec: string,
|
|
): string => {
|
|
if (parentRegionPrefix && getAPIProvider() === 'bedrock') {
|
|
if (getBedrockRegionPrefix(originalSpec)) return resolvedModel
|
|
return applyBedrockRegionPrefix(resolvedModel, parentRegionPrefix)
|
|
}
|
|
return resolvedModel
|
|
}
|
|
|
|
// Prioritize tool-specified model if provided
|
|
if (toolSpecifiedModel) {
|
|
if (aliasMatchesParentTier(toolSpecifiedModel, parentModel)) {
|
|
return parentModel
|
|
}
|
|
const model = parseUserSpecifiedModel(toolSpecifiedModel)
|
|
return applyParentRegionPrefix(model, toolSpecifiedModel)
|
|
}
|
|
|
|
const agentModelWithExp = agentModel ?? getDefaultSubagentModel()
|
|
|
|
// Provider-aware model alias fallback for agents.
|
|
// Claude-native providers (Bedrock, Vertex, Foundry, official Anthropic API)
|
|
// have guaranteed haiku/sonnet model availability. Custom Anthropic-compatible
|
|
// endpoints, OpenAI-shim, Gemini, Mistral, and other providers may not have
|
|
// equivalent models, causing "model not found" errors when resolving aliases.
|
|
// For haiku/sonnet aliases on non-Claude-native providers, inherit parent model.
|
|
// Note: 'opus' is NOT included here because it's handled separately by
|
|
// aliasMatchesParentTier() which checks if parent's tier matches the alias.
|
|
if (
|
|
(agentModelWithExp === 'haiku' || agentModelWithExp === 'sonnet') &&
|
|
!checkIsClaudeNativeProvider()
|
|
) {
|
|
// Non-Claude-native provider → inherit parent model
|
|
return getRuntimeMainLoopModel({
|
|
permissionMode: permissionMode ?? 'default',
|
|
mainLoopModel: parentModel,
|
|
exceeds200kTokens: false,
|
|
})
|
|
}
|
|
|
|
if (agentModelWithExp === 'inherit') {
|
|
// Apply runtime model resolution for inherit to get the effective model
|
|
// This ensures agents using 'inherit' get opusplan→Opus resolution in plan mode
|
|
return getRuntimeMainLoopModel({
|
|
permissionMode: permissionMode ?? 'default',
|
|
mainLoopModel: parentModel,
|
|
exceeds200kTokens: false,
|
|
})
|
|
}
|
|
|
|
if (aliasMatchesParentTier(agentModelWithExp, parentModel)) {
|
|
return parentModel
|
|
}
|
|
const model = parseUserSpecifiedModel(agentModelWithExp)
|
|
return applyParentRegionPrefix(model, agentModelWithExp)
|
|
}
|
|
|
|
/**
|
|
* Check if a bare family alias (opus/sonnet/haiku) matches the parent model's
|
|
* tier. When it does, the subagent inherits the parent's exact model string
|
|
* instead of resolving the alias to a provider default.
|
|
*
|
|
* Prevents surprising downgrades: a Vertex user on Opus 4.6 (via /model) who
|
|
* spawns a subagent with `model: opus` should get Opus 4.6, not whatever
|
|
* getDefaultOpusModel() returns for 3P.
|
|
* See https://github.com/anthropics/claude-code/issues/30815.
|
|
*
|
|
* Only bare family aliases match. `opus[1m]`, `best`, `opusplan` fall through
|
|
* since they carry semantics beyond "same tier as parent".
|
|
*/
|
|
function aliasMatchesParentTier(alias: string, parentModel: string): boolean {
|
|
const canonical = getCanonicalName(parentModel)
|
|
switch (alias.toLowerCase()) {
|
|
case 'opus':
|
|
return canonical.includes('opus')
|
|
case 'sonnet':
|
|
return canonical.includes('sonnet')
|
|
case 'haiku':
|
|
return canonical.includes('haiku')
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the current provider is Claude-native (has guaranteed haiku/sonnet models).
|
|
* Claude-native providers: Bedrock, Vertex, Foundry, official Anthropic API.
|
|
* Non-Claude-native: OpenAI, Gemini, Mistral, GitHub, NVIDIA NIM, MiniMax,
|
|
* and custom Anthropic-compatible endpoints (proxies, self-hosted).
|
|
*/
|
|
export function checkIsClaudeNativeProvider(): boolean {
|
|
const provider = getAPIProvider()
|
|
return (
|
|
provider === 'bedrock' ||
|
|
provider === 'vertex' ||
|
|
provider === 'foundry' ||
|
|
(provider === 'firstParty' && isFirstPartyAnthropicBaseUrl())
|
|
)
|
|
}
|
|
|
|
export function getAgentModelDisplay(model: string | undefined): string {
|
|
// When model is omitted, getDefaultSubagentModel() returns 'inherit' at runtime
|
|
if (!model) return 'Inherit from parent (default)'
|
|
if (model === 'inherit') return 'Inherit from parent'
|
|
return capitalize(model)
|
|
}
|
|
|
|
/**
|
|
* Get available model options for agents
|
|
*/
|
|
export function getAgentModelOptions(): AgentModelOption[] {
|
|
return [
|
|
{
|
|
value: 'sonnet',
|
|
label: 'Sonnet',
|
|
description: 'Balanced performance - best for most agents',
|
|
},
|
|
{
|
|
value: 'opus',
|
|
label: 'Opus',
|
|
description: 'Most capable for complex reasoning tasks',
|
|
},
|
|
{
|
|
value: 'haiku',
|
|
label: 'Haiku',
|
|
description: 'Fast and efficient for simple tasks',
|
|
},
|
|
{
|
|
value: 'inherit',
|
|
label: 'Inherit from parent',
|
|
description: 'Use the same model as the main conversation',
|
|
},
|
|
]
|
|
}
|