Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ jobs:
message-passing/introduction
message-passing/safe-message-handlers
openai-agents
openrouter
polling/infrequent
)
for project in "${projects[@]}"; do
Expand Down
1 change: 1 addition & 0 deletions .scripts/copy-shared-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const ESLINTIGNORE_EXCLUDE = [

const POST_CREATE_EXCLUDE = [
'openai-agents',
'openrouter',
'google-adk-agents',
'env-config',
'dsl-interpreter',
Expand Down
1 change: 1 addition & 0 deletions .scripts/list-of-samples.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"nexus-standalone-activity",
"nexus-standalone-operations",
"openai-agents",
"openrouter",
"patching-api",
"production",
"protobufs",
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ and you'll be given the list of sample options.
- [**Human in the Loop**](./google-adk-agents/src/human-in-the-loop): A `LongRunningFunctionTool` whose completion is gated by a Temporal Signal or Update.
- [**Structured Output**](./google-adk-agents/src/structured-output): Schema-constrained agent output validated at the Workflow boundary.
- [**Observability**](./google-adk-agents/src/observability): Token usage, latency, and call counts from the agent loop's OpenTelemetry spans, by composing `OpenTelemetryPlugin` onto the Worker alongside `GoogleAdkPlugin`.
- [**OpenRouter**](./openrouter): Call [OpenRouter](https://openrouter.ai/) from an Activity and fan a prompt batch out with bounded concurrency. Temporal owns the retries, `Retry-After` becomes the next retry delay, and OpenRouter's response cache makes a retried call free.

### Full-stack apps

Expand Down
3 changes: 3 additions & 0 deletions openrouter/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
lib
.eslintrc.js
48 changes: 48 additions & 0 deletions openrouter/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const { builtinModules } = require('module');

const ALLOWED_NODE_BUILTINS = new Set(['assert']);

module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
},
plugins: ['@typescript-eslint', 'deprecation'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
],
rules: {
// recommended for safety
'@typescript-eslint/no-floating-promises': 'error', // forgetting to await Activities and Workflow APIs is bad
'deprecation/deprecation': 'warn',

// code style preference
'object-shorthand': ['error', 'always'],

// relaxed rules, for convenience
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'off',
},
overrides: [
{
files: ['src/**/workflows.ts', 'src/**/workflows-*.ts', 'src/**/workflows/*.ts'],
rules: {
'no-restricted-imports': [
'error',
...builtinModules.filter((m) => !ALLOWED_NODE_BUILTINS.has(m)).flatMap((m) => [m, `node:${m}`]),
],
},
},
],
};
2 changes: 2 additions & 0 deletions openrouter/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
lib
node_modules
1 change: 1 addition & 0 deletions openrouter/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package-lock=false
1 change: 1 addition & 0 deletions openrouter/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
22 changes: 22 additions & 0 deletions openrouter/.post-create
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
To begin development, install the Temporal CLI:

Mac: {cyan brew install temporal}
Other: Download and extract the latest release from https://github.com/temporalio/cli/releases/latest

Start Temporal Server:

{cyan temporal server start-dev}

Use Node version 18+ (v22.x is recommended):

Mac: {cyan brew install node@22}
Other: https://nodejs.org/en/download/

Set your OpenRouter API key (https://openrouter.ai/settings/keys) in the shell that runs the Worker:

{cyan export OPENROUTER_API_KEY=sk-or-v1-...}

Then, in the project directory, using two other shells, run these commands:

{cyan npm run start.watch}
{cyan npm run workflow}
1 change: 1 addition & 0 deletions openrouter/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
lib
2 changes: 2 additions & 0 deletions openrouter/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
printWidth: 120
singleQuote: true
92 changes: 92 additions & 0 deletions openrouter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# OpenRouter

Call [OpenRouter](https://openrouter.ai/) from a Temporal Activity and fan a prompt batch out, one Activity per prompt. OpenRouter serves hundreds of models from many providers behind one OpenAI-compatible API and one API key, and picks providers and models per request. Temporal handles everything around those calls: retries with backoff, fan-out with bounded concurrency, crash recovery, and a durable record of each prompt's result, cost, and retry history.

This is the TypeScript port of the Python [`openrouter/prompt_batch`](https://github.com/temporalio/samples-python/tree/main/openrouter/prompt_batch) sample. The Python repo also has [`budget_gate`](https://github.com/temporalio/samples-python/tree/main/openrouter/budget_gate), a batch that pauses instead of failing when the budget or OpenRouter credits run out.

## What this sample demonstrates

- One Activity per prompt, run concurrently under a fixed number of runners, so a slow or failing prompt never blocks the others.
- OpenRouter's Auto Router (`openrouter/auto`) choosing a model per prompt, with the chosen model and OpenRouter's reported cost returned for each.
- Temporal-owned retries: the `openai` client is created with `maxRetries: 0`, so every attempt is one HTTP call driven by the Activity retry policy and Event History records the attempt count and last failure; 408, 429, 5xx, and OpenRouter's transient in-flight-budget 402 retry with backoff and honor `Retry-After`; other 4xx errors fail fast and the prompt is reported as skipped instead of failing the batch. Running out of money gets its own failure type, `OpenRouterOutOfCredits`, so a Workflow can pause on it: a 402 for the account or the API key (`error.metadata.limit_source` says which), or the 403 `Key limit exceeded` we have seen a per-key limit return in practice. OpenRouter can also return HTTP 200 with an `error` body and no `choices`, or with a partial answer and an `error` on the choice; the Activity checks for both.
- Retries served from OpenRouter's response cache at $0: 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 a cache hit.
- Heartbeats, so a dead Worker is detected after `heartbeatTimeout` (10s) rather than after the full `startToCloseTimeout`.

## Running this sample

1. `temporal server start-dev` to start [Temporal Server](https://github.com/temporalio/cli/#installation).
2. Set an [OpenRouter API key](https://openrouter.ai/settings/keys) in the Worker's environment. A few cents of credit is enough.
```bash
export OPENROUTER_API_KEY="sk-or-v1-..."
```
Optional: `OPENROUTER_HTTP_REFERER` and `OPENROUTER_APP_TITLE` for [app attribution](https://openrouter.ai/docs/app-attribution).
3. `npm install` to install dependencies.
4. `npm run start.watch` to start the Worker.
5. In another shell, `npm run workflow -- "Explain retries in one sentence." "Write a haiku about databases."` to run the batch.

```
Starting openrouter-prompt-batch-...

[deepseek/deepseek-v4-flash-0731] $0.000022 cache=MISS
Q: Explain retries in one sentence.
A: Retries are the automatic re-attempts of a failed operation ...

[deepseek/deepseek-v4-flash-0731] $0.000525 cache=MISS
Q: Write a haiku about databases.
A: Columns and table, ...

Reported cost: $0.000547 (what OpenRouter reported on each prompt's final attempt)
Inspect: temporal workflow show -w openrouter-prompt-batch-...
```

### See a retry that costs nothing

`--fail-once` makes each Activity fail its first attempt _after_ OpenRouter has answered, which is what a Worker crash at the wrong moment looks like. The retry re-sends the identical request and OpenRouter serves it from cache:

```bash
npm run workflow -- --fail-once "Explain idempotency in one sentence."
```

```
[deepseek/deepseek-v4-flash-0731] $0.000000 cache=HIT
Q: Explain idempotency in one sentence.
```

`temporal workflow show -w <workflow-id>` shows the Activity completing on attempt 2 with the simulated failure as its last failure; the Worker log has one line per attempt with model, cost, and cache status. The cache is keyed on your API key and the exact request body, so nothing per-attempt goes in the body. OpenRouter writes the cache shortly after the response completes; a retry that arrives before that write lands is a `MISS` and is billed, which you may see occasionally with the one-second retry interval used here.

### Other options

- `--model <slug>`: any OpenRouter model instead of the Auto Router.
- `--max-concurrency <n>`: how many prompts are in flight at once (default 5).

## Using OpenRouter's SDKs instead

This sample uses the `openai` package pointed at `https://openrouter.ai/api/v1`, which is the setup OpenRouter documents for OpenAI-compatible clients; OpenRouter-only fields such as `plugins` go in the request body. OpenRouter's own [`@openrouter/sdk`](https://www.npmjs.com/package/@openrouter/sdk) works too (it is ESM-only). If you use it, construct it with `retryConfig: { strategy: 'none' }`: by default it retries 5xx and connection errors for up to an hour, invisibly to Temporal.

For agents built on the [Vercel AI SDK](../ai-sdk), [`@openrouter/ai-sdk-provider`](https://www.npmjs.com/package/@openrouter/ai-sdk-provider) is a drop-in `modelProvider` for `AiSdkPlugin`. For the [OpenAI Agents SDK](../openai-agents/src/model-providers), point the provider's `baseURL` at OpenRouter.

## What Temporal does and does not guarantee

Activities are at-least-once. If a Worker dies mid-call, the retry re-sends the request; within the cache TTL that retry costs nothing, but two identical requests in flight at the same time both miss the cache and both bill. Completed Activities are never re-run, so a Worker that restarts mid-batch picks up at the first unfinished prompt.

The reported cost in the result is the sum of what OpenRouter reported on each prompt's final, successful attempt. An attempt that was billed but whose response never made it back to Temporal is not in that number (with `--fail-once`, the first attempt is billed and the result shows the $0 cache hit). For actual spend, use OpenRouter's dashboard or `GET /api/v1/key`. `unknownCostCount` is how many of the results had no cost at all.

Each Activity adds a few events to the Workflow's Event History, and every answer is part of the Workflow result. The sample caps a batch at 100 prompts; for larger batches, use one Workflow per slice or continue-as-new.

## Tests

The tests replace OpenRouter with a fake `fetch` and the Activity with a fake, so they need no API key and make no network calls:

```bash
npm test
```

## Files

| File | Description |
| -------------------------------------- | --------------------------------------------------------------------------------------------- |
| [src/activities.ts](src/activities.ts) | `callOpenRouter`: one HTTP call per attempt, error classification, cache headers, heartbeats. |
| [src/workflows.ts](src/workflows.ts) | `promptBatch`: bounded fan-out, per-prompt failure handling, retry policy. |
| [src/worker.ts](src/worker.ts) | Builds the OpenRouter client once and runs the Worker. |
| [src/client.ts](src/client.ts) | Starts a batch and prints answer, model, cost, and cache status per prompt. |
| [src/shared.ts](src/shared.ts) | Types shared by client, Workflow, and Activity. |
51 changes: 51 additions & 0 deletions openrouter/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"name": "temporal-openrouter",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc --build",
"build.watch": "tsc --build --watch",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint .",
"start": "ts-node src/worker.ts",
"start.watch": "nodemon src/worker.ts",
"workflow": "ts-node src/client.ts",
"test": "mocha --exit --require ts-node/register --require source-map-support/register src/mocha/*.test.ts"
},
"nodemonConfig": {
"execMap": {
"ts": "ts-node"
},
"ext": "ts",
"watch": [
"src"
]
},
"dependencies": {
"@temporalio/activity": "^1.24.0",
"@temporalio/client": "^1.24.0",
"@temporalio/envconfig": "^1.24.0",
"@temporalio/worker": "^1.24.0",
"@temporalio/workflow": "^1.24.0",
"nanoid": "3.x",
"openai": "^6.0.0"
},
"devDependencies": {
"@temporalio/testing": "^1.24.0",
"@tsconfig/node22": "^22.0.0",
"@types/mocha": "10.x",
"@types/node": "^22.9.1",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-deprecation": "^3.0.0",
"mocha": "10.x",
"nodemon": "^3.1.7",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typescript": "^5.6.3"
}
}
Loading
Loading