Skip to main content

The Python deltas

Running it

You need Python 3.11+ and uv. Neither is a prerequisite for the course itself — the labs are TypeScript, and this track is optional.

cd python && uv venv && uv pip install -e .
.venv/bin/uvicorn triage.server:app --port 8788
.venv/bin/python -m evals.quick

Port 8788 rather than 8787, so this runs beside the TypeScript service. Comparing the two is the fastest way to settle "is this the API or is this my code?".

Credentials come from the repo root's .env, the same file the TypeScript half reads. The gold set is shared too — evals/dataset.jsonl at the root, not a copy. Two copies of a gold set drift, and then you are comparing languages rather than measuring either.

Scope: /v1/triage and the scoreboard. See what is not here for why that is deliberate.


Read the TypeScript labs. They carry the teaching, and most of what this course is about — schema design, calibration, caching, the trust boundary, the batch arithmetic — is a property of the API, not of a language.

This page is only what is genuinely different. It is short on purpose.

Everything below was hit while porting src/ to python/, not recalled.


1. output_format is a separate parameter

The one that actually stops you.

// TypeScript — both live inside output_config
output_config: {
effort: EFFORT.triage,
format: zodOutputFormat(TriageSchema),
}
# Python — the schema is a TOP-LEVEL argument to parse()
output_config={"effort": EFFORT["triage"]},
output_format=TriageResult,

Put the pydantic class inside output_config["format"] and you get:

TypeError: Object of type ModelMetaclass is not JSON serializable

which points at the JSON encoder rather than at the mistake. messages.parse() in the Python SDK takes output_format: type[ResponseFormatT] alongside output_config, not inside it.

2. parsed_output is the same in both

Worth stating because you will go looking for a difference. It is response.parsed_output in Python and in TypeScript, and it is nullable in both. The Python type is your pydantic model rather than an inferred Zod type, and None rather than null, and that is the whole of it.

3. timeout is seconds here, milliseconds there

new Anthropic({ maxRetries: 3, timeout: 120_000 }); // ms
anthropic.Anthropic(max_retries=3, timeout=120.0) # seconds

Both default to ten minutes. Copying the number across without converting gives you either a 120-millisecond timeout that fails every call, or a 120,000-second one that never fires. The second is worse, because it looks fine.

Also max_retries / maxRetries — snake case throughout the Python SDK.

4. The two SDKs do not share a prompt cache

This one is subtle and it surprised us.

The system prompts are byte-identical — same role text, same handbook file, 10,937 characters in both. And yet:

TypeScript: cache_creation_input_tokens 5022, cache_read 0
Python: cache_creation_input_tokens 0, cache_read 4970

Two different cache entries, 52 tokens apart. The prompts are not the difference; the generated JSON Schema is. Zod emits 2,497 characters for TriageSchema; pydantic emits 2,441 for the same logical model — different key ordering, a $defs block, a $schema declaration. The schema is part of what gets cached, so a difference there is a different prefix.

Two things follow:

  • Running both services in parallel does not halve your cache costs. Each pays its own write.
  • "Byte-identical prompt" is not sufficient for a cache hit. Everything ahead of the breakpoint counts, and that includes things you did not write by hand.

5. Field descriptions are the prompt, and porting is where you lose them

The most valuable thing in this port, and it cost three eval cases.

The first version of triage/schemas.py paraphrased the field descriptions instead of copying them. In particular, requires_human went from:

True if policy section 5.3 mandates supervisor escalation, or if a confident automated reply is not possible.

to a tidier-looking version that dropped the second clause and listed the 5.3 triggers instead. Everything type-checked. The service ran. The schema was valid.

Accuracy went from 11/12 to 8/12, and both new failures were requires_human: expected True, got False on exactly the cases that turn on "a confident automated reply is not possible" — the deliberately ambiguous one and the repeat-contact one.

Restoring the sentence restored the score.

This is Lab 2 Step 2 happening for real, in a context where it is much easier to make the mistake: translating a schema feels like moving types around, and the types are the part that does not matter. The descriptions are the prompt.

If you port this to a third language, copy the description strings verbatim before you write anything else.

6. Small stuff

TypeScriptPython
Request shapeone object argument**kwargs
SchemaZod + zodOutputFormatpydantic class directly
ServerHono, app.request() in-processFastAPI, TestClient in-process
NamingcamelCasesnake_case throughout the SDK
Module namerequests.ts would be finerequests_.py — do not shadow the requests library

The in-process testing pattern survives intact: TestClient(app) is FastAPI's equivalent of Hono's app.request(), so the eval harness never opens a port and never needs a running server. That is one of the better ideas in the TypeScript half and it ports cleanly.


What is not here

The Python track mirrors /v1/triage and the scoreboard. It does not reimplement tool use, streaming, the tool-use guardrails, the batch script, or MCP.

That is deliberate rather than unfinished. Those labs teach ideas — the usage trap in an agent loop, SSE and client disconnects, deterministic authority checks, the batch-versus-cache arithmetic — and every one of those ideas is identical in Python. Reimplementing them would produce a second thing to maintain and a second thing to drift, in exchange for teaching nothing new.

The four differences above are the ones a Python developer actually needs, and /v1/triage is enough surface to demonstrate all four.