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 @@ -71,6 +71,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/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 @@ -191,6 +191,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
4 changes: 4 additions & 0 deletions openrouter/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
OPENROUTER_API_KEY=
# Optional app attribution for OpenRouter's rankings
OPENROUTER_HTTP_REFERER=
OPENROUTER_APP_TITLE=
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
18 changes: 18 additions & 0 deletions openrouter/.post-create
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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/

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
81 changes: 81 additions & 0 deletions openrouter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# 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 per-attempt record of what was called and what it cost.

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`; 429 and 5xx retry with backoff and honor `Retry-After`; 4xx errors fail fast and the prompt is reported as skipped instead of failing the batch. OpenRouter can also return HTTP 200 with an `error` body and no `choices`; the Activity checks for that.
- 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 ...

Total cost: $0.000547
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 both attempts. 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.

## 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 restarted batch resumes at the first unfinished prompt.

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.20.0",
"@temporalio/client": "^1.20.0",
"@temporalio/envconfig": "^1.20.0",
"@temporalio/worker": "^1.20.0",
"@temporalio/workflow": "^1.20.0",
"nanoid": "3.x",
"openai": "^6.0.0"
},
"devDependencies": {
"@temporalio/testing": "^1.20.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