Skip to content

feat(llm): background-mode OpenAI calls, call metadata, cancellation, bounded retries - #31

Draft
nflechner wants to merge 5 commits into
forecastingresearch:mainfrom
nflechner:llm-background-mode
Draft

nflechner wants to merge 5 commits into
forecastingresearch:mainfrom
nflechner:llm-background-mode

Conversation

@nflechner

Copy link
Copy Markdown
Collaborator

Why

On Oct 19 a dwh-llm-forecaster run cost $1,148 for zero forecasts. A VPN closed idle connections; the synchronous responses.create call sits silent for the minutes the model works, so every call was billed server-side but never returned. The retry wrapper then re-ran each task up to 5 times (times the SDK's own 3), and nothing held a response id, so nothing could be cancelled or fetched afterwards. This PR makes an OpenAI call something the caller holds a handle to from the first second.

Paired with dwh-llm-forecaster#24, which consumes it. forecastbench pins 843ae21 and is unaffected until it bumps; when it does, it gets background mode automatically with no call-site change.

What

OpenAI provider: Responses API background mode. responses.create(background=True) returns at once; the provider polls retrieve (2 s backing off to 10 s) until the response leaves queued/in_progress. No connection is idle while the model works. The response id is handed to the caller the moment the request is accepted. A failed poll is polled again for as long as the budget lasts; the generation it watches is already paid for and is never started again because a short status request failed. MetaProvider keeps the synchronous call since the Meta route has no background mode.

Budget and cancellation. A run's timeout option is now the budget for the whole background response (default 30 min when absent); when it runs out the response is cancelled server-side and the call fails without retry. A caller's cancel event stops the call at the next checkpoint; with cancel_server_side (the default) the response is cancelled, without it the response keeps running so it can be fetched later. fetch_response(id) returns a finished response, wait_for_response(id) polls a still-running one to completion; neither ever creates a new generation.

Call metadata. New get_response_with_metadata(prompt, context=..., **options) on ModelRun, model_registry and the providers returns an LLMResponse: text, provider response id, status, LLMUsage (input/output/reasoning tokens, tool-call count), attempts, duration. get_response keeps returning the text, so existing callers are unchanged. Anthropic fills the same metadata from its streamed final message. CallContext (utils/llm/types.py) carries the cancel event, the cancel_server_side flag and the response-id hook.

Retries. Attempts are per provider: 5 for OpenAI (in background mode a retry only follows a failed create or a response that ended failed, neither billed), 2 for every other provider (a dropped stream is a billed response lost). A response that ends incomplete raises LLMResponseIncomplete and is never retried: the same request would hit the same cap and be billed again. The wait between attempts ends early on cancel. Failed attempts are logged at WARNING so they show under a caller's default logging.

Docs. README section on metadata, cancellation, fetching by id and background mode. AGENTS.md records that transport mechanics (Anthropic streaming, OpenAI background mode) are provider behaviour, not run options, so they stay out of option fingerprints and immutability, and that an OpenAI run's timeout is the whole-response budget.

Semantic changes to published runs

  • The timeout: 900 on the gpt-5.6-sol variants was an SDK per-request timeout (a call cut at 15 min, retried up to 5 times). It is now the budget for the whole background response, after which the response is cancelled and the call fails without retry. Runs without timeout get 30 min (before: the SDK default of 10 min, then retries). The create/retrieve requests themselves keep the SDK default timeout.
  • Background mode needs store=true, the API default; responses are retained 30 days.
  • Anthropic streams cannot be cancelled mid-call; the cancel event is honoured between retries only.

Commits

  • 650127e feat(llm): background-mode OpenAI calls, call metadata, cancellation, two attempts
  • ab3c7cd fix(llm): retry failed polls in place; wait_for_response for a running response
  • de47c57 fix(llm): poll through failures, keep 5 attempts, never retry incomplete
  • fe4adce fix(llm): five attempts for OpenAI, two for every other provider
  • 7380e71 fix(llm): log failed attempts at WARNING

Tests

pytest tests/unit: 185 pass. isort, black, flake8, pydocstyle clean. Pre-existing and untouched: tests/unit/test_archiving.py fails on Python 3.13 (tarfile DeprecationWarning under filterwarnings=error).

New tests cover: polling to completion with id and usage extraction; cancel after accept (server-side cancel, no retry, no further poll) and cancel without server-side cancel; budget overrun; a failed poll retried in place and the generation created once; a failed response retried, an incomplete one not; fetch_response and wait_for_response never calling create; Anthropic id and usage from the final message; per-provider attempt counts; cancel before and during the retry wait; WARNING-level attempt logs.

Live, through dwh-llm-forecaster against the real APIs (Oct 21): four runs on the LEAP survey that failed on Oct 19, three with 3 question groups and one full survey with both gpt-6-astra and claude-opus-5-5 (16 calls, 92 rows). Every OpenAI response id was logged 1–4 s after the run started, before any result existed; one create and one completion per call, no retries, no duplicate generations; usage on every call; the dashboards show exactly the number of requests made. Cancellation, budget expiry and wait_for_response have unit coverage only so far.

🤖 Generated with Claude Code

https://claude.ai/code/session_013tLtyvBGmSaTwPwS81PuTB

nflechner and others added 5 commits October 20, 2026 14:11
… two attempts

The OpenAI provider now requests responses in the Responses API background
mode and polls them, so no connection stays idle while the model works and
a finished (billed) response cannot be lost to a dropped connection. The
response id is reported to the caller as soon as the request is accepted,
the response is cancelled server-side when the caller gives up or the run's
`timeout` budget is exceeded, and `fetch_response(id)` returns an earlier
response without a new call. Meta's OpenAI-compatible route has no
background mode and keeps the synchronous call.

A new `get_response_with_metadata` entry point (ModelRun, model_registry,
providers) returns an `LLMResponse` with the provider response id, status,
token usage, tool-call count and attempt count; `get_response` keeps
returning the text. The Anthropic provider fills the same metadata from
its streamed final message. A `CallContext` carries the caller's cancel
event and response-id hook.

The retry wrapper now makes two attempts in total instead of five, stops
early when the cancel event is set, and logs failed attempts at WARNING so
they are visible under a default logging setup.

Background mode is transport, not a model option, so it is not part of any
run's declared options or fingerprint; AGENTS.md records that precedent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013tLtyvBGmSaTwPwS81PuTB
…g response

A failed `responses.retrieve` poll is now retried in place (up to ten in a
row) instead of propagating to the retry wrapper, which would have started
a second, billed generation for a short status request that failed.

`wait_for_response(id)` on the providers, the registry and `ModelRun` polls
a background response another process started until it finishes, with the
same cancellation and budget as a fresh call and never a new create. A
resumed forecaster run uses it for a response its first process left
running, so a crash or kill cannot lead to the same call being paid twice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013tLtyvBGmSaTwPwS81PuTB
- A failed background poll is polled again for as long as the response
  budget lasts instead of escalating after 10 failures, so a poll outage
  never starts a second (billed) generation; the budget ends the wait
  with a non-retryable TimeoutError.
- Restore the default of 5 attempts and INFO-level retry logging.
- An `incomplete` OpenAI response (usually the output-token cap) raises
  LLMResponseIncomplete, which is never retried.
- CallContext.cancel_server_side=False stops waiting on cancellation and
  leaves the response running so it can be fetched later.
- Document that an OpenAI run's `timeout` is the whole-response budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqM83D2dF3X9btmt37ft7C
In background mode an OpenAI retry only follows a failed `create` or a
response that ended `failed`, neither of them billed, so it keeps five
attempts. Other providers' failed attempts (a dropped Anthropic stream,
a synchronous Meta call) are usually billed and lost, so they get two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqM83D2dF3X9btmt37ft7C
A failed attempt may be billed, so it must be visible to callers that
configure no handler for utils.llm (Python's default shows WARNING).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqM83D2dF3X9btmt37ft7C
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants