Skip to content

Commit e7ce0b5

Browse files
committed
Treat 402 and 403 key-limit errors as one out-of-credits type
Exceeding an OpenRouter API key's credit limit returns 403 "Key limit exceeded", not 402; 402 is the account running out of credits. The Activity now raises OpenRouterOutOfCredits for both so the budget gate pauses on either. Verified live with a key limit set below usage.
1 parent dddc5bd commit e7ce0b5

6 files changed

Lines changed: 76 additions & 18 deletions

File tree

openrouter/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ These samples call [OpenRouter](https://openrouter.ai/) from Temporal Activities
55
| Sample | Description |
66
|--------|-------------|
77
| [prompt_batch](prompt_batch) | Fan one OpenRouter call out per prompt with OpenRouter's Auto Router, and collect answer, model, and cost per prompt. Shows Temporal-owned retries, `Retry-After` handling, and retries served for free from OpenRouter's response cache. Start here. |
8-
| [budget_gate](budget_gate) | The same batch, but it pauses instead of failing when money runs out, whether a soft budget in the Workflow or OpenRouter's own "insufficient credits" error, and resumes on a `raise_budget` Update. |
8+
| [budget_gate](budget_gate) | The same batch, but it pauses instead of failing when money runs out, whether a soft budget in the Workflow or OpenRouter refusing the call for lack of credits, and resumes on a `raise_budget` Update. |
99

1010
For OpenRouter as the model provider behind the [OpenAI Agents SDK plugin](../openai_agents), see [openai_agents/model_providers](../openai_agents/model_providers#openrouter).
1111

@@ -50,7 +50,7 @@ uv run --group openrouter openrouter/prompt_batch/run_workflow.py "Explain retri
5050
[activities.py](activities.py) uses the `openai` SDK pointed at `https://openrouter.ai/api/v1`, which is the setup OpenRouter documents for OpenAI-compatible clients. OpenRouter-specific fields go in `extra_body`. Four things matter for durable execution:
5151

5252
- **Temporal owns retries.** The client is created with `max_retries=0`, so every attempt is one HTTP call and shows up in Event History. If you use OpenRouter's official `openrouter` package instead, pass `retry_config=RetryConfig("none", ...)`: by default it retries 5xx and connection errors for up to an hour, invisibly.
53-
- **Errors are classified.** 408, 429, and 5xx raise a retryable `ApplicationError`; 400, 401, 402 (out of credits), 403 (moderation), and other 4xx raise a non-retryable one. A `Retry-After` header becomes the next retry delay. OpenRouter can also return HTTP 200 with an `error` body and no `choices`; the Activity checks for that.
53+
- **Errors are classified.** 408, 429, and 5xx raise a retryable `ApplicationError`; 400, 401, 403 (moderation or permissions), and other 4xx raise a non-retryable one. Running out of money gets its own type, `OpenRouterOutOfCredits`: OpenRouter returns 402 when the account has no credits and 403 `Key limit exceeded` when the API key hit its own credit limit. A `Retry-After` header becomes the next retry delay. OpenRouter can also return HTTP 200 with an `error` body and no `choices`; the Activity checks for that.
5454
- **Retries are free when the first call succeeded.** The Activity sends `X-OpenRouter-Cache: true`, so if a Worker dies after OpenRouter answered but before Temporal recorded the result, the retried, byte-identical request is served from OpenRouter's response cache and billed at $0. Nothing per-attempt goes in the request body, so attempts stay identical.
5555
- **Heartbeats.** The Activity heartbeats so a dead Worker is detected after `heartbeat_timeout` (10s) rather than after the full `start_to_close_timeout`.
5656

openrouter/activities.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ def error_type(status: int) -> str:
4444
return f"OpenRouterHTTP{status}"
4545

4646

47+
# Raised instead of an HTTP status type when the call failed for lack of money:
48+
# 402 when the account is out of credits, or 403 "Key limit exceeded" when the
49+
# API key hit its own credit limit. A Workflow can pause on this and resume
50+
# once someone tops up.
51+
OUT_OF_CREDITS = "OpenRouterOutOfCredits"
52+
53+
4754
def _retry_after(headers: Mapping[str, str]) -> Optional[timedelta]:
4855
value = headers.get("retry-after")
4956
if value is None:
@@ -60,10 +67,18 @@ def raise_for_status(status: int, message: str, headers: Mapping[str, str]) -> N
6067
6168
Retryable: 408 (timeout), 429 (rate limited, honoring Retry-After), and
6269
any 5xx (500, 502 model down, 503 no provider available, 524, 529).
63-
Non-retryable: other 4xx. 400 is a bad request, 401 a bad key, 402 means
64-
the key is out of credits, 403 a moderation or permission block. Retrying
65-
those only costs time.
70+
Non-retryable: other 4xx. 400 is a bad request, 401 a bad key, 403 a
71+
moderation or permission block. Retrying those only costs time.
72+
Out of money is its own type (OUT_OF_CREDITS): 402 when the account has no
73+
credits, 403 "Key limit exceeded" when the API key hit its credit limit.
6674
"""
75+
if status == 402 or (status == 403 and "limit exceeded" in message.lower()):
76+
raise ApplicationError(
77+
f"OpenRouter returned HTTP {status}: {message}",
78+
{"status": status},
79+
type=OUT_OF_CREDITS,
80+
non_retryable=True,
81+
)
6782
retryable = status in (408, 429) or status >= 500
6883
raise ApplicationError(
6984
f"OpenRouter returned HTTP {status}: {message}",

openrouter/budget_gate/README.md

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ A prompt batch that pauses instead of failing when money runs out, and resumes w
55
## What this sample demonstrates
66

77
- A soft budget enforced by the Workflow from the cost OpenRouter reports on every response. When the next call would exceed it, the batch parks on `workflow.wait_condition` and stays parked for as long as it takes (hours, days) without a Worker doing anything.
8-
- OpenRouter's own "insufficient credits" error (HTTP 402, raised when the API key hits its credit limit) handled the same way: the failing prompt parks instead of failing, and is re-run after the operator tops up.
8+
- OpenRouter refusing a call for lack of credits handled the same way: HTTP 402 when the account is out of credits, or HTTP 403 `Key limit exceeded` when the API key hit its own credit limit. The failing prompt parks instead of failing, and is re-run after the operator tops up.
99
- A `raise_budget` Update to resume, with a validator that rejects lowering the budget, and a `spend_report` Query showing spend, reservations, the ledger, and which prompts are parked and why.
1010
- Completed prompts are never re-run. A restarted Worker, or a resumed batch, continues from the first unfinished prompt.
1111

@@ -60,21 +60,45 @@ Total cost: $0.000873
6060

6161
### Out of credits at OpenRouter
6262

63-
Set a credit limit on your API key in the [OpenRouter dashboard](https://openrouter.ai/settings/keys) below what the batch needs, and run with a generous soft budget. When OpenRouter returns 402, the prompt parks with reason `insufficient_credits`. Raise the key's limit, then send `raise_budget` with the current budget value to resume; the parked prompt is re-run.
63+
Set a credit limit on your API key in the [OpenRouter dashboard](https://openrouter.ai/settings/keys) below what the batch needs, and run with a generous soft budget:
64+
65+
```bash
66+
uv run --group openrouter openrouter/budget_gate/run_workflow.py --budget-usd 1.0
67+
```
68+
69+
When OpenRouter refuses the call (`403 Key limit exceeded` for a per-key limit, `402` when the account is out of credits), the prompt parks with reason `insufficient_credits`:
70+
71+
```json
72+
{
73+
"budget_usd": 1,
74+
"spent_usd": 0,
75+
"completed": 0,
76+
"paused": {
77+
"Define durable execution in one sentence.": "insufficient_credits",
78+
"Why do LLM calls belong in Activities?": "insufficient_credits"
79+
}
80+
}
81+
```
82+
83+
Raise the key's limit in the dashboard, then send `raise_budget` with the current budget value to resume; the parked prompts are re-run:
84+
85+
```bash
86+
uv run --group openrouter openrouter/budget_gate/raise_budget.py <workflow-id> 1.0
87+
```
6488

6589
If nobody raises the budget within `--approval-timeout-seconds` (default one hour), the batch completes with the remaining prompts listed as skipped.
6690

6791
## What the soft budget does and does not guarantee
6892

69-
The cost of a call is only known after the response, so the Workflow reserves `--estimate-usd` per in-flight call and checks `spent + reserved + estimate <= budget` before starting one. Overshoot is therefore bounded by `max_concurrency * estimate`, plus the gap between the estimate and the real cost of the calls already in flight. In the run above, the second prompt alone cost more than the whole budget; the third prompt is where the gate closed. To bound the cost of a single call, set `provider.max_price` in the request (see OpenRouter's provider routing docs). The hard cap is the credit limit on the OpenRouter API key, which is what produces the 402.
93+
The cost of a call is only known after the response, so the Workflow reserves `--estimate-usd` per in-flight call and checks `spent + reserved + estimate <= budget` before starting one. Overshoot is therefore bounded by `max_concurrency * estimate`, plus the gap between the estimate and the real cost of the calls already in flight. In the run above, the second prompt alone cost more than the whole budget; the third prompt is where the gate closed. To bound the cost of a single call, set `provider.max_price` in the request (see OpenRouter's provider routing docs). The hard cap is the credit limit on the OpenRouter API key, which is what produces the `403 Key limit exceeded`.
7094

7195
While parked, in-flight prompts keep their concurrency slots and every remaining prompt parks on the same condition, so nothing spends until the budget is raised.
7296

7397
## Files
7498

7599
| File | Description |
76100
|------|-------------|
77-
| [workflow.py](workflow.py) | `BudgetGateWorkflow`: reservation ledger, pause on soft budget or 402, `raise_budget` Update with validator, `spend_report` Query. |
101+
| [workflow.py](workflow.py) | `BudgetGateWorkflow`: reservation ledger, pause on soft budget or out-of-credits, `raise_budget` Update with validator, `spend_report` Query. |
78102
| [run_worker.py](run_worker.py) | Builds the OpenRouter client once and runs the Worker. |
79103
| [run_workflow.py](run_workflow.py) | Starts a batch with a budget and prints the result. |
80104
| [raise_budget.py](raise_budget.py) | Sends the `raise_budget` Update. |

openrouter/budget_gate/workflow.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
# The shared dataclasses are passed through the sandbox so that objects the
1111
# Activity returns are the same classes the Workflow compares against.
1212
with workflow.unsafe.imports_passed_through():
13-
from openrouter.activities import OpenRouterActivities, error_type
13+
from openrouter.activities import OUT_OF_CREDITS, OpenRouterActivities
1414
from openrouter.shared import (
1515
MAX_PROMPTS_PER_BATCH,
1616
BatchResult,
@@ -22,16 +22,17 @@
2222
SpendReport,
2323
)
2424

25-
INSUFFICIENT_CREDITS = error_type(402)
25+
INSUFFICIENT_CREDITS = OUT_OF_CREDITS
2626

2727

2828
@workflow.defn
2929
class BudgetGateWorkflow:
3030
"""A prompt batch that pauses instead of failing when money runs out.
3131
3232
Two things can pause it: the soft budget in the input (checked against the
33-
cost OpenRouter reports per response) and OpenRouter itself returning 402
34-
because the API key hit its credit limit. Either way the batch parks until
33+
cost OpenRouter reports per response) and OpenRouter itself refusing the
34+
call for lack of credits (402 for the account, 403 "Key limit exceeded"
35+
for the API key). Either way the batch parks until
3536
a `raise_budget` Update arrives, then resumes exactly where it stopped.
3637
Completed prompts are never re-run.
3738
"""
@@ -130,7 +131,7 @@ async def _answer(
130131
isinstance(cause, ApplicationError)
131132
and cause.type == INSUFFICIENT_CREDITS
132133
):
133-
# The API key is out of credits. Park until the
134+
# Out of credits at OpenRouter. Park until the
134135
# operator tops up and sends raise_budget.
135136
if await self._wait_for_more_credits(prompt, timeout):
136137
continue

tests/openrouter/activity_test.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,29 @@ def handler(request: httpx.Request) -> httpx.Response:
131131
make_activities(handler).call_openrouter, OpenRouterRequest(prompt="hi")
132132
)
133133

134-
assert excinfo.value.type == "OpenRouterHTTP402"
134+
assert excinfo.value.type == "OpenRouterOutOfCredits"
135135
assert excinfo.value.non_retryable
136136
assert "Insufficient credits" in str(excinfo.value)
137137

138138

139+
async def test_key_limit_exceeded_is_out_of_credits_too() -> None:
140+
def handler(request: httpx.Request) -> httpx.Response:
141+
return httpx.Response(
142+
403,
143+
json={
144+
"error": {"code": 403, "message": "Key limit exceeded (total limit)"}
145+
},
146+
)
147+
148+
with pytest.raises(ApplicationError) as excinfo:
149+
await ActivityEnvironment().run(
150+
make_activities(handler).call_openrouter, OpenRouterRequest(prompt="hi")
151+
)
152+
153+
assert excinfo.value.type == "OpenRouterOutOfCredits"
154+
assert excinfo.value.non_retryable
155+
156+
139157
async def test_server_error_is_retryable() -> None:
140158
def handler(request: httpx.Request) -> httpx.Response:
141159
return httpx.Response(502, json={"error": {"code": 502, "message": "down"}})

tests/openrouter/budget_gate_test.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323

2424
class FakeOpenRouter:
25-
"""Mock Activity with a per-prompt count and an optional 402 on first call."""
25+
"""Mock Activity with a per-prompt count; optionally out of credits on first call."""
2626

2727
def __init__(self, out_of_credits_for: set[str] | None = None) -> None:
2828
self.calls: dict[str, int] = {}
@@ -36,8 +36,8 @@ async def call_openrouter(self, request: OpenRouterRequest) -> OpenRouterResult:
3636
and self.calls[request.prompt] == 1
3737
):
3838
raise ApplicationError(
39-
"OpenRouter returned HTTP 402: Insufficient credits",
40-
type="OpenRouterHTTP402",
39+
"OpenRouter returned HTTP 403: Key limit exceeded (total limit)",
40+
type="OpenRouterOutOfCredits",
4141
non_retryable=True,
4242
)
4343
return OpenRouterResult(

0 commit comments

Comments
 (0)