feat: expose cumulative token usage on success + budget enforcement - #2392
feat: expose cumulative token usage on success + budget enforcement#2392mimran-khan wants to merge 2 commits into
Conversation
Previously, total_usage (accumulated token counts across retry attempts) was only available when retries FAILED via InstructorRetryException. On successful extraction, this data was computed but discarded. Changes: - Attach _total_usage to successful parsed responses (like _raw_response) - Add completion:usage hook that fires after each attempt with cumulative token counts, enabling observability integration - Add token_budget parameter to create() that raises TokenBudgetExceeded if cumulative tokens across all attempts exceed the configured limit - TokenBudgetExceeded exception includes usage data, budget, and attempt count This prevents runaway retry costs in production and gives users visibility into how many tokens their retries actually consume when things work. Refs 567-labs#2391
|
Reviewed for merge readiness. Directionally this is the right PR to keep for #2391 / the #2056 retry-cost thread, and I closed the older conflicted #2296 in favor of this one. Before merge, I would like this refreshed with CI and one follow-up check: make sure cumulative usage is exposed consistently for non-BaseModel successful response shapes too, especially |
Addresses reviewer feedback: - _total_usage is now attached to ListResponse results (list[Model]), not just single BaseModel responses - Added 5 async tests mirroring the sync coverage (usage attachment, hook firing, budget enforcement, under-budget success, list response) - Added 3 ListResponse-specific tests (sync) for budget enforcement and usage attachment on list results AdapterBase (primitive return types like str/int) cannot carry attributes, so _total_usage is only available on BaseModel and ListResponse shapes.
|
Thanks for the review and for closing #2296 in favor of this. Pushed an update addressing both points: ListResponse coverage: Async coverage: Added 5 async tests matching the sync suite - usage attachment, hook firing, budget exceeded, under-budget success, and list response attachment. All use Note on AdapterBase: For primitive return types (like 14 tests total now, all passing. |
## Summary - accumulate declared, nested, and unknown numeric OpenAI/Anthropic usage fields across retries while preserving non-numeric metadata - add corrective feedback when a Responses API retry receives no tool call - preserve raw iterable type hints through sync and async v2 parallel-tool wrappers - strengthen API-key-free coverage for current and future SDK usage counters ## Consolidated and superseded items - closes #2493 - consolidates contributor work from #2498, #2500, and #2501 with original commit authorship preserved - supersedes #2497 because it drops unknown `model_extra` counters - supersedes #2499 because its hand-maintained provider field lists would drift as SDKs evolve ## Validation - focused changed-surface suite: `110 passed` - broad offline v2/coverage suite: `2368 passed, 91 skipped, 73 deselected` - Ruff check and format check: passed - scoped `ty check`: passed - `uv lock --check`: passed - pre-commit hooks and `git diff --check`: passed The 73 deselected tests require live provider credentials. An unfiltered local run confirmed its 22 failures were provider network connections in the restricted environment; GitHub provider jobs remain the authoritative validation for those paths. ## Intentionally skipped - provider additions or expansions: #2436, #2435, #2423, #2409, #2384, #2322, #2306, #2298, #2283, #2168, #2086; issues #2408, #2383, #2365, #2260, #2084, #2076 - broad architecture, product, security, or streaming decisions: #2394, #2392, #2357, #2356, #2355, #2351, #2321, #2307, #2287, #2263; issues #2479, #2403, #2393, #2391, #2316, #2272, #2056 - dependency batch: #2433 - nontrivial examples and editorial/resource additions: #2468, #2405, #2401, #2354, #2346, #2311, #2305; issue #2404 These remain open because they need dedicated product, architecture, provider, security, dependency, or editorial review and are not required for the `1.15.5` patch release. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes retry usage totals and reask message content on failure paths; scope is limited and heavily covered by tests, with no auth or data-store changes. > > **Overview** > Bundles three v2 retry and wrapper fixes for a patch release. > > **Retry usage accounting** replaces hand-maintained token field sums with generic `_accumulate_models` on Pydantic usage objects. Numeric fields (including nested models and `model_extra` counters) add across retries; booleans and other non-numeric metadata are not treated as billable. OpenAI and Anthropic paths share this logic. > > **OpenAI Responses reask** appends a user correction when `RESPONSES_TOOLS` validation fails but the output has no tool calls (e.g. reasoning-only), so retries include feedback instead of repeating the same request. > > **Parallel tools** in `patch_v2` skips `prepare_response_model` and does not replace `response_model` with the handler’s prepared wrapper for parallel modes, keeping raw `Iterable[...]` hints so schemas and parsed results include every member type. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit bbddca1. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
Fixes #2391. Related to #2056.
Problem
The retry system tracks cumulative token usage across all attempts, but this data is only accessible when retries fail (via
InstructorRetryException.total_usage). On success, it's computed and thrown away. Users have no visibility into how expensive a successful extraction actually was, and no way to cap runaway costs from complex schemas that trigger many retries.This came up in the #2056 discussion where someone mentioned losing "hundreds of dollars a day" from retries they couldn't observe or control.
What this PR does
Three things, all backward-compatible:
1.
_total_usageon successful responsesAfter a successful extraction, the parsed model now has
_total_usageattached (same pattern as_raw_response):2.
completion:usagehookNew hook that fires after each API attempt with the running total. Enables integration with metrics/observability without touching core logic:
3.
token_budgetparameterOptional parameter that raises
TokenBudgetExceededif cumulative tokens exceed the limit:Files changed
instructor/v2/core/hooks.py- addedCOMPLETION_USAGEhook name +CompletionUsageHandlerprotocol +emit_completion_usage()methodinstructor/v2/core/errors.py- addedTokenBudgetExceededexceptioninstructor/v2/core/retry.py- emit usage hook, check budget, attach_total_usageon success, letTokenBudgetExceededescape the retry wrapperinstructor/v2/core/patch.py- passtoken_budgetfromcreate()down to retry logictests/test_token_budget.py- 6 tests covering all new behaviorBackward compatibility
All changes are additive. Existing code is unaffected:
token_budgetdefaults toNone(no enforcement)_total_usageis only attached, never requiredChecklist before requesting a review