# Adapter guarantee matrix

> What each integration guarantees, capability by capability, and the SDK versions it is tested against.

Source: https://docs.pyrula.io/agents/integrations/guarantees/

Every integration below records work in Pyrula's journal rather than only matching
another framework's API. The six drop-ins (`compat.openai`,
`compat.anthropic`, `compat.genai`, `compat.bedrock`, `durable_chat_model` and
`PyrulaDurability`) do that inside a Pyrula
run and pass through unchanged outside one; `native`, `as_langgraph_node` and `mcp.server`
own a run of their own, and `mcp.client` supplies its tools to the native runner as
ordinary `@tool`s. See
[what's durable, and what isn't](https://docs.pyrula.io/agents/integrations/durable-vs-not.md) for that
distinction and how to move work across it.

`yes` means the guarantee holds; `no` means the work re-executes and is not journaled;
`partial` means it holds within the boundary under Limits; `n/a` means the capability has
no meaning for that surface.

| Integration | streaming | structured output | tools | cancellation | restart | duplicate request |
|---|---|---|---|---|---|---|
| `native` | yes | yes | yes | yes | yes | yes |
| `compat.openai` | yes | yes | partial | n/a | n/a | n/a |
| `compat.anthropic` | yes | yes | partial | n/a | n/a | n/a |
| `compat.genai` | yes | yes | partial | n/a | n/a | n/a |
| `compat.bedrock` | partial | partial | partial | n/a | n/a | n/a |
| `as_langgraph_node` | n/a | n/a | n/a | yes | yes | partial |
| `durable_chat_model` | partial | yes | partial | n/a | n/a | n/a |
| `PyrulaDurability` | partial | yes | partial | n/a | n/a | n/a |
| `openai-agents (recipe)` | yes | yes | partial | n/a | n/a | n/a |
| `mcp.server` | n/a | n/a | n/a | yes | yes | partial |
| `mcp.client` | n/a | n/a | n/a | n/a | n/a | n/a |

## Column definitions

- **streaming:** A stream consumed live is journaled chunk by chunk as it arrives.
  - A replay serves the recorded response without calling the provider: chunk for chunk on the compat drop-ins, as the recorded message on native (whose replay is silent).
  - A stream abandoned partway is not journaled at all, so a truncated response is never replayed as if it were complete. On the compat drop-ins its spend is still metered, as an `llm:done` marked `estimated`.
- **structured output:** A structured (schema-validated) response is journaled whole.
  - A replay returns the identical parsed value without calling the provider.
- **tools:** The tool calls a model requests are journaled verbatim.
  - A replay returns the same requested calls without asking the provider, so a resumed run takes the branch it took live.
  - Executing a requested call is your code: durable only if it runs inside a step or a @tool. This guarantee does not provide that.
- **cancellation:** A cancel is requested on the store and observed cooperatively.
  - The run body sees it at its own boundaries: before each new step, step retry, child run, signal send, interrupt, emit, sub-agent call, or native or drop-in LLM or tool call starts, in ctx.run_child's wait, and wherever it checks ctx.cancelled. A step already in flight, in any gather leg, or a sleep is not interrupted: sibling legs finish their current step first.
  - A cancel that arrives while no body is running (still queued, or resuming after a crash) is checked before the body is entered, so that attempt ends cancelled without running.
  - The node or tool call awaiting the run ends with DurableInvocationCancelled once the run's terminal reflects the cancel.
  - Cancelling the awaiting caller's own task only detaches it; the run keeps going, and a later re-invocation re-attaches.
  - When a run ends, however it ends, each child run it started with ctx.run_child and has not collected is cancelled, terminated or left running by the child's parent_close policy (cancel by default). The children are closed before the run's own terminal is written, so a crash in between is repaired when replay reaches the terminal again.
- **restart:** A run whose worker dies mid-attempt resumes from its journal: completed steps and calls are read back, not re-executed, and their side effects are not repeated.
- **duplicate request:** A request submitted twice under one idempotency token attaches to the run the first submission created instead of starting a second.
  - The token lasts its own TTL; the run stays readable for the completed-run retention. Set retention to at least the token TTL if a late resubmission must read the result. A token that outlives its run still attaches rather than re-running the work.
  - Without a token, resubmitting an existing run id is refused, not attached: the token is how a caller says a resubmission is expected.

## Install and tested versions

| Integration | Extra | Tested versions |
|---|---|---|
| `native` | built in | no third-party SDK |
| `compat.openai` | built in | `openai` 2.2 – 2.41.0 (locked) and 3.41.0 (dedicated CI leg) |
| `compat.anthropic` | built in | `anthropic` 0.47 – 0.106.0 (locked) and 2.3.0 (dedicated CI leg) |
| `compat.genai` | `"pyrula[agents,gemini]"` | `google-genai` 1.46 – 2.34.0 |
| `compat.bedrock` | `"pyrula[agents,bedrock]"` | `boto3` 1.38 – 1.43.24 |
| `as_langgraph_node` | `"pyrula[agents,langgraph]"` | `langgraph` 1.0.0 – 1.2.4 |
| `durable_chat_model` | `"pyrula[agents,langchain]"` | `langchain-core` 0.3.67 – 1.4.1 |
| `PyrulaDurability` | `"pyrula[agents,pydantic-ai]"` | `pydantic-ai-slim` 2.62 – 2.63.0 |
| `openai-agents (recipe)` | built in | `openai-agents` 0.24.0 (dedicated CI leg, on `openai` 3.41.0) |
| `mcp.server` | built in | `mcp` 1.24 – 1.30.0 (locked) and 2.3.0 (dedicated CI leg) |
| `mcp.client` | built in | `mcp` 1.24 – 1.30.0 (locked) and 2.3.0 (dedicated CI leg) |

## Notes

- **`compat.openai`:** the sync OpenAI client journals only on a thread of the run (ctx.to_thread, ctx.map_threads): the run's loop does the journaling and the request runs on the calling thread. A sync call made on the run's own loop thread raises UnjournaledSurface before any request, and a thread started without the run's context is not journaled and warns once per call site. Still refused inside a run: with_raw_response and with_streaming_response, and responses.stream(response_id=...). A stream stopped early, cancelled or dropped is metered once as an estimate, also when it is still open as the run parks on an interrupt, a sleep or a signal wait, or ends. instructor composes when the client is journaled first (journaled(client) or compat.OpenAI(), then instructor.from_openai); wrapping a client already patched by instructor raises TypeError. Tested with instructor 1.17.0 on the locked openai (2.41) and on 3.3.0, the newest release that resolves with it; not on openai 3.4 or later, which need a newer jiter than instructor caps. AsyncOpenAI needs no thread
- **`compat.anthropic`:** the sync Anthropic client journals only on a thread of the run (ctx.to_thread, ctx.map_threads), as the sync OpenAI client does: a sync call on the run's own loop thread raises UnjournaledSurface before any request, and a thread started without the run's context is not journaled and warns once per call site. messages.create and beta.messages.create are journaled, streamed or not, and so are the SDK's messages.stream and messages.parse (and the beta pair) on a release that has them; beta.messages.tool_runner and beta.messages.thread are refused inside a run, as are with_raw_response and with_streaming_response and, on anthropic 0.7x, parse and output_format streams (only the beta stream managers take output_format there). A stream stopped early, cancelled or dropped is metered once as an estimate, also when it is still open as the run parks on an interrupt, a sleep or a signal wait, or ends. Server-side tools that act (mcp_servers, code execution, container skills) are refused too: they would run again on a what-if branch or after a crash, while a replay runs nothing. instructor composes when the client is journaled first (journaled(client) or compat.Anthropic(), then instructor.from_anthropic); wrapping a client already patched by instructor raises TypeError. Tested with instructor 1.17.0 on the locked anthropic, on 2.3.0 and on the 0.47.0 floor, in TOOLS and JSON modes and, with beta=True, in TOOLS mode (instructor's JSON modes reject the SDK's BetaMessage). AsyncAnthropic needs no thread.
  - AnthropicBedrock and AnthropicVertex, sync and async, are journaled by the same drop-in and covered by the same cells (priced by the canonical form of their model id); that coverage is in the vendor unit tests, not in the conformance class
- **`compat.genai`:** models.generate_content and generate_content_stream are journaled on the sync client (only on a thread of the run: ctx.to_thread, ctx.map_threads; a sync call on the run's own loop thread raises UnjournaledSurface before any request) and on client.aio, and so is every chats turn. batches.create, batches.create_embeddings and tunings.tune are journaled as writes. A replay makes no second request.
  - refused inside a run, because a replay of them would call the provider outside the journal: models.generate_images, edit_image, upscale_image, generate_videos, recontext_image and segment_image, aio.live, the interactions, agents and triggers APIs, a Python function or MCP session in config.tools unless automatic_function_calling is disabled, and the server tools that act (code_execution, mcp_servers, and any Tool field the drop-in does not list as read-only).
  - forwarded unjournaled, so a resume sends them again: embeddings, token counts and the other non-generation calls. A native GeminiLLM runs under the native row
- **`compat.bedrock`:** journaled(client) replaces converse and converse_stream on the boto3 bedrock-runtime client you built. boto3 is blocking, so a call journals only on a thread of the run (ctx.to_thread, ctx.map_threads) and raises UnjournaledSurface on the run's own loop thread; aioboto3 is not supported. A replay serves the recorded reply or the recorded stream events with no second request, and a failure of the call is journaled and replays as the same error class.
  - refused inside a run, because a replay of them would call the provider outside the journal: invoke_model, invoke_model_with_response_stream, invoke_model_with_bidirectional_stream, start_async_invoke, any operation that is not classified, and a server tool that acts (systemTool other than nova_grounding).
  - forwarded unjournaled, so a resume sends them again: count_tokens, apply_guardrail (which can be billed), get_async_invoke and list_async_invokes. A client a library captured converse from before journaled(client) ran keeps the unjournaled method. A native BedrockLLM runs under the native row
- **`durable_chat_model`:** only the async path (ainvoke/astream) journals; the synchronous path (invoke, via _generate) raises UnjournaledSurface inside a run rather than silently forwarding to the provider live and unjournaled
- **`PyrulaDurability`:** inside a Pyrula run the agent's model requests and function-tool calls are journaled and replay without calling the provider or running the tool again; the cells above say how far that goes per capability.
  - every model request and tool call is a cancel boundary: a cancelled run makes and journals no further call.
  - inside an @agent each model call is metered into llm:done and held to AgentLimits (call, token and cost caps); a plain @workflow writes the same llm:done for a drop-in call but holds no AgentLimits, so only a case run's cost cap sees that spend.
  - below the io capture level a model or tool result is withheld, and a failed call's step:error keeps only its exception type, and so does a failure that ends the run: its run:error keeps the code and the type and ships the message only at io.
  - a killed worker's run resumes without repeating a journaled call.
  - requires_approval, writes= and per-tool limits belong to a Pyrula @tool and do not apply to a Pydantic AI tool
- **`openai-agents (recipe)`:** a recipe, not an adapter: the SDK's default client is the journaled AsyncOpenAI (set_default_openai_client), and a model call that finished is not sent again on a resume, handoff and guardrail calls included, streamed or not. A tool's side effect runs once only inside a Pyrula @step with failure_error_function=None and max_function_tool_concurrency=1. A changed prompt, history or tool list raises ReplayInputChanged.
  - not journaled, and nothing says so at run time: the Responses websocket transport, LitellmModel and any-llm models, Realtime and voice. SDK Session memory raises ReplayInputChanged on a resume. A needs_approval interruption is not a durable pause. Trace export is sent again on a replay.
  - tested on openai 3.41; the SDK needs openai 3, so it cannot share an environment with pyrula[litellm] until litellm lifts its openai<3 cap
- **`mcp.server`:** a tool declared requires_approval=True is refused when the server is built: a tool call is one atomic step and cannot park for a reviewer. A predicate-gated tool is built and fails the call whose condition holds
- **`mcp.client`:** each tool a server exposes (mcp_servers=, ctx.mcp_tools) is an ordinary @tool: its result is recorded the first time it runs and replayed on recovery without calling the server again, so a run that already used a server replays even while that server is down. A call that still fails after its retries is recorded as that error and replays as it, as for any @tool

## Limits

- **`compat.openai` - tools; `compat.anthropic` - tools; `compat.genai` - tools; `compat.bedrock` - tools; `durable_chat_model` - tools; `openai-agents (recipe)` - tools:** the tool calls the model requested are journaled and replay verbatim; executing them is the caller's code, durable only if it runs in a step or @tool
- **`compat.openai` - cancellation, restart, duplicate request; `compat.anthropic` - cancellation, restart, duplicate request; `compat.genai` - cancellation, restart, duplicate request; `compat.bedrock` - cancellation, restart, duplicate request; `durable_chat_model` - cancellation, restart, duplicate request; `PyrulaDurability` - cancellation, restart, duplicate request; `openai-agents (recipe)` - cancellation, restart, duplicate request; `mcp.client` - cancellation, restart, duplicate request:** this surface contributes journaled calls to an enclosing run and has no run identity of its own; the run's lifecycle is the native row
- **`compat.bedrock` - streaming:** via converse_stream; invoke_model_with_response_stream is refused inside a run
- **`compat.bedrock` - structured output:** via converse with a forced tool in toolConfig; invoke_model, which takes a raw provider body, is refused inside a run
- **`as_langgraph_node` - streaming:** the node submits and awaits one durable run; it does not stream tokens
- **`as_langgraph_node` - structured output:** the node returns the workflow's own result under output_key, not a model response
- **`as_langgraph_node` - tools:** the wrapped workflow's tools are native @tools, covered by the native row
- **`as_langgraph_node` - duplicate request:** run identity is thread_id::<LangGraph task>::node_key: LangGraph's own resume (ainvoke(None, config)) re-attaches the same run, while every cycle pass and every new ainvoke starts its own. Re-sending the original input after a crash is a new invocation, not a resume, and re-attaching with different input raises ValueError
- **`durable_chat_model` - streaming:** the wrapper never overrides _stream/_astream, so astream() journals the whole response as one atomic call rather than recording chunks as they arrive; replay serves that response from the journal without calling the provider again, but a caller who abandons the stream after one chunk has already paid for a fully journaled call
- **`PyrulaDurability` - streaming:** a streamed request is one journaled call, so inside a run nothing streams live: event_stream_handler gets the events (no deltas) after the provider call has completed, and run_stream hands the response over whole. A stream abandoned after one chunk has already been journaled in full. A replay serves the recorded response without calling the provider, and the handler runs again with events rebuilt from it
- **`PyrulaDurability` - tools:** the tool calls the model requested replay verbatim, and a function tool on the agent runs as its own journaled call, so its result replays without running it again. A worker killed after a tool's effect and before its record runs that tool again, so a tool with an external effect needs an idempotency key (ctx.tool_call_id is stable across a resume). A toolset passed to agent.run(toolsets=) is refused inside a run. MCP and dynamic toolsets on the agent are journaled the same way but not covered by these tests
- **`mcp.server` - streaming:** a tool call returns one durable result; the server does not stream partials
- **`mcp.server` - structured output:** the tool's return value is its own shape, not a model response
- **`mcp.server` - tools:** this row is the outbound durable tool server, not a model client: there is no model asking it for tool calls to replay. Each tool it exposes becomes a durable run instead, and that run's durability is what this row's restart and duplicate request cells describe. The inbound MCP client is the `mcp.client` row
- **`mcp.server` - duplicate request:** strong when the client sends _meta.idempotency_key, which survives a reconnect; without one each call is its own run, so a retry re-runs the tool. The JSON-RPC request id is not a fallback: ids restart per session, and two clients sharing one would read each other's result
- **`mcp.client` - streaming:** a remote tool call returns one result; nothing is streamed
- **`mcp.client` - structured output:** the tool's result is the server's own shape, not a model response
- **`mcp.client` - tools:** this row is a tool source, not a model client: the model that asks for these tools is the native row, whose tools cell governs the request. What this row adds is on the execution side - see the row note
