Week 11 — Production: cost, latency, reliability, AWS¶
Why this week: once a demo works, interviewers ask three questions. What does one check cost? How long does it take? What happens when the model provider goes down? This week you answer all three with numbers from your own service, and you show you can run it on a second cloud.
By Friday you have: every check traced in Langfuse, with tokens, cost and time; cost and time stored with each check in Postgres; before-and-after numbers for prompt caching and for routing easy messages down a cheaper path; a fallback model, a time limit and a general-advice answer when Claude is unavailable; /health endpoints and one-line JSON logs; the API running on AWS; a one-page Google Cloud to AWS map; and docs/cost-report.md.
Main online course: none this week; the docs below are the course. If you want one long read, Reducing latency covers most of the week's ideas in one place.
Concepts¶
Trace and span. A trace is the record of one check from start to finish. It's made of spans, one per step: the check as a whole, each call_claude() inside it, each tool call. Each span records how long it took and, for model calls, the tokens and cost. Laid out on a timeline, a trace shows where the time and money went.
p50 and p95 latency. Latency is how long a check takes. The average hides the slow ones, so you report percentiles: p50 (the median: half of checks are faster) and p95 (95% are faster; the slowest one in twenty is slower). Users remember the p95.
Cost per check. Add up every model call one check makes: the agent's steps, the reviewer, the explanation. That total, not the price per token, is the number that tells you what a thousand users would cost.
Prompt caching. Each agent step sends the same long system prompt again. With caching, the API keeps the processed start of the prompt for five minutes; the next request that starts with exactly the same text reads it from the cache instead, for a tenth of the normal input price, and a little faster. It works only on an identical prefix, and only above a minimum length (512 tokens on current models); below that, it silently does nothing.
Routing. Not every message needs the full agent. "See you at dinner tonight?" has no link to expand and no number to look up. Routing sends messages like that down a cheaper path: one model call instead of six. It's only a saving if your eval says recall didn't drop.
Timeout, fallback and graceful degradation. A timeout is how long you'll wait for one call. A fallback is what you try next: here, a second model when the first is overloaded. Graceful degradation is what the user gets when everything fails: not an error page, but "we couldn't check this; here's what to do with any suspicious message". A scam checker that crashes at the busiest moment teaches people not to rely on it.
Health checks. A health check is an address the platform calls every few seconds to ask "are you alive?". Liveness (/health) answers instantly and only says the process is up. Readiness (/health/ready) also checks the database. Neither calls Claude, because health checks run constantly and would cost money.
Structured logs. A log line like check 42 took 3100 ms is easy to read and hard to search. One JSON object per line, such as {"message": "check done", "check_id": 42, "duration_ms": 3100}, lets Cloud Logging and AWS CloudWatch filter and add up fields for you. Never log the message itself; log its id and the numbers.
The same job on another cloud. Every cloud has the same building blocks under different names: somewhere to run a container, somewhere to keep images, somewhere for secrets, a permissions system, logs. Once you've deployed on one, the second is mostly a matter of finding the names. Friday's map is that list.
Monday — See every check¶
Learn (75 min):
- Langfuse: Get started with tracing (25 min)
- Langfuse: Token and cost tracking (20 min)
- Langfuse pricing, the free Hobby plan (5 min)
- Cloud Run: Logging, the "structured logs" part (15 min)
Build:
-
Create a free account on Langfuse Cloud. The Hobby plan has no card requirement and enough monthly usage for this course. Langfuse offers several regions, including EU, US and Japan (
https://jp.cloud.langfuse.com); Japan is the closest to Singapore. Create a project, then API keys, and add three lines to.env:LANGFUSE_PUBLIC_KEY=pk-lf-... LANGFUSE_SECRET_KEY=sk-lf-... LANGFUSE_BASE_URL=https://jp.cloud.langfuse.comTraces leave Singapore and sit on Langfuse's servers. That's acceptable only because of what you'll send: stripped text and numbers, never the raw message.
-
Add the SDK:
-
Replace
src/scam_checker/llm.py. The name, arguments and return type ofcall_claude()still don't change;ClaudeResultgains three fields with defaults, so old code that builds or reads it still works. The body is now split:call_claude()decides what to try,_call_once()makes one call."""Call Claude through the Anthropic API, using your API key. Weeks 1-8 called the Claude Code CLI here. Week 9 swapped the body of call_claude() for the Anthropic SDK; its name, arguments and ClaudeResult are unchanged, so nothing that calls it needs to change. """ from __future__ import annotations import json import logging import time from collections.abc import Iterator from contextlib import contextmanager from contextvars import ContextVar from dataclasses import dataclass from functools import cache from typing import Any import anthropic from langfuse import get_client logger = logging.getLogger(__name__) # The short names used since week 1, mapped to API model IDs. # Check the models overview page when you start: IDs change as models are released. MODELS = { "haiku": "claude-haiku-5-5", "sonnet": "claude-sonnet-5-5", "opus": "claude-opus-5-5", } # US dollars per million tokens: (input, output, cache write, cache read). # From the pricing page; cache writes are the 5-minute kind. # Haiku 5.5's price is for prompts up to 100,000 tokens; yours are far shorter. PRICES = { "claude-haiku-5-5": (0.10, 0.50, 0.125, 0.01), "claude-sonnet-5-5": (2.00, 10.00, 2.50, 0.10), "claude-opus-5-5": (4.00, 20.00, 5.00, 0.20), } # If a model is overloaded or down, try this one once instead. FALLBACK_MODEL = {"claude-haiku-5-5": "claude-sonnet-5-5"} CACHE_SYSTEM_PROMPT = True # set False to measure the "before" numbers MAX_TOKENS = 4096 # room for the model's thinking plus a short JSON answer class ClaudeError(RuntimeError): """The API call failed, timed out, or gave no usable answer.""" def __init__(self, message: str, *, retryable: bool = False) -> None: super().__init__(message) self.retryable = retryable # worth trying again, or another model @dataclass class ClaudeResult: text: str data: dict[str, Any] | None # parsed JSON, only when you pass a schema input_tokens: int output_tokens: int cost_usd: float # estimated from PRICES; your Console bill is the real number duration_ms: int model: str = "" cache_read_tokens: int = 0 cache_write_tokens: int = 0 # A list that collects every call made while track_usage() is active. _calls: ContextVar[list[ClaudeResult] | None] = ContextVar("calls", default=None) @contextmanager def track_usage() -> Iterator[list[ClaudeResult]]: """Collect every call_claude() result inside the with-block, e.g. one check.""" calls: list[ClaudeResult] = [] token = _calls.set(calls) try: yield calls finally: _calls.reset(token) @cache def _client() -> anthropic.Anthropic: # Reads ANTHROPIC_API_KEY from the environment. max_retries=0 because # check_message() already retries; two retry layers multiply the waiting. return anthropic.Anthropic(max_retries=0) def estimate_cost( model_id: str, input_tokens: int, output_tokens: int, cache_write: int = 0, cache_read: int = 0, ) -> float: p_in, p_out, p_write, p_read = PRICES.get(model_id, (0.0, 0.0, 0.0, 0.0)) dollars = ( input_tokens * p_in + output_tokens * p_out + cache_write * p_write + cache_read * p_read ) return dollars / 1_000_000 def call_claude( prompt: str, *, system: str | None = None, schema: dict[str, Any] | None = None, model: str = "haiku", timeout: float = 120, ) -> ClaudeResult: """Send one prompt to Claude and return the reply. If the model is overloaded or unreachable, try its fallback model once. """ model_id = MODELS.get(model, model) # a full model ID also works try: result = _call_once(model_id, prompt, system, schema, timeout) except ClaudeError as exc: backup = FALLBACK_MODEL.get(model_id) if not exc.retryable or backup is None: raise logger.warning("%s failed (%s); trying %s", model_id, exc, backup) result = _call_once(backup, prompt, system, schema, timeout) calls = _calls.get() if calls is not None: calls.append(result) return result def _call_once( model_id: str, prompt: str, system: str | None, schema: dict[str, Any] | None, timeout: float, ) -> ClaudeResult: output_config: Any = anthropic.omit if schema: # transform_schema adds what the API insists on, such as # "additionalProperties": false, to a Pydantic-made schema. output_config = { "format": { "type": "json_schema", "schema": anthropic.transform_schema(schema), } } # Mark the system prompt for caching. It is identical on every call, so # later calls within 5 minutes read it from the cache at a tenth of the price. system_blocks: Any = system or anthropic.omit if system and CACHE_SYSTEM_PROMPT: system_blocks = [ {"type": "text", "text": system, "cache_control": {"type": "ephemeral"}} ] langfuse = get_client() start = time.perf_counter() with langfuse.start_as_current_observation( as_type="generation", name="call_claude", model=model_id ) as generation: try: response = _client().messages.create( model=model_id, max_tokens=MAX_TOKENS, system=system_blocks, messages=[{"role": "user", "content": prompt}], output_config=output_config, timeout=timeout, ) except anthropic.APITimeoutError as exc: # check before APIConnectionError raise ClaudeError( f"no reply after {timeout} seconds", retryable=True ) from exc except anthropic.APIConnectionError as exc: raise ClaudeError( f"could not reach the API: {exc}", retryable=True ) from exc except anthropic.APIStatusError as exc: # 429 rate limit, 500s and 529 overloaded are worth another try; # 400 (bad request, or your own spend limit) and 401 (bad key) are not. retryable = exc.status_code == 429 or exc.status_code >= 500 raise ClaudeError( f"API error {exc.status_code}: {exc.message}", retryable=retryable ) from exc duration_ms = int((time.perf_counter() - start) * 1000) usage = response.usage cache_read = usage.cache_read_input_tokens or 0 cache_write = usage.cache_creation_input_tokens or 0 cost = estimate_cost( model_id, usage.input_tokens, usage.output_tokens, cache_write, cache_read ) # Token counts and cost only: no prompt or reply text, so no personal data. generation.update( usage_details={ "input": usage.input_tokens, "output": usage.output_tokens, "cache_read_input_tokens": cache_read, }, cost_details={"total": cost}, ) if response.stop_reason == "refusal": raise ClaudeError("the model declined to answer") if response.stop_reason == "max_tokens": raise ClaudeError(f"the reply was cut off at {MAX_TOKENS} tokens") # The reply can start with thinking blocks; the answer is in the text blocks. text = "".join(block.text for block in response.content if block.type == "text") data = None if schema: try: data = json.loads(text) except json.JSONDecodeError as exc: raise ClaudeError(f"reply was not valid JSON: {text[:200]}") from exc return ClaudeResult( text=text, data=data, input_tokens=usage.input_tokens, output_tokens=usage.output_tokens, cost_usd=cost, duration_ms=duration_ms, model=model_id, cache_read_tokens=cache_read, cache_write_tokens=cache_write, ) def stream_claude( prompt: str, *, system: str | None = None, model: str = "haiku", timeout: float = 60, ) -> Iterator[str]: """Yield the reply in small pieces of text, as the model writes it.""" try: with _client().messages.stream( model=MODELS.get(model, model), max_tokens=MAX_TOKENS, system=system or anthropic.omit, messages=[{"role": "user", "content": prompt}], timeout=timeout, ) as stream: yield from stream.text_stream except anthropic.APIError as exc: # covers timeouts, network and status errors raise ClaudeError(f"streaming failed: {exc}") from exc if __name__ == "__main__": reply = call_claude("Say hello in five words.") print(reply.text) print(f"{reply.input_tokens} in, {reply.output_tokens} out, {reply.duration_ms} ms") print(f"about US${reply.cost_usd:.6f}")New today:
track_usage()collects every call made during one check, wherever in your agent it happens, without changing the agent's code. A context variable (ContextVar) is a value that belongs to the current piece of work, so two checks running at once don't mix up their lists.PRICESnow includes cache prices, and each call is recorded in Langfuse as a "generation" with token counts and cost but no text.Two parts are for later in the week. For today's "before" numbers, change these two lines:
You'll set them back on Tuesday and Wednesday. (The
dict[str, str]tells mypy what an empty dictionary will hold.) -
One check, timed, costed and traced, in a new
src/scam_checker/service.py. Both endpoints and tomorrow's measuring script use it:"""Run one check with a time limit, cost tracking and a safe fallback answer.""" import contextvars import logging import time from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from langfuse import get_client, observe from scam_checker.agent import AgentRun from scam_checker.checker import FALLBACK from scam_checker.llm import ClaudeError, ClaudeResult, track_usage from scam_checker.pii import strip_pii logger = logging.getLogger(__name__) DEADLINE_SECONDS = 45.0 # after this, the user gets general advice instead of a wait _pool = ThreadPoolExecutor(max_workers=8) Agent = Callable[[str], AgentRun] @dataclass class Outcome: run: AgentRun calls: list[ClaudeResult] = field(default_factory=list) duration_ms: int = 0 degraded: bool = False # True when we gave the general-advice fallback @property def cost_usd(self) -> float: return sum(call.cost_usd for call in self.calls) @property def input_tokens(self) -> int: return sum( call.input_tokens + call.cache_read_tokens + call.cache_write_tokens for call in self.calls ) @property def output_tokens(self) -> int: return sum(call.output_tokens for call in self.calls) @observe(name="check", capture_input=False, capture_output=False) def run_check(agent: Agent, text: str) -> Outcome: start = time.perf_counter() degraded = False with track_usage() as calls: # Run the agent in a worker thread so we can stop waiting at the deadline. # copy_context() carries track_usage() and the trace into that thread. future = _pool.submit(contextvars.copy_context().run, agent, text) try: run = future.result(timeout=DEADLINE_SECONDS) except (ClaudeError, TimeoutError) as exc: logger.warning("check degraded: %s", type(exc).__name__) run, degraded = AgentRun(result=FALLBACK, steps=[]), True duration_ms = int((time.perf_counter() - start) * 1000) outcome = Outcome( run=run, calls=list(calls), duration_ms=duration_ms, degraded=degraded ) # What the trace shows: the message without personal data, and the numbers. get_client().update_current_span( input=strip_pii(text), output=run.result.verdict, metadata={ "steps": len(run.steps), "cost_usd": outcome.cost_usd, "degraded": degraded, }, ) return outcome@observemakes the whole check one trace, with everycall_claude()inside it nested underneath.capture_input=Falsestops Langfuse recording the raw message;update_current_spanthen sends the stripped one. The worker thread andDEADLINE_SECONDSare Wednesday's subject; today it's enough thatrun_checkreturns anOutcomewith the calls and their cost. -
JSON logs,
src/scam_checker/logs.py:"""One JSON object per log line, so Cloud Logging and CloudWatch can search it.""" import json import logging import sys from datetime import UTC, datetime class JsonFormatter(logging.Formatter): def format(self, record: logging.LogRecord) -> str: entry = { "time": datetime.fromtimestamp(record.created, UTC).isoformat(), "severity": record.levelname, # the field name Cloud Logging looks for "logger": record.name, "message": record.getMessage(), } entry.update( getattr(record, "fields", {}) ) # logger.info(..., extra={"fields": {...}}) if record.exc_info: entry["error"] = self.formatException(record.exc_info) return json.dumps(entry) def configure_logging(level: int = logging.INFO) -> None: handler = logging.StreamHandler(sys.stdout) handler.setFormatter(JsonFormatter()) logging.basicConfig(level=level, handlers=[handler], force=True) for name in ( "uvicorn", "uvicorn.error", "uvicorn.access", ): # the web server's own logs logging.getLogger(name).handlers = [handler] logging.getLogger(name).propagate = False for name in ("httpx2", "presidio-analyzer"): # chatty libraries: warnings only logging.getLogger(name).setLevel(logging.WARNING)The
severityfield is the one Cloud Logging reads to colour a line as info, warning or error. The last loop quietens two chatty libraries: Presidio logs every recogniser it loads. -
Store cost and time with each check. Add
Floatandfalseto thesqlalchemyimports insrc/scam_checker/api/db.py, and four columns at the end ofCheckRecord:# Added in week 11. server_default fills in the rows that already exist. input_tokens: Mapped[int] = mapped_column(server_default="0") output_tokens: Mapped[int] = mapped_column(server_default="0") cost_usd: Mapped[float] = mapped_column(Float, server_default="0") degraded: Mapped[bool] = mapped_column(server_default=false())Then a second migration.
server_defaultis what lets Alembic add a required column to a table that already has rows: the old rows get 0 and false.uv run --env-file .env alembic revision --autogenerate -m "add cost columns" uv run ruff check --fix alembic && uv run ruff format alembic uv run --env-file .env alembic upgrade head DATABASE_URL='postgresql://...your Neon string...' uv run alembic upgrade headThe last line updates Neon by hand this once. From now on, your deploy workflow applies new migrations itself, before each deploy.
-
Add
degradedto both response models insrc/scam_checker/api/schemas.py. InCheckResponse:And the same
degraded: bool = Falseline at the end ofCheckPanel. -
The new
src/scam_checker/api/main.py. The endpoints now callrun_check()instead of the agent directly,save_check()stores the numbers and writes one JSON log line, and two health endpoints appear (tomorrow and Wednesday explain the rest):"""The checker as a web API. Run locally with: uv run fastapi dev src/scam_checker/api/main.py """ import logging import os from collections.abc import Callable, Iterator from typing import Annotated from fastapi import Depends, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.sse import EventSourceResponse, ServerSentEvent from sqlalchemy import text as sql from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from scam_checker.agent import run_agent from scam_checker.api.db import CheckRecord, get_session from scam_checker.api.schemas import CheckPanel, CheckRequest, CheckResponse from scam_checker.llm import ClaudeError, stream_claude from scam_checker.logs import configure_logging from scam_checker.models import CheckResult from scam_checker.pii import strip_pii from scam_checker.retrieval import search_patterns from scam_checker.service import Agent, Outcome, run_check configure_logging() logger = logging.getLogger(__name__) app = FastAPI(title="Scam checker API", version="0.3.0") # Which web pages may call this API from a browser. Set ALLOWED_ORIGINS on # Cloud Run to your front end's address, e.g. https://<you>.github.io app.add_middleware( CORSMiddleware, allow_origins=os.environ.get("ALLOWED_ORIGINS", "http://localhost:5173").split(","), allow_methods=["GET", "POST"], allow_headers=["Content-Type"], ) Explainer = Callable[..., Iterator[str]] EXPLAIN_SYSTEM = """You explain a scam checker's verdict to an ordinary person in Singapore. Write two or three short sentences in plain words: why the message looks the way it does. Do not repeat the advice. Do not include phone numbers, links or names from the message.""" def get_agent() -> Agent: """The agent to use. A function, so tests can swap in a fake one.""" return run_agent def get_explainer() -> Explainer: """The streaming explanation. Also swappable in tests.""" return stream_claude def save_check(session: Session, text: str, outcome: Outcome) -> CheckRecord: result = outcome.run.result record = CheckRecord( message=strip_pii(text), verdict=result.verdict, red_flags=[strip_pii(flag) for flag in result.red_flags], advice=strip_pii(result.advice), steps=len(outcome.run.steps), duration_ms=outcome.duration_ms, input_tokens=outcome.input_tokens, output_tokens=outcome.output_tokens, cost_usd=outcome.cost_usd, degraded=outcome.degraded, ) session.add(record) session.commit() # One JSON line per check: numbers only, never the message itself. fields = { "check_id": record.id, "verdict": record.verdict, "steps": record.steps, "duration_ms": record.duration_ms, "cost_usd": round(record.cost_usd, 6), "degraded": record.degraded, } logger.info("check done", extra={"fields": fields}) return record def plain_label(result: CheckResult) -> str: """A confidence label a non-technical reader understands.""" if result.verdict == "scam": return "Very likely a scam" if len(result.red_flags) >= 2 else "Likely a scam" if result.verdict == "not_scam": return "Probably genuine" return "Hard to tell" def top_pattern(text: str) -> str | None: """The closest known scam pattern from week 4, if any.""" matches = search_patterns(text, k=1) return matches[0].name if matches else None # Match.name from week 4 @app.get("/health") def health() -> dict[str, str]: """Liveness: the process is up and answering. Cheap, so it can be called often.""" return {"status": "ok"} @app.get("/health/ready") def ready(session: Annotated[Session, Depends(get_session)]) -> dict[str, str]: """Readiness: the database answers too. Never calls the model, which costs money.""" try: session.execute(sql("SELECT 1")) except SQLAlchemyError as exc: logger.error("database not ready: %s", type(exc).__name__) raise HTTPException(503, "database unavailable") return {"status": "ready"} @app.post("/check", response_model=CheckResponse) def check( request: CheckRequest, session: Annotated[Session, Depends(get_session)], agent: Annotated[Agent, Depends(get_agent)], ) -> CheckResponse: outcome = run_check(agent, request.text) record = save_check(session, request.text, outcome) return CheckResponse( check_id=record.id, degraded=outcome.degraded, **outcome.run.result.model_dump() ) @app.post("/check/stream", response_class=EventSourceResponse) def check_stream( request: CheckRequest, session: Annotated[Session, Depends(get_session)], agent: Annotated[Agent, Depends(get_agent)], explain: Annotated[Explainer, Depends(get_explainer)], ) -> Iterator[ServerSentEvent]: """Send the answer in stages: status, result, explanation tokens, done.""" yield ServerSentEvent(event="status", data="Checking the message...") outcome = run_check(agent, request.text) result = outcome.run.result pattern = top_pattern(request.text) if result.verdict != "not_scam" else None panel = CheckPanel( **result.model_dump(), label=plain_label(result), pattern=pattern, degraded=outcome.degraded, ) yield ServerSentEvent(event="result", data=panel) if not outcome.degraded: # if the model is down, don't wait on it a second time prompt = ( f"Message:\n{request.text}\n\nVerdict: {result.verdict}\n" f"Warning signs: {'; '.join(result.red_flags) or 'none'}" ) try: for piece in explain(prompt, system=EXPLAIN_SYSTEM): yield ServerSentEvent(event="token", data=piece) except ClaudeError as exc: # the verdict has already arrived; carry on logger.warning("explanation failed: %s", exc) record = save_check(session, request.text, outcome) yield ServerSentEvent(event="done", data={"check_id": record.id})The
503from week 9 is gone: when Claude fails,run_check()now returns the general-advice answer withdegradedset, and the user always gets something useful. -
Update the two tests that expected the old behaviour. In
tests/test_api.py, replacetest_llm_failure_gives_503with:def test_llm_failure_gives_general_advice(client): app.dependency_overrides[get_agent] = lambda: broken_agent response = client.post("/check", json={"text": "hello"}) assert response.status_code == 200 body = response.json() assert body["degraded"] is True assert body["verdict"] == "unsure"In
tests/test_stream.py, replacetest_stream_reports_a_busy_checkerwith:def test_stream_gives_general_advice_when_the_model_is_down(client): app.dependency_overrides[get_agent] = lambda: broken_agent events = read_events(client.post("/check/stream", json={"text": "hi"}).text) assert dict(events)["result"]["degraded"] is True assert "token" not in [name for name, _ in events] assert events[-1][0] == "done"And add two tests for the new pieces to
tests/test_llm.py:def test_track_usage_collects_every_call(fake_api): fake_api(lambda request: httpx2.Response(200, json=api_message("hello"))) with llm.track_usage() as calls: llm.call_claude("one") llm.call_claude("two") assert len(calls) == 2 assert sum(c.input_tokens for c in calls) == 200 def test_cache_reads_are_priced_lower(): full = llm.estimate_cost("claude-haiku-5-5", 1000, 0) cached = llm.estimate_cost("claude-haiku-5-5", 0, 0, cache_read=1000) assert cached == pytest.approx(full / 10) -
Run the API with your keys and check three messages through
/docs:In Langfuse, open Tracing: each check is a trace called
check, with the stripped message as input and acall_claudegeneration for each model call, carrying tokens and cost. In the terminal, each check ends with a JSON line like{"severity": "INFO", "message": "check done", "check_id": 12, "cost_usd": 0.000412, ...}. -
Your own numbers, straight from Postgres. Save this as
scripts/report.sql; you'll run it against Neon on Friday:SELECT count(*) AS checks, round(avg(cost_usd)::numeric, 6) AS avg_cost_usd, round(sum(cost_usd)::numeric, 4) AS total_cost_usd, percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms) AS p50_ms, percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95_ms, sum(CASE WHEN degraded THEN 1 ELSE 0 END) AS degraded, sum(CASE WHEN steps = 0 THEN 1 ELSE 0 END) AS quick_path FROM checks WHERE created_at > now() - interval '7 days';
Done when: a check shows up in Langfuse as one trace with its model calls nested inside, with no raw message anywhere in it; the checks table has cost and token columns filled in for new rows; and pytest passes.
Stuck?
Authentication error: Langfuse client initialized without public_key: the Langfuse lines aren't in the environment. Run with--env-file .env. In tests this warning is expected and harmless: tracing is simply off.Context error: No active span in current context: the same cause. With no keys,@observecreates no span, so there's nothing to update.- No traces appear: Langfuse sends them in the background a few seconds later. Wait, refresh, and check
LANGFUSE_BASE_URLmatches the region your project is in. - Every call costs 0: the model ID isn't in
PRICES. Printreply.modeland add it. - The migration fails with
column "input_tokens" contains null values:server_defaultis missing from the new column. test_structured_reply_is_parsedfails: you setCACHE_SYSTEM_PROMPT = Truetoo early. Leave itFalseuntil Tuesday.
Tuesday — Cheaper and faster, with numbers¶
Learn (60 min):
- Prompt caching: "How prompt caching works", the pricing table and "Cache limitations" (35 min)
- Reducing latency (25 min)
Build:
-
A script that measures cost, time and quality on your labelled set,
scripts/cost_latency.py. It assumesdata/labelled100.csvhastextandis_scamcolumns; adjust the two names if your week 5 file differs."""Cost, speed and quality of the checker on the labelled set, for the cost report. Usage: uv run --env-file .env python scripts/cost_latency.py MODE [N] MODE is one of: nocache (the "before"), cached, routed (cached plus routing). """ import statistics import sys import pandas as pd from scam_checker import llm from scam_checker.agent import run_agent from scam_checker.metrics import precision_recall from scam_checker.routing import routed_agent from scam_checker.service import run_check mode = sys.argv[1] limit = int(sys.argv[2]) if len(sys.argv) > 2 else 100 llm.CACHE_SYSTEM_PROMPT = mode != "nocache" agent = routed_agent if mode == "routed" else run_agent df = pd.read_csv("data/labelled100.csv").head(limit) outcomes = [run_check(agent, text) for text in df["text"]] flagged = [o.run.result.verdict == "scam" for o in outcomes] precision, recall = precision_recall(df["is_scam"].tolist(), flagged) costs = [o.cost_usd for o in outcomes] seconds = sorted(o.duration_ms / 1000 for o in outcomes) cache_reads = sum(c.cache_read_tokens for o in outcomes for c in o.calls) all_input = sum(o.input_tokens for o in outcomes) print(f"{mode}: {len(df)} messages") print(f"precision {precision:.2f} recall {recall:.2f}") print( f"cost per check: mean US${statistics.mean(costs):.5f}, total US${sum(costs):.4f}" ) print( f"latency: p50 {seconds[len(seconds) // 2]:.1f}s, p95 {seconds[int(len(seconds) * 0.95)]:.1f}s" ) print(f"input tokens read from cache: {cache_reads / max(all_input, 1):.0%}") print(f"quick path used: {sum(len(o.run.steps) == 0 for o in outcomes)} of {len(df)}") print(f"degraded answers: {sum(o.degraded for o in outcomes)}") -
The "before" numbers. With
CACHE_SYSTEM_PROMPT = False, run the full agent on all 100 messages. It takes a while and costs a few US cents; note the total from the output.Copy all the output into a new file,
docs/cost-report.md, under a heading "Before". Then look at one trace in Langfuse and write down which step takes longest. -
Caching. Set
CACHE_SYSTEM_PROMPT = Trueinllm.py. Look at what it switches on in_call_once:# Mark the system prompt for caching. It is identical on every call, so # later calls within 5 minutes read it from the cache at a tenth of the price. system_blocks: Any = system or anthropic.omit if system and CACHE_SYSTEM_PROMPT: system_blocks = [ {"type": "text", "text": system, "cache_control": {"type": "ephemeral"}} ]Update the system-prompt assertion in
test_structured_reply_is_parsed, since the system prompt is now sent as a block:assert sent["system"][0]["text"] == "Be careful" assert sent["system"][0]["cache_control"] == {"type": "ephemeral"}Then measure again:
The "input tokens read from cache" line tells you whether it worked. If it says 0%, your system prompt is under 512 tokens, or something in it changes between calls, such as a timestamp. Caching helps most when the agent's instructions and tool descriptions are long and identical on every step.
-
Routing.
src/scam_checker/routing.py:"""Send easy messages down a cheaper path: one model call instead of the agent.""" import re from scam_checker.agent import AgentRun, run_agent from scam_checker.checker import check_message LINK = re.compile( r"https?://|www\.|\b[\w-]+\.(?:com|sg|net|org|ly|xyz|top)\b", re.IGNORECASE ) PHONE = re.compile(r"(?:\+65[\s-]?)?\b[3689]\d{3}[\s-]?\d{4}\b") MONEY = re.compile(r"[$£]\s?\d|\b\d+\s?(?:sgd|usd|dollars)\b", re.IGNORECASE) def is_easy(text: str) -> bool: """Nothing for the agent's tools to look up: no links, phone numbers or money.""" return not (LINK.search(text) or PHONE.search(text) or MONEY.search(text)) def routed_agent(text: str) -> AgentRun: if is_easy(text): return AgentRun(result=check_message(text), steps=[]) # 0 steps = quick path return run_agent(text)In
main.py, swap the agent the API uses: replacefrom scam_checker.agent import run_agentwithfrom scam_checker.routing import routed_agent, and makeget_agent()returnrouted_agent. Then: -
Your turn: write three tests for
is_easyintests/test_routing.py. A friendly message with no link, number or money is easy; a message with a link isn't; a message with a Singapore mobile number isn't. Then add one test for a case you think the rules get wrong.Stuck? Three tests
from scam_checker.routing import is_easy def test_friendly_message_is_easy(): assert is_easy("See you at dinner tonight?") def test_link_is_not_easy(): assert not is_easy("Your parcel is held, pay at singpost-redelivery.com") def test_singapore_mobile_number_is_not_easy(): assert not is_easy("Hi mum, this is my new number 8123 4567") -
Decide, with the numbers in front of you. Add a table to
docs/cost-report.md: one row each for nocache, cached and routed, with precision, recall, mean cost per check, p50 and p95. Keep routing only if recall didn't drop. If it fell, look at which scams took the quick path and tightenis_easy; a cheaper checker that misses scams is a worse product.
Done when: docs/cost-report.md has the three rows, measured on the same messages, and a sentence on what you kept and why.
Stuck?
- The script stops with
KeyError: 'is_scam': your labelled file uses another column name. Changedf["is_scam"]anddf["text"]to match. - The cache rate is 0% in every run: check
CACHE_SYSTEM_PROMPTisTrueand that your system prompt is long enough. A rough rule: 512 tokens is about 350 English words. - Costs went up with caching: the first call writes the cache, at 1.25 times the input price. You save only when later calls read it within five minutes. On a single check with one call, caching can't help.
- p95 is much worse than p50: look at the slowest trace in Langfuse. It's usually one tool (a slow website lookup) or a retry.
Wednesday — When Claude is down¶
Learn (45 min):
- Claude API errors, again, for which errors are worth retrying (10 min)
- LiteLLM: Fallbacks (15 min). Read it to see how a gateway switches providers. You won't use it; see the note below.
- Cloud Run: Configure health checks (20 min)
Why a fallback model, not a fallback provider
The plan suggests LiteLLM with a second provider. That protects against a whole provider going down, but needs a second account, a second key, a second bill, and structured-output behaviour that differs between providers, all of which your eval would have to cover. This week uses a simpler layer that covers the common failures: if Haiku is overloaded or times out, try Sonnet once, and if that fails too, give general advice. Mention in interviews that a second provider is the next step, and why you didn't take it yet.
Build:
-
Switch on the fallback model: set
FALLBACK_MODELback to{"claude-haiku-5-5": "claude-sonnet-5-5"}. Readcall_claude()and theexceptblocks in_call_once()again. Errors now carryretryable: a rate limit (429), an overload (529), a server error or a timeout is worth one try on another model; a bad request or bad key isn't, because it would fail the same way. -
Tests for both cases, in
tests/test_llm.py:def test_overloaded_model_falls_back_to_sonnet(fake_api): models = [] def handler(request): model = json.loads(request.content)["model"] models.append(model) if model == "claude-haiku-5-5": error = { "type": "error", "error": {"type": "overloaded_error", "message": "busy"}, } return httpx2.Response(529, json=error) return httpx2.Response(200, json=api_message("hello")) fake_api(handler) reply = llm.call_claude("hi") assert models == ["claude-haiku-5-5", "claude-sonnet-5-5"] assert reply.model == "claude-sonnet-5-5" def test_bad_request_does_not_fall_back(fake_api): error = { "type": "error", "error": {"type": "invalid_request_error", "message": "no"}, } fake_api(lambda request: httpx2.Response(400, json=error)) with pytest.raises(llm.ClaudeError) as caught: llm.call_claude("hi") assert caught.value.retryable is False -
The time limit. Read
run_check()inservice.pyagain. The agent runs in a worker thread, andfuture.result(timeout=DEADLINE_SECONDS)waits at most 45 seconds. After that the user gets the general-advice answer, markeddegraded. Without a limit, a slow website lookup plus retries could keep someone waiting for minutes.copy_context().runmatters: it carriestrack_usage()and the Langfuse trace into the worker thread. (The worker keeps running in the background until its own call timeouts end; that's the price of this simple approach.) -
Your turn: test the time limit without waiting 45 seconds. In
tests/test_api.py(addimport timeandfrom scam_checker import serviceat the top), shrinkDEADLINE_SECONDSto 0.1 withmonkeypatch, swap in an agent that sleeps for half a second, and check the response saysdegraded.Stuck? One version
def test_slow_agent_hits_the_deadline(client, monkeypatch): monkeypatch.setattr(service, "DEADLINE_SECONDS", 0.1) def slow_agent(text): time.sleep(0.5) return fake_agent(text) app.dependency_overrides[get_agent] = lambda: slow_agent assert client.post("/check", json={"text": "hello"}).json()["degraded"] is Truemonkeypatchputs the old value back after the test, so other tests still get 45 seconds. -
Health checks.
main.pyalready has/healthand/health/ready. Test them, including a database that isn't there:def test_health(client): assert client.get("/health").json() == {"status": "ok"} assert client.get("/health/ready").json() == {"status": "ready"} def test_ready_fails_without_a_database(client): broken = create_engine("sqlite:////no/such/folder/checks.db") def broken_session(): with Session(broken) as session: yield session app.dependency_overrides[get_session] = broken_session assert client.get("/health/ready").status_code == 503 -
Show users when they're getting the fallback. In
frontend/src/WhyPanel.jsx, under the heading: -
Break it on purpose, locally. Put a wrong value in
.env(ANTHROPIC_API_KEY=sk-ant-wrong), run the API, and check a message. You should get the general advice,degraded: true, warning lines in the JSON log, and no crash. Put the real key back. -
Ship it to Cloud Run. Langfuse's secret key goes in Secret Manager like the others; the public key and address are ordinary settings:
read -rs KEY && printf '%s' "$KEY" | gcloud secrets create langfuse-secret-key --data-file=- && unset KEY gcloud secrets add-iam-policy-binding langfuse-secret-key \ --member="serviceAccount:${PROJECT_NUMBER}-compute@developer.gserviceaccount.com" \ --role=roles/secretmanager.secretAccessor gcloud run services update scam-checker-api --region asia-southeast1 \ --update-env-vars LANGFUSE_PUBLIC_KEY=pk-lf-...,LANGFUSE_BASE_URL=https://jp.cloud.langfuse.com \ --update-secrets LANGFUSE_SECRET_KEY=langfuse-secret-key:latest \ --startup-probe httpGet.path=/health,httpGet.port=8080The startup probe makes Cloud Run send traffic only once
/healthanswers. Then add the new secret to thesecrets:block in.github/workflows/deploy.yml. The action replaces the whole list on every deploy rather than adding to it, so a secret missing from the file disappears from the service:secrets: |- ANTHROPIC_API_KEY=anthropic-api-key:latest DATABASE_URL=database-url:latest LANGFUSE_SECRET_KEY=langfuse-secret-key:latestIf deploy on merge slipped from week 9, set it up now, following week 9's Friday.
Done when: the fallback and time-limit tests pass, a wrong key gives general advice instead of an error, and the live service has the startup probe and Langfuse keys.
Stuck?
- Every request falls back to Sonnet: Haiku is failing for a reason that isn't overload. Read the warning line; a
400or401shouldn't fall back at all, so checkretryable. - The deadline test passes, but slowly: the worker thread finishes its 0.5-second sleep in the background. That's expected.
/health/readyreturns 503 on Cloud Run: the service can't reach Neon. Check thedatabase-urlsecret, and remember Neon's free compute sleeps when idle and takes a moment to wake.- Cloud Run logs show text, not JSON: some lines come from Cloud Run itself, not your app. Your
check donelines should show as structured entries with fields you can click. - Traces are missing on Cloud Run but work locally: Cloud Run slows a container's CPU between requests, which can delay the background upload. Traces usually arrive with the next request; if they never do, check the three Langfuse settings on the service.
Thursday — The same API on AWS¶
Learn (75 min):
- AWS App Runner availability change (5 min). The plan named App Runner; it closed to new customers on 30 April 2026, and AWS now points new users to ECS Express Mode.
- Create your first Express Mode service using the AWS CLI (25 min). ECS on Fargate, with the load balancer, scaling and address set up for you.
- Pass Secrets Manager secrets through environment variables (15 min)
- Login for local development using console credentials (10 min)
- AWS Free Tier (10 min): new accounts get credits for the first six months
This one costs money while it runs
Cloud Run scales to zero. An ECS Express Mode service doesn't: it keeps at least one task running, plus an Application Load Balancer billed by the hour, even when nobody visits. New-account credits should cover a few days, but check the Fargate and Elastic Load Balancing pricing pages, set the budget in step 1 first, and delete the service once you have your screenshots and numbers (step 9). Keep Cloud Run as your live version.
Build:
-
Account basics, if you haven't done them on the cloud side track: create an AWS account, turn on multi-factor authentication for the root user, create an everyday admin user for yourself (AWS recommends IAM Identity Center; an IAM user with MFA also works for a solo project), and create a monthly cost budget with email alerts, say US$10. From here on, sign in as the admin user, never as root.
-
The AWS command line, signed in through your browser:
brew install awscli aws --version # aws login needs 2.32.0 or later aws login aws configure set region ap-southeast-1 ACCOUNT=$(aws sts get-caller-identity --query Account --output text) REGION=ap-southeast-1ap-southeast-1is Singapore, the same AWS region your Neon database is in. -
Push your image to ECR (Elastic Container Registry), AWS's version of Artifact Registry:
aws ecr create-repository --repository-name scam-checker-api aws ecr get-login-password | docker login --username AWS --password-stdin "${ACCOUNT}.dkr.ecr.${REGION}.amazonaws.com" IMAGE="${ACCOUNT}.dkr.ecr.${REGION}.amazonaws.com/scam-checker-api:v1" docker build -t "$IMAGE" . docker push "$IMAGE"On an Apple-silicon Mac this builds an ARM image, which is why step 6 asks for ARM (Graviton) machines. Cloud Build made an x86 image for Cloud Run; both run the same code.
-
Two secrets in Secrets Manager. Create a fourth Anthropic key in the Console,
aws, so you can cancel it on its own:read -rs KEY && KEY_ARN=$(aws secretsmanager create-secret --name scam-checker/anthropic-api-key --secret-string "$KEY" --query ARN --output text) && unset KEY read -rs KEY && DB_ARN=$(aws secretsmanager create-secret --name scam-checker/database-url --secret-string "$KEY" --query ARN --output text) && unset KEY echo "$KEY_ARN" "$DB_ARN"An ARN (Amazon Resource Name) is AWS's full address for a thing, like the long resource names in Google Cloud.
-
The two roles Express Mode needs, from AWS's tutorial. A role is an identity that AWS services can take on; the "trust policy" says which service may.
aws iam create-role --role-name ecsTaskExecutionRole --assume-role-policy-document \ '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ecs-tasks.amazonaws.com"},"Action":"sts:AssumeRole"}]}' aws iam create-role --role-name ecsInfrastructureRoleForExpressServices --assume-role-policy-document \ '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ecs.amazonaws.com"},"Action":"sts:AssumeRole"}]}' aws iam attach-role-policy --role-name ecsTaskExecutionRole \ --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy aws iam attach-role-policy --role-name ecsInfrastructureRoleForExpressServices \ --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServicesThen let the task execution role read your two secrets, and only those two. This is the AWS version of week 9's
secretAccessorbinding: -
Describe the container in a file, outside git (add
container.jsonto.gitignore):cat > container.json <<EOF { "image": "${IMAGE}", "containerPort": 8080, "secrets": [ {"name": "ANTHROPIC_API_KEY", "valueFrom": "${KEY_ARN}"}, {"name": "DATABASE_URL", "valueFrom": "${DB_ARN}"} ], "environment": [{"name": "ALLOWED_ORIGINS", "value": "https://<you>.github.io"}] } EOFThen create the service. It takes a few minutes;
--monitor-resourcesshows progress:aws ecs create-express-gateway-service \ --service-name scam-checker-api \ --execution-role-arn "arn:aws:iam::${ACCOUNT}:role/ecsTaskExecutionRole" \ --infrastructure-role-arn "arn:aws:iam::${ACCOUNT}:role/ecsInfrastructureRoleForExpressServices" \ --primary-container file://container.json \ --cpu-architecture ARM64 \ --health-check-path /health \ --scaling-target '{"minTaskCount":1,"maxTaskCount":1}' \ --monitor-resourcesmaxTaskCountof 1 is your AWS--max-instances: whatever happens, one copy runs. -
Test it at the address it prints, which looks like
https://scam-checker-api.ecs.ap-southeast-1.on.aws/:curl -s https://<your-address>/health curl -s -X POST https://<your-address>/check -H 'content-type: application/json' -d '{"text":"Your parcel is held. Pay $1.99 at singpost-redelivery.com"}'The check is stored in the same Neon database as Cloud Run's checks.
-
Find your JSON logs in CloudWatch. Open CloudWatch in the AWS console, then Logs Insights, pick the service's log group, and run:
fields @timestamp, check_id, verdict, cost_usd, duration_ms | filter message = "check done" | stats count(*) as checks, avg(cost_usd) as avg_cost, pct(duration_ms, 95) as p95_msLogs Insights reads the JSON fields for you, which is the payoff for Monday's
logs.py. Take a screenshot for your report. -
Clean up when you've got what you need:
aws ecs delete-express-gateway-service --service-arn <the serviceArn it printed> --monitor-resourcesKeep the image, roles and secrets if you'll redeploy for an interview; delete the secrets too if you're done, since each one has a small monthly charge.
Done when: your API answered /check from an AWS address, the logs query ran, and the service is deleted (or you know exactly when you'll delete it).
Stuck?
aws: error: argument command: Invalid choice ... login: your AWS CLI is older than 2.32. Runbrew upgrade awscli.Unable to assume the service linked roleright after creating the roles: IAM changes take a minute to spread. Wait and run the create command again.- The service never becomes healthy: the health check path must be
/healthand the container port 8080. Look at the task's logs for a Python error. - The task stops with an error about secrets: the inline policy's ARNs must match the secrets exactly.
echo "$KEY_ARN"and compare. exec format errorin the logs: the image's architecture doesn't match. On an Intel Mac, leave out--cpu-architecture ARM64.no basic auth credentialsondocker push: the ECR login expired. Run theget-login-passwordline again.
Friday — The map, the report, and demo day¶
Learn (30 min): re-read your docs/cost-report.md and the Langfuse dashboard for the week, and pick the three numbers you'd quote in an interview.
Build (before 9:00 or after demo day):
-
The one-page Google Cloud to AWS map,
docs/cloud-map.md. Fill in the last column from what you did this week:Job Google Cloud (week 9) AWS (week 11) What surprised you Run the container Cloud Run ECS Express Mode (ECS on Fargate, with a load balancer) Store images Artifact Registry ECR Keep secrets Secret Manager Secrets Manager Who may do what IAM, service accounts IAM, roles and policies Logs Cloud Logging CloudWatch Logs Build an image Cloud Build ( --source .)Your laptop, or CodeBuild or GitHub Actions Deploy from GitHub without keys Workload Identity Federation IAM OpenID Connect provider and a role Spending alerts Cloud Billing budgets AWS Budgets Managed Postgres Cloud SQL RDS (you used Neon, which runs on AWS) Health checks Startup and liveness probes Load balancer target health check Idle cost Zero: scales to nothing Not zero: a task and a load balancer -
Finish
docs/cost-report.md. Keep it to one page:# Cost and performance report ## Headline One check costs about US$__ and takes __ s (p50) / __ s (p95), at precision __ and recall __ on 100 labelled messages. ## How it was measured Model, date, data set, script (scripts/cost_latency.py), and what counts as one check. ## Before and after | Setup | Precision | Recall | Cost per check (US$) | p50 (s) | p95 (s) | | --- | --- | --- | --- | --- | --- | | Full agent, no caching | | | | | | | Plus prompt caching | | | | | | | Plus routing | | | | | | ## Where the time and money go From one typical trace: which steps, how long, how many tokens. ## Reliability What happens on a timeout, an overload, a bad key; what the user sees; how often answers were degraded this week (from scripts/report.sql). ## At scale 1,000 checks a day would cost about US$__ a month in model calls, plus hosting. ## Next The next three things you'd change, and the number each should move.Run
scripts/report.sqlagainst Neon for the live numbers, using its SQL editor orpsql. The "at scale" line is your cost per check × 1,000 × 30: simple arithmetic, stated as an estimate.
Demo day (9:00–12:00):
- Record a 3-minute video: one trace in Langfuse, the before-and-after table, the wrong-key test giving general advice, and the API answering from AWS.
- Write five lines on what you learned this week.
- Send both to Claude with the Friday check-in, with the cost report attached.
Friday afternoon: the cloud side track. This week's concept, logs, follows straight on from Thursday.
Deliverable checklist¶
- Every check traced in Langfuse, with no raw message in the traces
- Cost, tokens and time stored per check, through a second Alembic migration
- Before-and-after numbers for caching and routing on the same 100 messages
- A fallback model, a 45-second time limit and a general-advice answer, all tested
-
/healthand/health/ready, and a startup probe on Cloud Run - One-line JSON logs, searchable in Cloud Logging and CloudWatch
- The API answering from AWS, then cleaned up
-
docs/cloud-map.mdanddocs/cost-report.md - Demo video, five lines, and the Friday check-in sent
If you have time left: try the effort setting, which controls how much the model thinks before answering. Add "effort": "low" next to "format" in output_config, run cost_latency.py cached again, and see whether output tokens and p95 drop without recall dropping.