diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d3f83d48..2482acbe 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **AWS Bedrock is now routable for research runs (main-process).** Registered + the Amazon Nova models (`us.amazon.nova-micro-v1:0`, `-lite-`, `-pro-`) with + real prices, and wired the model→provider router and the standard-pipeline + dispatch so a stage model set to a Nova id (e.g. `AI_REASONING_MODEL`) runs + through the Bedrock `converse` API and prices correctly through the cost gate. + Bedrock routing is refused inside a supervised MCP worker — AWS credentials + are deliberately never inherited by workers, and a regression test pins that + boundary. (Azure Foundry routing is tracked as a follow-up: its model id is a + user deployment name, which needs a small deployment-pricing mechanism.) + ## [1.37.2] - 2026-07-22 ### Added diff --git a/examples/deploy/aws-bedrock/README.md b/examples/deploy/aws-bedrock/README.md index 7033bd10..9339158b 100644 --- a/examples/deploy/aws-bedrock/README.md +++ b/examples/deploy/aws-bedrock/README.md @@ -52,13 +52,21 @@ primr keys test bedrock # free, auth-only: "authenticated; N foundation mode ``` This validates the deployment (credentials, region, and visible foundation -models). Note that full research-pipeline routing through Bedrock is **not yet -wired**: the model-name→provider router has no Bedrock branch, so a Bedrock -model id set via `AI_REASONING_MODEL` (e.g. `us.amazon.nova-lite-v1:0`) is not -recognized as a Bedrock model — it falls through to the first-party xAI path and -the run cannot even be priced. To route utility-tier inference through a custom -endpoint today, use the OpenAI-compatible gateway seam (`LOCAL_LLM_BASE_URL` / -`LOCAL_LLM_API_KEY`). +models). To route a pipeline stage through Bedrock, set the stage model env var +to a registered Nova model id (the `us.` cross-region inference-profile form): + +```bash +AI_REASONING_MODEL=us.amazon.nova-pro-v1:0 # reasoning/analysis stage +AI_REPORT_MODEL=us.amazon.nova-pro-v1:0 # section-writing stage +AI_FAST_MODEL=us.amazon.nova-lite-v1:0 # scraping/utility stage +``` + +The registered ids — `us.amazon.nova-micro-v1:0`, `us.amazon.nova-lite-v1:0`, +`us.amazon.nova-pro-v1:0` — carry real prices, so the mandatory cost estimate +prices them correctly, and routing dispatches them to the Bedrock `converse` +API. **Main-process only:** Bedrock routing is refused inside a supervised MCP +worker, which deliberately does not carry AWS credentials. For other +Bedrock-hosted models, use their inference-profile id via the same env vars. ## 4. Clean up diff --git a/src/primr/ai/llm.py b/src/primr/ai/llm.py index bd23a8d8..bb6922eb 100644 --- a/src/primr/ai/llm.py +++ b/src/primr/ai/llm.py @@ -199,7 +199,13 @@ def llm( # than fall through to the Gemini code path below (Gemini API rejects # unknown model names with 404). The xAI branch above stays separate # because grok_llm carries xAI-specific session-token bookkeeping. - if config is not None and config.provider in ("openai", "anthropic", "ollama"): + if config is not None and config.provider in ( + "openai", + "anthropic", + "ollama", + "bedrock", + "foundry", + ): from primr.ai.routing import get_provider_for_model log_chat_interaction(prompt, f"Model: {model_name} ({config.provider} dispatch)") diff --git a/src/primr/ai/routing.py b/src/primr/ai/routing.py index 4e80df6f..a79ad96c 100644 --- a/src/primr/ai/routing.py +++ b/src/primr/ai/routing.py @@ -369,6 +369,19 @@ def get_provider_for_model(model_name: str) -> Provider: return _get_anthropic_provider() if provider_name == "ollama": return _get_ollama_provider() + if provider_name in ("bedrock", "foundry"): + # Bedrock/Foundry credentials live only in the main process; a + # supervised worker deliberately never inherits AWS/Azure secrets + # (config/env.py). Refuse rather than fall through to a broken call. + if os.environ.get("PRIMR_SUPERVISED_WORKER") == "1": + raise ValueError( + f"Model {model_name!r} uses the {provider_name!r} provider, which " + "is main-process only. Supervised workers do not carry AWS/Azure " + "credentials by design; run this route in the main process." + ) + from primr.ai.providers.registry import get_registered_provider_for_model + + return get_registered_provider_for_model(model_name) raise ValueError( f"Model {model_name!r} has provider {provider_name!r} which has no " diff --git a/src/primr/ai/stage_routing.py b/src/primr/ai/stage_routing.py index 98f65d98..fd6e6ba0 100644 --- a/src/primr/ai/stage_routing.py +++ b/src/primr/ai/stage_routing.py @@ -617,6 +617,14 @@ def _provider_configured(config: ModelConfig) -> bool: return bool(os.getenv("OPENAI_API_KEY")) if provider == "anthropic": return bool(os.getenv("ANTHROPIC_API_KEY")) + if provider == "bedrock": + return bool( + os.getenv("AWS_BEARER_TOKEN_BEDROCK") + or os.getenv("AWS_ACCESS_KEY_ID") + or os.getenv("AWS_PROFILE") + ) + if provider == "foundry": + return bool(os.getenv("AZURE_OPENAI_API_KEY")) return provider == "ollama" diff --git a/src/primr/config/model_registry.py b/src/primr/config/model_registry.py index 0df01f55..94390426 100644 --- a/src/primr/config/model_registry.py +++ b/src/primr/config/model_registry.py @@ -902,6 +902,54 @@ class ModelRegistry: supports_multimodal=False, ) + # ========================================================================= + # AMAZON BEDROCK - Amazon Nova family via the boto3 converse API. + # Model ids are cross-region inference-profile ids (the `us.` prefix is + # required for on-demand invocation). Prices verified July 2026 against the + # AWS Bedrock pricing page. Cached-input (Bedrock prompt caching, ~75% off) + # is left unset so the estimate gate assumes no cache discount (conservative). + # Bedrock routing is MAIN-PROCESS ONLY — see providers/registry.py; the + # supervised-worker credential boundary (config/env.py) is not crossed. + # ========================================================================= + BEDROCK_NOVA_MICRO = ModelConfig( + name="us.amazon.nova-micro-v1:0", + display_name="Amazon Nova Micro (Bedrock)", + provider="bedrock", + cost_per_1m_input_tokens=0.035, + cost_per_1m_output_tokens=0.14, + max_input_tokens=128_000, + max_output_tokens=5_120, + supports_thinking=False, + supports_tools=True, + supports_multimodal=False, + ) + + BEDROCK_NOVA_LITE = ModelConfig( + name="us.amazon.nova-lite-v1:0", + display_name="Amazon Nova Lite (Bedrock)", + provider="bedrock", + cost_per_1m_input_tokens=0.06, + cost_per_1m_output_tokens=0.24, + max_input_tokens=300_000, + max_output_tokens=5_120, + supports_thinking=False, + supports_tools=True, + supports_multimodal=True, + ) + + BEDROCK_NOVA_PRO = ModelConfig( + name="us.amazon.nova-pro-v1:0", + display_name="Amazon Nova Pro (Bedrock)", + provider="bedrock", + cost_per_1m_input_tokens=0.80, + cost_per_1m_output_tokens=3.20, + max_input_tokens=300_000, + max_output_tokens=5_120, + supports_thinking=False, + supports_tools=True, + supports_multimodal=True, + ) + # ========================================================================= # DEEP RESEARCH AGENT - Autonomous research producing 12+ page reports # This is a SEPARATE API (Interactions API), not generate_content diff --git a/src/primr/config/models.py b/src/primr/config/models.py index e02c9ee3..fcfb4cfb 100644 --- a/src/primr/config/models.py +++ b/src/primr/config/models.py @@ -281,6 +281,10 @@ class PrimrModels: ModelRegistry.OLLAMA_LLAMA4_SCOUT.name: ModelRegistry.OLLAMA_LLAMA4_SCOUT, ModelRegistry.OLLAMA_GLM_4_6.name: ModelRegistry.OLLAMA_GLM_4_6, ModelRegistry.OLLAMA_PHI4_14B.name: ModelRegistry.OLLAMA_PHI4_14B, + # Amazon Bedrock (Nova family, main-process routing only) + ModelRegistry.BEDROCK_NOVA_MICRO.name: ModelRegistry.BEDROCK_NOVA_MICRO, + ModelRegistry.BEDROCK_NOVA_LITE.name: ModelRegistry.BEDROCK_NOVA_LITE, + ModelRegistry.BEDROCK_NOVA_PRO.name: ModelRegistry.BEDROCK_NOVA_PRO, } @classmethod diff --git a/tests/test_ai/test_bedrock_routing.py b/tests/test_ai/test_bedrock_routing.py new file mode 100644 index 00000000..10ea9aff --- /dev/null +++ b/tests/test_ai/test_bedrock_routing.py @@ -0,0 +1,67 @@ +"""Amazon Bedrock is routable in the main process only, and never crosses the +supervised-worker credential boundary. + +Bedrock (Nova) models are registered with real prices so the mandatory cost +gate can price them. Routing reaches ``BedrockProvider`` through the registry +builder. Crucially, a supervised worker must refuse Bedrock routing (AWS +secrets are deliberately stripped from worker environments), and the env +allowlist must never be widened to let them through. +""" + +from __future__ import annotations + +import pytest + +from primr.config.env import is_supervised_worker_env_allowed +from primr.config.models import PrimrModels + +BEDROCK_MODELS = [ + "us.amazon.nova-micro-v1:0", + "us.amazon.nova-lite-v1:0", + "us.amazon.nova-pro-v1:0", +] + + +@pytest.mark.parametrize("model", BEDROCK_MODELS) +def test_bedrock_model_is_registered_and_priced(model: str) -> None: + config = PrimrModels.get_model_config(model) + assert config is not None, f"{model} must be in ALL_MODELS" + assert config.provider == "bedrock" + assert config.cost_per_1m_input_tokens > 0 + assert config.cost_per_1m_output_tokens > 0 + # The mandatory pre-run estimate must not KeyError on a Bedrock model. + assert PrimrModels.calculate_cost(model, 100_000, 20_000) > 0 + + +def test_bedrock_routes_to_registry_provider_in_main_process(monkeypatch) -> None: + monkeypatch.delenv("PRIMR_SUPERVISED_WORKER", raising=False) + sentinel = object() + import primr.ai.providers.registry as registry + + monkeypatch.setattr(registry, "get_registered_provider_for_model", lambda name: sentinel) + from primr.ai.routing import get_provider_for_model + + assert get_provider_for_model("us.amazon.nova-micro-v1:0") is sentinel + + +def test_bedrock_routing_refused_in_supervised_worker(monkeypatch) -> None: + monkeypatch.setenv("PRIMR_SUPERVISED_WORKER", "1") + from primr.ai.routing import get_provider_for_model + + with pytest.raises(ValueError, match="main-process only"): + get_provider_for_model("us.amazon.nova-micro-v1:0") + + +@pytest.mark.parametrize( + "secret", + [ + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_SECRET_ACCESS_KEY", + "AWS_ACCESS_KEY_ID", + "AZURE_OPENAI_API_KEY", + ], +) +def test_cloud_infra_secrets_never_enter_a_worker(secret: str) -> None: + # Regression pin: nobody may "fix" Bedrock/Foundry worker routing by + # widening the allowlist. These stay stripped from supervised workers. + assert is_supervised_worker_env_allowed(secret) is False