In Java you call a third-party API with an API key: pasted into a config file,
injected as an env var, rotated by hand when someone leaves. Bedrock is called with
SigV4-signed IAM credentials — there is no Bedrock API key to paste anywhere. The caller's
identity is a role, and a role's permissions are a policy, so "this service may call this one
model" is a normal least-privilege statement instead of a secret to protect. This page adds one
IAM policy to the task role cloud-09 already created, writes a ~20-line class that satisfies
py-10's Model protocol over Bedrock instead of the stub, and measures what a real
call actually costs — which, unlike cloud-09's RDS or cloud-12's SageMaker endpoint, is
nothing while nobody is asking it anything.
One new resource: aws_iam_role_policy.task_bedrock, attached to
aws_iam_role.task — the task role cloud-09's step 1 already added to
modules/fargate-service/ for ECS Exec. It grants exactly two actions,
bedrock:InvokeModel and bedrock:InvokeModelWithResponseStream, on exactly
one Resource: the ARN of Anthropic's Claude 3 Haiku on Bedrock in
ap-south-1 — not bedrock:*, not Resource: "*". Bedrock bills
on-demand InvokeModel calls per 1,000 tokens, split input/output, and nothing else: no per-hour
charge for the model existing, no charge for a task that never calls it. The figures on this page
(Claude 3 Haiku on-demand: ~$0.25 / 1M input tokens, ~$1.25 / 1M output tokens) are from the
Bedrock pricing page as published, not re-verified live in this session — approx., verify on the
pricing page today before you rely on them. Three prompt sizes below turn that per-token price into
a per-1,000-request line; a fourth calls a second model this policy does not name, so you
can see the deny, not just read about it.
/readyz with 200. Note the ALB's
DNS name — step 4 curls it.modules/fargate-service/main.tf already
has aws_iam_role.task and the task definition's task_role_arn already
points at it. If you deployed straight from cloud-07 and skipped cloud-09's RDS root, do only its
step 1 (the module edit, not the database) before continuing here — the task needs an identity
before this page can grant it anything.exercises/py-10-fastapi-service/ passes its local tests and
cloud-07's task is running its image.1. Request model access — console, once per account per region. Bedrock console →
Model access → Modify model access → check Anthropic · Claude 3 Haiku → Submit.
The row usually reads Access granted within a minute; if the console asks for a
one-screen use-case form first (it does for some accounts, and sometimes not at all any more),
fill it — that is the whole gate. If Claude 3 Haiku shows as legacy in your region, pick
the current Haiku instead and use its ARN in step 2; the policy shape and the client do not
change, only the price and the model id. Nothing here is Terraform — model access is an
account-level grant, not a resource this page's state can create or destroy.
2. The IAM policy — modules/fargate-service/main.tf, next to cloud-09's
task_exec policy on the same role. A second variable so the module never hardcodes
a model id:
# modules/fargate-service/variables.tf — add variable "bedrock_model_arn" { type = string default = "" description = "Full ARN of the one Bedrock model this task may invoke; \"\" attaches no policy" } # modules/fargate-service/main.tf — add, only when the variable is set resource "aws_iam_role_policy" "task_bedrock" { count = var.bedrock_model_arn == "" ? 0 : 1 name = "bedrock-invoke-one-model" role = aws_iam_role.task.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"] Resource = var.bedrock_model_arn # exactly one model — never bedrock:* or Resource "*" }] }) } # 06-fargate/main.tf — the root that instantiates the module, add the two values module "fargate" { # ...existing arguments unchanged... bedrock_model_arn = "arn:aws:bedrock:ap-south-1::foundation-model/anthropic.claude-3-haiku-20240307-v1:0" environment = { ASKAPI_MODEL = "bedrock" BEDROCK_MODEL_ARN = "arn:aws:bedrock:ap-south-1::foundation-model/anthropic.claude-3-haiku-20240307-v1:0" } }
terraform apply in 06-fargate: 1 to add (the policy), the
task definition replaced (new env vars), the service updated to roll one new task. No
aws_iam_role is created here — cloud-09 already made the identity; this step only
widens what it may do.
3. The ~20-line client swap — a new file, src/askapi/bedrock_model.py, next to
model.py. py-10's tests build their own app around an unloaded
StubModel and never import this file, so nothing graded changes:
# src/askapi/bedrock_model.py — new file; satisfies model.Model's load/answer/stream shape import json import boto3 class BedrockModel: loaded = True # the call itself is the "load" — nothing to warm up first def __init__(self, model_arn: str, region: str = "ap-south-1") -> None: # boto3.client() with no key/secret args: it walks the default credential chain, # which inside an ECS task means the container credentials endpoint the task role # backs — the same temporary credentials aws_iam_role.task.arn grants, auto-refreshed. self._client = boto3.client("bedrock-runtime", region_name=region) self._model_arn = model_arn def load(self) -> None: pass def answer(self, prompt: str) -> str: body = {"anthropic_version": "bedrock-2023-05-31", "max_tokens": 512, "messages": [{"role": "user", "content": prompt}]} resp = self._client.invoke_model(modelId=self._model_arn, body=json.dumps(body)) return json.loads(resp["body"].read())["content"][0]["text"] async def stream(self, prompt: str): body = {"anthropic_version": "bedrock-2023-05-31", "max_tokens": 512, "messages": [{"role": "user", "content": prompt}]} resp = self._client.invoke_model_with_response_stream(modelId=self._model_arn, body=json.dumps(body)) for event in resp["body"]: # boto3's stream is a sync iterator — blocking, one Bedrock chunk = json.loads(event["chunk"]["bytes"]) # call stalls this task's whole event loop; if chunk.get("type") == "content_block_delta": # py-12 (Q4) is what fixes that, not this page. yield chunk["delta"]["text"]
And two lines in main.py, the only place StubModel is constructed:
# src/askapi/main.py — was: model = StubModel()
from askapi.bedrock_model import BedrockModel
model = (
BedrockModel(os.environ["BEDROCK_MODEL_ARN"])
if os.environ.get("ASKAPI_MODEL") == "bedrock"
else StubModel()
)
Rebuild and push cloud-05's image as a new tag,
lbv-api:v3, and point 06-fargate's container definition at it. Re-apply —
this is the same terraform apply as step 2; do both edits together and one new task
revision carries the policy, the env vars and the image tag at once.
4. Drive it — 100 requests at each size, then py-10's own test.
ALB=<your ALB DNS from cloud-06/07>
for i in $(seq 1 100); do
curl -s -X POST "http://$ALB/ask" -H 'content-type: application/json' \
-d '{"prompt": "Summarize what a Fargate task role is in one sentence."}' > /dev/null
done
# repeat with a ~500-token prompt, then a ~2,000-token one built from a few pasted paragraphs —
# the three sizes chip 1-3 show. Then prove the contract still holds, now served by Bedrock:
BASE_URL="http://$ALB" uv run pytest -q -m integration
# 3 passed
aws iam get-role-policy --role-name <prefix>-task --policy-name bedrock-invoke-one-model --query 'PolicyDocument.Statement[0].Resource'
prints exactly the Claude 3 Haiku ARN as a single string, never "*".curl's response body has an
answer that is not "hi there! you said: …" — StubModel's fixed prefix —
and changes wording between runs on the same prompt.3 passed line above:
the /ask contract (schema, request id, status codes) holds whether it is answered by
a ten-line stub or a real model behind it.aws ecs execute-command --cluster … --task … --container api --interactive --command "python3"
— then from inside the task, call boto3.client("bedrock-runtime").invoke_model(modelId="amazon.titan-text-express-v1", …):
an AccessDeniedException naming that ARN, because the policy names a different one.Nothing to destroy — an IAM policy statement costs nothing sitting unused, and Bedrock never
bills for a model that no request called. Unlike cloud-09's RDS instance (bills by the hour,
running or idle) or cloud-12's SageMaker endpoint (the box the plan explicitly warns bills while
idle), Bedrock's whole bill is the tokens in the requests you actually sent — stop sending them and
the line stops growing, with no destroy step required. If you want the task to lose Bedrock access
entirely, delete the task_bedrock policy block and re-apply; revoking model access in
the console is optional and does not affect the bill either way, since access without a call still
costs $0.00.
There is no teardown checkbox on this page, because there is nothing left running that bills. Press the button once the five Verify lines above are true.
This is self-attestation — the site
cannot see your AWS account, so pressing the button is you telling The Path the policy names one
ARN, /ask answered for real, and the second model was denied.
Resource to one model ARN is not paranoia: it is the same "keep 5" instinct
as cloud-05's lifecycle policy, applied to blast radius instead of storage — a bug that loops and
calls whatever model id a prompt-injected string names cannot, because the role was never granted
permission to call anything else. And because Bedrock bills per token instead of per hour, "no
teardown" is not a shortcut this page is taking; it is the actual shape of the bill.