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
12 changes: 12 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 15 additions & 7 deletions examples/deploy/aws-bedrock/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion src/primr/ai/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
13 changes: 13 additions & 0 deletions src/primr/ai/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
8 changes: 8 additions & 0 deletions src/primr/ai/stage_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
48 changes: 48 additions & 0 deletions src/primr/config/model_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/primr/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions tests/test_ai/test_bedrock_routing.py
Original file line number Diff line number Diff line change
@@ -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