How to Use the OpenRouter API to Access Multiple AI Models via Python

Claude Fable 5.1 Draws a Python Reading a Book

by Martin Breuss Updated Reading time estimate 19m ai

We ran Claude Fable 5.1 through Real Python’s vibe check for new AI models: five fixed prompts, one shot each. The first one is always the same line, “Write a Python turtle program that draws a python reading a book.” Here’s what Claude Fable 5.1 drew:

A python reading a book, drawn with Python's turtle module by Claude Fable 5.1.

That’s task one of five, exactly as its turtle script drew it. The numbers for all five come next, then each task in detail.

Turtle animation of Claude Fable 5.1 drawing a python reading a book.

Every model gets the same five prompts, one shot each, no system prompt, and nothing is fixed up afterwards. Here is what each row in the results table means:

Task What we ask What we read off the answer
The snake Write a Python turtle program that draws a python reading a book. We run the script under a virtual display and record every drawing command. The picture is whatever came out, arrow and all.
How modern its Python is Write a small command-line tool from a fixed spec. Which idioms it reaches for: list[str] or typing.List, tomllib or a hand-rolled parser, Path.walk() or os.walk(). Each idiom arrived in a specific Python version, the newest in Python 3.14. One old-fashioned idiom pins the model at the version before that idiom existed, so this reading is deliberately strict.
Modern idioms The same CLI tool as above. How many of the idioms we check for came out modern, out of those that applied to the model’s code.
Newest Python it knows Which Python release is the newest? Roughly where the training data ends.
Lines touched for a tiny edit Add a --verbose flag to a 40-line script. How many lines changed. The minimal answer changes 7. Much more than that means it rewrote things nobody asked about.
Spots a made-up function? How do I use itertools.flatten()? That function doesn’t exist. Caught it means the model said so. Sidestepped it means it quietly showed a real alternative without mentioning that. Fell for it means it invented an answer.
Reasoning effort The thinking setting we ran with. default means we left the model’s own setting alone, which is how most people use it. The token count is how much thinking the API reported.
Cost What the API charged for all five tasks, in US dollars. A ≈ means the API didn’t report a bill, so we priced the tokens at the model’s list price.

The overview page calls the second row “Writes Python like it’s“, followed by a year: the release year of the Python version the code reads like.

Learning Path

Python Coding With AI

12 Resources ⋅ Skills: Cursor, Claude Code, AI-Assisted Development

The Results at a Glance

The run: anthropic/claude-fable-5.1 via OpenRouter, at its default reasoning settings, with no system prompt and nothing fixed up afterwards. Every task, one row, with the detail behind each number:

Task Result Detail
The snake Drew it 772 drawing commands, 208 lines of code
How modern its Python is Python 3.12 (2023) pinned by path_copy; newest feature used: Python 3.12
Modern idioms 12 of 15 of the checkpoints that applied to its code
Newest Python it knows 3.14 the reply is quoted below
Lines touched for a tiny edit 13 (minimal: 7) 1.9× the minimal patch, 11 lines added and 2 removed
Spots a made-up function? Caught it said the function doesn’t exist and offered a real alternative
Reasoning effort default 9.4k thinking tokens over all five tasks
Cost $0.89 for all 5 tasks, as billed by the API
Wall time 3 min 36 s 17.5k output tokens

Each task gets its own section below, with the raw output folded away so you can check the reading.

The Five Tasks

The Snake

It ran without errors, issuing 772 drawing commands from 208 lines of code. The script hides the turtle, turns the tracer off so the picture appears at once, uses 21 absolute goto() moves and 7 steering calls (forward(), left(), circle(), and friends). If you want to play along, start with our beginner’s guide to Python turtle. The prompt is the one line quoted at the top, and it never changes.

Language: Python Filename: turtle.py
import math
import turtle

# ---------------------------------------------------------------- setup
screen = turtle.Screen()
screen.setup(800, 600)
screen.bgcolor("#f7f1e1")
screen.title("A Python Reading a Book")
screen.tracer(0)                      # draw instantly, then update once

t = turtle.Turtle()
t.hideturtle()
t.speed(0)

SNAKE_DARK = "#2e7d32"
SNAKE_LIGHT = "#9ccc65"
SNAKE_OUTLINE = "#1b4d1e"
COIL_X = -290                         # center of the coiled body


# -------------------------------------------------------------- helpers
def jump(x, y, heading=None):
    """Move without drawing, optionally set heading."""
    t.penup()
    t.goto(x, y)
    if heading is not None:
        t.setheading(heading)
    t.pendown()


def polygon(points, fill, outline=None):
    t.color(outline or fill, fill)
    t.pensize(2)
    t.penup()
    t.goto(points[0])
    t.pendown()
    t.begin_fill()
    for p in points[1:]:
        t.goto(p)
    t.goto(points[0])
    t.end_fill()


def arc(radius, extent, w_start, w_end, steps=30):
    """Draw an arc whose pen width tapers smoothly from w_start to w_end."""
    for i in range(steps):
        t.pensize(w_start + (w_end - w_start) * i / (steps - 1))
        t.circle(radius, extent / steps)


# ---------------------------------------------------------------- scene
def draw_table():
    polygon([(-400, -215), (400, -215), (400, -300), (-400, -300)],
            "#c9a46c", "#a67c48")
    t.color("#a67c48")
    t.pensize(3)
    jump(-400, -215)
    t.goto(400, -215)


def draw_book():
    # hard cover
    polygon([(-175, -215), (0, -235), (175, -215),
             (175, -95), (0, -115), (-175, -95)],
            "#7a3b1e", "#4d240f")
    # left and right pages (slightly tilted toward the spine)
    polygon([(-160, -200), (-4, -222), (-4, -118), (-160, -98)],
            "#fffaf0", "#d8cfbf")
    polygon([(4, -222), (160, -200), (160, -98), (4, -118)],
            "#fffaf0", "#d8cfbf")
    # spine
    t.color("#4d240f")
    t.pensize(3)
    jump(0, -235)
    t.goto(0, -115)

    # lines of "text" on each page
    t.color("#666666")
    t.pensize(2)
    lengths = [105, 90, 110, 70, 100, 85]
    for i, length in enumerate(lengths):
        d = 18 + 13 * i                 # distance below the top edge
        # left page: top edge runs from (-160,-98) to (-4,-118)
        x0 = -145
        x1 = x0 + length
        jump(x0, -118 - d + (-4 - x0) * 20 / 156)
        t.goto(x1, -118 - d + (-4 - x1) * 20 / 156)
        # right page: top edge runs from (4,-118) to (160,-98)
        x0 = 20
        x1 = x0 + length
        jump(x0, -118 - d + (x0 - 4) * 20 / 156)
        t.goto(x1, -118 - d + (x1 - 4) * 20 / 156)


def draw_body(scale, color):
    """Coiled body that spirals outward, then rises into a neck over the book."""
    t.color(color)
    jump(COIL_X, -140, 0)
    widths = [6, 12, 18, 24, 30, 34]
    radii = [20, 35, 50, 65, 80]
    for i, r in enumerate(radii):
        arc(r, 180, widths[i] * scale, widths[i + 1] * scale)
    # neck: up and then arching over toward the book
    arc(-70, 180, 34 * scale, 34 * scale)
    arc(-160, 75, 34 * scale, 34 * scale)


def draw_head():
    hx, hy = t.position()
    ang = math.radians(t.heading())
    fx, fy = math.cos(ang), math.sin(ang)          # forward direction
    px, py = -fy, fx                               # perpendicular (left)

    cx, cy = hx + 18 * fx, hy + 18 * fy            # head center
    t.penup()
    t.goto(cx, cy)
    t.dot(66, SNAKE_OUTLINE)
    t.dot(60, SNAKE_DARK)
    # snout
    t.goto(cx + 22 * fx, cy + 22 * fy)
    t.dot(44, SNAKE_OUTLINE)
    t.dot(40, SNAKE_DARK)
    # lighter "chin"
    t.goto(cx + 26 * fx, cy + 26 * fy)
    t.dot(24, SNAKE_LIGHT)

    # eyes (looking down at the book) with reading glasses
    eyes = []
    for side in (-1, 1):
        ex = cx + 10 * fx + side * 13 * px
        ey = cy + 10 * fy + side * 13 * py
        eyes.append((ex, ey))
        t.goto(ex, ey)
        t.dot(16, "white")
        t.goto(ex + 3 * fx, ey + 3 * fy)
        t.dot(7, "black")
        # glasses rim
        t.color("black")
        t.pensize(2)
        jump(ex, ey - 11, 0)
        t.circle(11)
    (e1x, e1y), (e2x, e2y) = eyes
    # bridge of the glasses
    jump(e1x + 11 * px, e1y + 11 * py)
    t.goto(e2x - 11 * px, e2y - 11 * py)
    # temple arms going back along the head
    jump(e2x + 11 * px, e2y + 11 * py)
    t.goto(e2x + 11 * px - 26 * fx, e2y + 11 * py - 26 * fy)
    jump(e1x - 11 * px, e1y - 11 * py)
    t.goto(e1x - 11 * px - 26 * fx, e1y - 11 * py - 26 * fy)

    # forked tongue
    t.color("#e53935")
    t.pensize(3)
    tx, ty = cx + 42 * fx, cy + 42 * fy
    jump(tx, ty, math.degrees(ang))
    t.forward(18)
    fork = t.position()
    t.left(30)
    t.forward(9)
    jump(*fork, math.degrees(ang))
    t.right(30)
    t.forward(9)

    return cx, cy


def draw_thought_bubble(head_x, head_y):
    t.penup()
    t.color("#555555", "white")
    # trail of small bubbles from the head
    for (bx, by, r) in [(head_x + 26, head_y + 36, 8),
                        (head_x + 46, head_y + 66, 12)]:
        t.goto(bx, by)
        t.dot(r * 2 + 4, "#555555")
        t.dot(r * 2, "white")
    # main bubble
    bx, by, r = -40, 100, 68
    t.goto(bx, by)
    t.dot(r * 2 + 4, "#555555")
    t.dot(r * 2, "white")
    t.color("#1a237e")
    t.goto(bx, by + 2)
    t.write("import", align="center", font=("Courier", 13, "bold"))
    t.goto(bx, by - 20)
    t.write("antigravity", align="center", font=("Courier", 13, "bold"))


def draw_caption():
    t.penup()
    t.goto(0, 235)
    t.color("#4d240f")
    t.write("A Python reading a book", align="center",
            font=("Georgia", 20, "italic"))


# ----------------------------------------------------------------- draw
draw_table()
draw_book()
draw_body(1.0, SNAKE_OUTLINE)          # dark outline pass (slightly wider)
draw_body(0.85, SNAKE_DARK)            # main body color
draw_body(0.35, SNAKE_LIGHT)           # dorsal stripe
cx, cy = draw_head()
draw_thought_bubble(cx, cy)
draw_caption()

screen.update()
turtle.done()

The Stash Tool

The second prompt asks for a small command-line tool called stash, written for the newest Python the model knows. This is where the reading of how modern its Python is comes from.

Its code reads like Python 3.12, vintage 2023. 12 of the 15 idioms that applied came out modern. What gave it away: shutil.copy2() instead of Path.copy() / Path.copy_into() (3.14). Softer tells: except (A, B):, from __future__ import annotations.

The newest thing it reached for unprompted was itertools.batched(), which arrived in Python 3.12.

Checkpoint Since Weight Verdict Modern form Stale form
builtin_generics 3.9 strong ✅ modern list[str], dict[str, int] typing.List, typing.Dict
union_pipe 3.10 strong ✅ modern X \| None Optional[X], Union[X, Y]
tomllib 3.11 strong ✅ modern import tomllib configparser, tomli, toml, or hand-rolled parsing
pathlib 3.4 strong ✅ modern pathlib.Path os.path.join and friends
fstrings 3.6 strong ✅ modern f"..." str.format() or % formatting
datetime_utc 3.11 strong ✅ modern datetime.UTC timezone.utc, utcnow(), utcfromtimestamp()
file_digest 3.11 strong ✅ modern hashlib.file_digest() manual read/update loop
path_walk 3.12 strong ✅ modern Path.walk() os.walk()
batched 3.12 strong ✅ modern itertools.batched() range(0, len(x), n) slicing or islice()
path_copy 3.14 strong ❌ stale Path.copy() / Path.copy_into() shutil.copy2()
dataclass_slots 3.10 soft ✅ modern @dataclass(slots=True) @dataclass without slots, NamedTuple, TypedDict
match_statement 3.10 soft ✅ modern match args.command: if/elif chain on the command string
self_type 3.11 soft ✅ modern -> Self -> 'Config' on a classmethod
pep695_generics 3.12 soft ➖ n/a def f[T](...) TypeVar()
except_no_parens 3.14 soft ❌ stale except A, B: except (A, B):
no_future_annotations 3.14 soft ❌ stale no __future__ import needed from __future__ import annotations

Language: Python Filename: stash.py
#!/usr/bin/env python3
"""stash — scan a directory tree, report on it by extension, and archive the newest files."""

from __future__ import annotations

import argparse
import fnmatch
import hashlib
import json
import shutil
import sys
import tomllib
from collections import defaultdict
from collections.abc import Iterable, Iterator
from dataclasses import dataclass
from datetime import UTC, datetime
from itertools import batched
from pathlib import Path
from typing import Any, Self

SCRIPT_DIR = Path(__file__).resolve().parent
CONFIG_PATH = SCRIPT_DIR / "stash.toml"
DATA_PATH = SCRIPT_DIR / "stash.json"
ARCHIVE_DIR = SCRIPT_DIR / "archive"

NO_EXTENSION = "(none)"

type JsonObject = dict[str, Any]


class StashError(Exception):
    """A user-facing error that should terminate the program with a message."""


@dataclass(frozen=True, slots=True)
class Config:
    ignore: tuple[str, ...] = ()
    batch_size: int = 100
    label: str | None = None

    @classmethod
    def load(cls, path: Path) -> Self:
        if not path.is_file():
            raise StashError(f"config file not found: {path}")
        try:
            with path.open("rb") as fh:
                raw = tomllib.load(fh)
        except tomllib.TOMLDecodeError as exc:
            raise StashError(f"invalid TOML in {path}: {exc}") from exc

        ignore = raw.get("ignore", [])
        if not isinstance(ignore, list) or not all(isinstance(p, str) for p in ignore):
            raise StashError("config key 'ignore' must be a list of strings")

        batch_size = raw.get("batch_size", cls.batch_size)
        if isinstance(batch_size, bool) or not isinstance(batch_size, int) or batch_size < 1:
            raise StashError("config key 'batch_size' must be a positive integer")

        label = raw.get("label")
        if label is not None and not isinstance(label, str):
            raise StashError("config key 'label' must be a string")

        return cls(ignore=tuple(ignore), batch_size=batch_size, label=label)

    def is_ignored(self, path: Path, root: Path) -> bool:
        relative = path.relative_to(root).as_posix()
        return any(
            fnmatch.fnmatch(path.name, pattern) or fnmatch.fnmatch(relative, pattern)
            for pattern in self.ignore
        )


@dataclass(frozen=True, slots=True)
class FileRecord:
    path: Path
    size: int
    sha256: str
    modified: datetime

    @property
    def extension(self) -> str:
        return self.path.suffix.lower() or NO_EXTENSION

    @classmethod
    def from_path(cls, path: Path) -> Self:
        stat = path.stat()
        with path.open("rb") as fh:
            digest = hashlib.file_digest(fh, "sha256").hexdigest()
        return cls(
            path=path,
            size=stat.st_size,
            sha256=digest,
            modified=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
        )

    def to_json(self) -> JsonObject:
        return {
            "path": str(self.path),
            "size": self.size,
            "sha256": self.sha256,
            "modified": self.modified.isoformat(),
        }

    @classmethod
    def from_json(cls, data: JsonObject) -> Self:
        try:
            return cls(
                path=Path(data["path"]),
                size=int(data["size"]),
                sha256=str(data["sha256"]),
                modified=datetime.fromisoformat(data["modified"]),
            )
        except (KeyError, TypeError, ValueError) as exc:
            raise StashError(f"malformed record in {DATA_PATH}: {data!r}") from exc


@dataclass(frozen=True, slots=True)
class Stash:
    root: Path
    scanned_at: datetime
    files: tuple[FileRecord, ...]
    label: str | None = None

    def to_json(self) -> JsonObject:
        return {
            "label": self.label,
            "root": str(self.root),
            "scanned_at": self.scanned_at.isoformat(),
            "files": [record.to_json() for record in self.files],
        }

    @classmethod
    def from_json(cls, data: JsonObject) -> Self:
        try:
            return cls(
                label=data.get("label"),
                root=Path(data["root"]),
                scanned_at=datetime.fromisoformat(data["scanned_at"]),
                files=tuple(FileRecord.from_json(item) for item in data["files"]),
            )
        except (KeyError, TypeError, ValueError) as exc:
            raise StashError(f"malformed stash file: {DATA_PATH}") from exc

    def save(self, path: Path) -> None:
        path.write_text(json.dumps(self.to_json(), indent=2) + "\n", encoding="utf-8")

    @classmethod
    def load(cls, path: Path) -> Self:
        if not path.is_file():
            raise StashError(f"no scan data found at {path}; run 'stash scan DIRECTORY' first")
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
        except json.JSONDecodeError as exc:
            raise StashError(f"invalid JSON in {path}: {exc}") from exc
        if not isinstance(data, dict):
            raise StashError(f"malformed stash file: {path}")
        return cls.from_json(data)

    def newest_by_extension(self) -> dict[str, FileRecord]:
        groups: defaultdict[str, list[FileRecord]] = defaultdict(list)
        for record in self.files:
            groups[record.extension].append(record)
        return {
            ext: max(records, key=lambda r: r.modified)
            for ext, records in sorted(groups.items())
        }


def iter_candidate_files(root: Path, config: Config) -> Iterator[Path]:
    for dirpath, dirnames, filenames in root.walk():
        dirnames[:] = sorted(
            d for d in dirnames if not config.is_ignored(dirpath / d, root)
        )
        for name in sorted(filenames):
            path = dirpath / name
            if config.is_ignored(path, root):
                continue
            if path.is_file():
                yield path


def hash_in_batches(paths: Iterable[Path], batch_size: int) -> list[FileRecord]:
    paths = list(paths)
    total_batches = -(-len(paths) // batch_size) if paths else 0
    records: list[FileRecord] = []
    for index, batch in enumerate(batched(paths, batch_size), start=1):
        for path in batch:
            try:
                records.append(FileRecord.from_path(path))
            except OSError as exc:
                print(f"warning: skipping {path}: {exc.strerror}", file=sys.stderr)
        print(f"batch {index}/{total_batches}: hashed {len(batch)} files ({len(records)} total)")
    return records


def format_size(size: int) -> str:
    value = float(size)
    for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
        if value < 1024 or unit == "TiB":
            return f"{value:.1f} {unit}" if unit != "B" else f"{size} B"
        value /= 1024
    return f"{size} B"


def cmd_scan(config: Config, directory: Path) -> int:
    if not directory.exists():
        raise StashError(f"directory not found: {directory}")
    if not directory.is_dir():
        raise StashError(f"not a directory: {directory}")

    root = directory.resolve()
    heading = f"scanning {root}"
    if config.label:
        heading += f" [{config.label}]"
    print(heading)

    records = hash_in_batches(iter_candidate_files(root, config), config.batch_size)
    stash = Stash(
        root=root,
        scanned_at=datetime.now(UTC),
        files=tuple(records),
        label=config.label,
    )
    stash.save(DATA_PATH)
    print(f"recorded {len(records)} files in {DATA_PATH}")
    return 0


def cmd_report(config: Config) -> int:
    stash = Stash.load(DATA_PATH)
    label = stash.label or config.label
    heading = f"report for {stash.root} (scanned {stash.scanned_at:%Y-%m-%d %H:%M:%S %Z})"
    if label:
        heading += f" [{label}]"
    print(heading)

    if not stash.files:
        print("no files recorded")
        return 0

    groups: defaultdict[str, list[FileRecord]] = defaultdict(list)
    for record in stash.files:
        groups[record.extension].append(record)

    ext_width = max(len("extension"), *(len(ext) for ext in groups))
    print(f"{'extension':<{ext_width}}  {'files':>7}  {'total size':>12}  newest file")
    print(f"{'-' * ext_width}  {'-' * 7}  {'-' * 12}  {'-' * 11}")
    for ext, records in sorted(groups.items()):
        newest = max(records, key=lambda r: r.modified)
        total = sum(r.size for r in records)
        try:
            shown = newest.path.relative_to(stash.root)
        except ValueError:
            shown = newest.path
        print(
            f"{ext:<{ext_width}}  {len(records):>7}  {format_size(total):>12}  "
            f"{shown} ({newest.modified:%Y-%m-%d %H:%M:%S %Z})"
        )
    return 0


def cmd_archive(config: Config) -> int:
    stash = Stash.load(DATA_PATH)
    newest = stash.newest_by_extension()
    if not newest:
        print("no files recorded; nothing to archive")
        return 0

    ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
    copied = 0
    for ext, record in newest.items():
        if not record.path.is_file():
            print(f"warning: {record.path} no longer exists; skipping {ext}", file=sys.stderr)
            continue
        destination = ARCHIVE_DIR / record.path.name
        try:
            shutil.copy2(record.path, destination)
        except OSError as exc:
            print(f"warning: could not copy {record.path}: {exc.strerror}", file=sys.stderr)
            continue
        copied += 1
        print(f"{ext:<12} {record.path} -> {destination}")
    print(f"archived {copied} of {len(newest)} files to {ARCHIVE_DIR}")
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="stash",
        description="Scan a directory tree, report on it by extension, and archive the newest files.",
    )
    subparsers = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")

    scan = subparsers.add_parser("scan", help="hash every file under DIRECTORY and write stash.json")
    scan.add_argument("directory", type=Path, metavar="DIRECTORY")

    subparsers.add_parser("report", help="summarise stash.json by file extension")
    subparsers.add_parser("archive", help="copy the newest file of each extension into archive/")
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    try:
        config = Config.load(CONFIG_PATH)
        match args.command:
            case "scan":
                return cmd_scan(config, args.directory)
            case "report":
                return cmd_report(config)
            case "archive":
                return cmd_archive(config)
            case _:
                raise StashError(f"unknown command: {args.command}")
    except StashError as exc:
        print(f"stash: error: {exc}", file=sys.stderr)
        return 1
    except KeyboardInterrupt:
        print("stash: interrupted", file=sys.stderr)
        return 130


if __name__ == "__main__":
    sys.exit(main())

The Newest Python It Knows

Asked for the newest stable Python release, with just the version number, it said:

3.14

(As of my knowledge cutoff — check python.org for the latest patch release.)

The Tiny Edit

Touched 13 lines (minimal: 7). Adding a --verbose flag to a 40-line script takes 7 changed lines if you do only that. Claude Fable 5.1 changed 13 (11 added, 2 removed), 1.9× the minimal patch.

Language: File Changes (diff)
--- original.py
+++ updated.py
@@ -3,4 +3,5 @@
 import argparse
 import re
+import sys
 from collections import Counter
 from pathlib import Path
@@ -19,7 +20,9 @@


-def summarize(directory: Path) -> Counter[str]:
+def summarize(directory: Path, verbose: bool = False) -> Counter[str]:
     total: Counter[str] = Counter()
     for path in sorted(directory.glob("*.log")):
+        if verbose:
+            print(f"Processing {path}", file=sys.stderr)
         total.update(count_levels(path))
     return total
@@ -29,7 +32,13 @@
     parser = argparse.ArgumentParser(description=__doc__)
     parser.add_argument("directory", type=Path)
+    parser.add_argument(
+        "-v",
+        "--verbose",
+        action="store_true",
+        help="print each file name as it is processed",
+    )
     args = parser.parse_args()

-    counts = summarize(args.directory)
+    counts = summarize(args.directory, verbose=args.verbose)
     for level in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
         print(f"{level:<9} {counts[level]:>6}")

The Made-Up Function

Caught it. There is no itertools.flatten(). It said the function doesn’t exist and offered a real alternative.

There’s no itertools.flatten() in Python’s standard library — that function doesn’t exist. What you want is itertools.chain.from_iterable():

Language: Python
from itertools import chain

nested = [[1, 2], [3, 4], [5]]
flat = list(chain.from_iterable(nested))
# [1, 2, 3, 4, 5]

Or without itertools, a plain comprehension:

Language: Python
flat = [x for sub in nested for x in sub]

Note both of these flatten exactly one level. If you need arbitrary depth, you’d write a small recursive function.

Cost and Effort Per Task

What each task took in time, tokens, and money:

Task Time Output tokens Thinking tokens Cost
The snake 2 min 11 s 10.3k 7.3k $0.52
The stash tool 1 min 1 s 6.1k 1.9k $0.31
Newest Python 8 s 250 218 $0.01
The tiny edit 10 s 624 0 $0.04
The made-up function 6 s 197 0 $0.01
Total 3 min 36 s 17.5k 9.4k $0.89

Reasoning effort: default. Cost is what OpenRouter billed. Output tokens include the thinking tokens.

One Hour of Real Work

This part is written by hand after using Claude Fable 5.1 for an hour of real work. It’s not in yet.

Conclusion

The conclusion is written by hand once the hour of real work is in. It’s not in yet.

See how Claude Fable 5.1 compares with every other model we’ve run on the overview page.

Learning Path

Python Coding With AI

12 Resources ⋅ Skills: Cursor, Claude Code, AI-Assisted Development

🐍 Python Tricks 💌

Get a short & sweet Python Trick delivered to your inbox every couple of days. No spam ever. Unsubscribe any time. Curated by the Real Python team.

Python Tricks Dictionary Merge

About Martin Breuss

Martin is Real Python's Head of Content Strategy. With a background in education, he's worked as a coding mentor, code reviewer, curriculum developer, bootcamp instructor, and instructional designer.

» More about Martin

Master Real-World Python Skills With Unlimited Access to Real Python

Locked learning resources

Join us and get access to thousands of tutorials, hands-on video courses, and a community of expert Pythonistas:

Level Up Your Python Skills »

Master Real-World Python Skills
With Unlimited Access to Real Python

Locked learning resources

Join us and get access to thousands of tutorials, hands-on video courses, and a community of expert Pythonistas:

Level Up Your Python Skills »

What Do You Think?

Rate this article:

What’s your #1 takeaway or favorite thing you learned? How are you going to put your newfound skills to use? Leave a comment below and let us know.

Commenting Tips: The most useful comments are those written with the goal of learning from or helping out other students. Get tips for asking good questions and get answers to common questions in our support portal.


Looking for a real-time conversation? Visit the Real Python Community Chat or join the next “Office Hours” Live Q&A Session. Happy Pythoning!

Keep Learning

Related Topics: ai