What's durable, and what isn't
An integration is durable only when its work is recorded in the journal. API compatibility alone gives no replay or crash recovery.
| Tier | Execution | Replay |
|---|---|---|
| T1 durable-native | A Pyrula run, step or tool | Recorded work replays without re-executing and may suspend for human input. |
| T3 compatibility | A compatible API outside a run | Calls execute normally and are not journaled. |
The guarantee matrix lists each capability’s tier and tested SDK versions.
Moving work from T3 to T1
Section titled “Moving work from T3 to T1”ctx.steparound anything that charges a card, writes to a system of record, or must not run twice.@toolfor consequential tools, so a crash mid-agent doesn’t re-fire them.as_langgraph_nodeto make one node of a graph a Pyrula run (LangGraph).
durable_chat_model
Section titled “durable_chat_model”Wraps a LangChain chat model so its async calls (ainvoke/astream) are journaled inside a Pyrula run. Install with pip install "pyrula[agents,langchain]".
from langchain_openai import ChatOpenAI
from pyrula.agents import agentfrom pyrula.agents.integrations import durable_chat_model
model = durable_chat_model(ChatOpenAI(model="gpt-6-luna"))
@agentasync def my_agent(ctx, message: str) -> str: result = await model.ainvoke([{"role": "user", "content": message}]) return result.contentOn replay the response comes from the journal. The sync path (invoke) raises UnjournaledSurface inside a run; outside a run both paths pass through.
Each call is a cancel and pause boundary. Don’t wrap the model in with_fallbacks or with_retry: stops arrive as exceptions, and a fallback would call a model live during replay. Use LangGraph’s RetryPolicy on the calling node. Whole-graph behavior is on LangGraph.
The OpenAI drop-in
Section titled “The OpenAI drop-in”pyrula.agents.compat.openai.AsyncOpenAI has the openai.AsyncOpenAI shape. Swap the import (pip install "pyrula[agents]"):
# from openai import AsyncOpenAIfrom pyrula.agents.compat.openai import AsyncOpenAI
client = AsyncOpenAI()resp = await client.chat.completions.create(model="gpt-6-luna", messages=[...])Inside a run, chat.completions.create and responses.create are journaled and replay without calling the provider. Outside a run they pass through with a one-time warning. For sync code, see Sync code and threads.
| Inside a run | Surfaces |
|---|---|
| Journaled | chat.completions.create and responses.create, including stream=True, response_format= and text={"format": ...}, and the SDK helpers built on them: chat.completions.parse / stream and responses.parse / stream |
| Passed through | embeddings, files, models, responses.delete, responses.input_items, responses.input_tokens, beta.assistants and other non-generation surfaces |
| Refused (would call the provider live on every replay) | completions, audio, images, videos, realtime, beta.chat, beta.realtime, beta.threads, responses.stream(response_id=...), responses.retrieve, responses.cancel, responses.compact, responses.connect, and with_raw_response / with_streaming_response at the top level, under chat, chat.completions, responses and beta |
Use create(...) or the helpers above instead of the refused surfaces; beta.chat.completions.parse is chat.completions.parse.
The OpenAI Agents SDK runs on this client: see OpenAI Agents SDK for what replays and what doesn’t.
The SDK’s parse and stream helpers
Section titled “The SDK’s parse and stream helpers”A helper is its create plus the SDK’s own result builder, so it shares create’s journal entry: chat.completions.parse(response_format=Model) and chat.completions.create(response_format=<Model's schema>) journal the same call and replay each other. On replay the SDK’s own parser rebuilds the typed object (message.parsed, output_parsed) from the stored reply and the helper makes no provider request; the stream helpers return the SDK’s own manager (get_final_completion(), text_stream, events) over the journaled stream. The request fingerprint covers the schema, so a changed response_format / text_format class refuses the resume.
- A reply the parser rejects (
LengthFinishReasonError, a pydanticValidationError) is journaled as a successful call and the same error is raised again on replay. - Your model’s validators and
default_factoryrun again on replay, so keep them free of side effects.request_idisNoneon a replayed object (response headers are not stored). - A manager you build and never enter sends and journals nothing. A stream you stop early, cancel or drop is metered once as an estimate, as for
create(stream=True). One still open as the run parks on an interrupt, a sleep or a signal wait, or ends, is metered the same way once the calls in flight have drained (a stream that ends meanwhile is journaled exact), ahead of the question the run re-journals last; the resumed attempt’s own call is metered on its own. responses.parse(parse_as=...)(openai 3.42 and later) parses with the class you name, in a run or outside one, and an input whoseconfiguration_updatechanges the format withoutparse_asraises the SDK’sTypeErrorbefore anything is sent.responses.streamhas noparse_as.responses.stream(response_id=...)andstarting_afterre-read stored server state and are refused in a run.with_raw_responseandwith_streaming_responsestay refused,.parseincluded.- If a future openai release moves the SDK internals a helper needs, the helper is refused in a run with an error naming the openai version (outside a run it calls the SDK’s own helper), until the drop-in is updated.
Chat completions and streams
Section titled “Chat completions and streams”chat.completions.create returns the SDK’s ChatCompletion, rebuilt from the journal on replay. stream=True returns one object that supports async for, async with and close(), live and on replay. It is not an openai.AsyncStream: its .response is a small stand-in (close(), headers) for the SDK’s stream helpers, not the HTTP response.
A streamed call is journaled, and its usage metered, when its last choice’s finish_reason chunk arrives (with n above 1, once every choice has one). With usage requested (stream_options={"include_usage": True}, or by the drop-in inside an @agent), the usage chunk that follows is recorded with it.
- Breaking out of the loop on the
finish_reasonchunk leaves the call journaled; a resume sends no request. - A stream you stop reading earlier is not journaled and runs live again.
- A stream the provider ends without a
finish_reasonchunk, read to its end, raisesProviderStreamTruncated, journaled like a provider error.
The Responses API
Section titled “The Responses API”responses.create is journaled like chat.completions.create. A replay returns the SDK’s Response rebuilt from the journal. With stream=True the object supports async for, async with and close(), and a replay yields the recorded events without calling the provider. It is not an openai.AsyncStream: code that reads headers or x-request-id off the stream raises AttributeError inside a run.
A streamed call is journaled when its terminal event arrives (response.completed, response.incomplete, response.failed or error). A stream you stop before it runs live again. A stream the provider ends without a terminal event raises ProviderStreamTruncated. Import it, and UnjournaledSurface, from pyrula.agents. The streamed and non-streamed forms are separate steps, so switching stream on a resumed call runs it live.
import asyncioimport json
import httpx
from pyrula.agents import AgentContext, agentfrom pyrula.agents.compat.openai import AsyncOpenAIfrom pyrula.agents.testing.runner import AgentRunner
provider_calls = []
def provider(request: httpx.Request) -> httpx.Response: # A stand-in for the OpenAI API, so this block runs offline. provider_calls.append(json.loads(request.content)) return httpx.Response(200, json={ "id": "resp_1", "object": "response", "created_at": 0, "model": "gpt-6-luna", "status": "completed", "parallel_tool_calls": True, "tool_choice": "auto", "tools": [], "output": [{"id": "msg_1", "type": "message", "role": "assistant", "status": "completed", "content": [{"type": "output_text", "text": "Pong.", "annotations": []}]}], "usage": {"input_tokens": 9, "input_tokens_details": {"cached_tokens": 0}, "output_tokens": 2, "output_tokens_details": {"reasoning_tokens": 0}, "total_tokens": 11}, })
@agent(name="responses_ping")async def ping(ctx: AgentContext) -> str: client = AsyncOpenAI( api_key="unused", base_url="http://openai.test/v1", http_client=httpx.AsyncClient(transport=httpx.MockTransport(provider)), ) response = await client.responses.create(model="gpt-6-luna", input="Ping?") return response.output_text
events = asyncio.run(AgentRunner(ping).run())assert events[-1].payload["result"] == "Pong." and len(provider_calls) == 1Failures. A provider error (a 429, a 404, a dropped connection) is journaled as the call’s outcome and re-raised. A body that catches it and retries takes the same branch on replay, with no request sent. The rebuilt error is the SDK’s own class with what code branches on. OpenAI and Anthropic: status_code, request_id, body, code, type and param (where the SDK has them), request, and a response with the status, the retry-after, retry-after-ms, x-should-retry and request-id headers and the provider’s JSON error body. google-genai: code, status, message, details and a response of the same shape. The response is rebuilt, not the live one: no other header is kept, request is the method and URL without query or credentials, and the request’s headers and body are never journaled. The body is scrubbed of secret-shaped values on the way in, and one over 16 KiB is not kept (the attribute is None and the response has an empty body). The response is an httpx (httpx2 on openai 3 and anthropic 2) response of the replaying SDK’s flavour, also for a google-genai error that was an aiohttp response live. message and str(error) are the live text, unscrubbed, as before. An error journaled by an earlier release replays as before, with the class and message only.
Refused arguments. These raise UnjournaledSurface before anything is written or sent:
background=True.conversation=: it is mutable server state. Chain withprevious_response_id, or send the fullinput.- Tools that OpenAI’s servers run with side effects:
mcp,code_interpreter, ashellwith a container environment,programmatic_tool_calling,image_generationand any tool type this release does not know.function,custom,computer,local_shell,apply_patch, a localshelland the read-onlyweb_search*,file_searchandtool_searchare allowed.
Tool calls. function_call items are part of the recorded response, so their call_ids replay unchanged. Wrap the tool’s side effects in ctx.step or @tool. The next call’s function_call_output is part of its fingerprint, so a tool that returns something different on resume raises ReplayInputChanged.
Server-side state. The first live call after the replayed prefix carries the previous_response_id recorded earlier. That response must still be stored at OpenAI (not created with store=False, not deleted, within the retention window), or the provider rejects the call with a 4xx. A stored prompt without a pinned version resolves to the latest version on a live re-run.
At-least-once. A call in flight when the process died has no recording, so a resume sends it again and the provider may bill it twice.
Replay check. A resumed call is compared on input, instructions, tools, tool_choice, text.format, previous_response_id and the stored prompt (id, version, variables). Model, sampling, store, metadata, reasoning, include and text.verbosity are not compared.
Metering. A Responses call inside an @agent counts toward max_llm_calls, max_total_tokens and max_cost_usd. Hosted-tool fees are not metered, so a cost cap undercounts runs that use them. With a stored prompt and no model=, the call is unpriced.
Size. Each journaled event is capped at 1,000,000 bytes by default. A streamed call records every event, roughly 170 bytes per five-character delta. A longer streamed answer ends the run with PayloadTooLarge after the call was paid for. Raise max_event_payload_bytes or stream shorter answers.
Calls inside a step or tool
Section titled “Calls inside a step or tool”A drop-in call made inside a ctx.step or @tool body is metered but not journaled. The step or tool result is the durable unit, and its body runs again whole on a retry, so the call itself has no journal entry.
- Metered. Before the call is sent it is held to the run’s caps (
max_llm_calls,max_total_tokens,max_cost_usd, and a case’s cost cap); after it, onellm:doneis written at once, so the spend survives a crash before the step ends. The event carries the enclosing unit’sstep_idandcall_indexand awithinfield:"step","tool", or"run"for a@toolcalled straight from a workflow body, which has no step to charge. - A cap behaves as it does outside a step. A refusal raised in the body fails the step or tool like any other error: it is not retried, it is journaled as the unit’s failure (a replay raises the same error and sends no request), and the run ends on
budget_exceededunless your code catches it. A body or atryaroundctx.stepthat catches the refusal carries on, for example with a cheaper fallback; the cap stays reached, so its next model call is refused again. A case’s cost cap (live_budget_exceeded) is routed the same way. - Replay. A step that finished replays its recorded result, so its calls are not made again and its
llm:doneevents are not repeated. The spend and themax_llm_callscount are rebuilt from the journal. A step that was open at a crash runs again, and its new calls are metered again, because they are billed again. - Streams. A streamed call is held to the caps before it opens and metered once: an exact
llm:donewhen it is read to its end, anestimatedone when it is closed, dropped or fails first, as for a journaled stream. It is not recorded, so a replay never reads it again. - Late replies. A sync tool that times out leaves its request running on its thread. If the reply arrives while the run is live it is metered once; after the run’s terminal event nothing is written, so that spend is not counted and a WARNING (
model_spend_after_terminal, with the model and token counts) says so. - Server-side tool fees. Per-request fees for server-side tools (Anthropic MCP and server tools, OpenAI hosted tools, Bedrock server tools) are not metered; only token usage is. Gemini automatic function calling stays refused inside a step or tool, since it would hide several model requests behind one cap check.
- Not covered. The SDK surfaces that are forwarded unwrapped (
completions,audio,with_raw_response, …) pass through unmetered inside a step or tool. A call inside a Pydantic AI model request is already counted by the adapter and is not counted twice.
Sync code and threads
Section titled “Sync code and threads”pyrula.agents.compat.openai.OpenAI (also from pyrula.agents.compat import OpenAI) is the sync twin: a subclass of openai.OpenAI with the same journaled, passed-through and refused surfaces as AsyncOpenAI, including rate_limit=, copy() and with_options(). A built openai.OpenAI converts in place with journaled(client). The sync Anthropic client, pyrula.agents.compat.Anthropic, follows every rule below the same way (AsyncAnthropic is its async twin, and journaled(anthropic.Anthropic(...)) converts a built one).
A run’s body is async, and the run’s event loop does all its journaling. A sync client therefore journals only when it is called on a thread of the run: await ctx.to_thread(fn) for one call and await ctx.map_threads(fn, items) for a fan-out. The thread carries the run’s context, and each call gets its own replay scope (thread:<n>, map:<n>:<i>), so concurrent threads have stable keys and a resume replays each one. See Blocking calls in threads.
import asyncioimport json
import httpx
from pyrula.agents import AgentContext, agentfrom pyrula.agents.compat.openai import OpenAIfrom pyrula.agents.testing.runner import AgentRunner
provider_calls = []
def provider(request: httpx.Request) -> httpx.Response: # A stand-in for the OpenAI API, so this block runs offline. provider_calls.append(json.loads(request.content)) return httpx.Response(200, json={ "id": "chatcmpl-1", "object": "chat.completion", "created": 0, "model": "gpt-6-luna", "choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "Pong."}}], "usage": {"prompt_tokens": 9, "completion_tokens": 2, "total_tokens": 11}, })
@agent(name="sync_ping")async def ping(ctx: AgentContext) -> str: client = OpenAI( api_key="unused", base_url="http://openai.test/v1", http_client=httpx.Client(transport=httpx.MockTransport(provider)), )
def ask() -> str: reply = client.chat.completions.create( model="gpt-6-luna", messages=[{"role": "user", "content": "Ping?"}] ) return reply.choices[0].message.content
return await ctx.to_thread(ask)
events = asyncio.run(AgentRunner(ping).run())assert events[-1].payload["result"] == "Pong." and len(provider_calls) == 1- On the loop thread. A sync call made directly in the async body would block the run’s heartbeats and cannot be journaled, so it raises
UnjournaledSurfacebefore any request is sent. The message names the fix:await asyncio.to_thread(<your function>)(orawait ctx.to_thread(<your function>)), or the async twin (AsyncOpenAI,AsyncAnthropic). - Outside a run, a sync call is a passthrough, as for the async client. Inside a
ctx.stepor@toolbody a call is metered, not journaled: see “Calls inside a step or tool” above. A sync call written directly in anasyncstep or tool body runs on the loop thread and cannot be metered, so it raisesUnjournaledSurfacebefore any request is sent. Call it asawait asyncio.to_thread(fn)(orawait ctx.to_thread(fn)) there, or use the async client. - A thread started without the run’s context (
threading.Thread,ThreadPoolExecutor,run_in_executor) sees no run. While a run is bound in the process, the call logs a warning once per call site namingctx.to_threadandctx.map_threads, and it runs live again on every resume. - Streams. A sync stream supports
for,withandclose(), live and on replay, and is not the SDK’sStream(no.response). Journaling and abandonment follow the async rules: a stream you stop reading earlier is metered once, markedestimated, and runs live again on resume. - Cancellation. The request runs on the calling thread. If the run is cancelled or its attempt ends while the request is in flight, the thread’s request finishes on its own, is billed and is not journaled. A sync call can bill a whole reply where the async client, which aborts the HTTP request, may bill part of it. The thread’s next call into the run raises
RunAttemptEnded. - Rate limits.
rate_limit=reserves its slot through the run’s loop, for a journaled call on a thread of the run and for a passthrough call on a thread inside a step or tool. The one case with no slot is a sync call with no run bound, which runs unlimited, on the SDK’s own retries, and logs a warning. On the loop thread the call is refused, as above. A streamed call is charged its estimate; its usage is not reported back to the slot.
instructor
Section titled “instructor”The journaled clients work unchanged under instructor: each attempt, a reask after a validation error included, is one journaled create whose messages (the reask text too) are in its fingerprint, so a resume replays every attempt with no second request. TOOLS, JSON_SCHEMA and RESPONSES_TOOLS modes (with a reask) are tested with the sync client in ctx.to_thread and with the async client; create_partial streaming is tested with the sync client in TOOLS and RESPONSES_TOOLS.
Tested versions: instructor 1.17.0 on the locked openai (2.41) and on openai 3.3.0, the newest release that resolves with it. instructor 1.17 caps jiter below 0.15 and openai 3.4 and later need a newer one, so instructor on openai 3.4 or later is untested until instructor relaxes the cap.
import instructorfrom pyrula.agents.compat import OpenAI
patched = instructor.from_openai(OpenAI()) # the drop-in, wrapped by instructorextracted = await ctx.to_thread( lambda: patched.chat.completions.create(model="gpt-6-luna", response_model=User, messages=[...]))instructor captures the client’s bound chat.completions.create when you wrap the client. Journal first:
| Order | Result |
|---|---|
instructor.from_openai(compat.OpenAI()) | Journaled. |
instructor.from_openai(journaled(openai.OpenAI())) | Journaled. |
instructor.from_openai(compat.AzureOpenAI(...)) | Journaled. |
journaled(client) after instructor.from_openai(client) | instructor keeps the SDK’s unjournaled method: its calls run live again on every resume. |
journaled(instructor.from_openai(client)) | TypeError: pass the provider client, before from_openai. |
The module-level client (openai.chat.completions.create) is not covered; construct compat.OpenAI() instead.
Azure OpenAI and Bedrock
Section titled “Azure OpenAI and Bedrock”AzureOpenAI and AsyncAzureOpenAI (and, on an openai release that has them, BedrockOpenAI and AsyncBedrockOpenAI) come from pyrula.agents.compat the same way: each is a subclass of the SDK’s class with the surfaces, rate_limit=, copy() and with_options() of OpenAI and AsyncOpenAI, and journaled(client) converts an Azure or Bedrock client you built. On an openai release without Bedrock, importing a Bedrock name raises ImportError naming the release. OpenAI(provider=...) (openai 3.x only) is the SDK’s own constructor and is journaled like any OpenAI. To detect Bedrock support, test hasattr(openai, "BedrockOpenAI"), not the compat module.
An Azure model is a deployment name, so its price is known only once a reply has named the model. The first call of a deployment under max_cost_usd skips the “no price” check; the reply’s model (the underlying model, such as gpt-4o) prices it from then on, unless the deployment name has a price row of its own, and llm:done records the deployment as requested_model. A reply whose model has no price stops the next costed call with unpriced_model. A streamed Azure call always sends stream_options.include_usage, because the meter needs the usage chunk. Whether every Azure api_version accepts it is not verified yet (the live Azure check, B102, will); until then, pin an api_version that does. Binary inputs (images, PDFs) of an abandoned Azure stream are counted at the largest image bound of any OpenAI model, since a deployment name does not say which one it is. A Bedrock model id is canonicalised (openai.gpt-oss-120b-1:0 is priced as gpt-oss-120b); a Bedrock application inference profile ARN is opaque like an Azure deployment.
Anthropic
Section titled “Anthropic”pyrula.agents.compat.anthropic.AsyncAnthropic and the sync Anthropic (pip install "pyrula[agents]") journal messages.create and beta.messages.create, streaming or not, and rebuild the Message (or BetaMessage) or the stream events on replay. A stream is journaled when message_stop arrives; truncation and failure follow the chat stream rules. messages.batches.create, beta.messages.batches.create and (anthropic 2.x) beta.dreams.create are journaled as writes: a replay returns the recorded object instead of creating another job. The sync client journals on a thread of the run, as described above for OpenAI.
- The SDK’s helpers.
messages.stream,messages.parse,beta.messages.streamandbeta.messages.parseare journaled as theircreateplus the SDK’s own builder (parseexists only on an anthropic release that has it; on 0.47hasattr(client.messages, "parse")isFalse). The parsed message is rebuilt by the SDK’s parser from the stored reply and the stream manager (text_stream,get_final_message(), events) runs over the journaled stream; neither makes a second provider request.output_format=is part of the replay check, a reply the parser rejects replays as the same error, your model’s validators run again on replay, andrequest_idisNoneon a replayed message. A manager you never enter sends and journals nothing. A stream you stop early, cancel or drop is metered once as an estimate. On anthropic 0.7x only the beta managers takeoutput_format, so thereoutput_format=streams andparseuse the SDK’s own helper outside a run and are refused inside one; a plainmessages.stream()is journaled on every release. - Refused in a run:
beta.messages.thread(the helper, not thethread=argument below) andbeta.messages.tool_runner,beta.sessions,beta.deployments,with_raw_response,with_streaming_responseand the legacycompletions. Their replay would call the provider again outside the journal. Config surfaces likebeta.agentspass through. - Server-side tools that act are refused, on
messages.createandbeta.messages.createalike:mcp_servers,mcp_toolset, thecode_executiontools (includingbash_code_executionandtext_editor_code_execution),advisor_*,browser_toolset,container={"skills": ...}and any tool type this list does not know (a type is matched by its exact family name plus an optional date, so a lookalike such asmemory_server_*is refused). Anthropic’s servers run them, so a crash before the journal write, a what-if branch or a re-run would run them again; a journal replays answers, not their side effects. The error names the argument. Put the call insidectx.stepor a@tool(the step replays its result), or leave the tool out. Custom tools,web_search,web_fetch,tool_searchand the client-executedbash,text_editor,computerandmemorytools are allowed: your code runs the last four, and the first three only read. This matches the OpenAI Responses rule. Before this releasemessages.createforwarded these tools; it now refuses them. - Replay check. A resumed call is compared on
system,messages,tools,tool_choice,output_configandcontainer; a beta call also onbetas,output_format,context_management,compaction,fallbacksand thethread=argument. Model, sampling,thinkingandmetadataare not compared. - Server-held state. A call that continues a
thread=or reuses acontainerdepends on state Anthropic holds. The journal records the reply, not that state, so the first live call after the replayed prefix needs it to still exist. - Middleware. A call on a client with middleware (anthropic 2.x
middleware=orwith_middleware) is refused in a run, since a middleware can send a request more than once and one journaled call would hide the rest. None is classified as sending once yet. - Metering. Usage is read from
usage(a stream:message_start, then the cumulativemessage_delta), cache reads and writes included. A stream stopped early is metered once, markedestimated, with images and PDFs in the request counted at Anthropic’s per-image and per-page upper bounds.
Anthropic on Bedrock and Vertex
Section titled “Anthropic on Bedrock and Vertex”AnthropicBedrock, AsyncAnthropicBedrock, AnthropicVertex and AsyncAnthropicVertex come from pyrula.agents.compat the same way (pip install "anthropic[bedrock]" or "anthropic[vertex]", which bring boto3 and google-auth). Each is a subclass of the SDK’s own class, which is not a subclass of Anthropic, with the surfaces, refusals, replay check, rate_limit= and sync rules of the section above. journaled(client) converts a client you built and keeps the messages and beta resources the SDK assigned. Foundry and AWS clients are not converted yet.
from pyrula.agents.compat import AsyncAnthropicBedrock, AsyncAnthropicVertex
bedrock = AsyncAnthropicBedrock(aws_region="us-east-1")vertex = AsyncAnthropicVertex(region="us-east5", project_id="my-project")reply = await bedrock.messages.create( model="anthropic.claude-haiku-4-5-20251001-v1:0", max_tokens=256, messages=messages)- Prices. A Bedrock or Vertex id is priced by the id you asked for, then its canonical form:
anthropic.claude-haiku-4-5-20251001-v1:0,us.anthropic.claude-haiku-4-5-20251001-v1:0andclaude-haiku-4-5@20251001are priced asclaude-haiku-4-5, the Anthropic row, unless a row keyed on the cloud’s own id exists (regional premiums are not in the packaged table).llm:donerecords the canonical id asmodeland your id asrequested_model. An application inference profile ARN names no model: its first call skips the “no price” check and the reply’s model prices the next. - Bedrock stream metrics. The last event of a Bedrock stream carries
amazon-bedrock-invocationMetrics. Usage is read frommessage_startandmessage_deltaalone, so those counts are not added; the key stays on the recorded event and comes back on replay. - Optional packages. Importing a name needs no boto3 or google-auth. A Bedrock client with an
api_key(orAWS_BEARER_TOKEN_BEDROCK) never needs boto3; SigV4 signing does, and the first call without it raisesImportErrornaminganthropic[bedrock]. A Vertex client without google-auth fails in the SDK, which namesanthropic[vertex]. - Local failures are not journaled. A failure raised before a request is sent (no AWS or Google credentials, a request that could not be signed, a missing boto3) says nothing about the model, so it is re-raised without a journal entry and a resume asks again. A status error, a timeout or a connection error is journaled and replays as the same class, as for
AnthropicandOpenAI, a refused connection included: a resume replays that error instead of retrying, whereasjournaled(boto3_client).converseleaves a connect failure unjournaled. A call that fails locally still takes its rate-limit estimate. - Not supported by the cloud. Bedrock has no batch API, so
messages.batches.createraises the SDK’s own error before any request. Server-side tools that act are refused as above. rate_limit=. Requests that wait for a slot are sent by a copy of the client with retries off. The SDK’scopy()of the Bedrock client leaves outaws_profileand builds a new HTTP client, so the drop-in rebuilds it with both, and a Bedrock client signs with its profile and sends through yourhttp_client.copy()andwith_options()on the drop-in keep the profile and the HTTP client for the same reason.
instructor with Anthropic
Section titled “instructor with Anthropic”The same rules as for OpenAI: journal first, then instructor.from_anthropic. TOOLS and JSON modes (with a reask) are tested with the sync client in ctx.to_thread and with the async client, create_partial streaming in TOOLS, and beta=True in TOOLS mode, which sends beta.messages.create. instructor 1.17’s JSON and JSON_SCHEMA modes reject the BetaMessage that beta.messages.create returns, with or without pyrula, so they do not combine with beta=True.
Tested versions: instructor 1.17.0 on the locked anthropic, on anthropic 2.3.0 and on the 0.47.0 floor. instructor takes the Bedrock and Vertex drop-ins too (instructor.from_anthropic(compat.AnthropicBedrock(...)), a reask included); it does not accept Foundry or AWS clients.
| Order | Result |
|---|---|
instructor.from_anthropic(compat.Anthropic()) | Journaled. |
instructor.from_anthropic(journaled(anthropic.Anthropic())) | Journaled. |
journaled(client) after instructor.from_anthropic(client) | instructor keeps the SDK’s unjournaled method: its calls run live again on every resume. |
journaled(instructor.from_anthropic(client)) | TypeError: pass the provider client, before from_anthropic. |
Gemini
Section titled “Gemini”pyrula.agents.compat.genai (pip install "pyrula[gemini]") replaces from google import genai:
from pyrula.agents.compat import genai
client = genai.Client(api_key="...")genai.Client is a subclass of google.genai.Client, so isinstance checks and every constructor argument are the SDK’s; types, errors and every other name forward to google.genai. models.generate_content and models.generate_content_stream are journaled on the sync client and on client.aio, and so is every chats turn, since the SDK builds a chat over models. batches.create, batches.create_embeddings and tunings.tune are journaled as writes: a replay returns the recorded job instead of creating another. A client you built converts in place with journaled(client); read client.aio after that, since an aio reference taken before it stays the SDK’s own and unjournaled. The sync client journals on a thread of the run, as described above for OpenAI, and raises UnjournaledSurface on the run’s own loop thread. The SDK’s client has no copy or with_options, and neither has this one.
- Refused in a run:
models.generate_images,edit_image,upscale_image,generate_videos,recontext_imageandsegment_image,aio.live, and theinteractions,agentsandtriggersAPIs. A replay of any of them would call the provider again outside the journal. Embeddings, token counts, model, file, cache, tuning reads and cancel, and store calls pass through: they are not generation. - Tools. A Python function or an MCP session in
config.toolsis refused unlessautomatic_function_calling={"disable": True}is set (ormaximum_remote_callsis 0 or less): with it on, the SDK runs your function and calls the model again inside onegenerate_content, which a replay cannot skip. Pass function declarations and run the calls yourself in actx.stepor@tool, or use an@agentwith Gemini tools. The tools that act on a server are refused too:code_execution,mcp_serversand anyToolfield this list does not know. Allowed:function_declarations,computer_use(your code performs the actions), and the tools that only read:google_search,google_search_retrieval,google_maps,url_context,file_search,retrieval,enterprise_web_search,exa_ai_searchandparallel_ai_search. A Python function with automatic calling off is fingerprinted by its name, signature and docstring, so changing a parameter refuses a resume; an MCP session has no stable form and is not fingerprinted. A replay runs none of them again, but a crash before the journal write or a what-if branch asks Google to search again, and the results can differ. - Replay check. A resumed call is compared on
contentsand, fromconfig,system_instruction,tools,tool_config,response_schema,response_json_schema,response_mime_typeandcached_content. Inline bytes (a PDF, an image, audio) and a PIL image are compared by a digest, never stored. Model, sampling,thinking_config,safety_settings,candidate_countandseedare not compared. - The replayed reply is the journaled
GenerateContentResponse(parsedrebuilt whenresponse_schemais a type), withoutsdk_http_responseandautomatic_function_calling_history. A journal written by a newer google-genai is read by an older one with the fields it does not declare dropped. - Automatic continuation (google-genai 2.x, on by default) is the SDK sending follow-up requests inside one call. The merged reply is one journaled call with the summed usage; a replay sends none of them.
- Rate limit.
genai.Client(rate_limit=LLMRate(...))takes a slot per live call, sync andaio, under the same default key asGeminiLLM, so a drop-in and a native client share a queue. The SDK has nomax_retriesand does not retry unless you setHttpOptions(retry_options=...), per client or per request; those retries and continuation hops happen inside the one slot. - Server-held state.
cached_contentnames and uploaded files live at Google. The journal records the reply, not that state, so the first live call after the replayed prefix needs it to still exist. - Metering. Usage is read from
usage_metadata(a stream: the last chunk that has it, which is cumulative): prompt and tool-use prompt tokens less cached tokens are input, cached tokens are cache reads, candidates and thoughts tokens are output. A stream stopped early is metered once, markedestimated, with images and PDFs in the request counted at Gemini’s per-image and per-page upper bounds and function-call arguments counted as output.
instructor with Gemini
Section titled “instructor with Gemini”Journal first, then instructor.from_genai: it calls client.models or client.aio.models when you call it, so it composes with compat.genai.Client() and with journaled(client) called before it. Tested with instructor 1.17.0 in GENAI_TOOLS mode, the sync client in ctx.to_thread and the async client, on the locked google-genai and on the 1.46.0 floor. Calling journaled(client) after from_genai is not tested.
Amazon Bedrock (boto3)
Section titled “Amazon Bedrock (boto3)”journaled(client) takes the boto3 bedrock-runtime client you already built (pip install "pyrula[bedrock]") and makes converse and converse_stream journaled, in place:
import boto3from pyrula.agents.compat import journaled
client = journaled(boto3.client("bedrock-runtime", region_name="us-east-1"))reply = await ctx.to_thread(lambda: client.converse(modelId=model_id, messages=messages))The client is still a boto3 client, with your profile, region and Config. boto3 is blocking, so a call journals on a thread of the run (described above) and raises UnjournaledSurface on the run’s own loop thread; aioboto3 is not supported. A resumed run replays the recorded reply (a stream’s events, ResponseMetadata stubbed) and makes no second request. rate_limit=LLMRate(...) takes a slot per live call under the same default key as BedrockLLM, so the two share a queue; botocore’s own retries happen inside that one slot and one journaled call. Call journaled(client) before instructor.from_bedrock or anything else that captures client.converse, and not on a method you already took a reference to.
- Refused in a run:
invoke_model,invoke_model_with_response_stream,invoke_model_with_bidirectional_streamandstart_async_invoke, and any operation a newer boto3 adds that is not classified: a replay would call the provider again outside the journal. Useconverse.count_tokens,apply_guardrail,get_async_invokeandlist_async_invokespass through and are not journaled, so a resume sends them again.apply_guardrailcan be model-backed (guardrail filters and contextual grounding are billed), and a guardrail edited between attempts can answer differently: run it in actx.stepwhen its verdict decides what the run does. - Tools. Your own
toolSpectools are allowed: the model asks, your code runs the call in actx.stepor@tool. A server-runsystemToolis refused except the read-onlynova_grounding. - Replay check. A resumed call is compared on
messages,system,toolConfig,outputConfig,promptVariablesandguardrailConfig. Image, document, video and audio bytes are compared by a digest, never stored.modelId,inferenceConfig,additionalModelRequestFields,additionalModelResponseFieldPaths,requestMetadata,serviceTierandperformanceConfigare not compared. - Errors. A service error (throttling, validation, access denied) and an error event in a stream are journaled once and replay as a
ClientErrorwith the recordedErrorcode, status and request id;except client.exceptions.ThrottlingExceptionstill matches. The rest of the response, headers included, is not kept. Failures botocore raises before the request goes out are not journaled, so a resume runs the call live: a request botocore rejects (ParamValidationError;Config(parameter_validation=False)turns that check off here too), missing credentials or region, and a connection that could not be made (EndpointConnectionError,ConnectTimeoutError). A read timeout is journaled, because the request was sent and may be billed; it replays asReadTimeoutError, as does a transport failure in the middle of a stream (ResponseStreamingError), so a resume does not send the call twice. - Metering. Usage comes from the reply’s
usage(a stream: themetadataevent), with cache read and write tokens counted as cache tokens. Spend is priced by the canonical id ofmodelId; an application inference profile ARN names no model, so it is priced only if you add a price for it. A stream stopped early is metered once, markedestimated, with images and documents counted at the Claude family’s per-image and per-page bounds (the largest bound of any family for other models) and tool-input deltas counted as output.
instructor with Bedrock
Section titled “instructor with Bedrock”Journal first, then instructor.from_bedrock(client, mode=instructor.Mode.BEDROCK_TOOLS): it reads client.converse when you call it. Tested with instructor 1.17.0, the sync client in ctx.to_thread, on the locked boto3 and on the 1.38.0 floor. The async form (async_client=True) calls boto3 on the event loop, which a run refuses.
The drop-ins and durable_chat_model count toward max_llm_calls, max_total_tokens and max_cost_usd (Spend caps). A side effect your code runs after the model replies (a tool call inside a LangGraph node) reruns on resume; make it a @tool or ctx.step.
build_mcp_server
Section titled “build_mcp_server”Exposes Pyrula tools over MCP, running each call as a durable workflow. Host setup for Claude Code and Cursor is in Serving tools over MCP. Ships with pip install "pyrula[agents]".
from pyrula.agents.mcp import build_mcp_server
server = build_mcp_server([charge_card, send_email], store=store)server.run_stdio() # or server.run_http(auth=...)The server submits the run and waits; a separate worker on the same store executes it, so a crash mid-tool resumes when a worker restarts.
- Tools must be context-generic (no
ctx.llm, no sub-agents). A tool whose first parameter is annotatedToolContextgets one with no turn bound (ctx.idempotency_keyisNone); others get aWorkflowContext. read_only=Truetools are advertised with MCPreadOnlyHint.- A
requires_approval=Truetool raisesToolRegistrationError; a predicate-gated tool is built and fails per call when its condition holds. - The tool call is one durable step: the body cannot call
ctx.step,ctx.interrupt, timers or signals.
Idempotency depends on the client. With a stable _meta: {idempotency_key: "..."}, a resubmission after a crash re-attaches the existing run; the token is scoped to the caller (the authenticated principal, or what identity_from_request returns) and to the tool, and reusing it with different arguments fails with IdempotencyConflict. Without one, every call is its own run and a retry runs the tool again; the JSON-RPC request id is not a fallback, since ids restart per session and two clients would read each other’s result. Pass idempotency_from_request to derive a token from any request field.