Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 67 additions & 8 deletions libs/code/deepagents_code/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3784,6 +3784,36 @@ def get_default_coding_instructions() -> str:
return default_prompt_path.read_text()


_BEDROCK_REGION_PREFIXES = ("us.", "eu.", "apac.", "us-gov.")
"""Cross-region inference-profile prefixes that front a vendor namespace.

E.g. `us.anthropic.claude-3-5-sonnet-20241022-v2:0`. Only stripped when a vendor
namespace follows, so a bare name merely starting with `us`/`eu` is untouched.
"""


def _is_bedrock_model_id(model_lower: str) -> bool:
"""Return whether *model_lower* is a bare Bedrock model ID.

Bedrock IDs have the shape `[<region>.]<vendor>.<model>[:<version>]`, e.g.
`meta.llama3-70b-instruct-v1:0` or the cross-region inference profile
`us.anthropic.claude-3-5-sonnet-20241022-v2:0`. Rather than enumerate AWS's
ever-growing vendor list, this keys off the structural signature: an
alphanumeric vendor token immediately followed by a dot. Bare direct-API
names don't fit -- they either have no dot (`mistral-large`, `command-r`),
carry a hyphen before their version dot (`claude-3.5`, `gemini-2.5`), or are
already claimed by an earlier prefix check (`gpt-4.1`). Case is folded by the
caller, and the explicit `bedrock:<model>` syntax is handled upstream via
`provider:model` parsing.
"""
for region in _BEDROCK_REGION_PREFIXES:
if model_lower.startswith(region):
model_lower = model_lower.removeprefix(region)
break
vendor, dot, _ = model_lower.partition(".")
return bool(dot) and vendor.isalnum()


def detect_provider(model_name: str) -> str | None:
"""Auto-detect provider from model name.

Expand All @@ -3799,15 +3829,37 @@ def detect_provider(model_name: str) -> str | None:
model_name: Model name to detect provider from.

Returns:
Provider name (openai, anthropic, google_genai, google_vertexai,
nvidia, fireworks) or `None` if the provider cannot be determined
from the name alone.
Provider name inferred from the model name (some names, e.g. `claude`
and `gemini`, are disambiguated using configured credentials), or
`None` if the provider cannot be determined.
"""
model_lower = model_name.lower()

if model_lower.startswith(("gpt-", "o1", "o3", "o4", "chatgpt")):
if model_lower.startswith(("gpt-", "o1", "o3", "o4", "chatgpt", "text-davinci")):
return "openai"

# Bedrock uses dotted, vendor-namespaced IDs. Match them before the bare
# `mistral`/`deepseek` prefixes below (which would otherwise swallow
# `mistral.`/`deepseek.` IDs) and before the fall-through `None`, so a
# `:version` suffix is never misparsed as a `provider:model` separator.
if _is_bedrock_model_id(model_lower):
return "bedrock"

if model_lower.startswith("command"):
return "cohere"

if model_lower.startswith(("mistral", "mixtral")):
return "mistralai"

if model_lower.startswith("deepseek"):
return "deepseek"

if model_lower.startswith("grok"):
return "xai"

if model_lower.startswith("sonar"):
return "perplexity"

if model_lower.startswith("claude"):
s = _get_settings()
if not s.has_anthropic and s.has_vertex_ai:
Expand Down Expand Up @@ -4359,11 +4411,19 @@ def create_model(
if not model_spec:
model_spec = _get_default_model_spec()

# Parse provider:model syntax
# Parse provider:model syntax. Bedrock model IDs can include a version suffix
# such as `:0`, so resolve their distinctive bare-ID prefixes unless the
# parsed provider is explicitly configured.
provider: str
model_name: str
config = ModelConfig.load()
inferred_provider = detect_provider(model_spec)
parsed = ModelSpec.try_parse(model_spec)
if parsed:
if parsed and parsed.provider in config.providers:
provider, model_name = parsed.provider, parsed.model
elif inferred_provider == "bedrock":
provider, model_name = inferred_provider, model_spec
elif parsed:
# Explicit provider:model (e.g., "anthropic:claude-sonnet-4-5")
provider, model_name = parsed.provider, parsed.model
elif ":" in model_spec:
Expand All @@ -4382,7 +4442,7 @@ def create_model(
else:
# Bare model name — auto-detect provider or let init_chat_model infer
model_name = model_spec
provider = detect_provider(model_spec) or ""
provider = inferred_provider or ""

# Stored API keys (added via `/auth`) take effect by being copied onto
# the env var name LangChain reads. Apply before the credential check so
Expand Down Expand Up @@ -4473,7 +4533,6 @@ def create_model(
kwargs[_resolve_retry_param_name(provider)] = cli_max_retries

# Check if this provider uses a custom BaseChatModel class
config = ModelConfig.load()
class_path = config.get_class_path(provider) if provider else None

if provider == CODEX_PROVIDER:
Expand Down
152 changes: 151 additions & 1 deletion libs/code/tests/unit_tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4369,6 +4369,38 @@ def test_create_model_uses_class_path(self, tmp_path: Path) -> None:
assert result.model_name == "my-model"
assert result.provider == "custom"

def test_configured_provider_takes_precedence_over_bedrock_inference(
self, tmp_path: Path
) -> None:
"""A configured explicit provider is not treated as a bare Bedrock ID."""
from unittest.mock import MagicMock

from langchain_core.language_models import BaseChatModel

config_path = tmp_path / "config.toml"
config_path.write_text("""
[models.providers."meta.custom"]
class_path = "my_pkg.models:MyChatModel"
models = ["my-model"]
""")
mock_instance = MagicMock(spec=BaseChatModel)
mock_instance.profile = None

with (
patch.object(model_config, "DEFAULT_CONFIG_PATH", config_path),
patch(
"deepagents_code.config._create_model_from_class",
return_value=mock_instance,
) as mock_factory,
):
result = create_model("meta.custom:my-model")

mock_factory.assert_called_once()
assert mock_factory.call_args.args[1:3] == ("my-model", "meta.custom")
assert result.model is mock_instance
assert result.model_name == "my-model"
assert result.provider == "meta.custom"

def test_create_model_falls_through_without_class_path(
self, tmp_path: Path
) -> None:
Expand Down Expand Up @@ -4500,6 +4532,96 @@ def test_leading_colon_treated_as_bare_model(
# Should have detected 'anthropic' provider and used 'claude-opus-4-6'
assert result.model_name == "claude-opus-4-6"

@patch("langchain.chat_models.init_chat_model")
def test_versioned_bedrock_id_treated_as_bare_model(
self, mock_init_chat_model: Mock
) -> None:
"""A Bedrock version suffix is not parsed as a provider separator."""
model_id = "meta.llama3-70b-instruct-v1:0"
mock_model = Mock()
mock_model.profile = None
mock_init_chat_model.return_value = mock_model

result = create_model(model_id)

assert result.provider == "bedrock"
assert result.model_name == model_id
assert mock_init_chat_model.call_args.args == (model_id,)
assert mock_init_chat_model.call_args.kwargs["model_provider"] == "bedrock"

@patch("langchain.chat_models.init_chat_model")
def test_versioned_bedrock_vendor_id_not_misparsed(
self, mock_init_chat_model: Mock
) -> None:
"""A Bedrock vendor namespace is not split on its `:version` suffix.

Regression: `mistral.` collides with the bare `mistral` prefix, so
without dotted-namespace detection this ID parses to the garbage pair
`provider='mistral.mistral-large-2402-v1', model='0'`.
"""
model_id = "mistral.mistral-large-2402-v1:0"
mock_model = Mock()
mock_model.profile = None
mock_init_chat_model.return_value = mock_model

result = create_model(model_id)

assert result.provider == "bedrock"
assert result.model_name == model_id
assert mock_init_chat_model.call_args.args == (model_id,)
assert mock_init_chat_model.call_args.kwargs["model_provider"] == "bedrock"

@patch("langchain.chat_models.init_chat_model")
def test_cross_region_bedrock_id_treated_as_bare_model(
self, mock_init_chat_model: Mock
) -> None:
"""A cross-region inference-profile ID resolves to Bedrock intact."""
model_id = "us.anthropic.claude-3-5-sonnet-20241022-v2:0"
mock_model = Mock()
mock_model.profile = None
mock_init_chat_model.return_value = mock_model

result = create_model(model_id)

assert result.provider == "bedrock"
assert result.model_name == model_id
assert mock_init_chat_model.call_args.args == (model_id,)
assert mock_init_chat_model.call_args.kwargs["model_provider"] == "bedrock"

@patch("langchain.chat_models.init_chat_model")
def test_non_versioned_bedrock_id_treated_as_bare_model(
self, mock_init_chat_model: Mock
) -> None:
"""A Bedrock ID without a `:version` suffix still routes to Bedrock."""
model_id = "amazon.titan-text-express-v1"
mock_model = Mock()
mock_model.profile = None
mock_init_chat_model.return_value = mock_model

result = create_model(model_id)

assert result.provider == "bedrock"
assert result.model_name == model_id
assert mock_init_chat_model.call_args.kwargs["model_provider"] == "bedrock"

@patch("langchain.chat_models.init_chat_model")
def test_explicit_provider_not_hijacked_by_bedrock(
self, mock_init_chat_model: Mock
) -> None:
"""An explicit `provider:model` spec wins over Bedrock inference.

`anthropic.` (dot) is a Bedrock namespace, but `anthropic:` (colon) is
the explicit-provider syntax and must resolve to Anthropic, not Bedrock.
"""
mock_model = Mock()
mock_model.profile = None
mock_init_chat_model.return_value = mock_model

result = create_model("anthropic:claude-sonnet-4-5")

assert result.provider == "anthropic"
assert result.model_name == "claude-sonnet-4-5"

def test_trailing_colon_raises_error(self) -> None:
"""Trailing colon (e.g., 'anthropic:') raises ModelConfigError."""
with pytest.raises(ModelConfigError, match="model name is required"):
Expand Down Expand Up @@ -4749,6 +4871,34 @@ class TestDetectProvider:
("o1-preview", "openai"),
("o3-mini", "openai"),
("o4-mini", "openai"),
("text-davinci-003", "openai"),
("command-r-plus", "cohere"),
("amazon.titan-text-express-v1", "bedrock"),
("anthropic.claude-3-sonnet", "bedrock"),
("meta.llama3-70b-instruct-v1:0", "bedrock"),
# Bedrock vendor namespaces that collide with the bare direct-API
# prefixes below: the dotted form must win so the `:version` suffix
# is not misparsed as a `provider:model` separator.
("mistral.mistral-large-2402-v1:0", "bedrock"),
("deepseek.r1-v1:0", "bedrock"),
("cohere.command-r-v1:0", "bedrock"),
("ai21.jamba-1-5-large-v1:0", "bedrock"),
("writer.palmyra-x5-v1:0", "bedrock"),
# Structural detection covers vendors with no hardcoded entry.
("qwen.qwen3-32b-v1:0", "bedrock"),
("google.gemma-3-27b-v1:0", "bedrock"),
# Cross-region inference-profile IDs front the vendor with a region.
("us.anthropic.claude-3-5-sonnet-20241022-v2:0", "bedrock"),
("eu.meta.llama3-2-3b-instruct-v1:0", "bedrock"),
("apac.anthropic.claude-3-5-sonnet-20241022-v2:0", "bedrock"),
("US.Anthropic.Claude-3-5-Sonnet-20241022-v2:0", "bedrock"),
# A bare name that merely starts with a region token is not Bedrock.
("useful-model", None),
("mistral-large", "mistralai"),
("mixtral-8x7b-instruct", "mistralai"),
("deepseek-chat", "deepseek"),
("grok-4", "xai"),
("sonar-pro", "perplexity"),
("claude-sonnet-4-5", "anthropic"),
("claude-opus-4-5", "anthropic"),
("gemini-3.1-pro-preview", "google_genai"),
Expand All @@ -4763,7 +4913,7 @@ class TestDetectProvider:
# is what anchors the match to the exact account namespace.
("accounts/fireworks-enterprise/models/kimi-k2p7-code", None),
("llama3", None),
("mistral-large", None),
("solar-pro", None),
("some-unknown-model", None),
],
)
Expand Down
Loading