Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## [v0.15.0] - 2026-07-22

### Added

- **Cost reports on every run.** Each result-producing command now prints a per-call cost report (pipe, model, tokens in→out, USD cost, and a total) to stderr, after the JSON result on stdout. A new shared `piper/usage.py` reads the run's per-call usage off the SDK result into a `RunUsage` (`usage_from_results` for the durable modes' typed `RunResults.tokens_usages`; `usage_from_execute` for blocking, which lifts the same records off its execute result) and renders it with `print_cost_report`. It respects the SDK's usage semantics: an unpriced call (`cost is None` — mock / own-GPU / dry-run) is excluded from the total rather than counted as zero, and token categories are never summed. Detached prints the report from `wait` / `result` (collection time), not from the start-only demo commands.

### Changed

- **Cleaner file upload for `summarize-pdf` (breaking):** the document is now uploaded to hosted storage up front with `client.upload_file` (new `inputs.upload_document_input`), and the run request carries only the returned `pipelex-storage://` URI — replacing the previous inline base64 `data:` URL. `inputs.build_document_input` becomes the pure envelope builder `build_document_input(path, uri)`; the upload is a separate step, so a file or capability error surfaces before any run is created. `piper/errors.py` gains hints for the file-upload error family (unsupported capability, rejected asset, authentication, invalid local source).
- **Lifecycle helpers return `(main_stuff, RunUsage)` (breaking):** `execute_pipe`, `start_and_wait`, and detached's `attend_run` now return the resolved main output *and* the run's usage, instead of `main_stuff` alone, so the commands can print the cost report. The demo commands unpack the pair; the SDK output accessor itself is unchanged.
- Bumped the `pipelex-sdk` floor from `>=0.4.0` to `>=0.5.0` (for `upload_file` and typed run usage).

## [v0.14.1] - 2026-07-15

- **Multi-file bundle support in the mode lifecycle helpers:** `execute_pipe`, `start_and_wait`, and `start_pipe` now take `mthds_contents: list[str]` (the bundle's `.mthds` files as strings — one entry for a single-file bundle, several for a multi-file one) instead of a single `bundle: str`, passed straight through to the SDK. Multi-file bundles cannot be concatenated into one string (duplicate top-level TOML keys), so the list is the interface. The three demos stay single-file (`mthds_contents=[bundle]`); a method dir with several `.mthds` files is read with `[p.read_text() for p in sorted((METHODS_DIR / "<name>").glob("*.mthds"))]`. The package-data glob broadens to `methods/*/*.mthds` so multi-file bundles ship.
Expand Down
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@ This starter calls the **hosted Pipelex API** via the `pipelex-sdk` package (`Pi

- Credentials/endpoint come from `PIPELEX_BASE_URL` / `PIPELEX_API_KEY` (see `.env.example`). `python-dotenv` loads `.env` when running the CLI or tests.
- **The execution mode is the command group, not an option.** There are exactly three, each a self-contained Typer sub-package: `piper blocking …` (`client.execute` — one call, dies at the hosted ~30s cap), `piper attended …` (`client.start` + `client.wait_for_result` — durable, you wait), `piper detached …` (`client.start` only — durable, you collect it later with `piper detached status|result|wait <id>`). Attended and detached start the *same* durable run; the axis they name is who waits. There is no default mode and no `--mode` option: `piper/cli.py` is a thin assembler (`load_dotenv` callback + three `add_typer` calls in reading order) and nothing else.
- **Each mode file is a copy-paste unit; lifecycle code is never shared.** `piper/<mode>/cli.py` holds that mode's whole story: its `typer.Typer`, its consoles (results → stdout, progress → stderr), its one public lifecycle helper (`execute_pipe` / `start_and_wait` / `start_pipe`, plus `attend_run` + the fetchers in detached), its demo commands, and a private `_run()` that wraps `asyncio.run` and catches SDK errors once. The **only** shared modules are the two that are orthogonal to execution: `piper/inputs.py` (text-or-file input with a built-in **sample fallback** so every demo runs with zero arguments — `read_text_input` returns `TextInput(text, is_sample)` and the demo prints a stderr notice when the sample was used; plus file → `{"concept": "Document", "content": …}` envelope with the file base64-encoded into a `data:` URL, and the `SAMPLE_*` constants) and `piper/errors.py` (SDK error → message + hint, hints naming the mode groups; it reads the RFC 7807 **problem+json** body off raw protocol-route `httpx.HTTPStatusError`s and branches on the structured `error_type`, e.g. `StartRequiresAsyncOrchestration` → "use `piper blocking`"). Do not introduce a shared runner — the dispatch indirection is exactly what this layout removed. See `docs/cli-architecture.md`.
- **Each mode file is a copy-paste unit; lifecycle code is never shared.** `piper/<mode>/cli.py` holds that mode's whole story: its `typer.Typer`, its consoles (results → stdout, progress → stderr), its one public lifecycle helper (`execute_pipe` / `start_and_wait` / `start_pipe`, plus `attend_run` + the fetchers in detached), its demo commands, and a private `_run()` that wraps `asyncio.run` and catches SDK errors once. The **only** shared modules are those orthogonal to execution: `piper/inputs.py` (text-or-file input with a built-in **sample fallback** so every demo runs with zero arguments — `read_text_input` returns `TextInput(text, is_sample)` and the demo prints a stderr notice when the sample was used; plus file → `{"concept": "Document", "content": …}` envelope — `upload_document_input` uploads the file to hosted storage with `client.upload_file` and wraps the returned `pipelex-storage://` URI, `build_document_input(path, uri)` being the pure envelope builder — and the `SAMPLE_*` constants), `piper/errors.py` (SDK error → message + hint, hints naming the mode groups; it reads the RFC 7807 **problem+json** body off raw protocol-route `httpx.HTTPStatusError`s and branches on the structured `error_type`, e.g. `StartRequiresAsyncOrchestration` → "use `piper blocking`"; it also hints the file-upload error family), and `piper/usage.py` (cost report: `usage_from_results` / `usage_from_execute` read the per-call usage off the SDK result into a `RunUsage`, `print_cost_report` renders it to **stderr**). Do not introduce a shared runner — the dispatch indirection is exactly what this layout removed. See `docs/cli-architecture.md`.
- **Full demo matrix, guarded.** All three demos exist in all three modes: `extract-entities` (text in), `summarize-pdf` (a *file* in), `generate-image` (prompt in). `generate-image` is the deliberate slow case that overruns the ~30s blocking cap — `piper blocking generate-image` is *expected to fail*, and that is the teaching moment for the durable modes. The near-duplication across mode files is the pedagogy (diff two mode files and only the lifecycle helper differs); `tests/unit/test_mode_symmetry.py` keeps it from drifting. `samples/sample-invoice.pdf` is shipped for `summarize-pdf`.
- The SDK resolves the main output on every result-producing path (`client.execute` returns a `PipelexExecuteResult`, the durable path a `RunResults`, both exposing a resolved `.main_stuff`, typed `Any`; a completed run with no main stuff raises `MissingMainStuffError`). So the result-producing lifecycle helpers (`execute_pipe`, `start_and_wait`, detached's `attend_run`) return `main_stuff`, and the blocking/attended demo commands narrow it inline — e.g. `ExtractedEntities.model_validate(main_stuff)` — into the generated model. Detached is the exception by design: `start_pipe` returns only the run id (the demos print it bare), and the run-id commands (`wait`/`result`) print the output generically — no model narrowing, since at collection time the command doesn't know which method the run executed. There is no per-example wrapper layer.
- The SDK resolves the main output on every result-producing path (`client.execute` returns a `PipelexExecuteResult`, the durable path a `RunResults`, both exposing a resolved `.main_stuff`, typed `Any`; a completed run with no main stuff raises `MissingMainStuffError`). So the result-producing lifecycle helpers (`execute_pipe`, `start_and_wait`, detached's `attend_run`) return a `(main_stuff, RunUsage)` pair — `main_stuff` plus the run's per-call cost/token usage — and the blocking/attended demo commands narrow `main_stuff` inline — e.g. `ExtractedEntities.model_validate(main_stuff)` — into the generated model, then print the usage as a cost report to stderr. Cost usage is `RunResults`-typed on the durable modes but only raw on blocking's execute result (lifted by `usage_from_execute`; the typed-on-execute follow-up is tracked in `wip/sdk-qol/typed-tokens-usages-on-execute.md`). Detached is the exception by design: `start_pipe` returns only the run id (the demos print it bare, no cost — the run isn't done), and the run-id commands (`wait`/`result`) print the output generically **and** its cost report — no model narrowing, since at collection time the command doesn't know which method the run executed. There is no per-example wrapper layer.
- The modes spell out lifecycles the SDK could hide: `client.start_and_wait()` is a self-healing one-liner that picks the path for you (the production shortcut). The starter writes them out because teaching the difference is the point.
- **The typed models are generated, never hand-written.** `pipelex codegen` projects each bundle's concepts into `piper/generated/<method>/models.py` (stamped, locked by a sibling `codegen.lock`); the mode CLIs and the e2e tests import from there. Do NOT edit generated files — edit the bundle, then `make codegen` (regenerates models + `inputs.template.json` scaffolds) and `make codegen-check` (offline drift check). The `pipelex` CLI is not a dependency of this starter; point the `PIPELEX` make variable at a pipelex install that ships `codegen`. `piper/generated` is excluded from ruff (reformatting would trip the drift check) but fully type-checked. See `docs/codegen.md`.

## Project Structure

- Package: `piper/` (Python 3.11+, target 3.11) — root `cli.py` + one sub-package per execution mode (`blocking/`, `attended/`, `detached/`) + the two shared modules (`inputs.py`, `errors.py`). New mode sub-packages must be added to `[tool.setuptools] packages` in `pyproject.toml`.
- Package: `piper/` (Python 3.11+, target 3.11) — root `cli.py` + one sub-package per execution mode (`blocking/`, `attended/`, `detached/`) + the shared modules (`inputs.py`, `errors.py`, `usage.py`). New mode sub-packages must be added to `[tool.setuptools] packages` in `pyproject.toml`.
- Tests: `tests/` (unit = offline per-mode CLI tests patching each mode's public lifecycle helper, plus mode-symmetry / error-mapping / generated-client tests; integration = offline boot/bundle checks + API `validate`; e2e = full run via the API, one execution mode per demo so all three get end-to-end coverage)
- Dependency manager: uv (>=0.7.2)
- Pipelex dependency: `pipelex-sdk` package from PyPI (the API client — see pyproject.toml). The `pipelex` runtime is **not** a dependency.
Expand Down
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ It ships a handful of demo methods, each exposed as a `piper` CLI command:
- **`summarize-pdf`** — given a document (PDF), produce a title, document type, and key points. Shows how to feed a *file* to a pipe.
- **`generate-image`** — given a text prompt, generate an image. Being slow, it's the example that best shows the split between the execution modes (image generation routinely outlives the hosted ~30s blocking cap).

Each prints its result as JSON.
Each prints its result as JSON on stdout, and a short **cost report** (per-call model + USD cost) to stderr — so the result stays pipeable while you still see what the run consumed.

### Use this template

Expand Down Expand Up @@ -157,11 +157,11 @@ flowchart TD

1. **Read the bundle.** `piper` reads `methods/extract-entities/main.mthds` from disk and constructs a `PipelexAPIClient`, which picks up `PIPELEX_BASE_URL` / `PIPELEX_API_KEY` from the environment. A method dir may hold a single `main.mthds` or several `.mthds` files (a multi-file bundle split across pipes) — read them all with `[p.read_text() for p in sorted((METHODS_DIR / "<name>").glob("*.mthds"))]`.
2. **Run it on the API.** The bundle's files are sent together as *content* (`mthds_contents`, one string per file), so nothing method-specific needs to live in the runtime — edit the `.mthds` file(s) and re-run, no redeploy.
3. **Narrow the result.** The SDK resolves the run's `main_stuff`; the command validates it into the generated `ExtractedEntities` model (`ExtractedEntities.model_validate(main_stuff)`), printed as JSON.
3. **Narrow the result.** The SDK resolves the run's `main_stuff`; the command validates it into the generated `ExtractedEntities` model (`ExtractedEntities.model_validate(main_stuff)`), printed as JSON. It also returns the run's per-call usage, which the command prints as a cost report to stderr (via `piper/usage.py`).

The typed models are **not hand-written**: they are generated from the `.mthds` bundles by `pipelex codegen` into `piper/generated/` (stamped, with a `codegen.lock` per method). Edit a bundle → `make codegen` regenerates the models and input templates → `make codegen-check` verifies offline that nothing is stale or hand-edited. See [docs/codegen.md](docs/codegen.md).

The other demos run through the exact same path — they differ only in their inputs and output shapes. `summarize-pdf` sends a `Document` envelope (`inputs.build_document_input()` base64-encodes the file into a `data:` URL); `generate-image` returns the built-in `Image` content.
The other demos run through the exact same path — they differ only in their inputs and output shapes. `summarize-pdf` feeds a *file* to a pipe: because a hosted run can't see your filesystem, `inputs.upload_document_input()` uploads the PDF first (`client.upload_file`) and the run request carries only the returned `pipelex-storage://` URI, wrapped in a `Document` envelope — the bytes never ride the request. `generate-image` returns the built-in `Image` content.

## Execution modes: blocking, attended, detached

Expand Down Expand Up @@ -260,7 +260,7 @@ Each mode is a **self-contained copy-paste unit**: `piper/<mode>/cli.py` holds t
| `attended` | `start_and_wait()` | `client.start` + `client.wait_for_result` |
| `detached` | `start_pipe()` (+ `attend_run()` for `wait`) | `client.start` (then `client.wait_for_result` later) |

The only things shared across modes are the two concerns that have nothing to do with execution: `piper/inputs.py` (encode the inputs) and `piper/errors.py` (present an SDK error). **Lifecycle code is never shared** — that is what keeps each mode file copy-pasteable, and it is why the demo commands are near-duplicated across the three: diff `blocking/cli.py` against `attended/cli.py` and the only difference is the lifecycle helper. (`tests/unit/test_mode_symmetry.py` guards that duplication against drift.)
The only things shared across modes are the concerns that have nothing to do with execution: `piper/inputs.py` (prepare the inputs — incl. uploading a file), `piper/errors.py` (present an SDK error), and `piper/usage.py` (read + print the cost report). **Lifecycle code is never shared** — that is what keeps each mode file copy-pasteable, and it is why the demo commands are near-duplicated across the three: diff `blocking/cli.py` against `attended/cli.py` and the only difference is the lifecycle helper. (`tests/unit/test_mode_symmetry.py` guards that duplication against drift.)

The modes also spell out lifecycles the SDK could hide: `client.start_and_wait()` is a self-healing one-liner that picks the right path by itself — the production shortcut when you don't care which one runs. This starter branches explicitly because teaching the difference is the point.

Expand All @@ -272,8 +272,9 @@ piper/
blocking/cli.py # the whole blocking mode in one file (execute_pipe)
attended/cli.py # the whole attended mode in one file (start_and_wait)
detached/cli.py # the whole detached mode in one file (start_pipe + wait/status/result)
inputs.py # SHARED: text-or-file input, and file → Document envelope
inputs.py # SHARED: text-or-file input, and file → upload → Document envelope
errors.py # SHARED: maps SDK errors to CLI messages + hints
usage.py # SHARED: reads per-call usage and prints the cost report (stderr)
generated/ # typed clients generated from the bundles (`make codegen`) — do not edit
extract_entities/ # models.py (stamped) + codegen.lock
summarize_pdf/
Expand All @@ -291,7 +292,7 @@ tests/
.env.example # PIPELEX_BASE_URL + PIPELEX_API_KEY
```

Only two modules are shared across the modes, and neither knows anything about execution: see [docs/cli-architecture.md](docs/cli-architecture.md) for the sharing rule and the anatomy of a mode file.
Only these modules are shared across the modes, and none knows anything about execution mode: see [docs/cli-architecture.md](docs/cli-architecture.md) for the sharing rule and the anatomy of a mode file.

## Useful commands

Expand Down
Loading
Loading