You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Copy file name to clipboardExpand all lines: openrouter/README.md
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -5,7 +5,7 @@ These samples call [OpenRouter](https://openrouter.ai/) from Temporal Activities
5
5
| Sample | Description |
6
6
|--------|-------------|
7
7
|[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. |
9
9
10
10
For OpenRouter as the model provider behind the [OpenAI Agents SDK plugin](../openai_agents), see [openai_agents/model_providers](../openai_agents/model_providers#openrouter).
[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:
51
51
52
52
-**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.
54
54
-**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.
55
55
-**Heartbeats.** The Activity heartbeats so a dead Worker is detected after `heartbeat_timeout` (10s) rather than after the full `start_to_close_timeout`.
Copy file name to clipboardExpand all lines: openrouter/budget_gate/README.md
+28-4Lines changed: 28 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -5,7 +5,7 @@ A prompt batch that pauses instead of failing when money runs out, and resumes w
5
5
## What this sample demonstrates
6
6
7
7
- 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.
9
9
- 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.
10
10
- Completed prompts are never re-run. A restarted Worker, or a resumed batch, continues from the first unfinished prompt.
11
11
@@ -60,21 +60,45 @@ Total cost: $0.000873
60
60
61
61
### Out of credits at OpenRouter
62
62
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
+
```
64
88
65
89
If nobody raises the budget within `--approval-timeout-seconds` (default one hour), the batch completes with the remaining prompts listed as skipped.
66
90
67
91
## What the soft budget does and does not guarantee
68
92
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`.
70
94
71
95
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.
72
96
73
97
## Files
74
98
75
99
| File | Description |
76
100
|------|-------------|
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. |
78
102
|[run_worker.py](run_worker.py)| Builds the OpenRouter client once and runs the Worker. |
79
103
|[run_workflow.py](run_workflow.py)| Starts a batch with a budget and prints the result. |
80
104
|[raise_budget.py](raise_budget.py)| Sends the `raise_budget` Update. |
0 commit comments