Model portability: swapping Bedrock for the Mistral API
Prerequisites + catch-up download
Tooling and AWS access common to every post in this series.
Tooling
- Terraform 1.x (install). Every post provisions infrastructure with Terraform.
- uv for Python project management (install). Each post ships a runnable script you can invoke with
uv run. - direnv (install) so
terraform,uv run, andawspick up AWS credentials automatically oncd. The project scaffold ships an.envrcthat sources a gitignored.envrc.local. - (Optional) A coding agent such as Claude Code, Cursor, Codex, or Gemini CLI to consume the
AgentPromptblocks throughout the series. Not required (each prompt has a manual equivalent shown alongside it), but it skips the boilerplate.
Agent prompt: Check and install missing tooling
You are helping set up tooling for a tutorial project.
For each of `terraform`, `uv`, and `direnv`, run `command -v` to
check whether it is installed. If present, print the version and
continue.
For missing tools, detect the system package manager in this order:
`command -v brew`, `command -v dnf`, `command -v apt-get`. Use the
first one available:
- Terraform: `brew tap hashicorp/tap && brew install hashicorp/tap/terraform`,
dnf via the HashiCorp RPM repo, or apt via the HashiCorp deb repo.
- uv: `brew install uv`, or the official installer
`curl -LsSf https://astral.sh/uv/install.sh | sh`.
- direnv: `brew install direnv`, `dnf install direnv`, or
`apt-get install direnv`.
If no package manager is available or the install fails, stop and
link the manual install page so the developer can finish by hand:
- Terraform: https://developer.hashicorp.com/terraform/install
- uv: https://docs.astral.sh/uv/getting-started/installation/
- direnv: https://direnv.net/docs/installation.html
After installing direnv, do not modify any shell rc files. Print the
hook line for the developer's shell (bash, zsh, or fish) and the path
to the relevant rc file, then wait for them to apply it themselves.
Report which tools were already present, which you installed, and
which need manual follow-up.
AWS access
- A sandbox, test, or personal AWS account with permission to create, modify, and delete the resources discussed in each post. If you don’t have one, follow the official Create Your AWS Account walkthrough (about ten minutes; requires a credit card and a phone number for verification). Treat it as disposable - you can close it from the billing console after the series.
- AWS credentials available locally via
aws configure sso,aws configure, or whichever method matches your setup. You wire them into the project through.envrc.localin the next section, not your shell rc.
Anthropic First Time Use
Bedrock requires a one-time use-case form per account (or per AWS Organization management account) before Anthropic models can be invoked. Easiest path: open any Claude model in the Bedrock console playground and submit the form. Auto-subscription on first invoke can take up to 15 minutes to settle, so it is worth clearing this before post 1.
CLI alternative and verification
Programmatic equivalent (requires AWS CLI 2.27.42 or later):
aws bedrock put-use-case-for-model-access \ --form-data "$(printf '{"companyName":"...","companyWebsite":"...","intendedUsers":"1","industryOption":"...","otherIndustryOption":"","useCases":"..."}' | base64)"Verify:
aws bedrock get-foundation-model-availability \ --model-id anthropic.claude-haiku-4-5-20251001-v1:0 \ --region eu-west-1Look for agreementAvailability.status: AVAILABLE. Expected output:
{ "modelId": "anthropic.claude-haiku-4-5-20251001-v1", "agreementAvailability": { "status": "AVAILABLE" }, "authorizationStatus": "AUTHORIZED", "entitlementAvailability": "AVAILABLE", "regionAvailability": "AVAILABLE"}If the form has not been submitted, only agreementAvailability.status flips to NOT_AVAILABLE. The other three fields stay green even when invocation would fail, so do not rely on them.
Project scaffold
Download the cumulative checkpoint that matches the state at the start of this post:
mkdir -p ~/projectscd ~/projectscurl -fsSL https://andreaslang.dev/terraform-pr-agent/terraform-pr-agent-02.tar.gz | tar xzThis contains everything through post 2. If you followed the previous post, your tree should already match; the curl above is for joining mid-series or recovering from drift. Tooling and AWS access from the sections above still apply.
The posts build on each other, so you may need artifacts created by previous posts to be able to run the examples.
What this post covers#
Recently the US government decided to put export controls in place for Anthropic Mythos and Fable models. See here for details. While this is only for the recently released Fable/Mythos models, it did get me thinking about the increasing risk of reliance on US only foundation models. While I am obviously aware that this post is still running on AWS, I wanted to at least make a move to a European foundational model.
Admittedly, there is not a grand deal of choice and it also meant moving away from AWS Bedrock. Bedrock does have a few Mistral models, but regions are extremely inflexible and the specific one I wanted to use (Mistral Large 3) was not available in the EU at all (the model card says so, but it is not). Losing Bedrock also meant losing direct integration with CloudWatch, but luckily the decision to go with OTLP for audit meant I already had the code hooked up to extract these metrics out of the trace. That in combination with EMF (Embedded Metrics Format) meant I could easily send these as custom metrics to CloudWatch without a great deal of code changes.
Originally I had planned to only add the ability to switch between the models later when we get to evaluation, but with the recent events I changed the order, so the new code does still support Haiku via Bedrock, but added also the ability to use Mistral models via Mistral’s API.
The final tree. + is new in post 3, ~ extends a post 2 file, blank carries unchanged. Click any changed or new file
to read it; the download below fast-forwards to this state.
"""AWS Lambda handler for the terraform-pr-agent.
Model portability: the agent runs on whichever model DEFAULT_MODEL names inthe registry, which Terraform parks in an SSM String parameter (MODELS_PARAMETER).Each registry entry declares a provider (``bedrock`` or ``mistral``) and a modelid; the handler builds the matching pydantic-ai model. Nothing else about theagent changes when the model does, which is the whole point of the abstraction.
First invocation:-- Reads the Logfire token from SSM via the Parameters and Secrets Lambda Extension at http://localhost:2773 when LOGFIRE_TOKEN_PARAMETER is set in the env; sets LOGFIRE_TOKEN so the logfire SDK picks it up.-- Configures logfire with send_to_logfire="if-token-present" and registers PerTraceAuditProcessor, a custom OTel SpanProcessor that buffers spans by trace_id and ships one OTLP-JSON Firehose record when the trace's root span ends.-- Instruments pydantic-ai with version=5 for the spec-compliant span names (invoke_agent, chat, execute_tool) and current GenAI semantic conventions. Pinned explicitly so the schema readers see in the audit copy stays stable across pydantic-ai releases.+- Reads the Logfire token and (for Mistral models) the Mistral API key from SSM via the Parameters and Secrets Lambda Extension at http://localhost:2773.+- Configures logfire and registers PerTraceAuditProcessor, the custom OTel SpanProcessor that buffers spans by trace_id and, when the root span ends, ships one OTLP-JSON Firehose record AND emits one EMF metric line. The EMF line is what makes the CloudWatch dashboard provider-agnostic: off Bedrock there are no AWS/Bedrock metrics, so the handler emits its own from the same gen_ai.* span attributes pydantic-ai records for every provider.
The extension's HTTP server rejects requests during INIT with a"not ready to serve traffic" 400, so this work runs on the firstINVOKE and is memoised with @cache for subsequent warm invocations.
Handler:-- Reads `prompt` from the invocation event.-- Runs the agent synchronously and returns the structured output.
The audit copy lands in Firehose from inside the processor's on_endwhen the agent root span closes, so the handler has no flush logic.A Firehose-side failure raises on the same thread as agent.run_syncand propagates as a Lambda 5xx; the system-of-record copy is neversilently dropped.The extension's HTTP server rejects requests during INIT, so the SSM reads (andthe model build that depends on them) run on the first INVOKE and are memoisedwith @cache for subsequent warm invocations. """
import json7 collapsed lines
import os import threading import urllib.parse import urllib.request from collections.abc import Callable, Sequence from functools import cache from typing import NotRequired, TypedDict
import boto3 import logfireimport structlog from google.protobuf import json_formatfrom httpx import AsyncClient, HTTPStatusError, Response from logfire.sampling import SamplingOptions from opentelemetry.context import ( _SUPPRESS_INSTRUMENTATION_KEY,5 collapsed lines
attach, detach, set_value, ) from opentelemetry.exporter.otlp.proto.common._internal.trace_encoder import ( encode_spans, ) from opentelemetry.sdk.trace import ReadableSpan, SpanProcessorfrom opentelemetry.trace.status import StatusCode from pydantic_ai import Agentfrom pydantic_ai.models import Model from pydantic_ai.models.bedrock import BedrockConverseModelfrom pydantic_ai.models.mistral import MistralModelfrom pydantic_ai.providers.mistral import MistralProviderfrom pydantic_ai.retries import AsyncTenacityTransport, RetryConfig, wait_retry_afterfrom tenacity import retry_if_exception_type, stop_after_attempt, wait_exponential
# JSON logs to stdout, which CloudWatch Logs ingests as-is. The same stream also# carries the EMF metric envelope (see _emit_emf), so one structured sink covers# both application logs and metrics. Logging has no extension dependency, so it# is configured at import rather than on the first INVOKE.structlog.configure( processors=[ structlog.processors.add_log_level, structlog.processors.TimeStamper(fmt="iso"), structlog.processors.EventRenamer("message"), structlog.processors.JSONRenderer(), ], logger_factory=structlog.PrintLoggerFactory(), cache_logger_on_first_use=True,)log = structlog.get_logger()
def _fetch_logfire_token() -> str | None: """Read the Logfire token from the Parameters and Secrets extension.
Returns None when no token parameter is configured, so the function runs fine without the Logfire integration. The extension caches the value across invocations, so this is cheap on warm starts. """ parameter_name = os.environ.get("LOGFIRE_TOKEN_PARAMETER") if not parameter_name: return Nonedef _fetch_ssm_parameter(name: str) -> str: """Read one parameter through the Parameters and Secrets extension.
Works for plain String and SecureString parameters alike; withDecryption is a no-op on a String. safe="" forces urllib to percent-encode the leading and embedded slashes in a hierarchical name (/a/b/c -> %2Fa%2Fb%2Fc), which the extension requires. """ session_token = os.environ["AWS_SESSION_TOKEN"]
# safe="" forces urllib to percent-encode the leading and embedded # slashes in a hierarchical parameter name (e.g. /a/b/c -> %2Fa%2Fb%2Fc). # The Parameters and Secrets extension rejects unencoded slashes with # HTTP 400; AWS' own Python sample shows the same %2F-encoded form. url = ( "http://localhost:2773/systemsmanager/parameters/get" f"?name={urllib.parse.quote(parameter_name, safe='')}&withDecryption=true" f"?name={urllib.parse.quote(name, safe='')}&withDecryption=true" ) req = urllib.request.Request( url,4 collapsed lines
headers={"X-Aws-Parameters-Secrets-Token": session_token}, ) with urllib.request.urlopen(req, timeout=2) as resp: payload = json.load(resp) return payload["Parameter"]["Value"]
def _fetch_logfire_token() -> str | None: """Logfire token, or None when the integration is not wired.""" name = os.environ.get("LOGFIRE_TOKEN_PARAMETER") if not name: return None return _fetch_ssm_parameter(name)
# Status codes worth retrying: rate limit and transient gateway errors. Others# (auth, bad request) fail fast so a real problem is not retried five times._RETRYABLE_STATUS = frozenset({429, 502, 503, 504})
def _raise_for_retryable(response: Response) -> None: if response.status_code in _RETRYABLE_STATUS: response.raise_for_status()
def _retrying_http_client() -> AsyncClient: """An httpx client that retries rate-limit and transient errors.
pydantic-ai raises ModelHTTPError on transport errors and does not retry them itself, so a rate-limited Mistral call (the agent loop can burst past the free-tier per-second cap) would otherwise fail the whole run. wait_retry_after honours the Retry-After header Mistral sends on a 429, falling back to exponential backoff. Bedrock uses boto3 rather than httpx and has its own retry config, so this is the Mistral client only. """ transport = AsyncTenacityTransport( config=RetryConfig( retry=retry_if_exception_type(HTTPStatusError), wait=wait_retry_after( fallback_strategy=wait_exponential(multiplier=1, max=60), max_wait=300, ), stop=stop_after_attempt(5), reraise=True, ), validate_response=_raise_for_retryable, ) return AsyncClient(transport=transport)
@cachedef _build_model(name: str) -> Model: """Build the pydantic-ai model registered under ``name``.
The registry lives in an SSM String parameter, so this runs on the first INVOKE (the extension is not ready during INIT) and is memoised per model name for warm invocations. Bedrock models authenticate via the Lambda role; Mistral models read an API key from a SecureString parameter, fetched the same way as the Logfire token. """ registry = json.loads(_fetch_ssm_parameter(os.environ["MODELS_PARAMETER"])) config = registry[name] provider = config["provider"] if provider == "bedrock": return BedrockConverseModel( config["model_id"], settings={"bedrock_inference_profile": config["inference_profile_arn"]}, ) if provider == "mistral": key_param = os.environ.get("MISTRAL_API_KEY_PARAMETER") if not key_param: raise RuntimeError( f"model {name!r} uses the Mistral API, but MISTRAL_API_KEY_PARAMETER " "is not set. Set MISTRAL_API_KEY and re-apply so the key is wired, or " "select a Bedrock model via DEFAULT_MODEL or the event's model field." ) return MistralModel( config["model_id"], provider=MistralProvider( api_key=_fetch_ssm_parameter(key_param), http_client=_retrying_http_client(), ), ) raise ValueError(f"unknown provider {provider!r} for model {name!r}")
class PerTraceAuditProcessor(SpanProcessor): """Buffer spans by trace_id, ship as one batch when the root ends. """Buffer spans by trace_id, hand them off as one batch when the root ends.
The OTel SDK has no `OnTraceComplete` hook, so this implements it against the only signal available: `on_end` fires synchronously and `span.parent is None` on a root. Late children (spans ended on a transport thread after the root has already shipped) are dropped, mirroring logfire's tail sampler. See pydantic/logfire#1034. The OTel SDK has no `OnTraceComplete` hook, so this implements it against the only signal available: `on_end` fires synchronously and `span.parent is None` on a root. Late children (spans ended on a transport thread after the root has already shipped) are dropped, mirroring logfire's tail sampler. See pydantic/logfire#1034. """
def __init__(42 collapsed lines
self, on_trace_complete: Callable[[Sequence[ReadableSpan]], None], ) -> None: self._on_trace_complete = on_trace_complete self._buffers: dict[int, list[ReadableSpan]] = {} self._shipped: set[int] = set() self._lock = threading.Lock()
def on_end(self, span: ReadableSpan) -> None: if not (span.context and span.context.trace_flags.sampled): return trace_id = span.context.trace_id with self._lock: if trace_id in self._shipped: return self._buffers.setdefault(trace_id, []).append(span) if span.parent is not None: return spans = self._buffers.pop(trace_id) self._shipped.add(trace_id) self._ship(spans)
def force_flush(self, timeout_millis: int = 30000) -> bool: with self._lock: pending = list(self._buffers.values()) self._shipped.update(self._buffers) self._buffers.clear() for spans in pending: self._ship(spans) return True
def shutdown(self) -> None: self.force_flush()
def _ship(self, spans: Sequence[ReadableSpan]) -> None: # Suppress instrumentation around the callback so an instrumented # boto3/requests client inside it does not emit a span that # re-enters on_end for a sibling trace. token = attach(set_value(_SUPPRESS_INSTRUMENTATION_KEY, True)) try: self._on_trace_complete(spans) finally: detach(token)
_firehose = boto3.client("firehose") _DELIVERY_STREAM = os.environ["FIREHOSE_DELIVERY_STREAM"]
7 collapsed lines
def _ship_trace(spans: Sequence[ReadableSpan]) -> None: """Serialise one trace as OTLP-JSON and ship it as a single Firehose record.""" payload = json_format.MessageToJson(encode_spans(spans), indent=None) + "\n" _firehose.put_record( DeliveryStreamName=_DELIVERY_STREAM, Record={"Data": payload.encode("utf-8")}, )
def _emit_emf(spans: Sequence[ReadableSpan]) -> None: """Emit one EMF metric line for the trace, read off the root span.
pydantic-ai records gen_ai.usage.* on the root agent span as the run total (the sum of its child chat spans), so a single read is the correct total, not a sum across every span. The model dimension is the registry key the handler passed as run metadata; pydantic-ai serialises that to the root span's `metadata` attribute (even on a failed run), so it is read back here rather than carried in module state. That key is exactly what the dashboard iterates, so a Bedrock run and a Mistral run share one set of widgets. Logging the _aws envelope to stdout is enough; CloudWatch Logs extracts the metrics from the structured line. """ root = next((span for span in spans if span.parent is None), None) if root is None: return attributes = root.attributes or {} model = json.loads(attributes.get("metadata", "{}")).get("model", "unknown") errored = root.status.status_code is StatusCode.ERROR record = { "_aws": { "Timestamp": root.end_time // 1_000_000, "CloudWatchMetrics": [ { "Namespace": os.environ["METRICS_NAMESPACE"], "Dimensions": [["Model"]], "Metrics": [ {"Name": "InputTokens", "Unit": "Count"}, {"Name": "OutputTokens", "Unit": "Count"}, {"Name": "CacheReadTokens", "Unit": "Count"}, {"Name": "CacheWriteTokens", "Unit": "Count"}, {"Name": "Latency", "Unit": "Milliseconds"}, {"Name": "Invocations", "Unit": "Count"}, {"Name": "Errors", "Unit": "Count"}, ], } ], }, "Model": model, "InputTokens": attributes.get("gen_ai.usage.input_tokens", 0), "OutputTokens": attributes.get("gen_ai.usage.output_tokens", 0), # pydantic-ai sets these only when non-zero, so default to 0. Providers # without prompt caching (e.g. the Mistral API) simply never report them. "CacheReadTokens": attributes.get("gen_ai.usage.cache_read.input_tokens", 0), "CacheWriteTokens": attributes.get("gen_ai.usage.cache_creation.input_tokens", 0), "Latency": (root.end_time - root.start_time) / 1_000_000, "Invocations": 1, "Errors": 1 if errored else 0, } log.info("trace_metrics", **record)
def _on_trace_complete(spans: Sequence[ReadableSpan]) -> None: """Ship the audit copy, then emit metrics: one hook, two sinks.""" _ship_trace(spans) _emit_emf(spans)
@cache def _init_logfire() -> None: """Wire Logfire and the audit span processor once per warm """Wire Logfire and the audit/metrics span processor once per warm container, on the first INVOKE.
The Parameters and Secrets extension is not ready to serve traffic during the Lambda INIT phase, so the token fetch (and the matching logfire setup) cannot run at module import time. @cache memoises on the empty argument tuple, so this runs exactly once per The Parameters and Secrets extension is not ready to serve traffic during the Lambda INIT phase, so the token fetch cannot run at module import time. @cache memoises on the empty argument tuple, so this runs exactly once per container and is a no-op on every subsequent invocation. """ token = _fetch_logfire_token() if token: os.environ["LOGFIRE_TOKEN"] = token # head=1.0 and tail=None are today's Logfire defaults; pinned here # because this is an audit pipeline, so every trace must reach S3. # Volume is low (one trace per Lambda invocation) and the audit # requirement outweighs Logfire ingest cost. Splitting the rates # (e.g. 1% to Logfire, 100% to S3) is possible with a small extra # sampler; see the post. logfire.configure( send_to_logfire="if-token-present", sampling=SamplingOptions(head=1.0, tail=None), additional_span_processors=[PerTraceAuditProcessor(_ship_trace)], additional_span_processors=[PerTraceAuditProcessor(_on_trace_complete)], ) # include_content=True is the pydantic-ai default; pinned because the # audit copy needs the actual prompts, tool args, and responses to be # useful for after-the-fact forensics. If that ever becomes a # compliance problem (PII, secrets in prompts), flip to False and # accept a metadata-only audit trail. logfire.instrument_pydantic_ai(version=5, include_content=True)
_INFERENCE_PROFILE_ARN = os.environ["BEDROCK_INFERENCE_PROFILE_ARN"]_MODEL_ID = os.environ["BEDROCK_MODEL_ID"]
def _build_model() -> BedrockConverseModel: return BedrockConverseModel( _MODEL_ID, settings={"bedrock_inference_profile": _INFERENCE_PROFILE_ARN}, )
SYSTEM_PROMPT = ( "You are the terraform-pr-agent. For now you are a placeholder; " "respond briefly to whatever prompt you are given." )
agent = Agent(_build_model(), system_prompt=SYSTEM_PROMPT)# The agent carries no default model: the model is built at INVOKE from the# registry and passed per run. Tests override the model on this instance, which# takes precedence over the per-run model.agent = Agent(system_prompt=SYSTEM_PROMPT)
class HandlerEvent(TypedDict): prompt: NotRequired[str] model: NotRequired[str]
class HandlerResponse(TypedDict): status: str model: str output: str
def handler(event: HandlerEvent, context: object) -> HandlerResponse: """Lambda entry point.
Falls back to a default prompt so the function can be smoke-tested with an empty payload.
The audit copy ships from inside PerTraceAuditProcessor.on_end when the agent's root span closes, so the handler does not need a finally block: a Firehose failure raises on the same thread as agent.run_sync and propagates as a Lambda 5xx. A failed agent run also closes its root span (with status=ERROR) before the exception unwinds, so the partial trace still ships. Falls back to a default prompt so the function can be smoke-tested with an empty payload. ``model`` in the event overrides DEFAULT_MODEL for the run, so a single deployment can be exercised against any registry entry without redeploying. The audit copy and the EMF metric line both ship from PerTraceAuditProcessor.on_end when the agent's root span closes, so the handler has no flush logic. """ _init_logfire() prompt = event.get("prompt", "Say hello.") result = agent.run_sync(prompt) model_name = event.get("model", os.environ["DEFAULT_MODEL"]) result = agent.run_sync(prompt, model=_build_model(model_name), metadata={"model": model_name}) return { "status": "ok", "model": model_name, "output": str(result.output), }"""AWS Lambda handler for the terraform-pr-agent.
Model portability: the agent runs on whichever model DEFAULT_MODEL names inthe registry, which Terraform parks in an SSM String parameter (MODELS_PARAMETER).Each registry entry declares a provider (``bedrock`` or ``mistral``) and a modelid; the handler builds the matching pydantic-ai model. Nothing else about theagent changes when the model does, which is the whole point of the abstraction.
First invocation:- Reads the Logfire token and (for Mistral models) the Mistral API key from SSM via the Parameters and Secrets Lambda Extension at http://localhost:2773.- Configures logfire and registers PerTraceAuditProcessor, the custom OTel SpanProcessor that buffers spans by trace_id and, when the root span ends, ships one OTLP-JSON Firehose record AND emits one EMF metric line. The EMF line is what makes the CloudWatch dashboard provider-agnostic: off Bedrock there are no AWS/Bedrock metrics, so the handler emits its own from the same gen_ai.* span attributes pydantic-ai records for every provider.
The extension's HTTP server rejects requests during INIT, so the SSM reads (andthe model build that depends on them) run on the first INVOKE and are memoisedwith @cache for subsequent warm invocations."""
import jsonimport osimport threadingimport urllib.parseimport urllib.requestfrom collections.abc import Callable, Sequencefrom functools import cachefrom typing import NotRequired, TypedDict
import boto3import logfireimport structlogfrom google.protobuf import json_formatfrom httpx import AsyncClient, HTTPStatusError, Responsefrom logfire.sampling import SamplingOptionsfrom opentelemetry.context import ( _SUPPRESS_INSTRUMENTATION_KEY, attach, detach, set_value,)from opentelemetry.exporter.otlp.proto.common._internal.trace_encoder import ( encode_spans,)from opentelemetry.sdk.trace import ReadableSpan, SpanProcessorfrom opentelemetry.trace.status import StatusCodefrom pydantic_ai import Agentfrom pydantic_ai.models import Modelfrom pydantic_ai.models.bedrock import BedrockConverseModelfrom pydantic_ai.models.mistral import MistralModelfrom pydantic_ai.providers.mistral import MistralProviderfrom pydantic_ai.retries import AsyncTenacityTransport, RetryConfig, wait_retry_afterfrom tenacity import retry_if_exception_type, stop_after_attempt, wait_exponential
# JSON logs to stdout, which CloudWatch Logs ingests as-is. The same stream also# carries the EMF metric envelope (see _emit_emf), so one structured sink covers# both application logs and metrics. Logging has no extension dependency, so it# is configured at import rather than on the first INVOKE.structlog.configure( processors=[ structlog.processors.add_log_level, structlog.processors.TimeStamper(fmt="iso"), structlog.processors.EventRenamer("message"), structlog.processors.JSONRenderer(), ], logger_factory=structlog.PrintLoggerFactory(), cache_logger_on_first_use=True,)log = structlog.get_logger()
def _fetch_ssm_parameter(name: str) -> str: """Read one parameter through the Parameters and Secrets extension.
Works for plain String and SecureString parameters alike; withDecryption is a no-op on a String. safe="" forces urllib to percent-encode the leading and embedded slashes in a hierarchical name (/a/b/c -> %2Fa%2Fb%2Fc), which the extension requires. """ session_token = os.environ["AWS_SESSION_TOKEN"] url = ( "http://localhost:2773/systemsmanager/parameters/get" f"?name={urllib.parse.quote(name, safe='')}&withDecryption=true" ) req = urllib.request.Request( url, headers={"X-Aws-Parameters-Secrets-Token": session_token}, ) with urllib.request.urlopen(req, timeout=2) as resp: payload = json.load(resp) return payload["Parameter"]["Value"]
def _fetch_logfire_token() -> str | None: """Logfire token, or None when the integration is not wired.""" name = os.environ.get("LOGFIRE_TOKEN_PARAMETER") if not name: return None return _fetch_ssm_parameter(name)
# Status codes worth retrying: rate limit and transient gateway errors. Others# (auth, bad request) fail fast so a real problem is not retried five times._RETRYABLE_STATUS = frozenset({429, 502, 503, 504})
def _raise_for_retryable(response: Response) -> None: if response.status_code in _RETRYABLE_STATUS: response.raise_for_status()
def _retrying_http_client() -> AsyncClient: """An httpx client that retries rate-limit and transient errors.
pydantic-ai raises ModelHTTPError on transport errors and does not retry them itself, so a rate-limited Mistral call (the agent loop can burst past the free-tier per-second cap) would otherwise fail the whole run. wait_retry_after honours the Retry-After header Mistral sends on a 429, falling back to exponential backoff. Bedrock uses boto3 rather than httpx and has its own retry config, so this is the Mistral client only. """ transport = AsyncTenacityTransport( config=RetryConfig( retry=retry_if_exception_type(HTTPStatusError), wait=wait_retry_after( fallback_strategy=wait_exponential(multiplier=1, max=60), max_wait=300, ), stop=stop_after_attempt(5), reraise=True, ), validate_response=_raise_for_retryable, ) return AsyncClient(transport=transport)
@cachedef _build_model(name: str) -> Model: """Build the pydantic-ai model registered under ``name``.
The registry lives in an SSM String parameter, so this runs on the first INVOKE (the extension is not ready during INIT) and is memoised per model name for warm invocations. Bedrock models authenticate via the Lambda role; Mistral models read an API key from a SecureString parameter, fetched the same way as the Logfire token. """ registry = json.loads(_fetch_ssm_parameter(os.environ["MODELS_PARAMETER"])) config = registry[name] provider = config["provider"] if provider == "bedrock": return BedrockConverseModel( config["model_id"], settings={"bedrock_inference_profile": config["inference_profile_arn"]}, ) if provider == "mistral": key_param = os.environ.get("MISTRAL_API_KEY_PARAMETER") if not key_param: raise RuntimeError( f"model {name!r} uses the Mistral API, but MISTRAL_API_KEY_PARAMETER " "is not set. Set MISTRAL_API_KEY and re-apply so the key is wired, or " "select a Bedrock model via DEFAULT_MODEL or the event's model field." ) return MistralModel( config["model_id"], provider=MistralProvider( api_key=_fetch_ssm_parameter(key_param), http_client=_retrying_http_client(), ), ) raise ValueError(f"unknown provider {provider!r} for model {name!r}")
class PerTraceAuditProcessor(SpanProcessor): """Buffer spans by trace_id, hand them off as one batch when the root ends.
The OTel SDK has no `OnTraceComplete` hook, so this implements it against the only signal available: `on_end` fires synchronously and `span.parent is None` on a root. Late children (spans ended on a transport thread after the root has already shipped) are dropped, mirroring logfire's tail sampler. See pydantic/logfire#1034. """
def __init__( self, on_trace_complete: Callable[[Sequence[ReadableSpan]], None], ) -> None: self._on_trace_complete = on_trace_complete self._buffers: dict[int, list[ReadableSpan]] = {} self._shipped: set[int] = set() self._lock = threading.Lock()
def on_end(self, span: ReadableSpan) -> None: if not (span.context and span.context.trace_flags.sampled): return trace_id = span.context.trace_id with self._lock: if trace_id in self._shipped: return self._buffers.setdefault(trace_id, []).append(span) if span.parent is not None: return spans = self._buffers.pop(trace_id) self._shipped.add(trace_id) self._ship(spans)
def force_flush(self, timeout_millis: int = 30000) -> bool: with self._lock: pending = list(self._buffers.values()) self._shipped.update(self._buffers) self._buffers.clear() for spans in pending: self._ship(spans) return True
def shutdown(self) -> None: self.force_flush()
def _ship(self, spans: Sequence[ReadableSpan]) -> None: # Suppress instrumentation around the callback so an instrumented # boto3/requests client inside it does not emit a span that # re-enters on_end for a sibling trace. token = attach(set_value(_SUPPRESS_INSTRUMENTATION_KEY, True)) try: self._on_trace_complete(spans) finally: detach(token)
_firehose = boto3.client("firehose")_DELIVERY_STREAM = os.environ["FIREHOSE_DELIVERY_STREAM"]
def _ship_trace(spans: Sequence[ReadableSpan]) -> None: """Serialise one trace as OTLP-JSON and ship it as a single Firehose record.""" payload = json_format.MessageToJson(encode_spans(spans), indent=None) + "\n" _firehose.put_record( DeliveryStreamName=_DELIVERY_STREAM, Record={"Data": payload.encode("utf-8")}, )
def _emit_emf(spans: Sequence[ReadableSpan]) -> None: """Emit one EMF metric line for the trace, read off the root span.
pydantic-ai records gen_ai.usage.* on the root agent span as the run total (the sum of its child chat spans), so a single read is the correct total, not a sum across every span. The model dimension is the registry key the handler passed as run metadata; pydantic-ai serialises that to the root span's `metadata` attribute (even on a failed run), so it is read back here rather than carried in module state. That key is exactly what the dashboard iterates, so a Bedrock run and a Mistral run share one set of widgets. Logging the _aws envelope to stdout is enough; CloudWatch Logs extracts the metrics from the structured line. """ root = next((span for span in spans if span.parent is None), None) if root is None: return attributes = root.attributes or {} model = json.loads(attributes.get("metadata", "{}")).get("model", "unknown") errored = root.status.status_code is StatusCode.ERROR record = { "_aws": { "Timestamp": root.end_time // 1_000_000, "CloudWatchMetrics": [ { "Namespace": os.environ["METRICS_NAMESPACE"], "Dimensions": [["Model"]], "Metrics": [ {"Name": "InputTokens", "Unit": "Count"}, {"Name": "OutputTokens", "Unit": "Count"}, {"Name": "CacheReadTokens", "Unit": "Count"}, {"Name": "CacheWriteTokens", "Unit": "Count"}, {"Name": "Latency", "Unit": "Milliseconds"}, {"Name": "Invocations", "Unit": "Count"}, {"Name": "Errors", "Unit": "Count"}, ], } ], }, "Model": model, "InputTokens": attributes.get("gen_ai.usage.input_tokens", 0), "OutputTokens": attributes.get("gen_ai.usage.output_tokens", 0), # pydantic-ai sets these only when non-zero, so default to 0. Providers # without prompt caching (e.g. the Mistral API) simply never report them. "CacheReadTokens": attributes.get("gen_ai.usage.cache_read.input_tokens", 0), "CacheWriteTokens": attributes.get("gen_ai.usage.cache_creation.input_tokens", 0), "Latency": (root.end_time - root.start_time) / 1_000_000, "Invocations": 1, "Errors": 1 if errored else 0, } log.info("trace_metrics", **record)
def _on_trace_complete(spans: Sequence[ReadableSpan]) -> None: """Ship the audit copy, then emit metrics: one hook, two sinks.""" _ship_trace(spans) _emit_emf(spans)
@cachedef _init_logfire() -> None: """Wire Logfire and the audit/metrics span processor once per warm container, on the first INVOKE.
The Parameters and Secrets extension is not ready to serve traffic during the Lambda INIT phase, so the token fetch cannot run at module import time. @cache memoises on the empty argument tuple, so this runs exactly once per container and is a no-op on every subsequent invocation. """ token = _fetch_logfire_token() if token: os.environ["LOGFIRE_TOKEN"] = token logfire.configure( send_to_logfire="if-token-present", sampling=SamplingOptions(head=1.0, tail=None), additional_span_processors=[PerTraceAuditProcessor(_on_trace_complete)], ) logfire.instrument_pydantic_ai(version=5, include_content=True)
SYSTEM_PROMPT = ( "You are the terraform-pr-agent. For now you are a placeholder; " "respond briefly to whatever prompt you are given.")
# The agent carries no default model: the model is built at INVOKE from the# registry and passed per run. Tests override the model on this instance, which# takes precedence over the per-run model.agent = Agent(system_prompt=SYSTEM_PROMPT)
class HandlerEvent(TypedDict): prompt: NotRequired[str] model: NotRequired[str]
class HandlerResponse(TypedDict): status: str model: str output: str
def handler(event: HandlerEvent, context: object) -> HandlerResponse: """Lambda entry point.
Falls back to a default prompt so the function can be smoke-tested with an empty payload. ``model`` in the event overrides DEFAULT_MODEL for the run, so a single deployment can be exercised against any registry entry without redeploying. The audit copy and the EMF metric line both ship from PerTraceAuditProcessor.on_end when the agent's root span closes, so the handler has no flush logic. """ _init_logfire() prompt = event.get("prompt", "Say hello.") model_name = event.get("model", os.environ["DEFAULT_MODEL"]) result = agent.run_sync(prompt, model=_build_model(model_name), metadata={"model": model_name}) return { "status": "ok", "model": model_name, "output": str(result.output), }# alias/aws/sns is the AWS-managed SNS key; a CMK is unnecessary for an# operational alarm topic that publishes alarm-fired events, not audit data.#trivy:ignore:avd-aws-013611 collapsed lines
resource "aws_sns_topic" "agent_alerts" { name = "terraform-pr-agent-alerts" kms_master_key_id = "alias/aws/sns"}
# The subscription stays PENDING until the recipient clicks the AWS# confirmation email. Alarms fire either way, but no email goes out until the# subscription is confirmed.resource "aws_sns_topic_subscription" "agent_alerts_email" { topic_arn = aws_sns_topic.agent_alerts.arn protocol = "email" endpoint = var.alert_email}
# Reads the EMF tokens the handler emits (Model dimension), not AWS/Bedrock,# so the budget guard works whichever provider DEFAULT_MODEL points at. Scoped# to the active model.resource "aws_cloudwatch_metric_alarm" "daily_tokens" { alarm_name = "${aws_bedrock_inference_profile.agent.name}-daily-tokens" alarm_description = "Daily input + output token usage for the agent inference profile exceeded the configured threshold." alarm_name = "terraform-pr-agent-daily-tokens" alarm_description = "Daily input + output token usage for the active model exceeded the configured threshold." comparison_operator = "GreaterThanThreshold" evaluation_periods = 1 threshold = var.daily_token_alarm_threshold9 collapsed lines
treat_missing_data = "notBreaching"
metric_query { id = "total" expression = "input + output" label = "Total tokens (input + output)" return_data = true }
metric_query { id = "input" metric { namespace = "AWS/Bedrock" metric_name = "InputTokenCount" dimensions = { ModelId = local.profile_id } namespace = local.metrics_namespace metric_name = "InputTokens" dimensions = { Model = var.default_model } stat = "Sum" period = 86400 }2 collapsed lines
}
metric_query { id = "output" metric { namespace = "AWS/Bedrock" metric_name = "OutputTokenCount" dimensions = { ModelId = local.profile_id } namespace = local.metrics_namespace metric_name = "OutputTokens" dimensions = { Model = var.default_model } stat = "Sum" period = 86400 } }
alarm_actions = [aws_sns_topic.agent_alerts.arn]}# alias/aws/sns is the AWS-managed SNS key; a CMK is unnecessary for an# operational alarm topic that publishes alarm-fired events, not audit data.#trivy:ignore:avd-aws-0136resource "aws_sns_topic" "agent_alerts" { name = "terraform-pr-agent-alerts" kms_master_key_id = "alias/aws/sns"}
# The subscription stays PENDING until the recipient clicks the AWS# confirmation email. Alarms fire either way, but no email goes out until the# subscription is confirmed.resource "aws_sns_topic_subscription" "agent_alerts_email" { topic_arn = aws_sns_topic.agent_alerts.arn protocol = "email" endpoint = var.alert_email}
# Reads the EMF tokens the handler emits (Model dimension), not AWS/Bedrock,# so the budget guard works whichever provider DEFAULT_MODEL points at. Scoped# to the active model.resource "aws_cloudwatch_metric_alarm" "daily_tokens" { alarm_name = "terraform-pr-agent-daily-tokens" alarm_description = "Daily input + output token usage for the active model exceeded the configured threshold." comparison_operator = "GreaterThanThreshold" evaluation_periods = 1 threshold = var.daily_token_alarm_threshold treat_missing_data = "notBreaching"
metric_query { id = "total" expression = "input + output" label = "Total tokens (input + output)" return_data = true }
metric_query { id = "input" metric { namespace = local.metrics_namespace metric_name = "InputTokens" dimensions = { Model = var.default_model } stat = "Sum" period = 86400 } }
metric_query { id = "output" metric { namespace = local.metrics_namespace metric_name = "OutputTokens" dimensions = { Model = var.default_model } stat = "Sum" period = 86400 } }
alarm_actions = [aws_sns_topic.agent_alerts.arn]}locals { audit_bucket_name = "terraform-pr-agent-audit-${data.aws_caller_identity.current.account_id}-${data.aws_region.current.region}"}
# Access logging would require a second bucket and is out of scope: the audit# copy here is the system of record for what the agent did, not for who read it.#trivy:ignore:avd-aws-0089resource "aws_s3_bucket" "audit" { bucket = local.audit_bucket_name object_lock_enabled = true}
resource "aws_s3_bucket_versioning" "audit" { bucket = aws_s3_bucket.audit.id
versioning_configuration { status = "Enabled" }}
resource "aws_s3_bucket_public_access_block" "audit" { bucket = aws_s3_bucket.audit.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true}
resource "aws_s3_bucket_server_side_encryption_configuration" "audit" { bucket = aws_s3_bucket.audit.id
rule { apply_server_side_encryption_by_default { sse_algorithm = "aws:kms" kms_master_key_id = aws_kms_key.audit.arn }
bucket_key_enabled = true }}
data "aws_iam_policy_document" "audit_bucket_resource_policy" { statement { sid = "DenyInsecureTransport" effect = "Deny" actions = ["s3:*"] resources = [ aws_s3_bucket.audit.arn, "${aws_s3_bucket.audit.arn}/*", ] principals { type = "*" identifiers = ["*"] } condition { test = "Bool" variable = "aws:SecureTransport" values = ["false"] } }}
resource "aws_s3_bucket_policy" "audit" { bucket = aws_s3_bucket.audit.id policy = data.aws_iam_policy_document.audit_bucket_resource_policy.json}
# GOVERNANCE leaves the bucket deletable for the tutorial; switch to "COMPLIANCE" in production.resource "aws_s3_bucket_object_lock_configuration" "audit" { bucket = aws_s3_bucket.audit.id
rule { default_retention { mode = "GOVERNANCE" days = var.audit_retention_days } }}
resource "aws_s3_bucket_lifecycle_configuration" "audit" { bucket = aws_s3_bucket.audit.id
rule { id = "transition-to-glacier-ir" status = "Enabled"
filter {}
transition { days = 90 storage_class = "GLACIER_IR" }
noncurrent_version_transition { noncurrent_days = 90 storage_class = "GLACIER_IR" } }
rule { id = "expire-after-retention" status = "Enabled"
filter {}
expiration { days = var.audit_retention_days }
noncurrent_version_expiration { noncurrent_days = 1 } }}
output "audit_bucket_name" { value = aws_s3_bucket.audit.id}
output "audit_bucket_arn" { value = aws_s3_bucket.audit.arn}locals { bedrock_model_id = "anthropic.claude-haiku-4-5-20251001-v1:0" bedrock_cross_region_prefix = "eu"
system_inference_profile_arn = format( "arn:aws:bedrock:%s:%s:inference-profile/%s.%s", data.aws_region.current.region, data.aws_caller_identity.current.account_id, local.bedrock_cross_region_prefix, local.bedrock_model_id, )}
resource "aws_bedrock_inference_profile" "agent" { name = "terraform-pr-agent" description = "Application inference profile for the Terraform PR Agent series."
model_source { copy_from = local.system_inference_profile_arn }}
output "inference_profile_arn" { value = aws_bedrock_inference_profile.agent.arn}locals { cloudwatch_region = data.aws_region.current.region dashboard_name = "terraform-pr-agent"
dashboard_label = aws_bedrock_inference_profile.agent.name profile_id = aws_bedrock_inference_profile.agent.id # The dashboard now reads the EMF metrics the handler emits (namespace # local.metrics_namespace, dimensioned by Model), not AWS/Bedrock. That is # the whole point: a Bedrock model and a Mistral-API model land in the same # namespace, so one set of widgets covers both. Each registry model gets its # own line, built by iterating keys(local.models). model_keys = keys(local.models)}
resource "aws_cloudwatch_dashboard" "agent" { dashboard_name = aws_bedrock_inference_profile.agent.name dashboard_name = local.dashboard_name dashboard_body = jsonencode({ widgets = [ {9 collapsed lines
type = "metric" x = 0 y = 0 width = 12 height = 6 properties = { title = "Tokens" region = local.cloudwatch_region view = "timeSeries" stat = "Sum" period = 60 metrics = [ # here we plug in the labels so this looks nicer ["AWS/Bedrock", "InputTokenCount", "ModelId", local.profile_id, { label = "${local.dashboard_label} / input" }], # the dot syntax allows not to repeat the same as above [".", "OutputTokenCount", ".", ".", { label = "${local.dashboard_label} / output" }], for m in flatten([ for key in local.model_keys : [ { metric = "InputTokens", label = "${key} / input", key = key }, { metric = "OutputTokens", label = "${key} / output", key = key }, ] ]) : [local.metrics_namespace, m.metric, "Model", m.key, { label = m.label }] ] } },4 collapsed lines
{ type = "metric" x = 12 y = 0 width = 12 height = 6 properties = { title = "Cache tokens" title = "Invocations and errors" region = local.cloudwatch_region view = "timeSeries" stat = "Sum" period = 60 metrics = [ ["AWS/Bedrock", "CacheReadInputTokenCount", "ModelId", local.profile_id, { label = "${local.dashboard_label} / cache read" }], [".", "CacheWriteInputTokenCount", ".", ".", { label = "${local.dashboard_label} / cache write" }], for m in flatten([ for key in local.model_keys : [ { metric = "Invocations", label = "${key} / invocations", key = key }, { metric = "Errors", label = "${key} / errors", key = key }, ] ]) : [local.metrics_namespace, m.metric, "Model", m.key, { label = m.label }] ] } },4 collapsed lines
{ type = "metric" x = 0 y = 6 width = 12 height = 6 properties = { title = "Invocations and errors" title = "Latency (ms)" region = local.cloudwatch_region view = "timeSeries" stat = "Sum" period = 60 metrics = [ ["AWS/Bedrock", "Invocations", "ModelId", local.profile_id, { label = "${local.dashboard_label} / invocations" }], [".", "InvocationClientErrors", ".", ".", { label = "${local.dashboard_label} / 4xx" }], [".", "InvocationServerErrors", ".", ".", { label = "${local.dashboard_label} / 5xx" }], [".", "InvocationThrottles", ".", ".", { label = "${local.dashboard_label} / throttles" }], for m in flatten([ for key in local.model_keys : [ { label = "${key} / avg", key = key, stat = "Average" }, { label = "${key} / p99", key = key, stat = "p99" }, ] ]) : [local.metrics_namespace, "Latency", "Model", m.key, { label = m.label, stat = m.stat }] ] } },4 collapsed lines
{ type = "metric" x = 12 y = 6 width = 12 height = 6 properties = { title = "Latency (ms)" title = "Cache tokens" region = local.cloudwatch_region view = "timeSeries" stat = "Sum" period = 60 metrics = [ ["AWS/Bedrock", "InvocationLatency", "ModelId", local.profile_id, { label = "${local.dashboard_label} / avg", stat = "Average" }], [".", ".", ".", ".", { label = "${local.dashboard_label} / p99", stat = "p99" }], for m in flatten([ for key in local.model_keys : [ { metric = "CacheReadTokens", label = "${key} / cache read", key = key }, { metric = "CacheWriteTokens", label = "${key} / cache write", key = key }, ] ]) : [local.metrics_namespace, m.metric, "Model", m.key, { label = m.label }] ] } },9 collapsed lines
] })}
output "cloudwatch_dashboard_url" { value = format( "https://%s.console.aws.amazon.com/cloudwatch/home?region=%s#dashboards/dashboard/%s", local.cloudwatch_region, local.cloudwatch_region, aws_cloudwatch_dashboard.agent.dashboard_name, )}locals { cloudwatch_region = data.aws_region.current.region dashboard_name = "terraform-pr-agent"
# The dashboard now reads the EMF metrics the handler emits (namespace # local.metrics_namespace, dimensioned by Model), not AWS/Bedrock. That is # the whole point: a Bedrock model and a Mistral-API model land in the same # namespace, so one set of widgets covers both. Each registry model gets its # own line, built by iterating keys(local.models). model_keys = keys(local.models)}
resource "aws_cloudwatch_dashboard" "agent" { dashboard_name = local.dashboard_name dashboard_body = jsonencode({ widgets = [ { type = "metric" x = 0 y = 0 width = 12 height = 6 properties = { title = "Tokens" region = local.cloudwatch_region view = "timeSeries" stat = "Sum" period = 60 metrics = [ for m in flatten([ for key in local.model_keys : [ { metric = "InputTokens", label = "${key} / input", key = key }, { metric = "OutputTokens", label = "${key} / output", key = key }, ] ]) : [local.metrics_namespace, m.metric, "Model", m.key, { label = m.label }] ] } }, { type = "metric" x = 12 y = 0 width = 12 height = 6 properties = { title = "Invocations and errors" region = local.cloudwatch_region view = "timeSeries" stat = "Sum" period = 60 metrics = [ for m in flatten([ for key in local.model_keys : [ { metric = "Invocations", label = "${key} / invocations", key = key }, { metric = "Errors", label = "${key} / errors", key = key }, ] ]) : [local.metrics_namespace, m.metric, "Model", m.key, { label = m.label }] ] } }, { type = "metric" x = 0 y = 6 width = 12 height = 6 properties = { title = "Latency (ms)" region = local.cloudwatch_region view = "timeSeries" period = 60 metrics = [ for m in flatten([ for key in local.model_keys : [ { label = "${key} / avg", key = key, stat = "Average" }, { label = "${key} / p99", key = key, stat = "p99" }, ] ]) : [local.metrics_namespace, "Latency", "Model", m.key, { label = m.label, stat = m.stat }] ] } }, { type = "metric" x = 12 y = 6 width = 12 height = 6 properties = { title = "Cache tokens" region = local.cloudwatch_region view = "timeSeries" stat = "Sum" period = 60 metrics = [ for m in flatten([ for key in local.model_keys : [ { metric = "CacheReadTokens", label = "${key} / cache read", key = key }, { metric = "CacheWriteTokens", label = "${key} / cache write", key = key }, ] ]) : [local.metrics_namespace, m.metric, "Model", m.key, { label = m.label }] ] } }, ] })}
output "cloudwatch_dashboard_url" { value = format( "https://%s.console.aws.amazon.com/cloudwatch/home?region=%s#dashboards/dashboard/%s", local.cloudwatch_region, local.cloudwatch_region, aws_cloudwatch_dashboard.agent.dashboard_name, )}data "aws_iam_policy_document" "firehose_assume" { statement { actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["firehose.amazonaws.com"] } }}
resource "aws_iam_role" "firehose" { name = "terraform-pr-agent-firehose" assume_role_policy = data.aws_iam_policy_document.firehose_assume.json}
data "aws_iam_policy_document" "firehose_permissions" { statement { actions = [ "s3:PutObject", "s3:PutObjectRetention", "s3:GetBucketLocation", "s3:ListBucket", ] # /* is how S3 IAM grants object-scoped actions; bounded to the audit bucket. #trivy:ignore:avd-aws-0057 resources = [ aws_s3_bucket.audit.arn, "${aws_s3_bucket.audit.arn}/*", ] }
statement { actions = [ "kms:GenerateDataKey", "kms:Decrypt", ] resources = [aws_kms_key.audit.arn] }
statement { actions = ["logs:PutLogEvents"] # :* covers log streams inside the named Firehose log group only. #trivy:ignore:avd-aws-0057 resources = ["${aws_cloudwatch_log_group.firehose.arn}:*"] }}
resource "aws_iam_role_policy" "firehose_permissions" { name = "terraform-pr-agent-firehose-permissions" role = aws_iam_role.firehose.id policy = data.aws_iam_policy_document.firehose_permissions.json}
# Operational delivery log for Firehose; carries error metadata, not audit# payload. The audit copy in S3 is the protected artefact and already uses a CMK.#trivy:ignore:avd-aws-0017resource "aws_cloudwatch_log_group" "firehose" { name = "/aws/kinesisfirehose/terraform-pr-agent-audit" retention_in_days = 7}
resource "aws_cloudwatch_log_stream" "firehose_s3" { name = "S3Delivery" log_group_name = aws_cloudwatch_log_group.firehose.name}
resource "aws_kinesis_firehose_delivery_stream" "audit" { name = "terraform-pr-agent-audit" destination = "extended_s3"
extended_s3_configuration { role_arn = aws_iam_role.firehose.arn bucket_arn = aws_s3_bucket.audit.arn prefix = "traces/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/hour=!{timestamp:HH}/" error_output_prefix = "errors/!{firehose:error-output-type}/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/" buffering_size = 5 buffering_interval = 60 compression_format = "GZIP"
cloudwatch_logging_options { enabled = true log_group_name = aws_cloudwatch_log_group.firehose.name log_stream_name = aws_cloudwatch_log_stream.firehose_s3.name } }}
output "firehose_stream_name" { value = aws_kinesis_firehose_delivery_stream.audit.name}
output "firehose_stream_arn" { value = aws_kinesis_firehose_delivery_stream.audit.arn}data "aws_iam_policy_document" "bedrock_invoke" { statement { actions = [ "bedrock:Converse", "bedrock:ConverseStream", "bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream", ] # Bedrock foundation-model ARNs do not pin to the caller region; the # inference profile fans out cross-region, so the * region segment is required. #trivy:ignore:avd-aws-0057 resources = [ aws_bedrock_inference_profile.agent.arn, local.system_inference_profile_arn, "arn:aws:bedrock:*::foundation-model/${local.bedrock_model_id}", ] }
# Anthropic models on Bedrock are distributed via AWS Marketplace. On the # first invocation from a new account, Bedrock auto-subscribes the account # to the model product, which requires the invoking principal to hold these # Marketplace actions. Once the subscription is active these calls become # no-ops, but the principal still needs ViewSubscriptions on every call so # Bedrock can confirm the subscription is in place. statement { actions = [ "aws-marketplace:Subscribe", "aws-marketplace:Unsubscribe", "aws-marketplace:ViewSubscriptions", ] # Marketplace subscription actions are global by design. #trivy:ignore:avd-aws-0057 resources = ["*"] }}
resource "aws_iam_policy" "bedrock_invoke" { name = "terraform-pr-agent-bedrock-invoke" description = "Invoke Claude via the terraform-pr-agent inference profile." policy = data.aws_iam_policy_document.bedrock_invoke.json}
data "aws_iam_policy_document" "agent_assume" { statement { actions = ["sts:AssumeRole"] principals { type = "AWS" identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"] } }}
resource "aws_iam_role" "agent" { name = "terraform-pr-agent" assume_role_policy = data.aws_iam_policy_document.agent_assume.json}
resource "aws_iam_role_policy_attachment" "agent_bedrock_invoke" { role = aws_iam_role.agent.name policy_arn = aws_iam_policy.bedrock_invoke.arn}
output "agent_role_arn" { value = aws_iam_role.agent.arn}resource "aws_kms_key" "audit" { description = "Encrypts the terraform-pr-agent audit bucket." enable_key_rotation = true deletion_window_in_days = 7 policy = data.aws_iam_policy_document.audit_kms_key_resource_policy.json}
resource "aws_kms_alias" "audit" { name = "alias/terraform-pr-agent-audit" target_key_id = aws_kms_key.audit.key_id}
data "aws_iam_policy_document" "audit_kms_key_resource_policy" { statement { sid = "EnableIAMUserPermissions" actions = ["kms:*"] resources = ["*"] principals { type = "AWS" identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"] } }}
output "audit_kms_key_arn" { value = aws_kms_key.audit.arn}data "aws_iam_policy_document" "lambda_assume" { statement { actions = ["sts:AssumeRole"]40 collapsed lines
principals { type = "Service" identifiers = ["lambda.amazonaws.com"] } }}
# trivy:ignore:avd-aws-0057# Bedrock foundation-model ARNs do not pin to the caller region (the inference# profile fans out cross-region), and Marketplace subscription actions are# global by design.data "aws_iam_policy_document" "lambda_permissions" { # Bedrock invocation. Same shape as iam.tf's bedrock_invoke; copied # here so the Lambda role is self-contained and does not require # the user-role policy to also be attached to the Lambda role. statement { actions = [ "bedrock:Converse", "bedrock:ConverseStream", "bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream", ] # Bedrock foundation-model ARNs do not pin to the caller region; the # inference profile fans out cross-region, so the * region segment is required. #trivy:ignore:avd-aws-0057 resources = [ aws_bedrock_inference_profile.agent.arn, local.system_inference_profile_arn, "arn:aws:bedrock:*::foundation-model/${local.bedrock_model_id}", ] }
statement { actions = [ "aws-marketplace:Subscribe", "aws-marketplace:Unsubscribe", "aws-marketplace:ViewSubscriptions", ] # Marketplace subscription actions are global by design. #trivy:ignore:avd-aws-0057 resources = ["*"] }
# Model registry read. A plain String parameter, so no KMS is involved. statement { actions = ["ssm:GetParameter"] resources = [aws_ssm_parameter.models.arn] }
# SSM SecureString read for the Logfire token, only when wired. dynamic "statement" { for_each = local.logfire_token_wired ? [1] : []3 collapsed lines
content { actions = ["ssm:GetParameter"] resources = [aws_ssm_parameter.logfire_token[0].arn] } }
# KMS Decrypt on the AWS-managed SSM key, required to read a # SecureString value through the extension. Only when wired. # SSM SecureString read for the Mistral API key, only when wired. dynamic "statement" { for_each = local.logfire_token_wired ? [1] : [] for_each = local.mistral_key_wired ? [1] : [] content { actions = ["ssm:GetParameter"] resources = [aws_ssm_parameter.mistral_api_key[0].arn] } }
# KMS Decrypt on the AWS-managed SSM key, required to read any SecureString # value through the extension. Present when either secret is wired. dynamic "statement" { for_each = local.logfire_token_wired || local.mistral_key_wired ? [1] : [] content { actions = ["kms:Decrypt"] resources = [ "arn:aws:kms:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:alias/aws/ssm",62 collapsed lines
] } }
statement { actions = [ "firehose:PutRecord", "firehose:PutRecordBatch", ] resources = [aws_kinesis_firehose_delivery_stream.audit.arn] }}
resource "aws_iam_role" "lambda" { name = "terraform-pr-agent-lambda" assume_role_policy = data.aws_iam_policy_document.lambda_assume.json}
resource "aws_iam_role_policy_attachment" "lambda_basic_execution" { role = aws_iam_role.lambda.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"}
resource "aws_iam_role_policy" "lambda_permissions" { name = "terraform-pr-agent-lambda-permissions" role = aws_iam_role.lambda.id policy = data.aws_iam_policy_document.lambda_permissions.json}
data "archive_file" "placeholder" { type = "zip" output_path = "${path.module}/.terraform/placeholder.zip"
source { content = "def handler(event, context):\n return {\"status\": \"placeholder\", \"note\": \"run scripts/build-lambda.sh then aws lambda update-function-code\"}\n" filename = "agent/handler.py" }
source { content = "" filename = "agent/__init__.py" }}
resource "aws_lambda_function" "agent" { function_name = "terraform-pr-agent" role = aws_iam_role.lambda.arn runtime = "python3.13" architectures = ["arm64"] handler = "agent.handler.handler" timeout = 60 memory_size = 512
filename = data.archive_file.placeholder.output_path source_code_hash = data.archive_file.placeholder.output_base64sha256
layers = [var.secrets_extension_layer_arn]
tracing_config { mode = "Active" }
environment { variables = merge( { BEDROCK_INFERENCE_PROFILE_ARN = aws_bedrock_inference_profile.agent.arn BEDROCK_MODEL_ID = local.bedrock_model_id MODELS_PARAMETER = aws_ssm_parameter.models.name DEFAULT_MODEL = var.default_model METRICS_NAMESPACE = local.metrics_namespace PARAMETERS_SECRETS_EXTENSION_CACHE_SIZE = "100" PARAMETERS_SECRETS_EXTENSION_HTTP_PORT = "2773" FIREHOSE_DELIVERY_STREAM = aws_kinesis_firehose_delivery_stream.audit.name }, local.logfire_token_wired ? { LOGFIRE_TOKEN_PARAMETER = local.logfire_token_parameter_name } : {}, local.mistral_key_wired ? { MISTRAL_API_KEY_PARAMETER = aws_ssm_parameter.mistral_api_key[0].name } : {}, ) }
17 collapsed lines
lifecycle { ignore_changes = [ filename, source_code_hash, ] }
depends_on = [ aws_iam_role_policy_attachment.lambda_basic_execution, aws_iam_role_policy.lambda_permissions, ]}
output "lambda_function_name" { value = aws_lambda_function.agent.function_name}
output "lambda_function_arn" { value = aws_lambda_function.agent.arn}data "aws_iam_policy_document" "lambda_assume" { statement { actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["lambda.amazonaws.com"] } }}
# trivy:ignore:avd-aws-0057# Bedrock foundation-model ARNs do not pin to the caller region (the inference# profile fans out cross-region), and Marketplace subscription actions are# global by design.data "aws_iam_policy_document" "lambda_permissions" { # Bedrock invocation. Same shape as iam.tf's bedrock_invoke; copied # here so the Lambda role is self-contained and does not require # the user-role policy to also be attached to the Lambda role. statement { actions = [ "bedrock:Converse", "bedrock:ConverseStream", "bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream", ] # Bedrock foundation-model ARNs do not pin to the caller region; the # inference profile fans out cross-region, so the * region segment is required. #trivy:ignore:avd-aws-0057 resources = [ aws_bedrock_inference_profile.agent.arn, local.system_inference_profile_arn, "arn:aws:bedrock:*::foundation-model/${local.bedrock_model_id}", ] }
statement { actions = [ "aws-marketplace:Subscribe", "aws-marketplace:Unsubscribe", "aws-marketplace:ViewSubscriptions", ] # Marketplace subscription actions are global by design. #trivy:ignore:avd-aws-0057 resources = ["*"] }
# Model registry read. A plain String parameter, so no KMS is involved. statement { actions = ["ssm:GetParameter"] resources = [aws_ssm_parameter.models.arn] }
# SSM SecureString read for the Logfire token, only when wired. dynamic "statement" { for_each = local.logfire_token_wired ? [1] : [] content { actions = ["ssm:GetParameter"] resources = [aws_ssm_parameter.logfire_token[0].arn] } }
# SSM SecureString read for the Mistral API key, only when wired. dynamic "statement" { for_each = local.mistral_key_wired ? [1] : [] content { actions = ["ssm:GetParameter"] resources = [aws_ssm_parameter.mistral_api_key[0].arn] } }
# KMS Decrypt on the AWS-managed SSM key, required to read any SecureString # value through the extension. Present when either secret is wired. dynamic "statement" { for_each = local.logfire_token_wired || local.mistral_key_wired ? [1] : [] content { actions = ["kms:Decrypt"] resources = [ "arn:aws:kms:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:alias/aws/ssm", ] } }
statement { actions = [ "firehose:PutRecord", "firehose:PutRecordBatch", ] resources = [aws_kinesis_firehose_delivery_stream.audit.arn] }}
resource "aws_iam_role" "lambda" { name = "terraform-pr-agent-lambda" assume_role_policy = data.aws_iam_policy_document.lambda_assume.json}
resource "aws_iam_role_policy_attachment" "lambda_basic_execution" { role = aws_iam_role.lambda.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"}
resource "aws_iam_role_policy" "lambda_permissions" { name = "terraform-pr-agent-lambda-permissions" role = aws_iam_role.lambda.id policy = data.aws_iam_policy_document.lambda_permissions.json}
data "archive_file" "placeholder" { type = "zip" output_path = "${path.module}/.terraform/placeholder.zip"
source { content = "def handler(event, context):\n return {\"status\": \"placeholder\", \"note\": \"run scripts/build-lambda.sh then aws lambda update-function-code\"}\n" filename = "agent/handler.py" }
source { content = "" filename = "agent/__init__.py" }}
resource "aws_lambda_function" "agent" { function_name = "terraform-pr-agent" role = aws_iam_role.lambda.arn runtime = "python3.13" architectures = ["arm64"] handler = "agent.handler.handler" timeout = 60 memory_size = 512
filename = data.archive_file.placeholder.output_path source_code_hash = data.archive_file.placeholder.output_base64sha256
layers = [var.secrets_extension_layer_arn]
tracing_config { mode = "Active" }
environment { variables = merge( { MODELS_PARAMETER = aws_ssm_parameter.models.name DEFAULT_MODEL = var.default_model METRICS_NAMESPACE = local.metrics_namespace PARAMETERS_SECRETS_EXTENSION_CACHE_SIZE = "100" PARAMETERS_SECRETS_EXTENSION_HTTP_PORT = "2773" FIREHOSE_DELIVERY_STREAM = aws_kinesis_firehose_delivery_stream.audit.name }, local.logfire_token_wired ? { LOGFIRE_TOKEN_PARAMETER = local.logfire_token_parameter_name } : {}, local.mistral_key_wired ? { MISTRAL_API_KEY_PARAMETER = aws_ssm_parameter.mistral_api_key[0].name } : {}, ) }
lifecycle { ignore_changes = [ filename, source_code_hash, ] }
depends_on = [ aws_iam_role_policy_attachment.lambda_basic_execution, aws_iam_role_policy.lambda_permissions, ]}
output "lambda_function_name" { value = aws_lambda_function.agent.function_name}
output "lambda_function_arn" { value = aws_lambda_function.agent.arn}locals { logfire_token_parameter_name = "/terraform-pr-agent/logfire-token" logfire_token_wired = var.logfire_token != ""}
resource "aws_ssm_parameter" "logfire_token" { count = local.logfire_token_wired ? 1 : 0
name = local.logfire_token_parameter_name description = "Logfire write token. Consumed by the terraform-pr-agent Lambda." type = "SecureString" value = var.logfire_token}
output "logfire_token_parameter_arn" { # nonsensitive: the ARN is just a resource identifier, but Terraform's # taint analysis propagates the sensitive marker from var.logfire_token # via the resource. Explicit unmark so the ARN renders in `terraform # output` (the value itself never leaks the token). value = nonsensitive(local.logfire_token_wired ? aws_ssm_parameter.logfire_token[0].arn : "") description = "ARN of the SSM SecureString; empty when LOGFIRE_TOKEN is not set."}terraform { required_providers { aws = { source = "hashicorp/aws" version = "6.17.0" } }}
provider "aws" {}
data "aws_caller_identity" "current" {}data "aws_region" "current" {}# The model registry: Terraform owns it, renders it to JSON, and parks it in# an SSM String parameter the handler reads at startup. Each entry names a# provider and a model id; Bedrock entries also carry the inference-profile# ARN. DEFAULT_MODEL (set on the Lambda) selects the active one, so switching# the agent's model is a parameter change, not a code change.locals { metrics_namespace = "TerraformPrAgent/Models"
models = { haiku = { provider = "bedrock" model_id = local.bedrock_model_id inference_profile_arn = aws_bedrock_inference_profile.agent.arn } "mistral-large" = { provider = "mistral" model_id = "mistral-large-latest" } "devstral-small" = { provider = "mistral" model_id = "devstral-small-2507" } }
mistral_key_wired = var.mistral_api_key != ""}
resource "aws_ssm_parameter" "models" { name = "/terraform-pr-agent/models" description = "Model registry for the terraform-pr-agent Lambda (provider + model id per entry)." type = "String" value = jsonencode(local.models)}
# The Mistral API key, SecureString, fetched by the handler through the same# Parameters and Secrets extension path as the Logfire token. Only created# when TF_VAR_mistral_api_key is set, mirroring the Logfire token wiring; with# it unset the Mistral providers are simply unreachable and a Bedrock default# still works.resource "aws_ssm_parameter" "mistral_api_key" { count = local.mistral_key_wired ? 1 : 0
name = "/terraform-pr-agent/mistral-api-key" description = "Mistral API key. Consumed by the terraform-pr-agent Lambda." type = "SecureString" value = var.mistral_api_key}
output "models_parameter_name" { value = aws_ssm_parameter.models.name}variable "alert_email" { description = "Email address subscribed to the agent alerts SNS topic. Set via TF_VAR_alert_email." type = string24 collapsed lines
}
variable "daily_token_alarm_threshold" { description = "Daily combined input + output token threshold. Crossing it sends an email via SNS." type = number default = 1000000}
variable "audit_retention_days" { type = number description = "Object Lock default retention in days. Tutorial default is 7 so the bucket is easy to clean up; production audit horizons are typically years (e.g. 2555 for SOX-style controls)." default = 7}
# arm64 build of the AWS Parameters and Secrets Lambda Extension, pinned to eu-west-1.variable "secrets_extension_layer_arn" { type = string description = "ARN of the AWS Parameters and Secrets Lambda Extension layer." default = "arn:aws:lambda:eu-west-1:015030872274:layer:AWS-Parameters-and-Secrets-Lambda-Extension-Arm64:87"}
variable "logfire_token" { type = string description = "Logfire write token. Leave empty to skip the Logfire integration. Set via TF_VAR_logfire_token in .envrc.local." default = "" sensitive = true}
variable "mistral_api_key" { type = string description = "Mistral API key. Leave empty to skip the Mistral providers (Bedrock models still work). Set via TF_VAR_mistral_api_key in .envrc.local." default = "" sensitive = true}
variable "default_model" { type = string description = "Registry key of the model the agent runs with (see models.tf). One of: haiku, mistral-large, devstral-small." default = "mistral-large"}variable "alert_email" { description = "Email address subscribed to the agent alerts SNS topic. Set via TF_VAR_alert_email." type = string}
variable "daily_token_alarm_threshold" { description = "Daily combined input + output token threshold. Crossing it sends an email via SNS." type = number default = 1000000}
variable "audit_retention_days" { type = number description = "Object Lock default retention in days. Tutorial default is 7 so the bucket is easy to clean up; production audit horizons are typically years (e.g. 2555 for SOX-style controls)." default = 7}
# arm64 build of the AWS Parameters and Secrets Lambda Extension, pinned to eu-west-1.variable "secrets_extension_layer_arn" { type = string description = "ARN of the AWS Parameters and Secrets Lambda Extension layer." default = "arn:aws:lambda:eu-west-1:015030872274:layer:AWS-Parameters-and-Secrets-Lambda-Extension-Arm64:87"}
variable "logfire_token" { type = string description = "Logfire write token. Leave empty to skip the Logfire integration. Set via TF_VAR_logfire_token in .envrc.local." default = "" sensitive = true}
variable "mistral_api_key" { type = string description = "Mistral API key. Leave empty to skip the Mistral providers (Bedrock models still work). Set via TF_VAR_mistral_api_key in .envrc.local." default = "" sensitive = true}
variable "default_model" { type = string description = "Registry key of the model the agent runs with (see models.tf). One of: haiku, mistral-large, devstral-small." default = "mistral-large"}#!/usr/bin/env bash# Build a deployable zip for the terraform-pr-agent Lambda.## Runs from anywhere; cd's to the project root (the dir holding# pyproject.toml). Produces build/lambda.zip ready for:## aws lambda update-function-code \# --function-name terraform-pr-agent \# --zip-file fileb://build/lambda.zip## Mirrors the pattern from# https://docs.astral.sh/uv/guides/integration/aws-lambda/set -euo pipefail
cd "$(dirname "$0")/.."
# Make sure the lock is in sync with pyproject.toml before exporting.uv sync --quiet
rm -rf buildmkdir -p build/packages
# Export the locked dependency set so uv pip install can consume it# without re-resolving.uv export --frozen --no-dev --no-editable -o build/requirements.txt
# Install deps for Lambda's runtime. --python-platform forces wheels# compatible with Amazon Linux 2 on arm64 (matches the function's# architectures = ["arm64"]). --no-compile-bytecode keeps the zip small# and avoids spending cold-start cycles on pyc creation.uv pip install \ --no-installer-metadata \ --no-compile-bytecode \ --python-platform aarch64-manylinux2014 \ --python 3.13 \ --target build/packages \ -r build/requirements.txt
# Drop the handler package alongside the installed deps.cp -r agent build/packages/
# Zip from inside the staging dir so paths sit at the zip root.( cd build/packages && zip -qr ../lambda.zip . )
echo "built: $(pwd)/build/lambda.zip ($(du -h build/lambda.zip | cut -f1))"# /// script# requires-python = ">=3.11"# dependencies = [# "pydantic-ai-slim[bedrock]>=1.106,<2",# "boto3>=1.35,<2",# ]# ///"""Command-line chat against Claude Haiku 4.5 on Bedrock.
Reads three env vars (set them in .envrc.local; see post 1):
AGENT_ROLE_ARN IAM role to assume. Source: terraform -chdir=infra output -raw agent_role_arn BEDROCK_INFERENCE_PROFILE_ARN Application inference profile that wraps the EU CRIS profile. Source: terraform -chdir=infra output -raw inference_profile_arn AWS_REGION Region for the bedrock-runtime endpoint. Defaults to eu-west-1.
Run with `uv run scripts/chat.py` from the project root."""
import osimport sys
import boto3from botocore.exceptions import ClientErrorfrom pydantic import BaseModel, Fieldfrom pydantic_ai import Agentfrom pydantic_ai.models.bedrock import BedrockConverseModelfrom pydantic_ai.providers.bedrock import BedrockProvider
MODEL_ID = "anthropic.claude-haiku-4-5-20251001-v1:0"
SYSTEM_PROMPT = ( "You are a conversational assistant running in a small command-line chat. " "Respond naturally to the user in the `message` field. " "Set `terminate=True` only when the user explicitly asks to end the " "conversation (for example: bye, quit, exit, stop, that's all). " "When you set `terminate=True`, include a short farewell in `message`. " "In every other case set `terminate=False`.")
class Reply(BaseModel): """Structured response on every turn so the loop can decide when to stop."""
message: str = Field(description="Natural-language reply to the user.") terminate: bool = Field( description=( "True only when the user has explicitly asked to end the conversation; False otherwise." ) )
def require_env(name: str) -> str: value = os.environ.get(name) if not value: sys.stderr.write( f"error: {name} is not set. Add it to .envrc.local from " f"`terraform output`, then run `direnv reload`.\n" ) sys.exit(2) return value
def assume_role(role_arn: str) -> dict[str, str]: """Trade the local SSO identity for short-lived terraform-pr-agent creds.""" sts = boto3.client("sts") response = sts.assume_role(RoleArn=role_arn, RoleSessionName="chat-cli") return response["Credentials"]
def build_model(role_arn: str, inference_profile_arn: str, region: str) -> BedrockConverseModel: creds = assume_role(role_arn) bedrock_client = boto3.client( "bedrock-runtime", region_name=region, aws_access_key_id=creds["AccessKeyId"], aws_secret_access_key=creds["SecretAccessKey"], aws_session_token=creds["SessionToken"], ) # The application inference profile ARN goes in settings, not model_name: # pydantic-ai still uses the foundation-model id for capability detection # and routes the actual Converse call through the profile. return BedrockConverseModel( MODEL_ID, provider=BedrockProvider(bedrock_client=bedrock_client), settings={"bedrock_inference_profile": inference_profile_arn}, )
def main() -> None: role_arn = require_env("AGENT_ROLE_ARN") inference_profile_arn = require_env("BEDROCK_INFERENCE_PROFILE_ARN") region = os.environ.get("AWS_REGION", "eu-west-1")
try: model = build_model(role_arn, inference_profile_arn, region) except ClientError as err: sys.stderr.write( f"error: could not assume {role_arn}: {err}\n" f"check that `terraform apply` has run and AWS_PROFILE is set.\n" ) sys.exit(1)
agent = Agent(model, output_type=Reply, system_prompt=SYSTEM_PROMPT)
print('Chatting with Claude Haiku 4.5 via terraform-pr-agent. Type "bye" to exit.') history: list = [] while True: try: user_input = input("you> ").strip() except (EOFError, KeyboardInterrupt): print() break if not user_input: continue try: result = agent.run_sync(user_input, message_history=history) except ClientError as err: sys.stderr.write(f"bedrock error: {err}\n") continue reply = result.output print(f"agent> {reply.message}") history = result.all_messages() if reply.terminate: break
if __name__ == "__main__": main()SELECT *FROM tracesWHERE year = 2026 AND month = 6 AND day = 8ORDER BY started DESC;
-- Example output (narrowed to 7 of the view's 18 columns; SELECT * also-- returns conversation_id, system_prompt, user_prompt, assistant_response,-- input_messages, output_messages, status, batch_key, source_file,-- year/month/day):---- ┌───────────────────────────────┬──────────┬────────────────────────────────┬─────────────┬───────────┬────────────┬────────┐-- │ started │ trace │ model │ dur_ms │ in_tokens │ out_tokens │ finish │-- ├───────────────────────────────┼──────────┼────────────────────────────────┼─────────────┼───────────┼────────────┼────────┤-- │ 2026-06-08 20:31:06.044709547 │ 019ea8ee │ claude-haiku-4-5-20251001-v1:0 │ 6815.733257 │ 36 │ 59 │ stop │-- │ 2026-06-08 20:31:05.984812728 │ 019ea8ee │ claude-haiku-4-5-20251001-v1:0 │ 1459.186377 │ 36 │ 38 │ stop │-- │ 2026-06-08 20:31:05.964196348 │ 019ea8ee │ claude-haiku-4-5-20251001-v1:0 │ 2024.133412 │ 36 │ 123 │ stop │-- │ 2026-06-08 20:31:05.944446527 │ 019ea8ee │ claude-haiku-4-5-20251001-v1:0 │ 1581.984687 │ 36 │ 50 │ stop │-- │ 2026-06-08 20:31:05.926721468 │ 019ea8ee │ claude-haiku-4-5-20251001-v1:0 │ 1480.496671 │ 36 │ 43 │ stop │-- └───────────────────────────────┴──────────┴────────────────────────────────┴─────────────┴───────────┴────────────┴────────┘
SELECT day, count(*) AS runsFROM tracesWHERE year = 2026 AND month = 6GROUP BY dayORDER BY day;
-- Example output:---- ┌─────┬──────┐-- │ day │ runs │-- ├─────┼──────┤-- │ 04 │ 2 │-- │ 08 │ 11 │-- │ 09 │ 1 │-- └─────┴──────┘
SELECT day, sum(in_tokens) AS input_tokens, sum(out_tokens) AS output_tokensFROM tracesWHERE year = 2026GROUP BY dayORDER BY day;
-- Example output:---- ┌─────┬──────────────┬───────────────┐-- │ day │ input_tokens │ output_tokens │-- ├─────┼──────────────┼───────────────┤-- │ 04 │ 80 │ 43 │-- │ 08 │ 397 │ 605 │-- │ 09 │ 38 │ 145 │-- └─────┴──────────────┴───────────────┘CREATE PERSISTENT SECRET ( TYPE s3, PROVIDER credential_chain, REFRESH auto);SET VARIABLE audit_bucket = getenv('AUDIT_BUCKET');
-- hive_partitioning = true reads year=YYYY/month=MM/day=DD/ from the-- object path as virtual columns, so the partition predicate in a-- query below prunes objects before any file is opened.CREATE OR REPLACE VIEW traces ASWITH spans AS ( -- Flatten the OTLP-JSON envelope into one row per span, with the -- common span fields lifted out as named columns so downstream -- CTEs and ad-hoc queries can work against `name`, `trace_id`, -- `dur_ms`, etc. without re-doing the struct navigation each time. SELECT year, month, day, span.name AS name, lower(hex(from_base64(span.traceId::VARCHAR))) AS trace_id, lower(hex(from_base64(span.spanId::VARCHAR))) AS span_id, lower(hex(from_base64(span.parentSpanId::VARCHAR))) AS parent_span_id, make_timestamp_ns(span.startTimeUnixNano::BIGINT) AS started, make_timestamp_ns(span.endTimeUnixNano::BIGINT) AS ended, (span.endTimeUnixNano::BIGINT - span.startTimeUnixNano::BIGINT) / 1e6 AS dur_ms, span.status.code::VARCHAR AS status_code, span.attributes AS attributes, data.filename AS source_file, -- Firehose names objects <stream>-<ver>-<YYYY-MM-DD-HH-MM-SS>-<uuid>.gz; -- stripping the trailing -<uuid>.gz collapses rows from the same flush -- batch onto a stable key for grouping. regexp_replace( split_part(data.filename, '/', -1), '-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.gz$', '' ) AS batch_key FROM read_ndjson( 's3://' || getvariable('audit_bucket') || '/traces/**/*.gz', compression = 'gzip', hive_partitioning = true, filename = true) AS data , UNNEST(data.resourceSpans) AS u1(rs) , UNNEST(rs.scopeSpans) AS u2(ss) , UNNEST(ss.spans) AS u3(span)),roots AS ( -- One row per trace: the invoke_agent root span pydantic-ai emits per run. SELECT * FROM spans WHERE parent_span_id IS NULL OR parent_span_id = ''),chats AS ( -- Per-trace summary of the LLM call: pulls the GenAI semantic -- convention attributes off the chat span and exposes each as a -- named column. SELECT trace_id, list_filter(attributes, x -> x.key = 'gen_ai.system')[1].value.stringValue AS gen_ai_system, list_filter(attributes, x -> x.key = 'gen_ai.operation.name')[1].value.stringValue AS operation, list_filter(attributes, x -> x.key = 'gen_ai.request.model')[1].value.stringValue AS request_model, list_filter(attributes, x -> x.key = 'gen_ai.response.model')[1].value.stringValue AS response_model, list_filter(attributes, x -> x.key = 'gen_ai.usage.input_tokens')[1].value.intValue::BIGINT AS in_tokens, list_filter(attributes, x -> x.key = 'gen_ai.usage.output_tokens')[1].value.intValue::BIGINT AS out_tokens, list_filter(attributes, x -> x.key = 'gen_ai.response.finish_reasons')[1] .value.arrayValue.values[1].stringValue AS finish, list_filter(attributes, x -> x.key = 'gen_ai.conversation.id')[1].value.stringValue AS conversation_id, list_filter(attributes, x -> x.key = 'gen_ai.agent.name')[1].value.stringValue AS agent_name, list_filter(attributes, x -> x.key = 'gen_ai.agent.call.id')[1].value.stringValue AS agent_call_id, list_filter(attributes, x -> x.key = 'gen_ai.input.messages')[1].value.stringValue AS input_messages, list_filter(attributes, x -> x.key = 'gen_ai.output.messages')[1].value.stringValue AS output_messages FROM spans WHERE name LIKE 'chat %')SELECT roots.started, roots.year, roots.month, roots.day, roots.trace_id, substr(roots.trace_id, 1, 8) AS trace, roots.batch_key, roots.source_file, roots.dur_ms, regexp_extract(chats.request_model, '[^./]+$') AS model, chats.in_tokens, chats.out_tokens, chats.finish, chats.agent_name, chats.conversation_id, -- Convenience columns for the common single-turn shape: system at -- input[0], user at input[1], assistant at output[0]. Multi-turn -- runs invalidate the indices, so reach for input_messages and -- output_messages directly for those. json_extract_string(chats.input_messages, '$[0].parts[0].content') AS system_prompt, json_extract_string(chats.input_messages, '$[1].parts[0].content') AS user_prompt, json_extract_string(chats.output_messages, '$[0].parts[0].content') AS assistant_response, chats.input_messages, chats.output_messages, -- Per the OTel spec, instrumentation libraries leave status unset -- on success (only application code may set it to Ok). Every OTel -- backend treats unset as "no error reported"; we render the same. CASE roots.status_code WHEN 'STATUS_CODE_ERROR' THEN 'err' ELSE 'ok' END AS statusFROM rootsJOIN chats USING (trace_id);"""Shared test setup.
handler.py reads FIREHOSE_DELIVERY_STREAM at import time, so a stand-in is setbefore any test imports it. The model registry, default model, and metricsnamespace are read at INVOKE time, so tests set or monkeypatch those per case;defaults here keep the common path terse. No real AWS or Mistral call is evermade: the model build is monkeypatched and the SSM fetch is stubbed."""
import os
os.environ.setdefault("AWS_DEFAULT_REGION", "eu-central-1")os.environ.setdefault("FIREHOSE_DELIVERY_STREAM", "test-stream")os.environ.setdefault("METRICS_NAMESPACE", "TerraformPrAgent/Models")os.environ.setdefault("DEFAULT_MODEL", "mistral-large")os.environ.setdefault("MODELS_PARAMETER", "/terraform-pr-agent/models")os.environ.setdefault("MISTRAL_API_KEY_PARAMETER", "/terraform-pr-agent/mistral-api-key")"""Handler tests for the model registry, the model factory, and EMF emission.
No real AWS or Mistral call is made: _fetch_ssm_parameter is stubbed, the modelbuild is monkeypatched for the handler path, and EMF is exercised againsthand-built spans."""
import jsonfrom types import SimpleNamespace
import agent.handler as handlerimport pytestfrom opentelemetry.trace.status import StatusCodefrom pydantic_ai.messages import ModelMessage, ModelResponse, TextPartfrom pydantic_ai.models.bedrock import BedrockConverseModelfrom pydantic_ai.models.function import AgentInfo, FunctionModelfrom pydantic_ai.models.mistral import MistralModel
REGISTRY = { "haiku": { "provider": "bedrock", "model_id": "anthropic.claude-haiku-4-5-20251001-v1:0", "inference_profile_arn": "arn:aws:bedrock:eu-west-1:0:inference-profile/test", }, "mistral-large": {"provider": "mistral", "model_id": "mistral-large-latest"},}
@pytest.fixture(autouse=True)def _reset_model_cache(): # Clear on setup only: a later test may monkeypatch _build_model (losing # cache_clear), and monkeypatch is undone before the next setup runs. handler._build_model.cache_clear()
@pytest.fixturedef stub_ssm(monkeypatch): def fetch(name: str) -> str: if name == "/terraform-pr-agent/models": return json.dumps(REGISTRY) return "mistral-key-xyz"
monkeypatch.setattr(handler, "_fetch_ssm_parameter", fetch)
def _root_span( *, model=None, input_tokens=120, output_tokens=45, cache_read=0, cache_write=0, errored=False, duration_ms=1500,): start = 1_000_000_000_000 status = StatusCode.ERROR if errored else StatusCode.OK attributes = { "gen_ai.usage.input_tokens": input_tokens, "gen_ai.usage.output_tokens": output_tokens, } # pydantic-ai serialises run metadata to a JSON string on the run span; the # handler passes {"model": <registry key>}, which _emit_emf reads back. if model is not None: attributes["metadata"] = json.dumps({"model": model}) # pydantic-ai only sets these keys when non-zero. if cache_read: attributes["gen_ai.usage.cache_read.input_tokens"] = cache_read if cache_write: attributes["gen_ai.usage.cache_creation.input_tokens"] = cache_write return SimpleNamespace( parent=None, attributes=attributes, status=SimpleNamespace(status_code=status), start_time=start, end_time=start + duration_ms * 1_000_000, )
def test_build_model_selects_bedrock(stub_ssm): model = handler._build_model("haiku") assert isinstance(model, BedrockConverseModel) assert model.model_name == "anthropic.claude-haiku-4-5-20251001-v1:0"
def test_build_model_selects_mistral(stub_ssm): model = handler._build_model("mistral-large") assert isinstance(model, MistralModel) assert model.model_name == "mistral-large-latest"
def test_build_model_mistral_without_key_raises(monkeypatch, stub_ssm): monkeypatch.delenv("MISTRAL_API_KEY_PARAMETER", raising=False) with pytest.raises(RuntimeError, match="MISTRAL_API_KEY_PARAMETER"): handler._build_model("mistral-large")
def test_build_model_rejects_unknown_provider(monkeypatch): monkeypatch.setattr( handler, "_fetch_ssm_parameter", lambda name: json.dumps({"weird": {"provider": "acme", "model_id": "x"}}), ) with pytest.raises(ValueError, match="unknown provider"): handler._build_model("weird")
def test_raise_for_retryable_only_raises_on_retryable(): import httpx
request = httpx.Request("POST", "https://api.mistral.ai/v1/chat/completions") for status in (429, 502, 503, 504): with pytest.raises(httpx.HTTPStatusError): handler._raise_for_retryable(httpx.Response(status, request=request)) # Non-retryable statuses pass through, so a real error fails fast instead of # being retried five times. for status in (200, 400, 401): handler._raise_for_retryable(httpx.Response(status, request=request))
def test_emit_emf_writes_metric_line(capsys): handler._emit_emf( [_root_span(model="mistral-large", input_tokens=120, output_tokens=45, duration_ms=1500)] )
record = json.loads(capsys.readouterr().out.strip()) assert record["Model"] == "mistral-large" assert record["InputTokens"] == 120 assert record["OutputTokens"] == 45 assert record["CacheReadTokens"] == 0 assert record["CacheWriteTokens"] == 0 assert record["Latency"] == 1500 assert record["Invocations"] == 1 assert record["Errors"] == 0
metric = record["_aws"]["CloudWatchMetrics"][0] assert metric["Namespace"] == "TerraformPrAgent/Models" assert metric["Dimensions"] == [["Model"]] assert {m["Name"] for m in metric["Metrics"]} == { "InputTokens", "OutputTokens", "CacheReadTokens", "CacheWriteTokens", "Latency", "Invocations", "Errors", }
def test_emit_emf_reports_cache_tokens(capsys): handler._emit_emf([_root_span(model="haiku", cache_read=512, cache_write=128)]) record = json.loads(capsys.readouterr().out.strip()) assert record["CacheReadTokens"] == 512 assert record["CacheWriteTokens"] == 128
def test_emit_emf_marks_errors(capsys): handler._emit_emf([_root_span(errored=True)]) record = json.loads(capsys.readouterr().out.strip()) assert record["Errors"] == 1
def test_emit_emf_ignores_trace_without_root(capsys): child = SimpleNamespace(parent=object()) handler._emit_emf([child]) assert capsys.readouterr().out == ""
@pytest.fixture(autouse=True)def no_observability(monkeypatch): monkeypatch.setattr(handler, "_init_logfire", lambda: None)
def test_handler_returns_model_output(monkeypatch): def call(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: return ModelResponse(parts=[TextPart(content="placeholder reply")])
monkeypatch.setattr(handler, "_build_model", lambda name: FunctionModel(call)) response = handler.handler({"prompt": "hello"}, None) assert response == { "status": "ok", "model": "mistral-large", "output": "placeholder reply", }
def test_handler_event_overrides_default_model(monkeypatch): seen: list[str] = []
def fake_build(name: str) -> FunctionModel: seen.append(name) return FunctionModel(lambda messages, info: ModelResponse(parts=[TextPart(content="ok")]))
monkeypatch.setattr(handler, "_build_model", fake_build) response = handler.handler({"prompt": "hello", "model": "haiku"}, None) assert seen == ["haiku"] assert response["model"] == "haiku"source_env_if_exists .envrc.local# Local AWS env for this project. Gitignored.# Set the region (shared by both auth options) and uncomment ONE of the# credential blocks below, then run `direnv allow` in this directory.27 collapsed lines
export AWS_REGION=eu-west-1
# Option A: Named profile (e.g. from `aws configure sso` or ~/.aws/credentials).# export AWS_PROFILE=your-profile-name
# Option B: Static or temporary credentials.# export AWS_ACCESS_KEY_ID=...# export AWS_SECRET_ACCESS_KEY=...# export AWS_SESSION_TOKEN=... # only if using temporary creds
# Email subscribed to the alarms SNS topic. Required by infra/alerts.tf.# Set before `terraform apply`; AWS sends a confirmation email that must be# clicked before alarms can deliver.# export [email protected]
# Fill in after `terraform apply` in post 1, then run `direnv reload`.# Values come from terraform outputs:# terraform -chdir=infra output -raw agent_role_arn# terraform -chdir=infra output -raw inference_profile_arn# export AGENT_ROLE_ARN=arn:aws:iam::<account>:role/terraform-pr-agent# export BEDROCK_INFERENCE_PROFILE_ARN=arn:aws:bedrock:eu-west-1:<account>:application-inference-profile/<id>
export LOGFIRE_TOKEN="pylf_v1_..."
# Same value as LOGFIRE_TOKEN above, but exposed as a Terraform variable# so infra/logfire.tf can put it in an SSM SecureString for the Lambda.# Leave unset to skip the SSM parameter and the matching IAM grants.# export TF_VAR_logfire_token="$LOGFIRE_TOKEN"
# API key for the Mistral direct API, used when DEFAULT_MODEL (or the per-event# model override) points at a Mistral registry entry. Get one at# https://console.mistral.ai/api-keys/.export MISTRAL_API_KEY="..."
# Same value as MISTRAL_API_KEY above, exposed as a Terraform variable so# infra/models.tf can put it in an SSM SecureString for the Lambda. Leave# unset to skip the SSM parameter, the matching IAM grants, and the Mistral# providers (a Bedrock default still works). The justfile's _tf-post-env# already derives this from MISTRAL_API_KEY, so this line is optional.# export TF_VAR_mistral_api_key="$MISTRAL_API_KEY"
# Bucket name for the audit copy. The DuckDB view in scripts/traces.sql# reads it via the CLI-only getenv() function so the SQL stays free of# account-specific values.export AUDIT_BUCKET=terraform-pr-agent-audit-$(aws sts get-caller-identity --query Account --output text)-${AWS_REGION}# Local AWS env for this project. Gitignored.# Set the region (shared by both auth options) and uncomment ONE of the# credential blocks below, then run `direnv allow` in this directory.
export AWS_REGION=eu-west-1
# Option A: Named profile (e.g. from `aws configure sso` or ~/.aws/credentials).# export AWS_PROFILE=your-profile-name
# Option B: Static or temporary credentials.# export AWS_ACCESS_KEY_ID=...# export AWS_SECRET_ACCESS_KEY=...# export AWS_SESSION_TOKEN=... # only if using temporary creds
# Email subscribed to the alarms SNS topic. Required by infra/alerts.tf.# Set before `terraform apply`; AWS sends a confirmation email that must be# clicked before alarms can deliver.# export [email protected]
# Fill in after `terraform apply` in post 1, then run `direnv reload`.# Values come from terraform outputs:# terraform -chdir=infra output -raw agent_role_arn# terraform -chdir=infra output -raw inference_profile_arn# export AGENT_ROLE_ARN=arn:aws:iam::<account>:role/terraform-pr-agent# export BEDROCK_INFERENCE_PROFILE_ARN=arn:aws:bedrock:eu-west-1:<account>:application-inference-profile/<id>
export LOGFIRE_TOKEN="pylf_v1_..."
# Same value as LOGFIRE_TOKEN above, but exposed as a Terraform variable# so infra/logfire.tf can put it in an SSM SecureString for the Lambda.# Leave unset to skip the SSM parameter and the matching IAM grants.# export TF_VAR_logfire_token="$LOGFIRE_TOKEN"
# API key for the Mistral direct API, used when DEFAULT_MODEL (or the per-event# model override) points at a Mistral registry entry. Get one at# https://console.mistral.ai/api-keys/.export MISTRAL_API_KEY="..."
# Same value as MISTRAL_API_KEY above, exposed as a Terraform variable so# infra/models.tf can put it in an SSM SecureString for the Lambda. Leave# unset to skip the SSM parameter, the matching IAM grants, and the Mistral# providers (a Bedrock default still works). The justfile's _tf-post-env# already derives this from MISTRAL_API_KEY, so this line is optional.# export TF_VAR_mistral_api_key="$MISTRAL_API_KEY"
# Bucket name for the audit copy. The DuckDB view in scripts/traces.sql# reads it via the CLI-only getenv() function so the SQL stays free of# account-specific values.export AUDIT_BUCKET=terraform-pr-agent-audit-$(aws sts get-caller-identity --query Account --output text)-${AWS_REGION}.envrc.local.terraform/*.tfstate*.tfstate.*.direnv/# Project conventions
Tutorial project from the Terraform PR Agent series athttps://andreaslang.dev/posts/terraform-pr-agent/
> When a post introduces a new top-level directory, ship an updated> `AGENTS.md` in that post's `scaffold/` overlay so this Layout section> stays accurate.
## Layout
- `infra/` Terraform (Bedrock, CloudWatch, DynamoDB, ...)- `agent/` Python package (pydantic-ai code)- `evals/` Golden cases + eval harness- `scripts/` Standalone single-file Python scripts with PEP 723 inline dependency metadata. Run with `uv run scripts/<name>.py`; do not move these into the `agent` package or a shared `pyproject.toml`.
## Tooling
- Python: use `uv` for dependency and script management. Run scripts with `uv run`. Single-file scripts under `scripts/` declare their deps inline via PEP 723 (`# /// script ... # ///` headers) so they stay self-contained and runnable without a project venv.- Terraform: 1.x.- AWS: credentials configured via `aws configure sso` or static keys.
## Conventions
- Run `terraform validate` after editing any `.tf` file.- Run `terraform fmt` before committing `.tf`.- Never auto-apply Terraform; print the plan first and wait for confirmation.- Don't introduce dependencies the current post hasn't covered.- AWS resources live in a sandbox sub-account; never assume a production account.[project]name = "terraform-pr-agent"version = "0.1.0"description = "The terraform-pr-agent Lambda handler."requires-python = ">=3.13"dependencies = [ "pydantic-ai-slim[bedrock]>=1.106,<2", "pydantic-ai-slim[bedrock,mistral,retries]>=1.106,<2", "logfire>=4.35,<5", "structlog>=24,<27", "boto3>=1.35,<2",]
[dependency-groups]dev = [ "pytest>=8,<10",]
[tool.pytest.ini_options]testpaths = ["tests"]pythonpath = ["."][project]name = "terraform-pr-agent"version = "0.1.0"description = "The terraform-pr-agent Lambda handler."requires-python = ">=3.13"dependencies = [ "pydantic-ai-slim[bedrock,mistral,retries]>=1.106,<2", "logfire>=4.35,<5", "structlog>=24,<27", "boto3>=1.35,<2",]
[dependency-groups]dev = [ "pytest>=8,<10",]
[tool.pytest.ini_options]testpaths = ["tests"]pythonpath = ["."]Fast-forward to the final code of this post
Download the cumulative checkpoint that matches the state at the end of this post. Useful for landing on the finished tree without working through every step.
mkdir -p ~/projectscd ~/projectscurl -fsSL https://andreaslang.dev/terraform-pr-agent/terraform-pr-agent-03.tar.gz | tar xzTo use Mistral models, you will need to create an API key and configure it in your .envrc.local file.
Sign up here and create an API key here. For this
post’s usage the free tier is fine, but you may as well load 10 Euros on it and switch to the “Scale” plan of the API.
Otherwise you will very quickly receive 429 errors.
Architecture#
Post 2 ran a single Bedrock model behind the Lambda and shipped spans to Logfire and the S3 audit copy. Post 3 keeps that intact and turns the model into a runtime choice: Terraform renders a model registry into SSM Parameter Store, the handler builds the pydantic-ai model on first invoke by reading that registry, and a Mistral API entry sits alongside the Bedrock one (with the Mistral key fetched from SSM the same way as the Logfire token). Metrics move to EMF, so a Bedrock model and a Mistral-API model land in the same CloudWatch namespace and one dashboard covers both.
The model registry#
To support both models I am passing a simple config via AWS SSM Parameter Store into the Lambda. It defines provider model id and if on bedrock inference profile to be used.
# The model registry: Terraform owns it, renders it to JSON, and parks it in# an SSM String parameter the handler reads at startup. Each entry names a# provider and a model id; Bedrock entries also carry the inference-profile# ARN. DEFAULT_MODEL (set on the Lambda) selects the active one, so switching# the agent's model is a parameter change, not a code change.locals { metrics_namespace = "TerraformPrAgent/Models"
models = { haiku = { provider = "bedrock" model_id = local.bedrock_model_id inference_profile_arn = aws_bedrock_inference_profile.agent.arn } "mistral-large" = { provider = "mistral" model_id = "mistral-large-latest" } "devstral-small" = { provider = "mistral" model_id = "devstral-small-2507" } }
mistral_key_wired = var.mistral_api_key != ""}
resource "aws_ssm_parameter" "models" { name = "/terraform-pr-agent/models" description = "Model registry for the terraform-pr-agent Lambda (provider + model id per entry)." type = "String" value = jsonencode(local.models)}In addition we need a Mistral API key wired and retrieved the same way as the Logfire key via SSM Parameter Store (encrypted).
# The Mistral API key, SecureString, fetched by the handler through the same# Parameters and Secrets extension path as the Logfire token. Only created# when TF_VAR_mistral_api_key is set, mirroring the Logfire token wiring; with# it unset the Mistral providers are simply unreachable and a Bedrock default# still works.resource "aws_ssm_parameter" "mistral_api_key" { count = local.mistral_key_wired ? 1 : 0
name = "/terraform-pr-agent/mistral-api-key" description = "Mistral API key. Consumed by the terraform-pr-agent Lambda." type = "SecureString" value = var.mistral_api_key}Building the model at invoke time#
Now that we support Bedrock and Mistral models, we just need to create the right pydantic-ai model object with the matching configuration. The handler has also been modified so the model to be used can be provided via the event payload. The default is Mistral Large 3 if nothing is provided.
@cachedef _build_model(name: str) -> Model: """Build the pydantic-ai model registered under ``name``.
The registry lives in an SSM String parameter, so this runs on the first INVOKE (the extension is not ready during INIT) and is memoised per model name for warm invocations. Bedrock models authenticate via the Lambda role; Mistral models read an API key from a SecureString parameter, fetched the same way as the Logfire token. """ registry = json.loads(_fetch_ssm_parameter(os.environ["MODELS_PARAMETER"])) config = registry[name] provider = config["provider"] if provider == "bedrock": return BedrockConverseModel( config["model_id"], settings={"bedrock_inference_profile": config["inference_profile_arn"]}, ) if provider == "mistral": key_param = os.environ.get("MISTRAL_API_KEY_PARAMETER") if not key_param: raise RuntimeError( f"model {name!r} uses the Mistral API, but MISTRAL_API_KEY_PARAMETER " "is not set. Set MISTRAL_API_KEY and re-apply so the key is wired, or " "select a Bedrock model via DEFAULT_MODEL or the event's model field." ) return MistralModel( config["model_id"], provider=MistralProvider( api_key=_fetch_ssm_parameter(key_param), http_client=_retrying_http_client(), ), ) raise ValueError(f"unknown provider {provider!r} for model {name!r}")Provider-agnostic metrics with EMF#
To avoid having one model via the inference profile and the Mistral models via a different mechanism, we switch all models to use EMF logged metrics, so we can build a clean dashboard (check it in the code you can download above).
def _emit_emf(spans: Sequence[ReadableSpan]) -> None: """Emit one EMF metric line for the trace, read off the root span.
pydantic-ai records gen_ai.usage.* on the root agent span as the run total (the sum of its child chat spans), so a single read is the correct total, not a sum across every span. The model dimension is the registry key the handler passed as run metadata; pydantic-ai serialises that to the root span's `metadata` attribute (even on a failed run), so it is read back here rather than carried in module state. That key is exactly what the dashboard iterates, so a Bedrock run and a Mistral run share one set of widgets. Logging the _aws envelope to stdout is enough; CloudWatch Logs extracts the metrics from the structured line. """ root = next((span for span in spans if span.parent is None), None) if root is None: return attributes = root.attributes or {} model = json.loads(attributes.get("metadata", "{}")).get("model", "unknown") errored = root.status.status_code is StatusCode.ERROR record = { "_aws": { "Timestamp": root.end_time // 1_000_000, "CloudWatchMetrics": [ { "Namespace": os.environ["METRICS_NAMESPACE"], "Dimensions": [["Model"]], "Metrics": [ {"Name": "InputTokens", "Unit": "Count"}, {"Name": "OutputTokens", "Unit": "Count"}, {"Name": "CacheReadTokens", "Unit": "Count"}, {"Name": "CacheWriteTokens", "Unit": "Count"}, {"Name": "Latency", "Unit": "Milliseconds"}, {"Name": "Invocations", "Unit": "Count"}, {"Name": "Errors", "Unit": "Count"}, ], } ], }, "Model": model, "InputTokens": attributes.get("gen_ai.usage.input_tokens", 0), "OutputTokens": attributes.get("gen_ai.usage.output_tokens", 0), # pydantic-ai sets these only when non-zero, so default to 0. Providers # without prompt caching (e.g. the Mistral API) simply never report them. "CacheReadTokens": attributes.get("gen_ai.usage.cache_read.input_tokens", 0), "CacheWriteTokens": attributes.get("gen_ai.usage.cache_creation.input_tokens", 0), "Latency": (root.end_time - root.start_time) / 1_000_000, "Invocations": 1, "Errors": 1 if errored else 0, } log.info("trace_metrics", **record)
def _on_trace_complete(spans: Sequence[ReadableSpan]) -> None: """Ship the audit copy, then emit metrics: one hook, two sinks.""" _ship_trace(spans) _emit_emf(spans)You might also wonder about log.info("trace_metrics", **record) and how this logs in the right format for EMF.
Well, the answer is I sneaked in structlog. It is an amazing Python logging library that has all the things and ease
of use the standard logging library misses.
# JSON logs to stdout, which CloudWatch Logs ingests as-is. The same stream also# carries the EMF metric envelope (see _emit_emf), so one structured sink covers# both application logs and metrics. Logging has no extension dependency, so it# is configured at import rather than on the first INVOKE.structlog.configure( processors=[ structlog.processors.add_log_level, structlog.processors.TimeStamper(fmt="iso"), structlog.processors.EventRenamer("message"), structlog.processors.JSONRenderer(), ], logger_factory=structlog.PrintLoggerFactory(), cache_logger_on_first_use=True,)log = structlog.get_logger()End State#
Ease of switching between models and EMF logging/monitoring configured and the ability to run a (good) European foundation model 🇪🇺!
Coming next: workspace and small toolkit for the agent to get to work.