{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*Lab 1 Answer Key*\n",
    "\n",
    "# Claude API Mechanics & Integration\n",
    "\n",
    "**Objective:** build a small client module that survives the real-world failure modes of the Messages API, then prove each one.\n",
    "\n",
    "Everything is one endpoint: `POST /v1/messages`. Tools, structured output, thinking, vision, and caching are parameters on that one call, not separate APIs. This lab builds a resilient client around the four things that break real integrations: typed content blocks, stop reasons, error handling, and the realtime-versus-batch decision.\n",
    "\n",
    "**Setup:** `pip install anthropic` and set `ANTHROPIC_API_KEY` in your environment (or use an `ant auth login` profile). Never hardcode a key.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 1: Block-safe extraction\n",
    "\n",
    "The single most common integration bug is `response.content[0].text`. It works until the day you enable thinking or tools, and then block zero is a `thinking` or `tool_use` block and you get an `AttributeError` in production. Always narrow by `.type`.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import anthropic\n",
    "\n",
    "client = anthropic.Anthropic()  # resolves credentials from the environment\n",
    "\n",
    "\n",
    "def extract_text(response):\n",
    "    \"\"\"Concatenate only text blocks. Safe under thinking and tool use.\"\"\"\n",
    "    return \"\".join(b.text for b in response.content if b.type == \"text\")\n",
    "\n",
    "\n",
    "# Prove it: with adaptive thinking on, a thinking block precedes the text,\n",
    "# so content[0].text would raise AttributeError. extract_text does not care.\n",
    "response = client.messages.create(\n",
    "    model=\"claude-opus-4-8\",\n",
    "    max_tokens=16000,\n",
    "    thinking={\"type\": \"adaptive\"},\n",
    "    messages=[{\"role\": \"user\", \"content\": \"In one sentence: why do ships float?\"}],\n",
    ")\n",
    "\n",
    "print(\"block types:\", [b.type for b in response.content])\n",
    "print(\"text:\", extract_text(response))\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 2: Stop-reason dispatch\n",
    "\n",
    "Never parse the text to decide what to do next; branch on `stop_reason`. Guard `stop_details` before reading it: it is populated only when `stop_reason == \"refusal\"` and is `None` for every other stop reason.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def dispatch(response):\n",
    "    \"\"\"Map every stop_reason to the action the caller must take.\"\"\"\n",
    "    reason = response.stop_reason\n",
    "    if reason == \"end_turn\":\n",
    "        return (\"done\", extract_text(response))\n",
    "    if reason == \"tool_use\":\n",
    "        return (\"run_tools\", [b for b in response.content if b.type == \"tool_use\"])\n",
    "    if reason == \"max_tokens\":\n",
    "        return (\"truncated\", \"raise max_tokens or stream; output ended mid-thought\")\n",
    "    if reason == \"stop_sequence\":\n",
    "        return (\"stop_sequence\", response.stop_sequence)\n",
    "    if reason == \"pause_turn\":\n",
    "        return (\"resume\", \"re-send the conversation as-is; do NOT append 'Continue.'\")\n",
    "    if reason == \"refusal\":\n",
    "        # stop_details is populated ONLY here; None everywhere else.\n",
    "        detail = response.stop_details.category if response.stop_details else None\n",
    "        return (\"refused\", detail)\n",
    "    return (\"unknown\", reason)\n",
    "\n",
    "\n",
    "# Force truncation: a long request against a 16-token cap.\n",
    "short = client.messages.create(\n",
    "    model=\"claude-opus-4-8\",\n",
    "    max_tokens=16,\n",
    "    messages=[{\"role\": \"user\", \"content\": \"Explain TCP congestion control in detail.\"}],\n",
    ")\n",
    "action, payload = dispatch(short)\n",
    "assert action == \"truncated\", f\"expected truncation, got {action}\"\n",
    "print(\"detected:\", action, \"-\", payload)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 3: Prove the 400s\n",
    "\n",
    "These shapes are removed on current models and return HTTP 400, not a deprecation warning. The point of this part is to see the rejection with your own eyes. The requests below are deliberate counter-examples: they are supposed to fail.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# DELIBERATE COUNTER-EXAMPLES: both requests below fail with HTTP 400.\n",
    "# budget_tokens is removed; use thinking={\"type\": \"adaptive\"} instead.\n",
    "try:\n",
    "    client.messages.create(\n",
    "        model=\"claude-opus-4-8\",\n",
    "        max_tokens=16000,\n",
    "        thinking={\"type\": \"enabled\", \"budget_tokens\": 8000},  # removed -> 400\n",
    "        messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n",
    "    )\n",
    "except anthropic.BadRequestError as e:\n",
    "    print(\"budget_tokens rejected:\", e.message)\n",
    "\n",
    "# temperature (and top_p / top_k) are removed on current models -> 400.\n",
    "try:\n",
    "    client.messages.create(\n",
    "        model=\"claude-opus-4-8\",\n",
    "        max_tokens=16000,\n",
    "        temperature=0.7,  # removed -> 400\n",
    "        messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n",
    "    )\n",
    "except anthropic.BadRequestError as e:\n",
    "    print(\"temperature rejected:\", e.message)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The current shapes are `thinking={\"type\": \"adaptive\"}` for reasoning and `output_config={\"effort\": \"low\" | \"medium\" | \"high\" | \"xhigh\" | \"max\"}` for depth. Steer style with prompting, not sampling parameters.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 4: Stream with a final message\n",
    "\n",
    "Stream whenever output could be long. The SDK refuses non-streaming requests it estimates will exceed the HTTP timeout, so large `max_tokens` requires streaming. The `stream()` helper accumulates state for you; `get_final_message()` returns the complete `Message` with `stop_reason` and `usage`.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "with client.messages.stream(\n",
    "    model=\"claude-opus-4-8\",\n",
    "    max_tokens=64000,\n",
    "    messages=[{\"role\": \"user\", \"content\": \"Write a detailed migration plan for moving a monolith to services.\"}],\n",
    ") as stream:\n",
    "    for text in stream.text_stream:\n",
    "        print(text, end=\"\", flush=True)\n",
    "\n",
    "    final = stream.get_final_message()\n",
    "\n",
    "print(\"\\n\\nstop:\", final.stop_reason, \"| out tokens:\", final.usage.output_tokens)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 5: Typed error chain\n",
    "\n",
    "Catch a chain, most specific first. 400/401/404 are fatal and must never be retried; 429, 5xx, and network failures are retryable. The SDK already retries the retryable ones (default `max_retries=2`), so custom retry logic is only for behavior the SDK does not provide.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def call_safely(client, **kwargs):\n",
    "    \"\"\"Typed error chain: fatal errors raise, retryable errors report.\"\"\"\n",
    "    try:\n",
    "        return client.messages.create(**kwargs)\n",
    "    except anthropic.NotFoundError:\n",
    "        print(\"404: bad model ID or endpoint. NEVER retry.\")\n",
    "        raise\n",
    "    except anthropic.BadRequestError as e:\n",
    "        print(\"400: fix the request, do not retry:\", e.message)\n",
    "        raise\n",
    "    except anthropic.AuthenticationError:\n",
    "        print(\"401: bad or missing key. NEVER retry.\")\n",
    "        raise\n",
    "    except anthropic.RateLimitError as e:\n",
    "        wait = int(e.response.headers.get(\"retry-after\", \"60\"))\n",
    "        print(f\"429: retryable, back off {wait}s\")\n",
    "        raise\n",
    "    except anthropic.APIStatusError as e:\n",
    "        print(\"retryable\" if e.status_code >= 500 else \"fatal\", e.status_code)\n",
    "        raise\n",
    "    except anthropic.APIConnectionError:\n",
    "        print(\"network failure before any response: retryable\")\n",
    "        raise\n",
    "\n",
    "\n",
    "try:\n",
    "    call_safely(\n",
    "        client,\n",
    "        model=\"claude-opus-9-9\",  # bogus on purpose\n",
    "        max_tokens=64,\n",
    "        messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n",
    "    )\n",
    "except anthropic.NotFoundError:\n",
    "    print(\"confirmed: handler surfaced the 404 without retrying\")\n",
    "\n",
    "# The SDK retries 429/5xx/network twice by default. Disable to compare:\n",
    "no_retry_client = anthropic.Anthropic(max_retries=0)\n",
    "print(\"default retries:\", client.max_retries, \"| disabled:\", no_retry_client.max_retries)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 6: Batch, keyed correctly\n",
    "\n",
    "The Message Batches API processes latency-tolerant workloads within a 24-hour window at 50% of standard token cost. Results arrive in ANY order: keying by position is a silent data-corruption bug, because every summary attaches to the wrong document and nothing throws.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import time\n",
    "from anthropic.types.message_create_params import MessageCreateParamsNonStreaming\n",
    "from anthropic.types.messages.batch_create_params import Request\n",
    "\n",
    "documents = [f\"Document {i}: the quarterly total for region {i} is {i * 111}.\" for i in range(20)]\n",
    "\n",
    "batch = client.messages.batches.create(requests=[\n",
    "    Request(\n",
    "        custom_id=f\"doc-{i}\",  # YOUR correlation key\n",
    "        params=MessageCreateParamsNonStreaming(\n",
    "            model=\"claude-haiku-4-5\",\n",
    "            max_tokens=64,\n",
    "            messages=[{\"role\": \"user\", \"content\": f\"Repeat only the number in: {doc}\"}],\n",
    "        ),\n",
    "    )\n",
    "    for i, doc in enumerate(documents)\n",
    "])\n",
    "\n",
    "while client.messages.batches.retrieve(batch.id).processing_status != \"ended\":\n",
    "    time.sleep(30)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# WRONG: positional reassembly. Results are unordered, so this maps\n",
    "# answers to the wrong documents and never raises.\n",
    "raw = list(client.messages.batches.results(batch.id))\n",
    "positional = {documents[i]: r for i, r in enumerate(raw)}\n",
    "mismatches = sum(\n",
    "    1 for i, r in enumerate(raw) if r.custom_id != f\"doc-{i}\"\n",
    ")\n",
    "print(\"results whose position does not match their custom_id:\", mismatches)\n",
    "\n",
    "# RIGHT: key by custom_id.\n",
    "results = {}\n",
    "for result in client.messages.batches.results(batch.id):\n",
    "    if result.result.type == \"succeeded\":\n",
    "        msg = result.result.message\n",
    "        results[result.custom_id] = next(\n",
    "            (b.text for b in msg.content if b.type == \"text\"), \"\"\n",
    "        )\n",
    "    elif result.result.type == \"errored\":\n",
    "        # \"invalid_request\" = fix and resubmit. Anything else = safe to retry.\n",
    "        print(\"errored:\", result.custom_id, result.result.error)\n",
    "\n",
    "for i in range(3):\n",
    "    print(f\"doc-{i} ->\", results.get(f\"doc-{i}\"))\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 7: Multi-format input\n",
    "\n",
    "Images and documents are content blocks inside the user turn, not a different endpoint. Put the document block before the text block that refers to it. For files reused across many requests, upload once via the Files API and reference by `file_id` instead of re-encoding megabytes on every call.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import base64\n",
    "\n",
    "with open(\"chart.png\", \"rb\") as f:\n",
    "    img = base64.standard_b64encode(f.read()).decode(\"utf-8\")\n",
    "\n",
    "with open(\"report.pdf\", \"rb\") as f:\n",
    "    pdf = base64.standard_b64encode(f.read()).decode(\"utf-8\")\n",
    "\n",
    "response = client.messages.create(\n",
    "    model=\"claude-opus-4-8\",\n",
    "    max_tokens=16000,\n",
    "    messages=[{\n",
    "        \"role\": \"user\",\n",
    "        \"content\": [\n",
    "            {\"type\": \"image\",\n",
    "             \"source\": {\"type\": \"base64\", \"media_type\": \"image/png\", \"data\": img}},\n",
    "            # Document block goes BEFORE the text block it refers to.\n",
    "            {\"type\": \"document\",\n",
    "             \"source\": {\"type\": \"base64\", \"media_type\": \"application/pdf\", \"data\": pdf}},\n",
    "            {\"type\": \"text\", \"text\": \"Does the chart agree with the report's Q3 figures?\"},\n",
    "        ],\n",
    "    }],\n",
    ")\n",
    "print(extract_text(response))\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.11.0"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
