Model portability: swapping Bedrock for the Mistral API

  • bedrock
  • pydantic-ai
  • mistral
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, and aws pick up AWS credentials automatically on cd. The project scaffold ships an .envrc that sources a gitignored .envrc.local.
  • (Optional) A coding agent such as Claude Code, Cursor, Codex, or Gemini CLI to consume the AgentPrompt blocks 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.local in 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):

Terminal window
aws bedrock put-use-case-for-model-access \
--form-data "$(printf '{"companyName":"...","companyWebsite":"...","intendedUsers":"1","industryOption":"...","otherIndustryOption":"","useCases":"..."}' | base64)"

Verify:

Terminal window
aws bedrock get-foundation-model-availability \
--model-id anthropic.claude-haiku-4-5-20251001-v1:0 \
--region eu-west-1

Look 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:

Terminal window
mkdir -p ~/projects
cd ~/projects
curl -fsSL https://andreaslang.dev/terraform-pr-agent/terraform-pr-agent-02.tar.gz | tar xz

This 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.

terraform-pr-agent/
agent/
__init__.py
infra/
scripts/
tests/
agent/handler.py
"""AWS Lambda handler for the terraform-pr-agent.
Model portability: the agent runs on whichever model DEFAULT_MODEL names in
the registry, which Terraform parks in an SSM String parameter (MODELS_PARAMETER).
Each registry entry declares a provider (``bedrock`` or ``mistral``) and a model
id; the handler builds the matching pydantic-ai model. Nothing else about the
agent 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 first
INVOKE 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_end
when the agent root span closes, so the handler has no flush logic.
A Firehose-side failure raises on the same thread as agent.run_sync
and propagates as a Lambda 5xx; the system-of-record copy is never
silently dropped.
The extension's HTTP server rejects requests during INIT, so the SSM reads (and
the model build that depends on them) run on the first INVOKE and are memoised
with @cache for subsequent warm invocations.
"""
import json
7 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 logfire
import structlog
from google.protobuf import json_format
from 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, SpanProcessor
from opentelemetry.trace.status import StatusCode
from pydantic_ai import Agent
from pydantic_ai.models import Model
from pydantic_ai.models.bedrock import BedrockConverseModel
from pydantic_ai.models.mistral import MistralModel
from pydantic_ai.providers.mistral import MistralProvider
from pydantic_ai.retries import AsyncTenacityTransport, RetryConfig, wait_retry_after
from 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 None
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"]
# 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)
@cache
def _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),
}
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.

Terminal window
mkdir -p ~/projects
cd ~/projects
curl -fsSL https://andreaslang.dev/terraform-pr-agent/terraform-pr-agent-03.tar.gz | tar xz

To 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.

Your machineAWS accountConventionPydantic LogfireMistral APIpydantic-ai scriptDuckDBtraces.duckdbIAMSSM Parameter Storeterraform-pr-agent Lambda(python 3.13, arm64)Bedrockadded in this postchanged in this postunchanged from a prior postoptional in this postCloudWatchAudit storageOTLP ingestapi.mistral.aiMistral Large 3 / Devstralterraform-pr-agent rolebedrock-invoke policyfirehose role/models registry(String)/mistral-api-key(SecureString)pydantic-ai handlermodel factory(invoke-time)Logfire & OTel span exportersterraform-pr-agentapplication profileeu.anthropic.claude-haiku-4-5inference profileClaude Haiku 4.5foundation modelEMF dashboardTerraformPrAgent/ModelsKinesis Firehoseterraform-pr-agent-auditS3 audit bucket(Object Lock) attachedbuild modelConverse GetParameterGetParameterConverse (Bedrock entry)chat (Mistral entry)EMF metric logs OTLP / HTTPPutRecordexecution role GetParameter + KMSgrantsassumed bywriteread

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.

infra/models.tf
# 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).

infra/models.tf
# 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.

agent/handler.py
@cache
def _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).

agent/handler.py
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.

agent/handler.py
# 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.

All posts