From dfdbf12436b13e14be0c9de42b41ea931763a30a Mon Sep 17 00:00:00 2001 From: Rohan Isawe Date: Mon, 22 Jun 2026 17:04:50 -0700 Subject: [PATCH] feat: add AWS Bedrock provider for Claude via SigV4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #82. Adds a `bedrock` provider so SkillSpector can run Claude models on AWS Bedrock alongside the existing OpenAI / Anthropic / Anthropic-proxy / NVIDIA paths. Bedrock uses SigV4 (not API keys); the provider implements the unified ChatModelProvider protocol introduced in main and returns its own ChatBedrockConverse from create_chat_model(). - New providers/bedrock/ package: BedrockProvider implements the full LLMProvider surface (ModelMetadataProvider + CredentialsProvider + ChatModelProvider). resolve_credentials() returns None since Bedrock doesn't fit the (api_key, base_url) shape; create_chat_model() probes the boto3 credential chain, returns None when no credentials resolve so the orchestrator can fall through, and otherwise constructs ChatBedrockConverse from a boto3 session. - Generic defaults (no environment-specific identifiers in OSS source): AWS_PROFILE is honored when set, otherwise the standard boto3 credential chain resolves. AWS_REGION defaults to us-west-2. Default model is the public cross-region inference profile us.anthropic.claude-sonnet-4-6-20250915-v1:0; users override via SKILLSPECTOR_MODEL with any Bedrock model ID, cross-region inference profile ID, or their own application-inference-profile ARN. - ARN model strings auto-pin ChatBedrockConverse's provider="anthropic" (required by LiteLLM/langchain-aws for inference-profile ARNs). - Per-call timeout is wired through to the boto3 client via botocore.config.Config(read_timeout=..., connect_timeout=10) so long Bedrock calls actually time out instead of hanging. - Wire selector: SKILLSPECTOR_PROVIDER=bedrock. - Deps: langchain-aws>=0.2.0, boto3>=1.34.0. - README and CLI help updated to document AWS_PROFILE / AWS_REGION behavior and the default model. - New tests/unit/test_bedrock_provider.py (16 tests) covers credential short-circuit, model registry lookup, resolve_model precedence, default-model-is-public-ID assertion, AWS_PROFILE omitted from boto3.Session when env is unset, env overrides, timeout wired through to BotocoreConfig, and ARN-vs-model-id provider pinning. - uv.lock not modified — let maintainer regenerate after merge. Full unit suite: 736 passed, 12 skipped. Signed-off-by: Rohan Isawe --- README.md | 17 +- pyproject.toml | 2 + src/skillspector/cli.py | 10 +- src/skillspector/providers/__init__.py | 7 +- .../providers/bedrock/__init__.py | 30 +++ .../providers/bedrock/model_registry.yaml | 29 ++ .../providers/bedrock/provider.py | 144 ++++++++++ tests/unit/test_bedrock_provider.py | 254 ++++++++++++++++++ 8 files changed, 488 insertions(+), 5 deletions(-) create mode 100644 src/skillspector/providers/bedrock/__init__.py create mode 100644 src/skillspector/providers/bedrock/model_registry.yaml create mode 100644 src/skillspector/providers/bedrock/provider.py create mode 100644 tests/unit/test_bedrock_provider.py diff --git a/README.md b/README.md index 8e6c5df..a4de5bf 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ inference gateways. | `openai` | `OPENAI_API_KEY` (+ optional `OPENAI_BASE_URL`) | api.openai.com (or any OpenAI-compatible URL) | `gpt-5.4` | | `anthropic` | `ANTHROPIC_API_KEY` | api.anthropic.com | `claude-opus-4-6` | | `anthropic_proxy` | `ANTHROPIC_PROXY_API_KEY` + `ANTHROPIC_PROXY_ENDPOINT_URL` | Any Vertex-style raw-predict proxy | `claude-sonnet-4-6` | +| `bedrock` | `AWS_PROFILE` (optional) + `AWS_REGION` — SigV4 via boto3 | AWS Bedrock Runtime | `us.anthropic.claude-sonnet-4-6-20250915-v1:0` | | `nv_build` | `NVIDIA_INFERENCE_KEY` | build.nvidia.com | `deepseek-ai/deepseek-v4-flash` | ```bash @@ -169,6 +170,18 @@ export ANTHROPIC_PROXY_API_KEY=your-bearer-token export SKILLSPECTOR_MODEL=claude-sonnet-4-6 skillspector scan ./my-skill/ +# AWS Bedrock (Claude via SigV4) +export SKILLSPECTOR_PROVIDER=bedrock +# Optional: select an AWS named profile. When unset, the standard +# boto3 credential chain (env vars, instance metadata, SSO, etc.) resolves. +# export AWS_PROFILE=my-profile +export AWS_REGION=us-west-2 # default if unset +# Default model: us.anthropic.claude-sonnet-4-6-20250915-v1:0 +# Override with any Bedrock model ID, cross-region inference-profile +# ID, or your own application-inference-profile ARN: +# export SKILLSPECTOR_MODEL=us.anthropic.claude-opus-4-6-20250915-v1:0 +skillspector scan ./my-skill/ + # NVIDIA build.nvidia.com export SKILLSPECTOR_PROVIDER=nv_build export NVIDIA_INFERENCE_KEY=nvapi-... @@ -404,7 +417,7 @@ Issues (2) | Variable | Description | Required | |----------|-------------|----------| -| `SKILLSPECTOR_PROVIDER` | Active LLM provider: `openai`, `anthropic`, or `nv_build`. Each provider has its own bundled `model_registry.yaml` and default model (see the LLM Analysis table above). Defaults to `nv_build`. | Optional | +| `SKILLSPECTOR_PROVIDER` | Active LLM provider: `openai`, `anthropic`, `anthropic_proxy`, `bedrock`, or `nv_build`. Each provider has its own bundled `model_registry.yaml` and default model (see the LLM Analysis table above). Defaults to `nv_build`. | Optional | | `NVIDIA_INFERENCE_KEY` | Credential for the `nv_build` provider (build.nvidia.com). | Required for LLM analysis when `SKILLSPECTOR_PROVIDER=nv_build` | | `OPENAI_API_KEY` | Credential for the OpenAI provider (`SKILLSPECTOR_PROVIDER=openai`). Also serves as the tier-2 fallback in the credential waterfall when the active provider returns no credentials. | Required for LLM analysis when `SKILLSPECTOR_PROVIDER=openai` | | `OPENAI_BASE_URL` | Override the OpenAI endpoint (e.g. point at Ollama). | Optional | @@ -412,6 +425,8 @@ Issues (2) | `ANTHROPIC_PROXY_ENDPOINT_URL` | Full endpoint URL for the Anthropic proxy provider (Vertex-style raw-predict). | Required when `SKILLSPECTOR_PROVIDER=anthropic_proxy` | | `ANTHROPIC_PROXY_API_KEY` | Bearer token for the Anthropic proxy provider. | Required when `SKILLSPECTOR_PROVIDER=anthropic_proxy` | | `ANTHROPIC_PROXY_API_VERSION` | `anthropic_version` value sent in the request body (default: `vertex-2023-10-16`). | Optional | +| `AWS_PROFILE` | Named AWS profile for the Bedrock provider — authenticates via SigV4 through boto3. When unset, the standard boto3 credential chain (env vars, instance metadata, SSO, etc.) resolves. | Optional (used when `SKILLSPECTOR_PROVIDER=bedrock`) | +| `AWS_REGION` | AWS region for the Bedrock Runtime endpoint. Defaults to `us-west-2`. | Optional (used when `SKILLSPECTOR_PROVIDER=bedrock`) | | `SKILLSPECTOR_MODEL` | Override the active provider's default model. See the LLM Analysis table for each provider's default. | Optional | | `SKILLSPECTOR_MODEL_REGISTRY` | Override the bundled per-provider YAML registry (`src/skillspector/providers//model_registry.yaml`) with a custom path. | Optional | | `SKILLSPECTOR_LOG_LEVEL` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `WARNING`). | Optional | diff --git a/pyproject.toml b/pyproject.toml index 6717185..af2235c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,8 +39,10 @@ dependencies = [ "langgraph>=1.0.10", "langgraph-cli[inmem]>=0.4.14", "langchain-anthropic>=1.4.5", + "langchain-aws>=0.2.0", "langchain-core>=1.2.17", "langchain-openai>=1.1.10", + "boto3>=1.34.0", "langsmith>=0.7.30", "yara-python>=4.5.0", ] diff --git a/src/skillspector/cli.py b/src/skillspector/cli.py index 83e3224..0321800 100644 --- a/src/skillspector/cli.py +++ b/src/skillspector/cli.py @@ -192,9 +192,10 @@ def scan( Environment variables: SKILLSPECTOR_PROVIDER Active LLM provider: openai | anthropic | - nv_build | nv_inference. Defaults to the - NVIDIA path (nv_inference, falling back to - nv_build in OSS builds). + anthropic_proxy | bedrock | nv_build | + nv_inference. Defaults to the NVIDIA path + (nv_inference, falling back to nv_build in + OSS builds). SKILLSPECTOR_MODEL Override the active provider's default model (applies to every analyzer slot). SKILLSPECTOR_LOG_LEVEL DEBUG | INFO | WARNING | ERROR (default WARNING). @@ -203,6 +204,9 @@ def scan( OPENAI_API_KEY [+ OPENAI_BASE_URL] for SKILLSPECTOR_PROVIDER=openai ANTHROPIC_API_KEY for SKILLSPECTOR_PROVIDER=anthropic + AWS_PROFILE (optional) + AWS_REGION for SKILLSPECTOR_PROVIDER=bedrock + (AWS_PROFILE: standard boto3 credential + chain when unset; AWS_REGION default: us-west-2) NVIDIA_INFERENCE_KEY for the NVIDIA providers """ result = None diff --git a/src/skillspector/providers/__init__.py b/src/skillspector/providers/__init__.py index 307ae6a..b8862b3 100644 --- a/src/skillspector/providers/__init__.py +++ b/src/skillspector/providers/__init__.py @@ -25,6 +25,7 @@ openai → OpenAIProvider (api.openai.com) anthropic → AnthropicProvider (api.anthropic.com) anthropic_proxy → AnthropicProxyProvider (Vertex-style raw-predict proxy) + bedrock → BedrockProvider (AWS Bedrock Runtime, SigV4) nv_build → NvBuildProvider (build.nvidia.com) When unset, the selector defaults to ``nv_build``. @@ -69,6 +70,10 @@ def _select_active_provider() -> LLMProvider: from .anthropic_proxy import AnthropicProxyProvider return AnthropicProxyProvider() + if name == "bedrock": + from .bedrock import BedrockProvider + + return BedrockProvider() if name == "nv_build": return NvBuildProvider() if name in ("nv_inference", ""): @@ -83,7 +88,7 @@ def _select_active_provider() -> LLMProvider: raise ValueError( f"Unknown SKILLSPECTOR_PROVIDER: {name!r}. " - "Expected one of: openai, anthropic, anthropic_proxy, nv_build (or unset)." + "Expected one of: openai, anthropic, anthropic_proxy, bedrock, nv_build (or unset)." ) diff --git a/src/skillspector/providers/bedrock/__init__.py b/src/skillspector/providers/bedrock/__init__.py new file mode 100644 index 0000000..f9bb323 --- /dev/null +++ b/src/skillspector/providers/bedrock/__init__.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""AWS Bedrock provider package (Claude via SigV4-authenticated Bedrock Runtime).""" + +from .provider import ( + BEDROCK_DEFAULT_MODEL, + BEDROCK_DEFAULT_REGION, + REGISTRY_PATH, + BedrockProvider, +) + +__all__ = [ + "BEDROCK_DEFAULT_MODEL", + "BEDROCK_DEFAULT_REGION", + "REGISTRY_PATH", + "BedrockProvider", +] diff --git a/src/skillspector/providers/bedrock/model_registry.yaml b/src/skillspector/providers/bedrock/model_registry.yaml new file mode 100644 index 0000000..49a3668 --- /dev/null +++ b/src/skillspector/providers/bedrock/model_registry.yaml @@ -0,0 +1,29 @@ +# Token-budget metadata for the BedrockProvider (AWS Bedrock Runtime). +# Bundled with the package; consulted whenever the active provider is +# BedrockProvider. +# +# Format: +# models: +# "": +# context_length: # total context window in tokens (required) +# max_output_tokens: # model's max output cap (optional) +# +# Keys are public Bedrock cross-region inference profile IDs. Users can +# also point SKILLSPECTOR_MODEL at a stock Bedrock model ID or their own +# application-inference-profile ARN — those won't be in this registry, +# so the scanner falls back to the default token budget for unknown +# models. Add entries here if you have a private ARN you want metadata +# for. + +models: + "us.anthropic.claude-sonnet-4-6-20250915-v1:0": + context_length: 1000000 + max_output_tokens: 128000 + + "us.anthropic.claude-opus-4-6-20250915-v1:0": + context_length: 1000000 + max_output_tokens: 128000 + + "us.anthropic.claude-opus-4-5-20250514-v1:0": + context_length: 200000 + max_output_tokens: 64000 diff --git a/src/skillspector/providers/bedrock/provider.py b/src/skillspector/providers/bedrock/provider.py new file mode 100644 index 0000000..2beac0d --- /dev/null +++ b/src/skillspector/providers/bedrock/provider.py @@ -0,0 +1,144 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""AWS Bedrock provider — Claude models via the Bedrock Runtime. + +Bedrock authenticates with AWS SigV4, not API keys. ``resolve_credentials`` +returns ``None`` because the ``(api_key, base_url)`` shape doesn't fit; +``create_chat_model`` instead probes the boto3 credential chain and +constructs ``ChatBedrockConverse`` directly when AWS credentials resolve. + +Environment variables: + ``AWS_PROFILE`` — when set, used as the boto3 named profile. + When unset, the standard boto3 credential chain + (env vars, instance metadata, SSO, etc.) resolves. + ``AWS_REGION`` — defaults to ``us-west-2``. + ``SKILLSPECTOR_MODEL`` — overrides the default model (a Bedrock + model ID, a cross-region inference-profile ID, or your own + application-inference-profile ARN). +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import boto3 +from botocore.config import Config as BotocoreConfig +from langchain_aws import ChatBedrockConverse +from langchain_core.language_models.chat_models import BaseChatModel + +from skillspector.providers import registry + +BEDROCK_DEFAULT_REGION = "us-west-2" +# Cross-region inference profile ID for Claude Sonnet 4.6. Public, +# available to any account with Anthropic-on-Bedrock model access. +# Users can override with SKILLSPECTOR_MODEL to point at a different +# model or their own application-inference-profile ARN. +BEDROCK_DEFAULT_MODEL = "us.anthropic.claude-sonnet-4-6-20250915-v1:0" +# Connect timeout for the Bedrock Runtime client. The per-call +# ``timeout`` from ``create_chat_model`` is applied as the read timeout. +_BEDROCK_CONNECT_TIMEOUT = 10 + +REGISTRY_PATH = str(Path(__file__).with_name("model_registry.yaml")) + + +class BedrockProvider: + """AWS Bedrock provider — SigV4 auth via boto3, bundled-YAML metadata.""" + + DEFAULT_MODEL = BEDROCK_DEFAULT_MODEL + SLOT_DEFAULTS: dict[str, str] = {} + + def resolve_credentials(self) -> tuple[str, str | None] | None: + """Bedrock uses SigV4, not ``(api_key, base_url)`` — always returns ``None``. + + ``providers.resolve_chat_model_credentials`` treats ``None`` as "this + provider doesn't supply OpenAI-style credentials" and falls through + to its OpenAI fallback (which is irrelevant for Bedrock — the + chat-model construction path in ``create_chat_model`` is what + matters). + """ + return None + + def create_chat_model( + self, + model: str, + *, + max_tokens: int, + timeout: float | None = 120, + ) -> BaseChatModel | None: + """Construct a ``ChatBedrockConverse`` bound to a Bedrock client. + + Returns ``None`` if no AWS credentials resolve in the boto3 + credential chain, so the orchestrator can fall through to its + OpenAI fallback. Otherwise returns a configured client. + + ``AWS_PROFILE`` selects a named profile when set; otherwise the + standard boto3 credential chain (env vars, instance metadata, + SSO, etc.) resolves. ``AWS_REGION`` defaults to ``us-west-2``. + + ``timeout`` is applied as the boto3 client's read timeout via a + ``botocore.config.Config`` so long Bedrock calls actually time + out instead of hanging. + + ``ChatBedrockConverse`` requires an explicit ``provider`` + argument when the model is supplied as an ARN (inference + profiles); we pin it to ``anthropic`` since this provider is + Claude-only. When a plain Bedrock model ID is supplied, + ``provider`` is inferred from the prefix and we omit it. + """ + profile = os.environ.get("AWS_PROFILE", "").strip() or None + region = os.environ.get("AWS_REGION", "").strip() or BEDROCK_DEFAULT_REGION + + session_kwargs: dict[str, object] = {"region_name": region} + if profile: + session_kwargs["profile_name"] = profile + session = boto3.Session(**session_kwargs) + + # If nothing in the boto3 credential chain resolves, return None so + # the orchestrator can fall through. + if session.get_credentials() is None: + return None + + client = session.client( + "bedrock-runtime", + region_name=region, + config=BotocoreConfig( + read_timeout=timeout, + connect_timeout=_BEDROCK_CONNECT_TIMEOUT, + ), + ) + + kwargs: dict[str, object] = { + "model": model, + "client": client, + "region_name": region, + "max_tokens": max_tokens, + } + if model.startswith("arn:"): + kwargs["provider"] = "anthropic" + + return ChatBedrockConverse(**kwargs) + + def get_context_length(self, model: str) -> int | None: + return registry.lookup_context_length(REGISTRY_PATH, model) + + def get_max_output_tokens(self, model: str) -> int | None: + return registry.lookup_max_output_tokens(REGISTRY_PATH, model) + + def resolve_model(self, slot: str = "default") -> str: + """Resolve model: ``SKILLSPECTOR_MODEL`` env > slot default > ``DEFAULT_MODEL``.""" + user_input = os.environ.get("SKILLSPECTOR_MODEL", "").strip() + return user_input or self.SLOT_DEFAULTS.get(slot, "") or self.DEFAULT_MODEL diff --git a/tests/unit/test_bedrock_provider.py b/tests/unit/test_bedrock_provider.py new file mode 100644 index 0000000..9c48eba --- /dev/null +++ b/tests/unit/test_bedrock_provider.py @@ -0,0 +1,254 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the AWS Bedrock provider. + +Bedrock authenticates via SigV4, not API keys, so ``resolve_credentials`` +returns ``None`` and the provider implements ``ChatModelProvider.create_chat_model`` +to construct ``ChatBedrockConverse`` directly. These tests stub +``boto3.Session`` and ``ChatBedrockConverse`` so no AWS calls are made. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from skillspector.providers import ( + get_metadata_provider, + registry, + resolve_provider_credentials, +) +from skillspector.providers.bedrock import ( + BEDROCK_DEFAULT_MODEL, + BEDROCK_DEFAULT_REGION, + BedrockProvider, +) + +# A real application-inference-profile ARN shape for testing ARN-specific +# behavior. Account ID and profile ID are placeholders — no live resource. +_TEST_ARN = "arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/abc123def456" + + +@pytest.fixture(autouse=True) +def _clean_provider_env(monkeypatch: pytest.MonkeyPatch): + """Isolate provider-related env vars and the YAML cache for each test.""" + monkeypatch.delenv("SKILLSPECTOR_PROVIDER", raising=False) + monkeypatch.delenv("SKILLSPECTOR_MODEL", raising=False) + monkeypatch.delenv("SKILLSPECTOR_MODEL_REGISTRY", raising=False) + monkeypatch.delenv("AWS_PROFILE", raising=False) + monkeypatch.delenv("AWS_REGION", raising=False) + registry._load.cache_clear() + yield + registry._load.cache_clear() + + +class TestBedrockProviderCredentials: + """Bedrock has no API key — resolve_credentials always returns None.""" + + def test_resolve_credentials_returns_none(self) -> None: + assert BedrockProvider().resolve_credentials() is None + + def test_resolve_credentials_returns_none_even_with_aws_env_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("AWS_PROFILE", "some-profile") + monkeypatch.setenv("AWS_REGION", "eu-west-1") + assert BedrockProvider().resolve_credentials() is None + + +class TestBedrockProviderMetadata: + """Token-budget metadata is read from the bundled model_registry.yaml.""" + + def test_metadata_known_default_model(self) -> None: + provider = BedrockProvider() + assert provider.get_context_length(BEDROCK_DEFAULT_MODEL) == 1_000_000 + assert provider.get_max_output_tokens(BEDROCK_DEFAULT_MODEL) == 128_000 + + def test_metadata_known_inference_profile_id(self) -> None: + provider = BedrockProvider() + model = "us.anthropic.claude-opus-4-6-20250915-v1:0" + assert provider.get_context_length(model) == 1_000_000 + assert provider.get_max_output_tokens(model) == 128_000 + + def test_metadata_unknown_model_returns_none(self) -> None: + provider = BedrockProvider() + assert provider.get_context_length("unknown.model") is None + assert provider.get_max_output_tokens("unknown.model") is None + + +class TestBedrockProviderResolveModel: + """resolve_model: SKILLSPECTOR_MODEL env > slot > DEFAULT_MODEL.""" + + def test_default_model_is_public_cross_region_inference_profile(self) -> None: + # The default must be a public Bedrock model ID, not a private ARN — + # this is checked in the OSS PR review and is load-bearing. + assert BEDROCK_DEFAULT_MODEL == "us.anthropic.claude-sonnet-4-6-20250915-v1:0" + assert not BEDROCK_DEFAULT_MODEL.startswith("arn:") + assert BedrockProvider().resolve_model() == BEDROCK_DEFAULT_MODEL + + def test_env_overrides_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_MODEL", "us.anthropic.claude-opus-4-6-20250915-v1:0") + assert BedrockProvider().resolve_model() == "us.anthropic.claude-opus-4-6-20250915-v1:0" + + def test_env_applies_to_every_slot(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_MODEL", "user/override") + assert BedrockProvider().resolve_model("meta_analyzer") == "user/override" + + def test_unknown_slot_falls_back_to_default(self) -> None: + assert BedrockProvider().resolve_model("mcp_least_privilege") == BEDROCK_DEFAULT_MODEL + + +class TestBedrockProviderCreateChatModel: + """create_chat_model wires boto3 + ChatBedrockConverse with the right config.""" + + @patch("skillspector.providers.bedrock.provider.ChatBedrockConverse") + @patch("skillspector.providers.bedrock.provider.boto3.Session") + def test_returns_none_when_no_aws_credentials( + self, mock_session: MagicMock, mock_chat: MagicMock + ) -> None: + """No AWS credentials in any chain → return None so the orchestrator falls through.""" + mock_session.return_value.get_credentials.return_value = None + + result = BedrockProvider().create_chat_model( + "us.anthropic.claude-sonnet-4-6-20250915-v1:0", + max_tokens=1024, + timeout=60, + ) + + assert result is None + mock_chat.assert_not_called() + + @patch("skillspector.providers.bedrock.provider.ChatBedrockConverse") + @patch("skillspector.providers.bedrock.provider.boto3.Session") + def test_omits_profile_when_aws_profile_unset( + self, mock_session: MagicMock, mock_chat: MagicMock + ) -> None: + """No AWS_PROFILE → boto3.Session called without profile_name. + + Defers to the standard boto3 credential chain (env vars, instance + metadata, SSO). This is the OSS-default behavior; hardcoding a + named profile is a footgun for external users. + """ + mock_session.return_value.get_credentials.return_value = MagicMock() + mock_session.return_value.client.return_value = MagicMock() + + BedrockProvider().create_chat_model( + "us.anthropic.claude-sonnet-4-6-20250915-v1:0", + max_tokens=1024, + timeout=60, + ) + + session_kwargs = mock_session.call_args.kwargs + assert "profile_name" not in session_kwargs + assert session_kwargs["region_name"] == BEDROCK_DEFAULT_REGION + + @patch("skillspector.providers.bedrock.provider.ChatBedrockConverse") + @patch("skillspector.providers.bedrock.provider.boto3.Session") + def test_env_overrides_profile_and_region( + self, + mock_session: MagicMock, + mock_chat: MagicMock, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("AWS_PROFILE", "custom-profile") + monkeypatch.setenv("AWS_REGION", "eu-central-1") + mock_session.return_value.get_credentials.return_value = MagicMock() + mock_session.return_value.client.return_value = MagicMock() + + BedrockProvider().create_chat_model( + "us.anthropic.claude-sonnet-4-6-20250915-v1:0", + max_tokens=1024, + timeout=60, + ) + + mock_session.assert_called_once_with( + profile_name="custom-profile", + region_name="eu-central-1", + ) + + @patch("skillspector.providers.bedrock.provider.ChatBedrockConverse") + @patch("skillspector.providers.bedrock.provider.boto3.Session") + def test_timeout_applied_to_botocore_config( + self, mock_session: MagicMock, mock_chat: MagicMock + ) -> None: + """The ``timeout`` argument flows through to the boto3 client. + + Without this, long Bedrock calls hang indefinitely instead of + respecting the caller's timeout budget. + """ + mock_session.return_value.get_credentials.return_value = MagicMock() + mock_session.return_value.client.return_value = MagicMock() + + BedrockProvider().create_chat_model( + "us.anthropic.claude-sonnet-4-6-20250915-v1:0", + max_tokens=1024, + timeout=90, + ) + + client_call = mock_session.return_value.client.call_args + config = client_call.kwargs["config"] + # botocore.config.Config exposes timeouts as attributes. + assert config.read_timeout == 90 + assert config.connect_timeout == 10 + + @patch("skillspector.providers.bedrock.provider.ChatBedrockConverse") + @patch("skillspector.providers.bedrock.provider.boto3.Session") + def test_arn_pins_provider_to_anthropic( + self, mock_session: MagicMock, mock_chat: MagicMock + ) -> None: + """ChatBedrockConverse requires explicit provider= when model is an ARN.""" + mock_session.return_value.get_credentials.return_value = MagicMock() + mock_session.return_value.client.return_value = MagicMock() + + BedrockProvider().create_chat_model( + _TEST_ARN, + max_tokens=2048, + timeout=120, + ) + + kwargs = mock_chat.call_args.kwargs + assert kwargs["model"] == _TEST_ARN + assert kwargs["provider"] == "anthropic" + assert kwargs["max_tokens"] == 2048 + assert kwargs["region_name"] == BEDROCK_DEFAULT_REGION + + @patch("skillspector.providers.bedrock.provider.ChatBedrockConverse") + @patch("skillspector.providers.bedrock.provider.boto3.Session") + def test_plain_model_id_does_not_pin_provider( + self, mock_session: MagicMock, mock_chat: MagicMock + ) -> None: + """For non-ARN model IDs, provider is inferred from the prefix — omit it.""" + mock_session.return_value.get_credentials.return_value = MagicMock() + mock_session.return_value.client.return_value = MagicMock() + + BedrockProvider().create_chat_model( + "us.anthropic.claude-sonnet-4-6-20250915-v1:0", + max_tokens=1024, + timeout=60, + ) + + assert "provider" not in mock_chat.call_args.kwargs + + +class TestBedrockProviderSelection: + """SKILLSPECTOR_PROVIDER=bedrock activates BedrockProvider.""" + + def test_select_bedrock(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "bedrock") + # Bedrock returns no OpenAI-style credentials. + assert resolve_provider_credentials() is None + assert isinstance(get_metadata_provider(), BedrockProvider)