Migration Guide: v0.8.x → v0.10.0
This guide covers the changes shipped across v0.9.0 (first-class OpenAI
and OpenRouter providers) and v0.10.0 (response metadata reaching
WorkItemResult). Both releases were designed to be mostly additive —
existing code keeps working unchanged. The one real breaking surface is
narrow and called out below.
Summary of changes
| Change | Breaking? | Required action |
|---|---|---|
LLMCallStrategy.execute() return shape (3-tuple) |
Sometimes | Only if you call .execute() directly on a built-in strategy |
WorkItemResult.metadata field added |
No | None |
WorkItemResult.gemini_safety_ratings deprecated |
No | Read metadata['safety_ratings'] instead (eventually) |
CachedTokenRates constants |
No | Pass to effective_input_tokens() for accurate non-Gemini billing |
OpenAIModel, OpenRouterModel and friends |
No | Optional new providers; install [openai] / [openrouter] extras |
OpenAIErrorClassifier, OpenRouterErrorClassifier |
No | Optional; pass to ParallelBatchProcessor if you want them |
DeepSeekModel / DeepSeekStrategy |
No | Optional new provider; install the [deepseek] extra |
ModelStrategy base class |
No | Internal dedupe; built-in strategies behave identically |
temperature=None to omit the parameter |
No | Use it for OpenAI reasoning models (o1/o3) that reject temperature |
effective_input_tokens() warns on implicit default |
Sometimes | Pass an explicit CachedTokenRates rate to silence the warning |
ErrorInfo.suggested_wait now honored as a cooldown floor |
No | None; only a parsed Retry-After sets it now |
Breaking change: 3-tuple execute() return shape
What changed
LLMCallStrategy.execute() now returns a 3-tuple:
where metadata: dict[str, Any] | None is forwarded into
WorkItemResult.metadata. All four built-in strategies — GeminiStrategy,
OpenAIStrategy, OpenRouterStrategy, PydanticAIStrategy — return the
3-tuple shape.
The framework still accepts the legacy 2-tuple (output, token_usage)
from custom strategies via a compat shim. Returning 2-tuple from your
custom strategy keeps working; you just don't get metadata in
WorkItemResult.
Why this matters
Most users never call strategy.execute() directly — the framework drives
it. The only place this breaks is direct callers, typically:
- Unit tests that exercise a strategy in isolation
- Custom processor implementations that drive strategies themselves
In those cases the 2-tuple unpack now fails with ValueError: too many
values to unpack.
Migration required if
You have code like:
…where my_strategy is one of the built-in strategies.
How to migrate
Unpack three values:
# Before
output, tokens = await strategy.execute(prompt, 1, 30.0)
# After
output, tokens, metadata = await strategy.execute(prompt, 1, 30.0)
# If you don't care about metadata in the test:
output, tokens, _ = await strategy.execute(prompt, 1, 30.0)
If you have a custom LLMCallStrategy and want to opt into the
metadata path so users see provider info in WorkItemResult.metadata,
update your execute() to return a 3-tuple:
class MyStrategy(LLMCallStrategy[str]):
async def execute(self, prompt, attempt, timeout, state=None):
response = await self.client.generate(prompt)
tokens = {...}
metadata = {"model": self.model, "finish_reason": response.stop_reason}
return response.text, tokens, metadata # 3-tuple
Returning 2-tuple still works — your strategy just yields
metadata=None on the resulting WorkItemResult.
Deprecation: WorkItemResult.gemini_safety_ratings
What changed
WorkItemResult gained a generic metadata: dict[str, Any] | None field.
Gemini safety ratings now live there under the safety_ratings key, same
shape as before. The legacy gemini_safety_ratings field is still
populated automatically (backfilled from metadata['safety_ratings'] via
__post_init__) for backward compatibility, but it's slated for removal
alongside the 2-tuple compat shim.
How to migrate
Switch reads from the named field to the metadata key when you next touch the relevant code:
# Before (now emits a DeprecationWarning on read)
ratings = result.gemini_safety_ratings
# After
ratings = (result.metadata or {}).get("safety_ratings")
Both paths still return the same data, but reading
gemini_safety_ratings now emits a DeprecationWarning. The field will
be removed alongside the 2-tuple compat shim in a future release.
New: provider-aware billing with CachedTokenRates
What changed
BatchResult.effective_input_tokens() previously hardcoded Gemini's 90%
cached-token discount, producing wrong numbers for OpenAI (50% discount),
Anthropic (90% on cache reads, 25% premium on writes), and DeepSeek
(98% since DeepSeek's April 2026 price cut; ~90% at the time of this
release). It now accepts a cached_token_rate parameter, with named
constants on CachedTokenRates:
from async_batch_llm import CachedTokenRates
billable = result.effective_input_tokens(CachedTokenRates.OPENAI)
billable = result.effective_input_tokens(CachedTokenRates.ANTHROPIC_READ)
billable = result.effective_input_tokens(CachedTokenRates.DEEPSEEK)
The default rate is CachedTokenRates.GEMINI for backward compat — if
you don't pass anything, you get exactly the same number as before.
v0.10.0 behavior change: calling
effective_input_tokens()without an explicit rate now emits aUserWarningwhen cached tokens are present (the implicit Gemini rate is wrong for other providers). The returned number is unchanged; pass an explicitCachedTokenRatesconstant to silence the warning. The no-cache case stays silent.
Migration required if
You're using effective_input_tokens() with a non-Gemini provider and
care about the accuracy of the result. Pass the matching constant (which
also silences the new warning).
Mixed-provider batches
For OpenRouter batches that mix providers per item, do per-item
arithmetic using WorkItemResult.metadata['provider']:
PROVIDER_RATES = {
"OpenAI": CachedTokenRates.OPENAI,
"Anthropic": CachedTokenRates.ANTHROPIC_READ,
"DeepSeek": CachedTokenRates.DEEPSEEK,
}
total_billable = 0
for r in result.results:
if not r.success:
continue
provider = (r.metadata or {}).get("provider")
rate = PROVIDER_RATES.get(provider, CachedTokenRates.OPENAI)
cached = r.token_usage.get("cached_input_tokens", 0)
inp = r.token_usage.get("input_tokens", 0)
total_billable += inp - int(cached * (1.0 - rate))
See OPENROUTER_INTEGRATION.md for more.
New: built-in OpenAI / OpenRouter providers
What changed
You no longer have to write a custom LLMCallStrategy for OpenAI or
OpenRouter. New built-ins:
# OpenAI
from async_batch_llm import OpenAIModel, OpenAIStrategy
model = OpenAIModel.from_api_key("gpt-4o-mini") # reads OPENAI_API_KEY
strategy = OpenAIStrategy(model)
# OpenRouter
from async_batch_llm import OpenRouterModel, OpenRouterStrategy
model = OpenRouterModel.from_api_key("anthropic/claude-haiku-4-5") # OPENROUTER_API_KEY
strategy = OpenRouterStrategy(model)
Install the matching extras:
The previous custom-strategy patterns in examples/example_openai.py
(pre-v0.9) still work; the example file has been rewritten to use the
new built-ins. If you're maintaining a custom strategy and want to
switch, deep dives are at OPENAI_INTEGRATION.md
and OPENROUTER_INTEGRATION.md.
Migration required if
Never required. The new providers are additive. Switch when convenient to drop the custom-strategy code.
New: built-in DeepSeek provider (v0.10.0)
DeepSeekModel / DeepSeekStrategy call DeepSeek's API directly. Unlike a
generic OpenAICompatibleModel pointed at DeepSeek, DeepSeekModel reads
DeepSeek's native prompt_cache_hit_tokens field into cached_input_tokens
(DeepSeek doesn't use OpenAI's nested prompt_tokens_details.cached_tokens),
so cache telemetry is accurate.
from async_batch_llm import CachedTokenRates, DeepSeekModel, DeepSeekStrategy
model = DeepSeekModel.from_api_key("deepseek-chat") # reads DEEPSEEK_API_KEY
strategy = DeepSeekStrategy(model)
# DeepSeek cache reads cost ~2% of normal (April 2026 pricing):
billable = result.effective_input_tokens(CachedTokenRates.DEEPSEEK)
DeepSeek is OpenAI-compatible, so reuse OpenAIErrorClassifier for its
error handling.
New: temperature=None to omit the parameter (v0.10.0)
The LLMModel protocol, all built-in models, and ModelStrategy now accept
temperature=None, which omits the parameter from the API call so the
provider uses its own default. This is required for OpenAI reasoning models
(o1/o3/etc.) that reject an explicit temperature:
model = OpenAIModel.from_api_key("o1-mini")
strategy = OpenAIStrategy(model, temperature=None) # don't send temperature
The default is still 0.0, so existing code is unaffected.
Behavior change: ErrorInfo.suggested_wait is now honored (v0.10.0)
suggested_wait was previously populated but unused. It is now applied by the
RateLimitCoordinator as a floor on the cooldown (the RateLimitStrategy
may wait longer, never shorter). To avoid a hardcoded constant silently
overriding your configured cooldown, the field now carries only genuine
server signals: OpenAIErrorClassifier parses the Retry-After header, and
the old DEFAULT_RATE_LIMIT_WAIT fallbacks were removed (the RateLimitStrategy
owns the default cooldown). No action required unless you built a custom
classifier that relied on the old constant being surfaced.
Compatibility cheatsheet
| You wrote | After upgrade |
|---|---|
out, toks = await s.execute(...) (built-in) |
Update to 3-tuple unpack |
out, toks = await s.execute(...) (custom) |
Still works (custom returns 2-tuple) |
Custom LLMCallStrategy.execute() returning 2-tuple |
Still works; opt into 3-tuple for metadata |
result.output, result.token_usage, result.error, etc. |
Unchanged |
result.gemini_safety_ratings |
Still populated (deprecated) |
result.effective_input_tokens() |
Still defaults to Gemini rate; warns if cached tokens present |
temperature=0.0 (default) |
Unchanged; pass None to omit for reasoning models |
See also
CHANGELOG.mdfor the full release-by-release detail.- Issue #8 for
the design discussion behind the metadata change, including why we
chose the 3-tuple-with-shim approach over a
RetryState-based handoff.