diff --git a/core/config.py b/core/config.py index 02345339..470032da 100644 --- a/core/config.py +++ b/core/config.py @@ -198,6 +198,7 @@ class ProvidersConfig(_Base): openrouter: ProviderConfig = Field(default_factory=ProviderConfig) forge: ProviderConfig = Field(default_factory=ProviderConfig) requesty: ProviderConfig = Field(default_factory=ProviderConfig) + bedrock: ProviderConfig = Field(default_factory=ProviderConfig) anthropic: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig) deepseek: ProviderConfig = Field(default_factory=ProviderConfig) diff --git a/core/providers/catalog.py b/core/providers/catalog.py index a484e690..f304f0e5 100644 --- a/core/providers/catalog.py +++ b/core/providers/catalog.py @@ -123,6 +123,18 @@ class ModelInfo: mandatory=True, ) +# Bedrock exposes the same Claude release through direct, geography-scoped, +# and global inference IDs. Keep these exact spellings in the offline catalog: +# the dots are part of the IDs and cannot be removed by the slash normalizer. +_BEDROCK_CLAUDE_SONNET_46_IDS = ( + "anthropic.claude-sonnet-4-6", + "us.anthropic.claude-sonnet-4-6", + "eu.anthropic.claude-sonnet-4-6", + "au.anthropic.claude-sonnet-4-6", + "jp.anthropic.claude-sonnet-4-6", + "global.anthropic.claude-sonnet-4-6", +) + # -------------------------------------------------------------------------- # Seed catalog — exact ids DeepCode targets, curated from models.dev. # Context/output are the vendor-published limits; costs are list price per 1M. @@ -186,6 +198,14 @@ class ModelInfo: "claude-haiku-4-5": ModelInfo( "claude-haiku-4-5", 200_000, 64_000, 1.0, 5.0, reasoning=_REASONING_ANTHROPIC ), + # Amazon Bedrock Claude Sonnet 4.6 inference IDs. Bedrock publishes a 1M + # context window for this release. Pricing is deliberately absent because + # it is account/region/service-tier dependent, and the OpenAI-compatible + # surface does not publish DeepCode's semantic reasoning-effort ladder. + **{ + model_id: ModelInfo(model_id, 1_000_000, 64_000) + for model_id in _BEDROCK_CLAUDE_SONNET_46_IDS + }, # Dotted spellings of the same Anthropic releases. Listed rather than # normalised away, because the dot is meaningful elsewhere in this table # (``gpt-5.4``, ``kimi-k2.5`` are distinct models, not separator noise). diff --git a/core/providers/catalog_service.py b/core/providers/catalog_service.py index e03c3035..b5d9fedc 100644 --- a/core/providers/catalog_service.py +++ b/core/providers/catalog_service.py @@ -517,6 +517,14 @@ def _looks_native(model_id: str, provider_name: str) -> bool: markers = { "openai": ("gpt-", "o1", "o3", "o4"), "anthropic": ("claude-",), + "bedrock": ( + "anthropic.", + "us.anthropic.", + "eu.anthropic.", + "au.anthropic.", + "jp.anthropic.", + "global.anthropic.", + ), "gemini": ("gemini-",), "deepseek": ("deepseek-",), "dashscope": ("qwen",), diff --git a/core/providers/registry.py b/core/providers/registry.py index b483c3af..c47a3366 100644 --- a/core/providers/registry.py +++ b/core/providers/registry.py @@ -99,6 +99,18 @@ def label(self) -> str: # OpenRouter-style gateways above. strip_model_prefix=True, ), + ProviderSpec( + name="bedrock", + keywords=("bedrock",), + env_key="AWS_BEARER_TOKEN_BEDROCK", + display_name="Amazon Bedrock", + backend="openai_compat", + is_gateway=True, + detect_by_base_keyword="bedrock-runtime", + # Bedrock Runtime endpoints are region-specific, so there is no safe + # registry default. The connection supplies its own ``/openai/v1`` URL. + requires_api_base=True, + ), ProviderSpec( name="anthropic", keywords=("anthropic", "claude"), diff --git a/docs/guide/models.md b/docs/guide/models.md index 9c2e8111..d4e0a535 100644 --- a/docs/guide/models.md +++ b/docs/guide/models.md @@ -25,6 +25,30 @@ Connections live under `providers` in `~/.deepcode/deepcode_config.json` work). Project-level config deliberately cannot add connections — credentials stay user-scoped. +### Amazon Bedrock + +Bedrock's OpenAI-compatible endpoint is region-specific. Supply its +`/openai/v1` base URL and expose an Amazon Bedrock API key through the standard +`AWS_BEARER_TOKEN_BEDROCK` environment variable: + +```console +deepcode provider set work-bedrock --template bedrock \ + --api-base https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1 \ + --api-key-env AWS_BEARER_TOKEN_BEDROCK +deepcode provider models work-bedrock --refresh +deepcode provider test work-bedrock --model us.anthropic.claude-sonnet-4-6 +``` + +Replace `us-east-1` with the Region that serves your account and choose a model +ID returned by the refresh command. Bedrock may return an in-Region ID such as +`anthropic.claude-sonnet-4-6`, a geography-scoped ID such as +`us.anthropic.claude-sonnet-4-6` or `eu.anthropic.claude-sonnet-4-6`, or a +`global.` ID depending on model and Region. + +This first integration sends the configured value as a bearer token through +Bedrock's OpenAI-compatible Chat Completions API. It does **not** implement AWS +SigV4 signing, IAM role/profile discovery, SSO, or the native Converse API. + ## Declared models When a gateway serves a model the catalog doesn't know — or knows wrongly — diff --git a/tests/test_bedrock_provider.py b/tests/test_bedrock_provider.py new file mode 100644 index 00000000..f57ff4c1 --- /dev/null +++ b/tests/test_bedrock_provider.py @@ -0,0 +1,194 @@ +"""Amazon Bedrock's bounded OpenAI-compatible provider integration.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from core.config import DeepCodeConfig, ProvidersConfig # noqa: E402 +from core.providers.catalog import resolve_model_info # noqa: E402 +from core.providers.catalog_service import ModelCatalogService # noqa: E402 +from core.providers.credentials import CredentialStore # noqa: E402 +from core.providers.openai_compat import OpenAICompatProvider # noqa: E402 +from core.providers.profiles import ConnectionResolver # noqa: E402 +from core.providers.registry import find_by_name # noqa: E402 + + +BEDROCK_MODEL_IDS = ( + "anthropic.claude-sonnet-4-6", + "us.anthropic.claude-sonnet-4-6", + "eu.anthropic.claude-sonnet-4-6", + "au.anthropic.claude-sonnet-4-6", + "jp.anthropic.claude-sonnet-4-6", + "global.anthropic.claude-sonnet-4-6", +) + + +def _connection(tmp_path: Path): + credentials = CredentialStore(tmp_path / "credentials.json") + credentials.set("work-bedrock", "bedrock-test-secret") + config = DeepCodeConfig.model_validate( + { + "providers": { + "profiles": { + "work-bedrock": { + "template": "bedrock", + "apiBase": ( + "https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1" + ), + } + } + } + } + ) + return ConnectionResolver(config, credentials).resolve_connection("work-bedrock") + + +def test_bedrock_is_a_region_scoped_openai_compatible_template() -> None: + spec = find_by_name("bedrock") + + assert spec is not None + assert spec.display_name == "Amazon Bedrock" + assert spec.backend == "openai_compat" + assert spec.is_gateway is True + assert spec.requires_api_base is True + assert spec.default_api_base == "" + assert spec.detect_by_base_keyword == "bedrock-runtime" + assert spec.env_key == "AWS_BEARER_TOKEN_BEDROCK" + assert hasattr(ProvidersConfig(), "bedrock") + + +def test_bedrock_profile_keeps_the_region_url_and_bearer_credential( + tmp_path: Path, +) -> None: + connection = _connection(tmp_path) + + assert connection.provider_name == "bedrock" + assert connection.adapter == "openai_compat" + assert connection.api_base == ( + "https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1" + ) + assert connection.api_key == "bedrock-test-secret" + assert connection.model_catalog == "openai" + assert connection.is_usable is True + + +def test_bedrock_requires_a_user_supplied_region_url( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "bedrock-test-secret") + connection = ConnectionResolver( + DeepCodeConfig(), CredentialStore(tmp_path / "credentials.json") + ).resolve_connection("bedrock") + + assert connection.api_key == "bedrock-test-secret" + assert connection.api_base is None + assert connection.is_configured is False + assert connection.is_usable is False + + +def test_bedrock_catalog_uses_models_endpoint_and_bearer_auth( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + connection = _connection(tmp_path) + seen: dict[str, object] = {} + + class Response: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return {"data": [{"id": "us.anthropic.claude-sonnet-4-6"}]} + + class Client: + def __init__(self, **kwargs: object) -> None: + seen["client"] = kwargs + + def __enter__(self): + return self + + def __exit__(self, *_args: object) -> None: + return None + + def get(self, url: str, *, headers: dict[str, str]): + seen["url"] = url + seen["headers"] = headers + return Response() + + monkeypatch.setattr("core.providers.catalog_service.httpx.Client", Client) + result = ModelCatalogService(tmp_path / "cache.json").list_models( + connection, refresh=True + ) + + assert result.source == "remote" + assert [model.id for model in result.models] == ["us.anthropic.claude-sonnet-4-6"] + assert seen["url"] == ( + "https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1/models" + ) + assert seen["headers"] == {"Authorization": "Bearer bedrock-test-secret"} + + +def test_bedrock_catalog_fallback_lists_exact_inference_ids( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + connection = _connection(tmp_path) + service = ModelCatalogService(tmp_path / "cache.json") + + def offline(_connection): + raise OSError("network unavailable") + + monkeypatch.setattr(service, "_fetch", offline) + result = service.list_models(connection, refresh=True) + + assert result.source == "fallback" + assert result.stale is True + assert tuple(model.id for model in result.models) == tuple( + sorted(BEDROCK_MODEL_IDS) + ) + assert {model.context_window for model in result.models} == {1_000_000} + assert {model.max_output_tokens for model in result.models} == {64_000} + assert all(model.reasoning is None for model in result.models) + + +def test_bedrock_chat_request_preserves_the_dotted_model_id() -> None: + provider = OpenAICompatProvider( + api_key="bedrock-test-secret", + api_base="https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1", + default_model="us.anthropic.claude-sonnet-4-6", + spec=find_by_name("bedrock"), + ) + + kwargs = provider._build_kwargs( + [{"role": "user", "content": "Hello"}], + tools=None, + model=None, + max_tokens=512, + temperature=0.2, + reasoning_effort=None, + tool_choice=None, + ) + + assert kwargs["model"] == "us.anthropic.claude-sonnet-4-6" + assert kwargs["max_tokens"] == 512 + assert "reasoning_effort" not in kwargs + assert provider._should_use_responses_api(None, None) is False + + +@pytest.mark.parametrize("model_id", BEDROCK_MODEL_IDS) +def test_bedrock_model_ids_have_conservative_offline_metadata(model_id: str) -> None: + info = resolve_model_info(model_id) + + assert info.id == model_id + assert info.source == "seed" + assert info.context_window == 1_000_000 + assert info.max_output_tokens == 64_000 + assert info.reasoning is None