Persistent predictor calls¶
Use explicit sampling settings, stable backend identities and retained receipts
when model draws must survive a restart. Language-model calls return
GenerationResult; decision calls return an ordered probability distribution.
Both interfaces can use CallStore and PersistentBackend.
Qualification¶
Small live probes cover Luna reasoning/schema requests, Decisions distributions, prefix-cache usage and OpenAI Batch submission/collection. These do not establish application-scale quotas, production cache savings or reconciled billing.
Qwen/vLLM and native d3 need GPU qualification for the exact model, runtime, hardware and settings being deployed. Offline tests alone do not establish model loading, reasoning and schema behavior, token/logprob alignment, memory use, numerical correctness or process cleanup. Validate these in the actual runtime. Container deployment also needs application termination and restart checks against its persistent storage.
Reasoning and answer format¶
reasoning_effort=None inherits the model or template default. An explicit
"none" disables reasoning where the configured backend supports it. Unsupported
efforts raise before dispatch.
For Luna Chat Completions:
from llenvs.inference import ChatMessage, ResponseSchema, SamplingParams
from llenvs.inference.backends import OpenAIBackend
schema = ResponseSchema({
"type": "object",
"properties": {"percentage": {"type": "integer", "minimum": 0, "maximum": 100}},
"required": ["percentage"],
"additionalProperties": False,
}, name="prediction")
params = SamplingParams(
max_tokens=4096,
reasoning_effort="medium",
temperature=None,
top_p=None,
response_schema=schema,
)
with OpenAIBackend(model="gpt-6-luna", max_retries=0) as backend:
result = backend.generate_chat([ChatMessage("user", "Estimate the percentage.")], params)
The qualified Luna Chat Completions endpoint accepts none, low, medium,
high and xhigh; it rejects max as unsupported. Its inherited
reasoning default is medium: omitting effort still requires
temperature=None, top_p=None and logprobs=False. none permits explicit
decoding controls. The backend uses max_completion_tokens, covering reasoning
and answer output. A schema does not eliminate refusals, truncation or the need
to validate the returned distribution.
For local Qwen3.8 templates, configure
reasoning_family="qwen3.8". The accepted explicit efforts are
none, low, medium and xhigh. Per-request effort overrides backend template
defaults. Qwen3.8 retains historical reasoning by default. Raw prompts passed to
in-process generate() cannot apply a chat-template effort; use generate_chat().
Chat, prefix and continuation-scoring paths translate ChatMessage.reasoning
to the template's native reasoning_content field. Opaque reasoning_details
cannot be rendered by this template and raise before dispatch.
response_schema uses native OpenAI/vLLM structured outputs. In-process vLLM's
configured Qwen path selects xgrammar, the qwen3 reasoning parser and
enable_in_reasoning=False. The owned vLLM server uses the corresponding engine
settings. Hugging Face rejects schema requests.
Text before the reasoning closure¶
Configured local families also accept after_reasoning_text. Supply text in
the model's voice without <think> or </think> delimiters:
from dataclasses import replace
local_params = replace(
params,
temperature=0.6,
top_p=0.95,
reasoning_effort="xhigh",
after_reasoning_text="I will now return the requested JSON object.",
)
The backend renders the initial prompt once, samples free reasoning until its verified closing token or explicit thinking budget, then supplies:
initial prompt IDs + sampled reasoning body IDs + supplied text IDs
+ supplied closing marker IDs + answer separator IDs
Tokenized template rendering explicitly requests a plain token-ID list; tokenizer metadata dictionaries are never passed as the phase's input tokens.
The answer is a second generation, optionally constrained by the schema. The sampled phase-one closing token and its original logprob remain in the record, although the supplied closing marker appears at a different position in phase two. Supplied text has no sampled-token logprobs. With reasoning disabled, the backend inserts before the template's empty-block closing marker and skips the reasoning generation.
max_tokens limits total sampled output across both phases, including a sampled
closing marker. Supplied tokens consume context and phase-two input usage, but
not the sampled-output budget. Exhausting the total budget yields a truncated
empty answer. EOS or a caller stop before the boundary does not authorize an
answer phase. Insertion requires an explicit output budget and known context
limit; it rejects images, per-block budgets and soft-budget controls.
metadata["phases"] retains each phase's IDs, logprobs, usage, finish information
and effective request. phase_two_prompt_ids and context_transformation record
the actual second context and supplied spans. These calls are not append-only
sampled trajectories. Training consumers must use the separate contexts and
token provenance.
Records and usage¶
Every result has a detached, sanitized metadata["request"], a requests list,
requested_sampling, and inherited/omitted control names. API request records
describe the serialized body after extra_body merging; local records describe
native generation and template options. LiteLLM records are labeled
request_capture="litellm_input" because its SDK can transform them. Public
request records exclude model inputs, credentials, callbacks and clients.
GenerationResult.text contains the answer. Native reasoning, raw generated IDs
and original logprobs remain in metadata. Public token_logprobs are sliced to
the answer only when its exact native span is known. Closed APIs can supply
token strings and probabilities without IDs; those IDs are None. An absent
reasoning trace is not reconstructed. An unclosed local reasoning block has an
empty answer and explicit truncation metadata.
The nullable usage fields are:
| Field | Meaning |
|---|---|
prompt_tokens |
Total input tokens |
completion_tokens |
Total generated output tokens |
cached_prompt_tokens |
Cache-read subset of input |
cache_write_prompt_tokens |
Cache-write subset of input |
reasoning_tokens |
Reasoning subset of output |
Missing counts stay None; reported zero stays zero. total_tokens and phase
sums propagate unknowns. Reasoning and cache subsets are not added to totals
again. Anthropic's ordinary input excludes cache categories, so its normalized
input sums ordinary input, cache reads and cache writes. Raw usage, including
provider-specific write-duration details, is retained.
Evaluation transitions retain these usage fields, finish reason and detached
result metadata in transition.info["generation"]. With second elicitation,
metadata["elicitation_results"] retains both generations and their original
logprobs; requests includes both requests. Available first-pass reasoning is
carried into the elicitation history separately from answer text.
Provider-reported reasoning counts take precedence. Exact counts from verified
local reasoning spans are labeled reasoning_tokens_source="token_ids".
Per-phase input includes reasoning reprocessed in phase two. Attempts retain
their own usage and elapsed/backoff time; successful-result totals do not also
add failed-attempt expenditure. Prices belong to the caller.
Backend identity and lifecycle¶
backend.descriptor() returns JSON-compatible identity and provenance.
Replay hashes only identity: model/revision or local content manifest,
tokenizer/processor/template digests, runtime content, dependency versions,
precision, hardware and relevant engine/batching/cache settings. An owned
server's random port and installation paths are provenance. Changing runtime,
precision or template changes identity.
Owned vLLM resolves a Hub revision before launch and uses it for server and host tokenizer loads. Offline resolution uses the cached snapshot revision. Its descriptor reads the reported engine configuration, including effective prefix caching; it does not infer settings from library defaults. Container identity uses the actual SIF content digest. Native container digests are explicitly null.
Hosted aliases have version_pinned=False. A retained response is replayable,
but does not establish immutable hosted weights. Local model descriptors require
an immutable revision or a content manifest. Runtime content identities require
non-editable wheel or source builds; editable
loader metadata is not accepted as a digest of the executed code.
Instance startup/provenance and observed shutdown are separate lifecycle records. A hard kill can leave shutdown
time unknown; GPU allocation accounting belongs to the caller.
OpenAIBackend and OpenRouterBackend accept independent connect timeout and
read_timeout values, in seconds. read_timeout must be finite and positive;
omitting it keeps the SDK default of 600 seconds. Both synchronous and async
clients use these settings. OpenAIDecisionBackend accepts them through the same
transport. They do not change model or call identity. A read timeout after
dispatch leaves an unknown outcome in a persistent store; a shorter timeout does
not make that draw safe to repeat. Use max_retries=0 to disable SDK retries.
Retaining individual draws¶
from llenvs.inference import CallStore, PersistentBackend
with CallStore.open("./model-calls", run_descriptor={"experiment": "pilot"}) as store:
with PersistentBackend(
OpenAIBackend(model="gpt-6-luna", max_retries=0), store,
) as backend:
results = backend.generate_chat_batch(
[[ChatMessage("user", "Question A")], [ChatMessage("user", "Question B")]],
params,
repeat_ids=["unit-a:replicate-0", "unit-b:replicate-0"],
)
store.mark_complete() # Retains records, receipts and lifecycle events.
The logical key includes backend identity, operation, exact inputs and settings, selection scope and repeat ID. Batch position, siblings and batch size are excluded. Reusing a request and explicit repeat ID retrieves the same draw across regrouping, subset retries and process restarts. Duplicate explicit positions are coalesced and restored to their original positions. Distinct repeat IDs identify independent draws.
Single methods use repeat_id=...; batch methods use aligned repeat_ids=....
Without explicit IDs, occurrence counters distinguish identical calls, and replay
requires the original invocation order. Use explicit IDs when regrouping work.
scope=... separates phases/selections; persist=lambda scope: ... can exclude
selected scopes from storage.
GCS journals¶
Install llenvs[gcs] and pass a GCSJournal instead of a filesystem path:
from llenvs.inference.persistence import CallStore
from llenvs.inference.persistence.storage.gcs import GCSJournal
journal = GCSJournal(
bucket="experiment-artifacts",
prefix="model-calls/pilot",
cache_dir="./batch-file-cache",
max_concurrency=128,
)
with CallStore.open(
journal, run_descriptor={"experiment": "pilot", "shard": 0}, validation="lazy",
) as store:
# Use PersistentBackend or OpenAIBatchBackend with this store.
pass
The bucket must already exist. Authentication uses the Storage SDK's normal
mechanism; client=... accepts an already authorized client without constructing
another one. Grant object read, create, replace and delete access for the journal,
plus the required object-listing permission. Ordinary bucket IAM prefix
conditions do not restrict the scope of listing; choose the bucket/access scope
accordingly. See GCS IAM conditions.
The extra is independent of the inference backend extras.
max_concurrency is a positive integer, defaulting to 128. It bounds concurrent
record downloads and uploads, including reads for cold replay and full startup
validation. It controls storage I/O independently of model-request concurrency;
changing it does not change run or call identities. Lower it for a smaller I/O
budget. Discovery, queued updates and the decoded-record cache are bounded;
active requests and submission payloads require additional memory.
Clients created by GCSJournal have connection pools at least as large as
max_concurrency. Supplied clients keep their transports by default. To enlarge
the pools of a supplied native Requests session, pass
connection_pool_size=256 (or another integer at least max_concurrency). This
preserves its authentication, retry policy and pool blocking settings, and never
shrinks larger pools. Configure pools before concurrent use of that client.
Custom adapters and wrapped transports reject explicit automatic tuning; configure
those transports yourself and omit connection_pool_size.
Independent call updates share a rolling window of at most max_concurrency
admitted records. Each bounded cohort checks ownership before its uploads.
While those uploads run, later queued writes can join the checked cohort for
at most 100 ms after its first record is admitted. The cohort ends sooner on
its first native completion, its reserved record limit or a fatal failure.
The deadline does not extend when more writes arrive. Each admitted write starts
immediately; the coordinator does not wait to fill a cohort before uploading.
In a healthy store, each confirmed write is acknowledged and releases its window
slot without waiting for another write in its cohort. Ownership reads for later
cohorts do not block those acknowledgements. Queued updates and observer work are bounded
by twice max_concurrency (one for a single storage worker). Record transfers
also obey the shared I/O limit. A single storage worker and filesystem journals
keep writes serial.
Close stops waiting for new arrivals and drains queued and admitted writes.
Each caller waits for its own confirmed durable state. A started intent is
durable before model dispatch, an answer is durable before it is returned, and
updates of one key remain ordered. Batch retains its individual call records and
state transitions; submission and collection write those records concurrently.
Active calls retain their observed generations to avoid redundant metadata reads.
The absence read for a new call supplies its generation-zero creation precondition;
its first write needs no separate metadata read.
Existing journals use the same object layout and remain readable.
Call records, receipts, Batch journals and exact Batch file bytes are authoritative
GCS objects. The local directory only caches Batch files; losing it does not lose
call state. Without cache_dir, the open store uses a temporary directory and
removes it on close. Batch transfers stream through disk with bounded transfer
buffers. blob_path() materializes authoritative bytes and verifies them by
default. store.run_dir is None for GCS; it remains a filesystem path for local
stores. Use the store's methods rather than manipulating cloud objects directly.
Each run has one persistent owner. Ownership has no timeout: a paused worker must not become eligible for replacement. Normal close conditionally releases exactly that owner. If a worker dies or ownership release cannot be confirmed, first confirm through the orchestrator that the old worker has terminated, then recover the observed owner token explicitly:
owner = journal.current_owner(run_descriptor={"experiment": "pilot", "shard": 0})
# Only after external confirmation that this owner's worker has terminated:
journal.acknowledge_owner_termination(
run_descriptor={"experiment": "pilot", "shard": 0},
expected_owner=owner,
reason="Orchestrator confirmed termination of the previous worker",
confirmed_terminated=True,
)
Recovery retains the assertion and reason before releasing ownership. It rejects a stale token and does not reset call attempts or authorize resubmission of an unknown outcome. The library does not query a scheduler. This contract requires termination; a separate ownership object is not transactional fencing against a still-running writer. Give concurrent shards distinct stable run descriptors.
The owner check and record writes are separate operations. Already checked
records can finish if ownership changes during their uploads, bounded by the
max_concurrency window across all cohorts. Each subsequent cohort checks again
and refuses a different owner. This does not provide fencing for a paused
worker. Recovery still requires confirmed termination.
Writes use generation preconditions and bounded retries. Lost acknowledgements
are reconciled against the operation's exact bytes. A fatal storage, decoding or
serialization failure rejects new requests and stops admission of new writes.
Already accepted writes drain and publish confirmed outcomes before
unresolved outcome waiters fail. This also applies when the failure is in a
separate Batch journal, file transfer or lifecycle operation: a confirmed call
still returns with its original source. Intent acknowledgements are held during
the drain, preventing new model dispatch from those writes. The failing operation
can report its error before other admitted writes finish; close waits for them.
Batch collection errors retain confirmed progress in partial_collection.
Generation checks apply to
ownership, call state, journals, artifacts and deletions. Storage retries never
authorize another model draw. Keep the prefix private and trusted: journal pickles
are executable Python artifacts, and checksums detect accidental corruption.
External deletion or expiration of live journal objects invalidates replay
guarantees. mark_complete(delete=True) removes the run's objects after checking
for unresolved calls; ownership and recovery assertions remain separate.
Finalization and close wait for active journal operations and accepted native
writes, including when further admission fails, and run exclusively.
A handle whose run was deleted refuses subsequent calls; close it normally to
release ownership.
Close also releases active generation metadata and fails unresolved local waiters
with CheckpointError; it does not resolve an uncertain provider outcome.
Lazy startup does not read or list completed calls. It still discovers submission journals and lifecycle records; full validation reads all retained records and referenced Batch files. GCS request, storage and transfer costs are additional to inference costs. Offline storage tests do not establish live-cloud performance or platform restart behavior.
Call identity and reuse¶
Persisted generation, decision and scoring results, and individual call errors,
carry metadata["persistence"]:
{
"run_fingerprint": "...", # Store run identity.
"call_key": "...", # Logical draw, also used in the journal.
"source": "store",
"previous_attempts": 0, # Attempt-history length at invocation entry.
}
source |
How this invocation obtained the result |
|---|---|
backend |
This invocation delegated the call to the backend |
store |
A completed result or failed/unknown outcome was already retained in the journal |
receipt |
A retained receipt was parsed locally |
shared |
This position joined an already reserved call, including a duplicate batch position |
batch |
This collection obtained an outcome from the retained Batch submission |
The identity stays the same across replay, regrouping and process restarts.
Provenance describes each returned position, so a mixed batch can contain new,
replayed and shared outcomes. Controlled reasoning/answer phases carry their own
keys and sources in each phase's metadata; a backend invocation can reuse a
previous phase. A completed parent replay preserves the original phase records
and marks the parent result as store. Batch sources describe collection, not
submission: collecting an earlier submission does not buy another model draw.
With wait=True, sources cover the entire collection call across all polls.
previous_attempts snapshots the call's attempt-history length when this
invocation reserves the key. Retries during the invocation do not change its
returned count; their diagnostics remain in the attempt records. A subsequent
invocation can therefore report a larger count. Reading a stored outcome or
reparsing a receipt does not add attempts. Batch collection snapshots history at
collection entry and retains it across polls. Controlled phases snapshot at
their own reservation boundary; a joined parent reporting an unknown child
retains that child's snapshot. Counts are local observations, not proof of
provider execution or charges. An aggregate controlled call has separate child
histories rather than a sum of phase attempts.
For a PartialBatchError, read each exception in error.failures[index] (also
present in the corresponding error.results[index] slot). Those exceptions
retain their native type, status and diagnostics on the initial invocation;
replayed errors retain the saved classification as described below. Each
position has detached provenance even when requests were coalesced. For
OpenAIBatchBackend.collect(), use collection.failures[index].
An UnknownOutcomeError for an individual call also carries this record and
retains its recovery key in error.key. An unknown controlled phase identifies
the child call requiring recovery. A Batch submission error can affect several
calls: it carries their records in error.metadata["persistence_calls"], while
error.key remains the submission ID used for Batch recovery. Local validation
and storage errors without an identified call have no call provenance.
Usage, raw receipts, sampled tokens and stored outcomes retain their original
values. The wrapper adds provenance to detached results and exceptions. An excluded
persist scope has no store provenance. ScoringResult.metadata is empty by
default and carries the same provenance when scoring is persisted.
Do not sum returned usage across repeated invocations as new spending. Use
(run_fingerprint, call_key) to identify repeated outcomes, and consult retained
attempt records for retries, failures and unknown outcomes. Phase costs require
the phase records rather than counting both their usage and the parent's total.
The source is not a billing assertion; provider prompt caching still represents
a new request and differs from a local replay.
The store takes an exclusive process lock. By default, opening it validates the manifest, retained records, journals and file checksums before dispatch. For a large store, opt into validation on access:
with CallStore.open(
"./model-calls", run_descriptor={"experiment": "pilot"}, validation="lazy",
) as store:
...
validation="lazy" checks the manifest, lifecycle events and submission journals
at open, then checks call payloads and Batch files when needed. It skips the
startup scan of call files. Requested records still undergo checksum, identity
and outcome validation before reuse or recovery. Detected corruption poisons
the store and prevents further dispatch. Corruption in unrelated, unread
payloads can remain undetected until access; close and reopen with
validation="full" (the default) for a complete integrity check. Lazy validation
does not reduce the number of call files or the size of Batch journals held in
memory. It does not change call keys or authorize resubmission of unknown work.
Pickles are trusted local artifacts: checksums detect corruption, not malicious changes. The private store contains full model inputs and raw responses. Keep it in an appropriate private location.
Nonsemantic callbacks and transport clients are excluded from private request identity. Custom policy callables, such as supplied logits processors, cannot establish stable identity and are rejected by persistence before dispatch.
Completion and recovery boundaries¶
The default checkpoint_granularity="request" requires individual completion
events. API and owned-server requests persist intent immediately before
dispatch, then a detached receipt before parsing, and a normalized result before
releasing the slot. Writes use a bounded worker outside the HTTP event loop.
Storage failures are fatal and poison the store.
In-process vLLM requires request_progress=True for native AsyncLLM completion
events. It retains native batching and a synchronous public interface. That mode
currently supports text chat/completions; prefix continuation and continuation
scoring require request_progress=False.
Hugging Face and synchronous native vLLM do not expose individual completion of
ordinary batches. Opt into checkpoint_granularity="batch" for the weaker
guarantee that only returned batches are durable. Controlled reasoning/answer
phases use stable child keys, including in this explicit batch mode.
A completed result replays. A durable receipt is reparsed locally. A definite
failure replays its saved error classification until an explicit recovery
decision; a legitimate model answer that downstream code cannot parse remains a
completed paid outcome. An attempt started without a durable receipt raises
UnknownOutcomeError and is not automatically resubmitted.
After investigating an uncertain outcome, a caller can explicitly accept possible duplicate expenditure:
store.acknowledge_resubmission(
key,
reason="Provider investigation could not recover the response.",
accept_duplicate_expenditure=True,
)
The decision and prior diagnostics remain recorded. This is not exactly-once
remote execution: a process can die after remote execution and before durable
receipt. SDK retries must be disabled (max_retries=0, or LiteLLM
num_retries=0); the backend owns bounded, recorded rate-limit retries. Ambiguous
network failures are not silently retried. Quota exhaustion is not a transient
rate limit.
mark_complete() retains everything. Explicit mark_complete(delete=True) is
allowed only when requests are completed or definitely failed; unresolved
submissions and unknown outcomes prevent deletion. Close a persistent backend
before its store to retain its observed shutdown event.
Decision backends¶
Decision calls return all requested option probabilities in request order. Missing, extra or duplicate labels, negative/nonfinite values and zero total mass raise. Valid non-unit distributions are normalized, retaining original values, sum and deviation. Confidence is not substituted for the distribution.
from llenvs.inference.decisions import DecisionOption, DecisionRequest
from llenvs.inference.decisions.openai import OpenAIDecisionBackend
question = DecisionRequest(
context="Dataset summary and held-out rows...",
question="Which range contains the hidden value?",
options=(DecisionOption("low", "Below 10"), DecisionOption("high", "At least 10")),
)
with OpenAIDecisionBackend(model="gpt-6-luna", max_retries=0) as backend:
result = backend.decide(question)
print(dict(zip(result.labels, result.probabilities)))
Install the decisions extra in a separate environment from LiteLLM and all;
see installation. It uses
OpenAI SDK >=3.26 and one named choice question at /v1/decisions. It supports
2..255 unique labels. Hosted version pinning and deterministic/batch-invariant
answers are not claimed. decide_batch() runs independent requests concurrently;
it does not use OpenAI's Batch service. Wrap it with PersistentBackend and use
decide(..., repeat_id=...) or decide_batch(..., repeat_ids=...) for persistence.
Native d3 worker¶
Install the d3 client extra. In a dedicated worker environment, install
GPU-compatible PyTorch and the pinned native runtime:
uv pip install --python /path/to/d3-worker/bin/python \
'vllm-srun @ git+https://github.com/vllm-project/semantic-router.git@72ce0e55f47a5f5b632719a28232308c57c78bc5#subdirectory=src/model-runtime'
from llenvs.inference.decisions.d3 import D3Backend
with D3Backend(
executable="/path/to/d3-worker/bin/vllm-srun",
revision="156a6d9330277b7e1a6571a1d881b516e7e1ea15",
device="cuda:0",
log_path="./d3-worker.log",
) as backend:
result = backend.decide(question)
This starts an owned loopback-only vllm-srun serve process without a container
frontend. It checks runtime source identity, health/API version, loaded revision,
choice limits, effective engine arguments, numerical contract and native golden
status. Requests use the native /v1/systemone mapping and request runtime
metadata. The served model ID can differ from its Hub repository name; responses
are checked against the verified worker identity. The response's model names
the served model, while meta.model_id names the card's repository, falling back
to its model ID when no repository is reported. Teardown signals only the owned
process group, with bounded SIGTERM/SIGKILL waits.
Loading and warming health responses are retried until the startup deadline;
terminal native failed health raises RuntimeError promptly. That error and
a startup TimeoutError expose startup_health: the last valid, credential-safe
{"status_code": ..., "body": ...} health snapshot, or None if no valid reply
was received. Their messages include the last native reason, when present, and
the worker log path. Transient connection failures or malformed nonready replies
do not replace an earlier snapshot. Health responses must use API major 3.
Ready workers with native golden status matched or unverified are accepted.
unverified can mean the runtime has no reference for that accelerator; the
original status and numerical contract remain in the descriptor's runtime card.
Failed, pending, missing or unknown golden status is rejected. Native dtype and
batching defaults are preserved, including approximate numerics when reported.
Readiness does not establish independent numerical parity or batch invariance.
The native decision3 family loads verified model artifacts through its own code. The model package's Python reference server is a separate parity-check path, not a fallback backend. Pin the resolved worker dependencies and qualify the actual accelerator, memory use, correctness and batching behavior before an experiment. Deterministic decoding does not imply invariance to batch composition.
OpenAI Batch submission and collection¶
import json
from llenvs.inference.backends.openai_batch import BatchHandle, OpenAIBatchBackend
with CallStore.open("./model-calls", run_descriptor={"experiment": "tree"}) as store:
with OpenAIBackend(model="gpt-6-luna", max_retries=0) as backend:
batch = OpenAIBatchBackend(backend, store)
handle = batch.submit_chat_batch(
[[ChatMessage("user", "Question A")]], params,
repeat_ids=["unit-a:replicate-0"],
)
saved_handle = json.dumps(handle.to_dict())
# Reopen the same store under exclusive ownership in another process.
with CallStore.open("./model-calls", run_descriptor={"experiment": "tree"}) as store:
with OpenAIBackend(model="gpt-6-luna", max_retries=0) as backend:
batch = OpenAIBatchBackend(backend, store)
collection = batch.collect(BatchHandle.from_dict(json.loads(saved_handle)), wait=False)
The Batch backend uses the same Chat Completions builder/parser. It splits exact UTF-8 JSONL bytes at 50,000 requests and 200,000,000 bytes, with one endpoint/model per file. Per-request transport options are unsupported. Account-specific queued token limits remain the caller's responsibility.
Before upload, the store retains exact file bytes/digest, logical keys and a submission UUID. It saves the upload ID before creation, includes the run/UUID/ digest in provider metadata, and saves the returned Batch ID immediately. Pending or unknown submissions are not enqueued again.
All pending call records are durable before the journal enters creating,
immediately before the create call. An intent, including one with a saved upload
ID or partially updated call records, resumes through submission or collection
without a recovery acknowledgment; a saved upload ID is reused. A hard kill
between the durable creating marker and HTTP dispatch remains uncertain: those operations
cannot be atomic. Existing creating journals retain this conservative rule,
even when some call records are not pending. A stop observed before invoking
create, or a native client already known to be closed at that boundary, leaves
the submission at intent. Arbitrary connection errors do not establish that
creation was undispatched.
Saving a handle is optional when the requests can be reconstructed. Reopen the
same durable store and call submit_chat_batch() with the same requests,
parameters and explicit repeat IDs to rebuild a handle. Request order and file
windows may change: existing keys reuse their submissions or stored outcomes,
and only eligible new attempts are uploaded. Preserve the run descriptor and
store across restarts.
When a creation acknowledgment is lost, collection paginates provider listings and matches metadata, endpoint and input-file ID. One match is adopted; zero matches remain unknown, and multiple matches require reconciliation. An absent listing match does not authorize another creation.
After investigating an unknown submission, an explicit
batch.acknowledge_resubmission(submission_id, reason=...,
accept_duplicate_expenditure=True) records a recovery decision. This alone does
not create a Batch. A subsequent explicit submission makes a new attempt under
the same logical keys, preserving the unresolved attempt's diagnostics and
unknown expenditure.
collect() reports aligned results, failure indices, pending keys and provider
statuses. wait=True polls existing submissions and never creates replacements
for unknown or still-pending work. A provably undispatched retained intent is
eligible for its original submission. Output/error files are retained before
parsing; entries are matched by logical custom_id, rejecting unknown/duplicate
IDs. Durable receipts and files can be recovered locally after parser/process
failure. Completed draws replay without provider access.
Collection normalizes and returns only the handle's keys, while retaining the
complete provider output/error files. Outcomes outside the handle are deferred
until a collection names those keys, even when the provider batch is already
terminal. Later subsets use the retained files without retrieving the provider
batch or downloading files again. The first collection returning a key's result
or failure reports source="batch"; later collections report source="store".
This applies across overlapping handles, reopened stores and process ownership
changes, including refusals, expired requests and missing outputs. Local receipt
reparsing keeps source="receipt". Attempt history and previous_attempts do not
change merely because requests are collected in different groups.
If collection raises after obtaining any outcomes, the same exception carries
partial_collection, a BatchCollection aligned with the handle. It includes
results and per-slot failures already committed or replayed, their usual sources
and attempt usage, the latest observed submission statuses, and uncollected keys
as pending. This includes progress from earlier wait=True polls and progress
before a retrieval, download or storage error. The exception's type and other
attributes are unchanged. There is no partial_collection when no outcome was
obtained, except for the cooperative stop described below.
try:
collection = batch.collect(handle, wait=True)
except Exception as error:
partial = getattr(error, "partial_collection", None)
if partial is not None:
record_outcomes(partial) # The same ledger path as a normal return.
raise
else:
record_outcomes(collection)
Carried outcomes count as returned: a later collection replays them with
source="store", while still-uncollected outcomes retain their usual first
collection source. The collection-level exception does not turn pending keys
into slot failures or authorize resubmission. Successful returns keep the same
BatchCollection fields and alignment. Carried outcomes are detached from the
store, just like normal returns.
Outcome persistence and the caller's ledger are separate writes. A process can
stop without a caught exception after committing an outcome, or before the caller
records a returned or carried outcome; a later
collection then replays it from the store. Reconcile ledger entries by run,
call key and attempt rather than assuming a replay proves the ledger already
contains the outcome. A batch source identifies collection, not a charge:
expired unexecuted requests were not run, and missing-output usage is unknown.
Cooperative stopping¶
submit_chat_batch(..., stop=event) and collect(..., stop=event) accept a
caller-owned threading.Event. With no event, ordinary behavior is unchanged.
After a check observes the event, the operation starts no more provider calls.
Checks occur during preparation, before upload/create/list/retrieve/download,
between submissions and between collection entries. Polling uses an
interruptible wait. Already admitted storage work drains before the operation
raises BatchStopped(RuntimeError), exported from llenvs.inference.backends
and its openai_batch module. A completed upload or download is retained before
stopping. Already submitted remote batches are not cancelled.
BatchStopped exposes:
handle: the recoverableBatchHandle, orNonewhen submission stopped before retaining recoverable work. A stopped submission can cover only a subset of its inputs; use the original requests and repeat IDs to reconstruct the full handle on restart. A stopped collection keeps its original handle.submission_states: a snapshot mapping retained submission UUIDs to journal states such asintent,creating,pendingorterminal. These are local journal states, rather than provider statuses or proof of remote execution.partial_collection: for collection, an alignedBatchCollection, including an empty one when nothing was obtained. For submission, this isNone. Collected results and failures keep their usual sources, usage and attempts; uncollected keys remain pending.
from threading import Event
from llenvs.inference.backends import BatchStopped
stop = Event() # The application sets this from its shutdown path.
try:
handle = batch.submit_chat_batch(messages, params, repeat_ids=repeat_ids, stop=stop)
collection = batch.collect(handle, wait=True, stop=stop)
except BatchStopped as error:
if error.partial_collection is not None:
record_outcomes(error.partial_collection)
if error.handle is not None:
save_handle(error.handle.to_dict())
# Close the store only after the stopped operation has finished.
else:
record_outcomes(collection)
A stop does not create slot failures, authorize resubmission or poison the store. Resume with the same durable store and requests/repeat IDs, or collect the saved handle. Retained collection files are reused, and committed outcomes are replayed without persisting them again. Clear or replace the event only after the stopped operation has finished.
Stopping is cooperative, with no absolute shutdown deadline. An upload, download, create or storage operation already in progress can finish or raise its original error. The event cannot interrupt a blocked synchronous network call; configure SDK/storage timeouts and the application's drain allowance accordingly. Closing the SDK client can cause an in-flight call to fail, but does not prove that a create request was never sent. Store ownership is released only after admitted writes have drained. Qualify the complete shutdown path against the deployment's network latency and termination deadline.
Failed slots and resubmission¶
Terminal expiry/cancellation/failure collects the handle's available successes
before marking its unfinished slots failed. Unrequested keys still need
collection before the store can be marked complete. A later
submit_chat_batch() replays stored failures with source="store", including
refusals, malformed responses, missing
logprobs and HTTP/per-request errors. A generated response may already be billed
even when parsing fails. Replaying a failure preserves its receipt and attempt
history without buying another draw.
For failures with a retained Batch entry, the exception exposes status_code
(including 200 for response-parsing failures), the optional provider error
code, and a detached attempts list with request/response IDs, pricing tier
and reported usage. Codes are taken from the per-request error or the response
body's error. These fields survive saved-error replay, including typed refusals
and malformed-response errors. transient follows the standard classification:
rate-limit 429 and server statuses of 500 or above are transient; quota exhaustion
is not. A missing output raises BatchUnfinished(RuntimeError) on first
collection, exported from llenvs.inference.backends and its openai_batch
module. Its saved replay remains ReplayedBackendError with
original_error_type="BatchUnfinished". It has no HTTP status or known attempt
usage and is not transient. Missing usage is unknown, not zero. Classification
does not authorize dispatch; the resubmission rules below still apply.
The only automatically eligible failed slot is a per-request batch_expired
error with no response in a terminal submission: the provider explicitly reports
that this request was not executed. A later explicit submit_chat_batch() sends
that key under a new submission UUID, preserving the previous attempt. A batch's
expired/cancelled/failed status, a missing output row or an arbitrary non-200
status does not establish that an individual request is safe to send again.
These failures continue to replay until the caller explicitly authorizes another
attempt:
store.acknowledge_resubmission(
call_key,
reason="Another draw is intended for this failed request.",
accept_duplicate_expenditure=True,
)
# Only a subsequent submit containing this key dispatches the authorized attempt.
This per-call decision is separate from the Batch-level authorization for an
unknown creation outcome. collect() never creates replacement attempts. A local
parser interruption keeps the original receipt for reparsing without provider
access. Missing/expired provider output files do not trigger regeneration. The
convenience generate_chat_batch(..., repeat_ids=...) submits, waits and uses
PartialBatchError for failed slots.
Result/attempt metadata records Batch ID, endpoint and pricing tier. Raw provider Batch usage summaries are retained for reconciliation and are not added again to per-request totals. Missing usage remains unknown expenditure. Pricing tables and cache-hit assumptions stay outside the backend. Decisions has no Batch backend.