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
30 changes: 30 additions & 0 deletions docs/adr/047-ai-bedrock-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# 047. AWS Bedrock provider for the AI module

Date: 2026-10-29

## Problem

The `ai` module supports Google, OpenAI, Anthropic and ElevenLabs. Applications running on AWS want to call models through Amazon Bedrock with their IAM credentials instead of a vendor API key. The current provider plumbing only forwards a single `api_key` to `init_chat_model`, so it cannot pass a region, IAM keys, a session token or a named profile.

## Alternatives

1. Use the Anthropic SDK's Bedrock client directly. It bypasses LangChain, so agents, tools, structured output, streaming and fakes would need a second code path, and it only covers Anthropic models.
2. Use `langchain-aws` `ChatBedrock` (InvokeModel). It needs per-model request bodies and is the legacy path.
3. Use `langchain-aws` `ChatBedrockConverse` through `init_chat_model(model_provider="bedrock_converse")`. It is the same mechanism every other text provider uses, and the Converse API is model-agnostic.

## Decision

Option 3.

- `BedrockConfig` (driver `bedrock`) reads `AWS_REGION` falling back to `AWS_DEFAULT_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_PROFILE`, the Bedrock API key `AWS_BEARER_TOKEN_BEDROCK` as `key`, and the text model from `AWS_BEDROCK_MODEL` (default `us.anthropic.claude-sonnet-4-6`, a cross-region inference profile id). It is registered as `providers["bedrock"]` in `AIConfig` and exported from `fastapi_startkit.ai`.
- `Lab.BEDROCK` maps to the LangChain provider key `bedrock_converse`.
- `Lab.get_client_options()` returns provider-specific constructor arguments. For Bedrock it maps `region`, `access_key_id`, `secret_access_key`, `session_token` and `profile` to `region_name`, `aws_access_key_id`, `aws_secret_access_key`, `aws_session_token` and `credentials_profile_name`, and drops empty values so boto3 falls back to its standard credential chain (env, shared config, instance/role credentials). Other providers return `{}`, so their behaviour is unchanged. `Ai.build` merges these options before `provider_options`, so per-agent options still win.
- The optional `bedrock` extra is `fastapi-startkit[ai]` plus `langchain-aws`. `langchain-aws` depends on `langchain-core` and `boto3` but not on `langchain`, which provides `init_chat_model`. Listing only `langchain-aws` made `pip install fastapi-startkit[bedrock]` fail with `ModuleNotFoundError: langchain`. The self-reference keeps the LangChain pins in one place (the `ai` extra), so `[bedrock]` alone is a working install and `[ai]` alone still excludes `langchain-aws`. Nothing in the framework imports `langchain-aws`; `init_chat_model` imports it only when a Bedrock agent builds its model and raises LangChain's `ImportError` naming `langchain-aws` when it is missing.

## Validation

- Config tests: env mapping, `AWS_DEFAULT_REGION` fallback, empty defaults, model override, registration in `AIConfig`.
- Lab tests: provider key, default model, model URL, client options with and without credentials, `{}` for other providers.
- Agent build test with a mocked `init_chat_model`: a `provider = "bedrock"` agent passes `model_provider="bedrock_converse"`, the model id and the AWS options, and omits unset credentials.
- Packaging: in a fresh venv, install only `fastapi-startkit[bedrock]`, then build a Bedrock agent with mocked AWS env and check that it returns a `ChatBedrockConverse`. Check that `[ai]` alone does not install `langchain-aws`.
- Full suite: `uv run pytest --ignore=tests/masoniteorm/postgres --cov`.
1 change: 1 addition & 0 deletions docs/adr/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ Read this index first, then open the records relevant to the change. Before impl
| [038](038-storage-binary-copy-and-request-scoped-vite-inertia.md) | 2026-10-25 | Storage binary reads and relative copy; request-scoped Vite and Inertia state | `LocalDriver.copy` resolves both paths through `get_path`. Local and S3 `get` read bytes and return `str` for UTF-8 content or raw `bytes` otherwise, and a new `get_bytes` (also on `StorageManager`/`Storage`) always returns bytes. `get` returns `bytes` for any non-UTF-8 content, including latin-1 text. `S3Driver.append` keeps the existing content. Vite `use_*_tag_attributes` accept dicts and `False`; the self-referencing lambda is gone. A pure ASGI `ViteMiddleware` (registered by `ViteProvider`) opens `Vite.request_scope()`, which holds the request's CSP nonce and preloaded assets in a per-instance `ContextVar` shared with child tasks. Outside a scope, the instance values are process-wide defaults, so a nonce set at startup still applies, and middleware inside the scope can read preloaded assets after `call_next`. `Inertia.set_root_view`/`Inertia.version` write to the current `InertiaRequestState` inside a request and to global defaults otherwise. |
| [040](040-deprecate-application-env-argument.md) | 2026-10-25 | Deprecate the `env` argument of `Application` | The deployment environment must come from outside the code: `APP_ENV`, the CLI `--env` option (turned into `APP_ENV` before Cleo parses arguments) or pytest detection (`testing`). `Application(env=...)` now emits a `DeprecationWarning` at the caller (`stacklevel=2`) and will be removed in 1.0; behaviour and precedence are unchanged until then. `set_environment()` stays. Framework tests no longer pass `env=`, and pytest `filterwarnings` turns this warning into an error so the suite cannot reintroduce it. |
| [041](041-database-install-command.md) | 2026-10-22 | InstallCommand base class and db:install | `console/install_command.py` adds an `InstallCommand` base class with a fluent class builder: `InstallCommand.register(name).copy(stub, dest).add_provider(Provider, "config.x.XConfig")`. Each call returns a new subclass. Providers and configs can be classes or dotted strings (strings for app-side configs and optional extras). `--force`, `publish_file` (skips existing files unless forced) and `register_provider` are shared. `register_provider` finds `Application(...)` in `bootstrap/application.py` with `ast` and inserts the entry and any missing imports at AST-reported positions, so formatting is kept. It handles `providers` passed by keyword or positionally. It does nothing if the provider is already listed, by bare name or attribute. It stops with manual steps and exit 1 if the bootstrap file is missing, or if a needed name is already imported from a different module (no aliasing). `db:install` (registered by `AppProvider`) subclasses it to add `--driver` and render `config/database.py` from the installer stub. After a successful run it prints next steps from the installer's database section (the `DB_*` env entries, `uv add` when SQLAlchemy is missing, migrations). Both live in `console/` so the optional SQLAlchemy extra is not imported. |
| [047](047-ai-bedrock-provider.md) | 2026-10-29 | AWS Bedrock provider for the AI module | Adds `provider="bedrock"` via LangChain's `bedrock_converse` (`ChatBedrockConverse`), the same `init_chat_model` path as other providers. `BedrockConfig` reads `AWS_REGION`/`AWS_DEFAULT_REGION`, IAM keys, session token, `AWS_PROFILE`, `AWS_BEARER_TOKEN_BEDROCK` and `AWS_BEDROCK_MODEL`. `Lab.get_client_options()` maps them to ChatBedrockConverse arguments and drops empty values so boto3's default credential chain applies; other providers return `{}`. The `bedrock` extra is `fastapi-startkit[ai]` plus `langchain-aws`, because `langchain-aws` does not pull in `langchain`. `langchain-aws` is imported only by `init_chat_model` when a Bedrock model is built. |
4 changes: 4 additions & 0 deletions fastapi_startkit/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ ai = [
"langchain-core>=1.0.0",
"langgraph>=1.0.0",
]
bedrock = [
"fastapi-startkit[ai]",
"langchain-aws>=1.0.0",
]
redis = [
"redis>=5.0.1",
]
Expand Down
3 changes: 2 additions & 1 deletion fastapi_startkit/src/fastapi_startkit/ai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from .pipeline import Middleware
from .audio import Audio, AudioResponse
from .audio_factory import AudioFactory
from .config.config import AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig
from .config.config import AnthropicConfig, BedrockConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig
from .config.ai import AIConfig
from .decorators import max_steps, max_tokens, model, provider, timeout, top_p
from .document import Document
Expand All @@ -29,6 +29,7 @@
"Audio",
"AudioResponse",
"AudioFactory",
"BedrockConfig",
"Document",
"ElevenLabsConfig",
"GoogleConfig",
Expand Down
1 change: 1 addition & 0 deletions fastapi_startkit/src/fastapi_startkit/ai/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def build(
api_key = lab.get_api_key()
if api_key:
kwargs["api_key"] = api_key
kwargs.update(lab.get_client_options())
if agent.max_tokens:
kwargs["max_tokens"] = agent.max_tokens
if agent.top_p != 1.0:
Expand Down
3 changes: 2 additions & 1 deletion fastapi_startkit/src/fastapi_startkit/ai/config/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from fastapi_startkit.environment import env

from fastapi_startkit.ai import AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig
from fastapi_startkit.ai import AnthropicConfig, BedrockConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig


@dataclass
Expand All @@ -21,5 +21,6 @@ class AIConfig(Config):
"openai": OpenAIConfig(),
"anthropic": AnthropicConfig(),
"elevenlabs": ElevenLabsConfig(),
"bedrock": BedrockConfig(),
}
)
17 changes: 17 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/ai/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,20 @@ class ElevenLabsConfig(Config):
"default_transcribe": "scribe_v1",
}
)


@dataclass
class BedrockConfig(Config):
driver: str = "bedrock"
key: str = env("AWS_BEARER_TOKEN_BEDROCK", "", cast=False)
region: str = field(default_factory=lambda: os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION") or "")
access_key_id: str = env("AWS_ACCESS_KEY_ID", "", cast=False)
secret_access_key: str = env("AWS_SECRET_ACCESS_KEY", "", cast=False)
session_token: str = env("AWS_SESSION_TOKEN", "", cast=False)
profile: str = env("AWS_PROFILE", "", cast=False)

models: dict[str, str] = field(
default_factory=lambda: {
"default": os.getenv("AWS_BEDROCK_MODEL") or "us.anthropic.claude-sonnet-4-6",
}
)
20 changes: 20 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/ai/lab.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

from fastapi_startkit import Config

CLIENT_OPTIONS: dict[str, dict[str, str]] = {
"bedrock": {
"region": "region_name",
"access_key_id": "aws_access_key_id",
"secret_access_key": "aws_secret_access_key",
"session_token": "aws_session_token",
"profile": "credentials_profile_name",
},
}


class ModelType(StrEnum):
TEXT = "text"
Expand All @@ -24,6 +34,7 @@ class Lab(StrEnum):
OPENAI = "openai"
ANTHROPIC = "anthropic"
ELEVENLABS = "elevenlabs"
BEDROCK = "bedrock"

def get_api_key(self) -> str:
return Config.get(f"ai.providers.{self.value}.key")
Expand All @@ -37,8 +48,17 @@ def get_provider_key(self) -> str:
"openai": "openai",
"google": "google_genai",
"elevenlabs": "elevenlabs",
"bedrock": "bedrock_converse",
}[self.value]

def get_client_options(self) -> dict[str, str]:
options: dict[str, str] = {}
for setting, option in CLIENT_OPTIONS.get(self.value, {}).items():
value = Config.get(f"ai.providers.{self.value}.{setting}")
if value:
options[option] = value
return options

@staticmethod
def get_provider(provider: str | None = None, model_type: ModelType = ModelType.TEXT) -> "Lab":
provider = provider or Config.get(f"ai.{model_type.key}")
Expand Down
38 changes: 37 additions & 1 deletion fastapi_startkit/tests/ai/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from langchain_core.outputs import ChatGenerationChunk
from langchain_core.tools import tool

from fastapi_startkit.ai import AIConfig, Document
from fastapi_startkit.ai import AIConfig, BedrockConfig, Document
from fastapi_startkit.ai import state as ai_state
from fastapi_startkit.ai.agent import Agent
from fastapi_startkit.ai.runner import Runner
Expand Down Expand Up @@ -117,6 +117,42 @@ class GoogleAgent(Agent):
self.assertEqual(captured["provider"], "google_genai")
self.assertEqual(captured["model"], "gemini-2.5-flash-lite")

async def test_build_model_passes_bedrock_credentials(self):
captured = {}

def fake_init(model, **kwargs):
captured["model"] = model
captured["kwargs"] = kwargs
return GenericFakeChatModel(messages=iter([AIMessage(content="ok")]))

patcher = mock.patch.object(chat_models, "init_chat_model", fake_init)
patcher.start()
self.addCleanup(patcher.stop)

config = AIConfig()
config.providers["bedrock"] = BedrockConfig(
key="",
region="us-east-1",
access_key_id="",
secret_access_key="",
session_token="",
profile="dev",
models={"default": "amazon.nova-pro-v1:0"},
)
app().make("config").set("ai", config)

class BedrockAgent(Agent):
provider = "bedrock"

await BedrockAgent().prompt("hi")

self.assertEqual(captured["model"], "amazon.nova-pro-v1:0")
self.assertEqual(captured["kwargs"]["model_provider"], "bedrock_converse")
self.assertEqual(captured["kwargs"]["region_name"], "us-east-1")
self.assertEqual(captured["kwargs"]["credentials_profile_name"], "dev")
self.assertNotIn("aws_access_key_id", captured["kwargs"])
self.assertNotIn("api_key", captured["kwargs"])

async def test_stream_yields_astream_events_shaped_events(self):
self.setup_agent([AIMessage(content="streamed reply")])

Expand Down
132 changes: 132 additions & 0 deletions fastapi_startkit/tests/ai/test_bedrock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import os
import unittest
from unittest import mock

from fastapi_startkit.ai import AIConfig, BedrockConfig
from fastapi_startkit.ai.lab import Lab
from fastapi_startkit.application import app

AWS_ENV = (
"AWS_REGION",
"AWS_DEFAULT_REGION",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_PROFILE",
"AWS_BEARER_TOKEN_BEDROCK",
"AWS_BEDROCK_MODEL",
)


class BedrockTestCase(unittest.TestCase):
def use_env(self, values=None):
patcher = mock.patch.dict(os.environ, values or {})
patcher.start()
self.addCleanup(patcher.stop)
for key in AWS_ENV:
if key not in (values or {}):
os.environ.pop(key, None)

def use_config(self, bedrock: BedrockConfig | None = None):
config = AIConfig()
if bedrock is not None:
config.providers["bedrock"] = bedrock
app().make("config").set("ai", config)


class TestBedrockConfig(BedrockTestCase):
def test_defaults_are_empty_without_aws_env(self):
self.use_env()
config = BedrockConfig()

self.assertEqual(config.driver, "bedrock")
self.assertEqual(
(
config.key,
config.region,
config.access_key_id,
config.secret_access_key,
config.session_token,
config.profile,
),
("", "", "", "", "", ""),
)
self.assertEqual(config.models["default"], "us.anthropic.claude-sonnet-4-6")

def test_reads_aws_env(self):
self.use_env(
{
"AWS_REGION": "eu-west-1",
"AWS_ACCESS_KEY_ID": "AKIATEST",
"AWS_SECRET_ACCESS_KEY": "secret",
"AWS_SESSION_TOKEN": "token",
"AWS_PROFILE": "dev",
"AWS_BEARER_TOKEN_BEDROCK": "bedrock-key",
"AWS_BEDROCK_MODEL": "amazon.nova-pro-v1:0",
}
)
config = BedrockConfig()

self.assertEqual(config.region, "eu-west-1")
self.assertEqual(config.access_key_id, "AKIATEST")
self.assertEqual(config.secret_access_key, "secret")
self.assertEqual(config.session_token, "token")
self.assertEqual(config.profile, "dev")
self.assertEqual(config.key, "bedrock-key")
self.assertEqual(config.models["default"], "amazon.nova-pro-v1:0")

def test_region_falls_back_to_default_region(self):
self.use_env({"AWS_DEFAULT_REGION": "ap-south-1"})
self.assertEqual(BedrockConfig().region, "ap-south-1")

def test_region_prefers_aws_region(self):
self.use_env({"AWS_REGION": "us-west-2", "AWS_DEFAULT_REGION": "ap-south-1"})
self.assertEqual(BedrockConfig().region, "us-west-2")

def test_registered_in_ai_config(self):
self.assertIsInstance(AIConfig().providers["bedrock"], BedrockConfig)


class TestBedrockLab(BedrockTestCase):
def test_resolves_bedrock_provider(self):
self.use_env()
self.use_config()

self.assertIs(Lab.get_provider("bedrock"), Lab.BEDROCK)
self.assertEqual(Lab.BEDROCK.get_provider_key(), "bedrock_converse")
self.assertEqual(Lab.get_model_url("bedrock"), "bedrock_converse:us.anthropic.claude-sonnet-4-6")

def test_client_options_map_credentials(self):
self.use_config(
BedrockConfig(
region="us-east-1",
access_key_id="AKIATEST",
secret_access_key="secret",
session_token="token",
profile="dev",
)
)

self.assertEqual(
Lab.BEDROCK.get_client_options(),
{
"region_name": "us-east-1",
"aws_access_key_id": "AKIATEST",
"aws_secret_access_key": "secret",
"aws_session_token": "token",
"credentials_profile_name": "dev",
},
)

def test_client_options_omit_unset_values_for_default_credential_chain(self):
self.use_env()
self.use_config()

self.assertEqual(Lab.BEDROCK.get_client_options(), {})
self.assertFalse(Lab.BEDROCK.get_api_key())

def test_other_providers_have_no_client_options(self):
self.use_config()

for lab in (Lab.GOOGLE, Lab.OPENAI, Lab.ANTHROPIC, Lab.ELEVENLABS):
self.assertEqual(lab.get_client_options(), {})
Loading
Loading