From 56c5b6a3829563ad334a44cafb13344cd568dbde Mon Sep 17 00:00:00 2001 From: DABH Date: Fri, 11 Sep 2026 01:13:19 -0500 Subject: [PATCH 01/12] Add OpenRouter sample: prompt batch with cached retries Call OpenRouter from an Activity with the openai client pointed at OpenRouter, client retries off so Temporal owns every attempt, error classification with Retry-After as the next retry delay, heartbeats, and OpenRouter response caching so a retried identical request is billed at zero. The Workflow fans a prompt batch out under bounded concurrency and reports skipped prompts instead of failing the batch. --- .github/workflows/ci.yml | 1 + .scripts/list-of-samples.json | 1 + README.md | 1 + openrouter/.env.example | 4 + openrouter/.eslintignore | 3 + openrouter/.eslintrc.js | 48 +++++++ openrouter/.gitignore | 2 + openrouter/.npmrc | 1 + openrouter/.nvmrc | 1 + openrouter/.post-create | 18 +++ openrouter/.prettierignore | 1 + openrouter/.prettierrc | 2 + openrouter/README.md | 81 ++++++++++++ openrouter/package.json | 51 ++++++++ openrouter/src/activities.ts | 163 ++++++++++++++++++++++++ openrouter/src/client.ts | 44 +++++++ openrouter/src/mocha/activities.test.ts | 144 +++++++++++++++++++++ openrouter/src/mocha/workflows.test.ts | 66 ++++++++++ openrouter/src/shared.ts | 59 +++++++++ openrouter/src/worker.ts | 32 +++++ openrouter/src/workflows.ts | 73 +++++++++++ openrouter/tsconfig.json | 13 ++ pnpm-lock.yaml | 139 +++++++++++--------- 23 files changed, 887 insertions(+), 61 deletions(-) create mode 100644 openrouter/.env.example create mode 100644 openrouter/.eslintignore create mode 100644 openrouter/.eslintrc.js create mode 100644 openrouter/.gitignore create mode 100644 openrouter/.npmrc create mode 100644 openrouter/.nvmrc create mode 100644 openrouter/.post-create create mode 100644 openrouter/.prettierignore create mode 100644 openrouter/.prettierrc create mode 100644 openrouter/README.md create mode 100644 openrouter/package.json create mode 100644 openrouter/src/activities.ts create mode 100644 openrouter/src/client.ts create mode 100644 openrouter/src/mocha/activities.test.ts create mode 100644 openrouter/src/mocha/workflows.test.ts create mode 100644 openrouter/src/shared.ts create mode 100644 openrouter/src/worker.ts create mode 100644 openrouter/src/workflows.ts create mode 100644 openrouter/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53918f748..6c7e4039e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,7 @@ jobs: message-passing/introduction message-passing/safe-message-handlers openai-agents + openrouter polling/infrequent ) for project in "${projects[@]}"; do diff --git a/.scripts/list-of-samples.json b/.scripts/list-of-samples.json index 4e635dba3..ca313d05d 100644 --- a/.scripts/list-of-samples.json +++ b/.scripts/list-of-samples.json @@ -37,6 +37,7 @@ "nexus-standalone-activity", "nexus-standalone-operations", "openai-agents", + "openrouter", "patching-api", "production", "protobufs", diff --git a/README.md b/README.md index adf0bf73f..1e6e445f7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/openrouter/.env.example b/openrouter/.env.example new file mode 100644 index 000000000..599fc1bd0 --- /dev/null +++ b/openrouter/.env.example @@ -0,0 +1,4 @@ +OPENROUTER_API_KEY= +# Optional app attribution for OpenRouter's rankings +OPENROUTER_HTTP_REFERER= +OPENROUTER_APP_TITLE= diff --git a/openrouter/.eslintignore b/openrouter/.eslintignore new file mode 100644 index 000000000..7bd99a41b --- /dev/null +++ b/openrouter/.eslintignore @@ -0,0 +1,3 @@ +node_modules +lib +.eslintrc.js \ No newline at end of file diff --git a/openrouter/.eslintrc.js b/openrouter/.eslintrc.js new file mode 100644 index 000000000..9f199cd97 --- /dev/null +++ b/openrouter/.eslintrc.js @@ -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}`]), + ], + }, + }, + ], +}; diff --git a/openrouter/.gitignore b/openrouter/.gitignore new file mode 100644 index 000000000..a9f4ed545 --- /dev/null +++ b/openrouter/.gitignore @@ -0,0 +1,2 @@ +lib +node_modules \ No newline at end of file diff --git a/openrouter/.npmrc b/openrouter/.npmrc new file mode 100644 index 000000000..9cf949503 --- /dev/null +++ b/openrouter/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/openrouter/.nvmrc b/openrouter/.nvmrc new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/openrouter/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/openrouter/.post-create b/openrouter/.post-create new file mode 100644 index 000000000..055c11e9e --- /dev/null +++ b/openrouter/.post-create @@ -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} diff --git a/openrouter/.prettierignore b/openrouter/.prettierignore new file mode 100644 index 000000000..7951405f8 --- /dev/null +++ b/openrouter/.prettierignore @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/openrouter/.prettierrc b/openrouter/.prettierrc new file mode 100644 index 000000000..965d50bff --- /dev/null +++ b/openrouter/.prettierrc @@ -0,0 +1,2 @@ +printWidth: 120 +singleQuote: true diff --git a/openrouter/README.md b/openrouter/README.md new file mode 100644 index 000000000..3ebfb3a8a --- /dev/null +++ b/openrouter/README.md @@ -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 ` 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. | diff --git a/openrouter/package.json b/openrouter/package.json new file mode 100644 index 000000000..68315611b --- /dev/null +++ b/openrouter/package.json @@ -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" + } +} diff --git a/openrouter/src/activities.ts b/openrouter/src/activities.ts new file mode 100644 index 000000000..41ee5a1b8 --- /dev/null +++ b/openrouter/src/activities.ts @@ -0,0 +1,163 @@ +import OpenAI, { APIError } from 'openai'; +import { ApplicationFailure, Context } from '@temporalio/activity'; +import { OPENROUTER_BASE_URL, OpenRouterRequest, OpenRouterResult } from './shared'; + +/** + * OpenAI SDK client pointed at OpenRouter. + * + * Client-side retries are disabled so that Temporal owns every retry and each + * attempt is visible in Event History. (OpenRouter's official `@openrouter/sdk` + * retries 5xx and connection errors for up to an hour by default; if you use it + * instead, pass `retryConfig: { strategy: 'none' }`.) + */ +export function buildClient(apiKey = process.env.OPENROUTER_API_KEY): OpenAI { + if (!apiKey) { + throw new Error('OPENROUTER_API_KEY is required'); + } + const defaultHeaders: Record = {}; + // App attribution is optional. When set, OpenRouter lists your app in its + // public rankings; add X-OpenRouter-App-Visibility: hidden to opt out. + if (process.env.OPENROUTER_HTTP_REFERER) { + defaultHeaders['HTTP-Referer'] = process.env.OPENROUTER_HTTP_REFERER; + } + if (process.env.OPENROUTER_APP_TITLE) { + defaultHeaders['X-OpenRouter-Title'] = process.env.OPENROUTER_APP_TITLE; + } + return new OpenAI({ + baseURL: OPENROUTER_BASE_URL, + apiKey, + maxRetries: 0, + timeout: 60_000, + defaultHeaders, + }); +} + +/** Error type recorded in Event History for an OpenRouter HTTP status. */ +export function errorType(status: number): string { + return `OpenRouterHTTP${status}`; +} + +function retryAfter(headers: Headers | undefined): string | undefined { + const value = headers?.get('retry-after'); + if (value === null || value === undefined) return undefined; + const seconds = Number(value); + // HTTP-date form: let the Activity retry policy decide the delay. + return Number.isFinite(seconds) ? `${seconds}s` : undefined; +} + +/** + * Turn an OpenRouter error into an ApplicationFailure with the right retry + * posture. Retryable: 408, 429 (honoring Retry-After), and any 5xx. + * Non-retryable: other 4xx. 400 is a bad request, 401 a bad key, 402 means + * the key is out of credits, 403 a moderation or permission block. + */ +export function throwForStatus(status: number, message: string, headers?: Headers): never { + const retryable = status === 408 || status === 429 || status >= 500; + throw ApplicationFailure.create({ + message: `OpenRouter returned HTTP ${status}: ${message}`, + type: errorType(status), + nonRetryable: !retryable, + nextRetryDelay: retryable ? retryAfter(headers) : undefined, + details: [{ status }], + }); +} + +function errorMessage(body: unknown): string { + if (body && typeof body === 'object' && 'error' in body) { + const error = (body as { error?: { message?: unknown } }).error; + if (error && typeof error.message === 'string') return error.message; + } + return ''; +} + +function contentToText(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .flatMap((part) => (part && typeof part === 'object' && typeof part.text === 'string' ? [part.text] : [])) + .join('\n'); +} + +export function createActivities(client: OpenAI) { + return { + /** One chat completion. One HTTP call per attempt; Temporal retries. */ + async callOpenRouter(request: OpenRouterRequest): Promise { + const context = Context.current(); + // Heartbeat so a killed Worker is noticed after heartbeatTimeout rather + // than after the full startToCloseTimeout. + const heartbeatMs = context.info.heartbeatTimeoutMs; + const heartbeat = heartbeatMs + ? setInterval(() => context.heartbeat(context.info.attempt), heartbeatMs / 2) + : undefined; + try { + return await send(client, request, context.info.attempt); + } finally { + if (heartbeat) clearInterval(heartbeat); + } + }, + }; +} + +async function send(client: OpenAI, request: OpenRouterRequest, attempt: number): Promise { + const params: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming & { plugins?: unknown } = { + model: request.model, + messages: [{ role: 'user', content: request.prompt }], + }; + if (request.model === 'openrouter/auto') { + params.plugins = [{ id: 'auto-router', cost_tier: request.costTier }]; + } + + let data: OpenAI.Chat.ChatCompletion & { error?: { code?: number; message?: string } }; + let response: Response; + try { + ({ data, response } = await client.chat.completions + .create(params, { + headers: { + // Ask OpenRouter to cache the successful response. A retry of the + // byte-identical request within the TTL is served from cache and + // billed at $0. + 'X-OpenRouter-Cache': 'true', + 'X-OpenRouter-Cache-TTL': String(request.cacheTtlSeconds), + }, + }) + .withResponse()); + } catch (e) { + if (e instanceof APIError && typeof e.status === 'number') { + throwForStatus(e.status, errorMessage(e.error) || e.message, e.headers); + } + // Connection errors and timeouts propagate as-is: Temporal retries them. + throw e; + } + + if (data.error) { + // OpenRouter can return HTTP 200 with an error body and no choices when + // the upstream provider failed after the request was accepted. + throwForStatus(data.error.code ?? 500, data.error.message ?? '', response.headers); + } + + const usage = data.usage as (OpenAI.CompletionUsage & { cost?: number }) | undefined; + const result: OpenRouterResult = { + prompt: request.prompt, + model: data.model, + answer: contentToText(data.choices?.[0]?.message?.content), + costUsd: typeof usage?.cost === 'number' ? usage.cost : 0, + generationId: data.id, + cacheStatus: response.headers.get('x-openrouter-cache-status') ?? '', + }; + Context.current().log.info('OpenRouter call completed', { + attempt, + model: result.model, + costUsd: result.costUsd, + cacheStatus: result.cacheStatus, + generationId: result.generationId, + }); + if (request.failOnceAfterCall && attempt === 1) { + // Demo hook: the Worker "crashes" after the response arrived. The retry + // re-sends the identical request and gets a cache hit. + throw ApplicationFailure.create({ + message: 'Simulated failure after the response was received', + type: 'SimulatedFailure', + }); + } + return result; +} diff --git a/openrouter/src/client.ts b/openrouter/src/client.ts new file mode 100644 index 000000000..131cc7cb8 --- /dev/null +++ b/openrouter/src/client.ts @@ -0,0 +1,44 @@ +import { Connection, Client } from '@temporalio/client'; +import { loadClientConnectConfig } from '@temporalio/envconfig'; +import { nanoid } from 'nanoid'; +import { promptBatch } from './workflows'; +import { DEFAULT_MODEL, TASK_QUEUE } from './shared'; + +const DEFAULT_PROMPTS = ['Explain retries in one sentence.', 'Write a haiku about databases.']; + +async function run() { + // Usage: npm run workflow -- [--fail-once] [--model ] [prompt ...] + const args = process.argv.slice(2); + const failOnceAfterCall = args.includes('--fail-once'); + const modelIndex = args.indexOf('--model'); + const model = modelIndex >= 0 ? args[modelIndex + 1] : DEFAULT_MODEL; + const prompts = args.filter((a, i) => !a.startsWith('--') && (modelIndex < 0 || i !== modelIndex + 1)); + + const config = loadClientConnectConfig(); + const connection = await Connection.connect(config.connectionOptions); + const client = new Client({ connection }); + + const workflowId = 'openrouter-prompt-batch-' + nanoid(); + console.log(`Starting ${workflowId}`); + const result = await client.workflow.execute(promptBatch, { + taskQueue: TASK_QUEUE, + workflowId, + args: [{ prompts: prompts.length ? prompts : DEFAULT_PROMPTS, model, failOnceAfterCall }], + }); + + for (const r of result.results) { + console.log(`\n[${r.model}] $${r.costUsd.toFixed(6)} cache=${r.cacheStatus || '-'}`); + console.log(` Q: ${r.prompt}`); + console.log(` A: ${r.answer.trim()}`); + } + for (const s of result.skipped) { + console.log(`\n[skipped: ${s.reason}] ${s.prompt}`); + } + console.log(`\nTotal cost: $${result.totalCostUsd.toFixed(6)}`); + console.log(`Inspect: temporal workflow show -w ${workflowId}`); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/openrouter/src/mocha/activities.test.ts b/openrouter/src/mocha/activities.test.ts new file mode 100644 index 000000000..dd8ee0e7b --- /dev/null +++ b/openrouter/src/mocha/activities.test.ts @@ -0,0 +1,144 @@ +import { MockActivityEnvironment } from '@temporalio/testing'; +import { ApplicationFailure } from '@temporalio/activity'; +import { describe, it } from 'mocha'; +import assert from 'assert'; +import OpenAI from 'openai'; +import { createActivities } from '../activities'; +import { OPENROUTER_BASE_URL, OpenRouterRequest, OpenRouterResult } from '../shared'; + +type FakeResponse = { status: number; body: unknown; headers?: Record }; + +/** Activities backed by a fake OpenRouter; no network, no API key. */ +function makeActivities(respond: (request: Request) => FakeResponse, seen: Request[] = []) { + const fetch = async (input: string | URL | Request, init?: RequestInit): Promise => { + const request = new Request(input, init); + seen.push(request); + const { status, body, headers } = respond(request); + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); + }; + const client = new OpenAI({ baseURL: OPENROUTER_BASE_URL, apiKey: 'test-key', maxRetries: 0, fetch }); + return createActivities(client); +} + +const request: OpenRouterRequest = { + prompt: 'Explain retries in one sentence.', + model: 'openrouter/auto', + costTier: 'low', + cacheTtlSeconds: 600, + failOnceAfterCall: false, +}; + +function completion(cost: number | undefined = 0.000123, model = 'openai/gpt-4o-mini') { + return { + id: 'gen-123', + object: 'chat.completion', + created: 0, + model, + choices: [ + { index: 0, finish_reason: 'stop', message: { role: 'assistant', content: 'Retries repeat a failed call.' } }, + ], + usage: { prompt_tokens: 5, completion_tokens: 7, total_tokens: 12, cost }, + }; +} + +async function expectFailure(fn: () => Promise): Promise { + try { + await fn(); + } catch (e) { + assert.ok(e instanceof ApplicationFailure, `expected ApplicationFailure, got ${String(e)}`); + return e; + } + assert.fail('expected the activity to throw'); +} + +describe('callOpenRouter activity', () => { + it('returns model, cost, and cache status, with one HTTP call per attempt', async () => { + const seen: Request[] = []; + const activities = makeActivities( + () => ({ status: 200, body: completion(), headers: { 'X-OpenRouter-Cache-Status': 'MISS' } }), + seen, + ); + + const result = (await new MockActivityEnvironment().run(activities.callOpenRouter, request)) as OpenRouterResult; + + assert.deepStrictEqual(result, { + prompt: request.prompt, + model: 'openai/gpt-4o-mini', + answer: 'Retries repeat a failed call.', + costUsd: 0.000123, + generationId: 'gen-123', + cacheStatus: 'MISS', + }); + assert.strictEqual(seen.length, 1); + const body = (await seen[0].json()) as { model: string; plugins: unknown }; + assert.strictEqual(body.model, 'openrouter/auto'); + assert.deepStrictEqual(body.plugins, [{ id: 'auto-router', cost_tier: 'low' }]); + assert.strictEqual(seen[0].headers.get('x-openrouter-cache'), 'true'); + assert.strictEqual(seen[0].headers.get('x-openrouter-cache-ttl'), '600'); + }); + + it('treats 429 as retryable and honors Retry-After', async () => { + const activities = makeActivities(() => ({ + status: 429, + body: { error: { code: 429, message: 'Rate limited' } }, + headers: { 'Retry-After': '7' }, + })); + const failure = await expectFailure(() => new MockActivityEnvironment().run(activities.callOpenRouter, request)); + assert.strictEqual(failure.type, 'OpenRouterHTTP429'); + assert.strictEqual(failure.nonRetryable, false); + assert.strictEqual(failure.nextRetryDelay, '7s'); + }); + + it('treats 402 insufficient credits as non-retryable', async () => { + const activities = makeActivities(() => ({ + status: 402, + body: { error: { code: 402, message: 'Insufficient credits' } }, + })); + const failure = await expectFailure(() => new MockActivityEnvironment().run(activities.callOpenRouter, request)); + assert.strictEqual(failure.type, 'OpenRouterHTTP402'); + assert.strictEqual(failure.nonRetryable, true); + assert.match(failure.message, /Insufficient credits/); + }); + + it('classifies an error body inside a 200 by its code', async () => { + const activities = makeActivities(() => ({ + status: 200, + body: { error: { code: 403, message: 'Flagged by moderation' } }, + })); + const failure = await expectFailure(() => new MockActivityEnvironment().run(activities.callOpenRouter, request)); + assert.strictEqual(failure.type, 'OpenRouterHTTP403'); + assert.strictEqual(failure.nonRetryable, true); + }); + + it('with failOnceAfterCall, fails the first attempt only', async () => { + const activities = makeActivities(() => ({ + status: 200, + body: completion(0), + headers: { 'X-OpenRouter-Cache-Status': 'HIT' }, + })); + const failOnce = { ...request, failOnceAfterCall: true }; + + const failure = await expectFailure(() => new MockActivityEnvironment().run(activities.callOpenRouter, failOnce)); + assert.strictEqual(failure.type, 'SimulatedFailure'); + assert.strictEqual(failure.nonRetryable, false); + + const result = (await new MockActivityEnvironment({ attempt: 2 }).run( + activities.callOpenRouter, + failOnce, + )) as OpenRouterResult; + assert.strictEqual(result.cacheStatus, 'HIT'); + assert.strictEqual(result.costUsd, 0); + }); + + it('reports a missing cost as zero', async () => { + const body = completion(); + delete (body.usage as { cost?: number }).cost; + const activities = makeActivities(() => ({ status: 200, body })); + const result = (await new MockActivityEnvironment().run(activities.callOpenRouter, request)) as OpenRouterResult; + assert.strictEqual(result.costUsd, 0); + assert.strictEqual(result.cacheStatus, ''); + }); +}); diff --git a/openrouter/src/mocha/workflows.test.ts b/openrouter/src/mocha/workflows.test.ts new file mode 100644 index 000000000..905c67154 --- /dev/null +++ b/openrouter/src/mocha/workflows.test.ts @@ -0,0 +1,66 @@ +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { after, before, describe, it } from 'mocha'; +import { Worker } from '@temporalio/worker'; +import { ApplicationFailure } from '@temporalio/activity'; +import assert from 'assert'; +import { promptBatch } from '../workflows'; +import { OpenRouterRequest, OpenRouterResult } from '../shared'; + +describe('promptBatch workflow', function () { + this.timeout(30_000); + + let testEnv: TestWorkflowEnvironment; + + before(async () => { + testEnv = await TestWorkflowEnvironment.createLocal(); + }); + + after(async () => { + await testEnv?.teardown(); + }); + + it('collects results and skips prompts that fail with a non-retryable error', async () => { + const taskQueue = 'test-openrouter-' + Date.now(); + const activities = { + async callOpenRouter(request: OpenRouterRequest): Promise { + if (request.prompt === 'bad') { + throw ApplicationFailure.create({ + message: 'OpenRouter returned HTTP 400: bad request', + type: 'OpenRouterHTTP400', + nonRetryable: true, + }); + } + return { + prompt: request.prompt, + model: 'openai/gpt-4o-mini', + answer: `Answer to: ${request.prompt}`, + costUsd: 0.001, + generationId: `gen-${request.prompt}`, + cacheStatus: 'MISS', + }; + }, + }; + + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + activities, + }); + + const result = await worker.runUntil( + testEnv.client.workflow.execute(promptBatch, { + args: [{ prompts: ['one', 'bad', 'two'], maxConcurrency: 2 }], + workflowId: 'test-openrouter-' + Date.now(), + taskQueue, + }), + ); + + assert.deepStrictEqual( + result.results.map((r) => r.prompt), + ['one', 'two'], + ); + assert.deepStrictEqual(result.skipped, [{ prompt: 'bad', reason: 'OpenRouterHTTP400' }]); + assert.strictEqual(result.totalCostUsd, 0.002); + }); +}); diff --git a/openrouter/src/shared.ts b/openrouter/src/shared.ts new file mode 100644 index 000000000..a181f5565 --- /dev/null +++ b/openrouter/src/shared.ts @@ -0,0 +1,59 @@ +export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; + +// OpenRouter's Auto Router picks a concrete model per request. The response's +// `model` field reports which one it chose. +export const DEFAULT_MODEL = 'openrouter/auto'; + +export const TASK_QUEUE = 'openrouter-prompt-batch'; + +// Each Activity adds a few events to the Workflow's Event History and each +// answer is stored in the Workflow result payload. Keep batches small enough +// to stay well under the history and payload limits. +export const MAX_PROMPTS_PER_BATCH = 100; + +/** + * One chat completion request. Everything here ends up in the request body, + * so keep it free of per-attempt values: OpenRouter's response cache keys on + * the exact body, and a retried attempt should be byte-identical to the first. + */ +export interface OpenRouterRequest { + prompt: string; + model: string; + /** Auto Router cost tier: low, medium, high, xhigh, or max. */ + costTier: 'low' | 'medium' | 'high' | 'xhigh' | 'max'; + /** How long OpenRouter caches a successful response, in seconds. */ + cacheTtlSeconds: number; + /** + * Demo hook: fail the first attempt *after* the response arrives, so the + * retry shows a cache hit billed at $0 in Event History. + */ + failOnceAfterCall: boolean; +} + +export interface OpenRouterResult { + prompt: string; + model: string; + answer: string; + costUsd: number; + generationId: string; + /** "HIT" or "MISS" from X-OpenRouter-Cache-Status, or "" when absent. */ + cacheStatus: string; +} + +export interface SkippedPrompt { + prompt: string; + reason: string; +} + +export interface BatchInput { + prompts: string[]; + model?: string; + maxConcurrency?: number; + failOnceAfterCall?: boolean; +} + +export interface BatchResult { + results: OpenRouterResult[]; + skipped: SkippedPrompt[]; + totalCostUsd: number; +} diff --git a/openrouter/src/worker.ts b/openrouter/src/worker.ts new file mode 100644 index 000000000..8060c17eb --- /dev/null +++ b/openrouter/src/worker.ts @@ -0,0 +1,32 @@ +import { NativeConnection, Worker } from '@temporalio/worker'; +import { buildClient, createActivities } from './activities'; +import { TASK_QUEUE } from './shared'; + +async function run() { + const connection = await NativeConnection.connect({ + address: 'localhost:7233', + }); + try { + // One OpenRouter client for the Worker's lifetime, shared by every + // concurrent Activity. Reads OPENROUTER_API_KEY from the environment. + const activities = createActivities(buildClient()); + + const worker = await Worker.create({ + connection, + namespace: 'default', + taskQueue: TASK_QUEUE, + // Workflows are registered using a path as they run in a separate JS context. + workflowsPath: require.resolve('./workflows'), + activities, + }); + + await worker.run(); + } finally { + await connection.close(); + } +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/openrouter/src/workflows.ts b/openrouter/src/workflows.ts new file mode 100644 index 000000000..47089f863 --- /dev/null +++ b/openrouter/src/workflows.ts @@ -0,0 +1,73 @@ +import { ActivityFailure, ApplicationFailure, log, proxyActivities } from '@temporalio/workflow'; +import type { createActivities } from './activities'; +import { + BatchInput, + BatchResult, + DEFAULT_MODEL, + MAX_PROMPTS_PER_BATCH, + OpenRouterResult, + SkippedPrompt, +} from './shared'; + +// Temporal owns retries: 1s, 2s, 4s, ... capped at 60s, five attempts. The +// Activity marks 4xx errors non-retryable and passes OpenRouter's Retry-After +// through as the next retry delay, so this policy only governs the rest. +const { callOpenRouter } = proxyActivities>({ + startToCloseTimeout: '90 seconds', + heartbeatTimeout: '10 seconds', + retry: { + initialInterval: '1 second', + backoffCoefficient: 2, + maximumInterval: '60 seconds', + maximumAttempts: 5, + }, +}); + +/** Fan one OpenRouter call out per prompt and collect the answers. */ +export async function promptBatch(batch: BatchInput): Promise { + if (batch.prompts.length > MAX_PROMPTS_PER_BATCH) { + throw ApplicationFailure.nonRetryable( + `Batch has ${batch.prompts.length} prompts; the limit is ${MAX_PROMPTS_PER_BATCH}. ` + + 'Split it, or see the README for the sliding-window pattern.', + ); + } + + const outcomes: (OpenRouterResult | SkippedPrompt)[] = new Array(batch.prompts.length); + let next = 0; + // Bounded concurrency: N runners pull from the shared prompt list. + const runner = async () => { + while (next < batch.prompts.length) { + const index = next++; + outcomes[index] = await answer(batch.prompts[index], batch); + } + }; + await Promise.all(Array.from({ length: batch.maxConcurrency ?? 5 }, runner)); + + const results = outcomes.filter((o): o is OpenRouterResult => 'answer' in o); + const skipped = outcomes.filter((o): o is SkippedPrompt => 'reason' in o); + return { + results, + skipped, + totalCostUsd: Number(results.reduce((sum, r) => sum + r.costUsd, 0).toFixed(6)), + }; +} + +async function answer(prompt: string, batch: BatchInput): Promise { + try { + return await callOpenRouter({ + prompt, + model: batch.model ?? DEFAULT_MODEL, + costTier: 'low', + cacheTtlSeconds: 600, + failOnceAfterCall: batch.failOnceAfterCall ?? false, + }); + } catch (e) { + // One bad prompt should not fail the batch. Record why and carry on; the + // caller decides what to do with skipped prompts. + const cause = e instanceof ActivityFailure ? e.cause : e; + const reason = + cause instanceof ApplicationFailure && cause.type ? cause.type : ((cause as Error)?.name ?? 'Unknown'); + log.warn('Skipping prompt', { prompt, reason }); + return { prompt, reason }; + } +} diff --git a/openrouter/tsconfig.json b/openrouter/tsconfig.json new file mode 100644 index 000000000..488f2c62a --- /dev/null +++ b/openrouter/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "version": "5.6.3", + "compilerOptions": { + "lib": ["es2021"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "rootDir": "./src", + "outDir": "./lib" + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0e526546..29d0f9062 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,7 +4,7 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -packageExtensionsChecksum: sha256-UuLKW9BCv/VaZT6E7tclNpwm12M1QVyh2vqxmcC/zL8= +packageExtensionsChecksum: 04bb838d42781e02e3862a2b181e3e60 importers: @@ -2589,7 +2589,7 @@ importers: version: 29.2.5(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest@29.7.0(@types/node@22.12.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.11(@swc/helpers@0.5.15))(@types/node@22.12.0)(typescript@5.7.3)))(typescript@5.7.3) ts-loader: specifier: ^9.5.1 - version: 9.5.2(typescript@5.7.3)(webpack@5.108.4(@swc/core@1.10.11(@swc/helpers@0.5.15))) + version: 9.5.2(typescript@5.7.3)(webpack@5.97.1(@swc/core@1.10.11(@swc/helpers@0.5.15))) ts-node: specifier: ^10.9.2 version: 10.9.2(@swc/core@1.10.11(@swc/helpers@0.5.15))(@types/node@22.12.0)(typescript@5.7.3) @@ -3114,6 +3114,76 @@ importers: specifier: ^5.6.3 version: 5.7.3 + openrouter: + dependencies: + '@temporalio/activity': + specifier: ^1.24.0 + version: 1.24.0 + '@temporalio/client': + specifier: ^1.24.0 + version: 1.24.0 + '@temporalio/envconfig': + specifier: ^1.24.0 + version: 1.24.0 + '@temporalio/worker': + specifier: ^1.24.0 + version: 1.24.0(@swc/helpers@0.5.15) + '@temporalio/workflow': + specifier: ^1.24.0 + version: 1.24.0 + nanoid: + specifier: 3.x + version: 3.3.12 + openai: + specifier: ^6.0.0 + version: 6.49.0(@aws-sdk/credential-provider-node@3.972.71)(@smithy/signature-v4@5.6.9)(ws@8.18.0)(zod@4.4.3) + devDependencies: + '@temporalio/testing': + specifier: ^1.24.0 + version: 1.24.0(@swc/helpers@0.5.15) + '@tsconfig/node22': + specifier: ^22.0.0 + version: 22.0.5 + '@types/mocha': + specifier: 10.x + version: 10.0.10 + '@types/node': + specifier: ^22.9.1 + version: 22.12.0 + '@typescript-eslint/eslint-plugin': + specifier: ^8.18.0 + version: 8.22.0(@typescript-eslint/parser@8.22.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1)(typescript@5.7.3) + '@typescript-eslint/parser': + specifier: ^8.18.0 + version: 8.22.0(eslint@8.57.1)(typescript@5.7.3) + eslint: + specifier: ^8.57.1 + version: 8.57.1 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.57.1) + eslint-plugin-deprecation: + specifier: ^3.0.0 + version: 3.0.0(eslint@8.57.1)(typescript@5.7.3) + mocha: + specifier: 10.x + version: 10.2.0(ts-node@10.9.2(@swc/core@1.10.11(@swc/helpers@0.5.15))(@types/node@22.12.0)(typescript@5.7.3)) + nodemon: + specifier: ^3.1.7 + version: 3.1.9 + prettier: + specifier: ^3.4.2 + version: 3.4.2 + source-map-support: + specifier: ^0.5.21 + version: 0.5.21 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@swc/core@1.10.11(@swc/helpers@0.5.15))(@types/node@22.12.0)(typescript@5.7.3) + typescript: + specifier: ^5.6.3 + version: 5.7.3 + patching-api: dependencies: '@temporalio/activity': @@ -6633,183 +6703,155 @@ packages: resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} @@ -7337,56 +7379,48 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-gnu@16.2.9': resolution: {integrity: sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@15.1.6': resolution: {integrity: sha512-+n3u//bfsrIaZch4cgOJ3tXCTbSxz0s6brJtU3SzLOvkJlPQMJ+eHVRi6qM2kKKKLuMY+tcau8XD9CJ1OjeSQQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-arm64-musl@16.2.9': resolution: {integrity: sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@15.1.6': resolution: {integrity: sha512-SpuDEXixM3PycniL4iVCLyUyvcl6Lt0mtv3am08sucskpG0tYkW1KlRhTgj4LI5ehyxriVVcfdoxuuP8csi3kQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-gnu@16.2.9': resolution: {integrity: sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@15.1.6': resolution: {integrity: sha512-L4druWmdFSZIIRhF+G60API5sFB7suTbDRhYWSjiw0RbE+15igQvE2g2+S973pMGvwN3guw7cJUjA/TmbPWTHQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-linux-x64-musl@16.2.9': resolution: {integrity: sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@15.1.6': resolution: {integrity: sha512-s8w6EeqNmi6gdvM19tqKKWbCyOBvXFbndkGHl+c9YrzsLARRdCHsD9S1fMj8gsXm9v8vhC8s3N8rjuC/XrtkEg==} @@ -8244,79 +8278,66 @@ packages: resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.61.1': resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.61.1': resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.61.1': resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.61.1': resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.61.1': resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.61.1': resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.61.1': resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.61.1': resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.61.1': resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.61.1': resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.61.1': resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.61.1': resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.61.1': resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} @@ -8575,28 +8596,24 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [glibc] '@swc/core-linux-arm64-musl@1.10.11': resolution: {integrity: sha512-2mMscXe/ivq8c4tO3eQSbQDFBvagMJGlalXCspn0DgDImLYTEnt/8KHMUMGVfh0gMJTZ9q4FlGLo7mlnbx99MQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [musl] '@swc/core-linux-x64-gnu@1.10.11': resolution: {integrity: sha512-eu2apgDbC4xwsigpl6LS+iyw6a3mL6kB4I+6PZMbFF2nIb1Dh7RGnu70Ai6mMn1o80fTmRSKsCT3CKMfVdeNFg==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [glibc] '@swc/core-linux-x64-musl@1.10.11': resolution: {integrity: sha512-0n+wPWpDigwqRay4IL2JIvAqSKCXv6nKxPig9M7+epAlEQlqX+8Oq/Ap3yHtuhjNPb7HmnqNJLCXT1Wx+BZo0w==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [musl] '@swc/core-win32-arm64-msvc@1.10.11': resolution: {integrity: sha512-7+bMSIoqcbXKosIVd314YjckDRPneA4OpG1cb3/GrkQTEDXmWT3pFBBlJf82hzJfw7b6lfv6rDVEFBX7/PJoLA==} @@ -21587,7 +21604,7 @@ snapshots: '@mikro-orm/mariadb@6.6.16(@mikro-orm/core@6.6.16)(pg@8.20.0)': dependencies: '@mikro-orm/core': 6.6.16 - '@mikro-orm/knex': 6.6.16(@mikro-orm/core@6.6.16)(mariadb@3.4.5)(mysql2@3.20.0(@types/node@22.12.0))(pg@8.20.0) + '@mikro-orm/knex': 6.6.16(@mikro-orm/core@6.6.16)(mariadb@3.4.5)(pg@8.20.0)(sqlite3@5.1.7) mariadb: 3.4.5 transitivePeerDependencies: - better-sqlite3 @@ -21640,7 +21657,7 @@ snapshots: '@mikro-orm/postgresql@6.6.16(@mikro-orm/core@6.6.16)(mariadb@3.4.5)': dependencies: '@mikro-orm/core': 6.6.16 - '@mikro-orm/knex': 6.6.16(@mikro-orm/core@6.6.16)(mariadb@3.4.5)(mysql2@3.20.0(@types/node@22.12.0))(pg@8.20.0) + '@mikro-orm/knex': 6.6.16(@mikro-orm/core@6.6.16)(mariadb@3.4.5)(pg@8.20.0)(sqlite3@5.1.7) pg: 8.20.0 postgres-array: 3.0.4 postgres-date: 2.1.0 @@ -34660,7 +34677,7 @@ snapshots: typescript: 5.7.3 webpack: 5.108.3(@swc/core@1.10.11(@swc/helpers@0.5.15)) - ts-loader@9.5.2(typescript@5.7.3)(webpack@5.108.4(@swc/core@1.10.11(@swc/helpers@0.5.15))): + ts-loader@9.5.2(typescript@5.7.3)(webpack@5.108.4): dependencies: chalk: 4.1.2 enhanced-resolve: 5.18.0 @@ -34668,9 +34685,9 @@ snapshots: semver: 7.6.3 source-map: 0.7.4 typescript: 5.7.3 - webpack: 5.108.4(@swc/core@1.10.11(@swc/helpers@0.5.15)) + webpack: 5.108.4 - ts-loader@9.5.2(typescript@5.7.3)(webpack@5.108.4): + ts-loader@9.5.2(typescript@5.7.3)(webpack@5.97.1(@swc/core@1.10.11(@swc/helpers@0.5.15))): dependencies: chalk: 4.1.2 enhanced-resolve: 5.18.0 @@ -34678,7 +34695,7 @@ snapshots: semver: 7.6.3 source-map: 0.7.4 typescript: 5.7.3 - webpack: 5.108.4 + webpack: 5.97.1(@swc/core@1.10.11(@swc/helpers@0.5.15)) ts-morph@12.0.0: dependencies: From 5c8c2d18a1c504e6560fb4d4c0f0ef8552fe36a7 Mon Sep 17 00:00:00 2001 From: DABH Date: Fri, 11 Sep 2026 01:17:35 -0500 Subject: [PATCH 02/12] Log each OpenRouter call's attempt, cost, and cache status --- openrouter/src/activities.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openrouter/src/activities.ts b/openrouter/src/activities.ts index 41ee5a1b8..1eb01b7f6 100644 --- a/openrouter/src/activities.ts +++ b/openrouter/src/activities.ts @@ -1,5 +1,5 @@ import OpenAI, { APIError } from 'openai'; -import { ApplicationFailure, Context } from '@temporalio/activity'; +import { ApplicationFailure, Context, log } from '@temporalio/activity'; import { OPENROUTER_BASE_URL, OpenRouterRequest, OpenRouterResult } from './shared'; /** @@ -144,7 +144,7 @@ async function send(client: OpenAI, request: OpenRouterRequest, attempt: number) generationId: data.id, cacheStatus: response.headers.get('x-openrouter-cache-status') ?? '', }; - Context.current().log.info('OpenRouter call completed', { + log.info('OpenRouter call completed', { attempt, model: result.model, costUsd: result.costUsd, From bdab7191e0e32ec83002c72909e5cc5a838aef5e Mon Sep 17 00:00:00 2001 From: DABH Date: Mon, 14 Sep 2026 12:36:02 -0500 Subject: [PATCH 03/12] Treat 402 and 403 key-limit errors as one out-of-credits type --- openrouter/README.md | 2 +- openrouter/src/activities.ts | 21 +++++++++++++++++++-- openrouter/src/mocha/activities.test.ts | 14 ++++++++++++-- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/openrouter/README.md b/openrouter/README.md index 3ebfb3a8a..d29c3aea5 100644 --- a/openrouter/README.md +++ b/openrouter/README.md @@ -8,7 +8,7 @@ This is the TypeScript port of the Python [`openrouter/prompt_batch`](https://gi - 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. +- 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. Running out of money gets its own failure type, `OpenRouterOutOfCredits`, for both 402 (account out of credits) and 403 `Key limit exceeded` (per-key limit), so a Workflow can pause on it. 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`. diff --git a/openrouter/src/activities.ts b/openrouter/src/activities.ts index 1eb01b7f6..50fa013d0 100644 --- a/openrouter/src/activities.ts +++ b/openrouter/src/activities.ts @@ -37,6 +37,14 @@ export function errorType(status: number): string { return `OpenRouterHTTP${status}`; } +/** + * Thrown instead of an HTTP status type when the call failed for lack of + * money: 402 when the account is out of credits, or 403 "Key limit exceeded" + * when the API key hit its own credit limit. A Workflow can pause on this and + * resume once someone tops up. + */ +export const OUT_OF_CREDITS = 'OpenRouterOutOfCredits'; + function retryAfter(headers: Headers | undefined): string | undefined { const value = headers?.get('retry-after'); if (value === null || value === undefined) return undefined; @@ -48,10 +56,19 @@ function retryAfter(headers: Headers | undefined): string | undefined { /** * Turn an OpenRouter error into an ApplicationFailure with the right retry * posture. Retryable: 408, 429 (honoring Retry-After), and any 5xx. - * Non-retryable: other 4xx. 400 is a bad request, 401 a bad key, 402 means - * the key is out of credits, 403 a moderation or permission block. + * Non-retryable: other 4xx. 400 is a bad request, 401 a bad key, 403 a + * moderation or permission block. Out of money is its own type + * (OUT_OF_CREDITS): 402 for the account, 403 "Key limit exceeded" for the key. */ export function throwForStatus(status: number, message: string, headers?: Headers): never { + if (status === 402 || (status === 403 && message.toLowerCase().includes('limit exceeded'))) { + throw ApplicationFailure.create({ + message: `OpenRouter returned HTTP ${status}: ${message}`, + type: OUT_OF_CREDITS, + nonRetryable: true, + details: [{ status }], + }); + } const retryable = status === 408 || status === 429 || status >= 500; throw ApplicationFailure.create({ message: `OpenRouter returned HTTP ${status}: ${message}`, diff --git a/openrouter/src/mocha/activities.test.ts b/openrouter/src/mocha/activities.test.ts index dd8ee0e7b..5536671a7 100644 --- a/openrouter/src/mocha/activities.test.ts +++ b/openrouter/src/mocha/activities.test.ts @@ -92,17 +92,27 @@ describe('callOpenRouter activity', () => { assert.strictEqual(failure.nextRetryDelay, '7s'); }); - it('treats 402 insufficient credits as non-retryable', async () => { + it('treats 402 insufficient credits as out of credits, non-retryable', async () => { const activities = makeActivities(() => ({ status: 402, body: { error: { code: 402, message: 'Insufficient credits' } }, })); const failure = await expectFailure(() => new MockActivityEnvironment().run(activities.callOpenRouter, request)); - assert.strictEqual(failure.type, 'OpenRouterHTTP402'); + assert.strictEqual(failure.type, 'OpenRouterOutOfCredits'); assert.strictEqual(failure.nonRetryable, true); assert.match(failure.message, /Insufficient credits/); }); + it('treats 403 key limit exceeded as out of credits too', async () => { + const activities = makeActivities(() => ({ + status: 403, + body: { error: { code: 403, message: 'Key limit exceeded (total limit)' } }, + })); + const failure = await expectFailure(() => new MockActivityEnvironment().run(activities.callOpenRouter, request)); + assert.strictEqual(failure.type, 'OpenRouterOutOfCredits'); + assert.strictEqual(failure.nonRetryable, true); + }); + it('classifies an error body inside a 200 by its code', async () => { const activities = makeActivities(() => ({ status: 200, From 01e2f846814fa3ed8c1c89af6a542305c10dd322 Mon Sep 17 00:00:00 2001 From: DABH Date: Wed, 30 Sep 2026 13:49:48 -0500 Subject: [PATCH 04/12] Relock with pnpm 10 main's lockfile carries a pnpm 9 checksum; CI installs pnpm 10, which expects sha256 and refuses a frozen install. Regenerated with pnpm 10.34 so CI can run. --- pnpm-lock.yaml | 69 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29d0f9062..a21f7cb50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,7 +4,7 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -packageExtensionsChecksum: 04bb838d42781e02e3862a2b181e3e60 +packageExtensionsChecksum: sha256-UuLKW9BCv/VaZT6E7tclNpwm12M1QVyh2vqxmcC/zL8= importers: @@ -2589,7 +2589,7 @@ importers: version: 29.2.5(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest@29.7.0(@types/node@22.12.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.11(@swc/helpers@0.5.15))(@types/node@22.12.0)(typescript@5.7.3)))(typescript@5.7.3) ts-loader: specifier: ^9.5.1 - version: 9.5.2(typescript@5.7.3)(webpack@5.97.1(@swc/core@1.10.11(@swc/helpers@0.5.15))) + version: 9.5.2(typescript@5.7.3)(webpack@5.108.4(@swc/core@1.10.11(@swc/helpers@0.5.15))) ts-node: specifier: ^10.9.2 version: 10.9.2(@swc/core@1.10.11(@swc/helpers@0.5.15))(@types/node@22.12.0)(typescript@5.7.3) @@ -6703,155 +6703,183 @@ packages: resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} @@ -7379,48 +7407,56 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-gnu@16.2.9': resolution: {integrity: sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.1.6': resolution: {integrity: sha512-+n3u//bfsrIaZch4cgOJ3tXCTbSxz0s6brJtU3SzLOvkJlPQMJ+eHVRi6qM2kKKKLuMY+tcau8XD9CJ1OjeSQQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-arm64-musl@16.2.9': resolution: {integrity: sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.1.6': resolution: {integrity: sha512-SpuDEXixM3PycniL4iVCLyUyvcl6Lt0mtv3am08sucskpG0tYkW1KlRhTgj4LI5ehyxriVVcfdoxuuP8csi3kQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-gnu@16.2.9': resolution: {integrity: sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.1.6': resolution: {integrity: sha512-L4druWmdFSZIIRhF+G60API5sFB7suTbDRhYWSjiw0RbE+15igQvE2g2+S973pMGvwN3guw7cJUjA/TmbPWTHQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-linux-x64-musl@16.2.9': resolution: {integrity: sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.1.6': resolution: {integrity: sha512-s8w6EeqNmi6gdvM19tqKKWbCyOBvXFbndkGHl+c9YrzsLARRdCHsD9S1fMj8gsXm9v8vhC8s3N8rjuC/XrtkEg==} @@ -8278,66 +8314,79 @@ packages: resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.61.1': resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.61.1': resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.61.1': resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.61.1': resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.61.1': resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.61.1': resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.61.1': resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.61.1': resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.61.1': resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.61.1': resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.61.1': resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.61.1': resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.61.1': resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} @@ -8596,24 +8645,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.10.11': resolution: {integrity: sha512-2mMscXe/ivq8c4tO3eQSbQDFBvagMJGlalXCspn0DgDImLYTEnt/8KHMUMGVfh0gMJTZ9q4FlGLo7mlnbx99MQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.10.11': resolution: {integrity: sha512-eu2apgDbC4xwsigpl6LS+iyw6a3mL6kB4I+6PZMbFF2nIb1Dh7RGnu70Ai6mMn1o80fTmRSKsCT3CKMfVdeNFg==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.10.11': resolution: {integrity: sha512-0n+wPWpDigwqRay4IL2JIvAqSKCXv6nKxPig9M7+epAlEQlqX+8Oq/Ap3yHtuhjNPb7HmnqNJLCXT1Wx+BZo0w==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.10.11': resolution: {integrity: sha512-7+bMSIoqcbXKosIVd314YjckDRPneA4OpG1cb3/GrkQTEDXmWT3pFBBlJf82hzJfw7b6lfv6rDVEFBX7/PJoLA==} @@ -21604,7 +21657,7 @@ snapshots: '@mikro-orm/mariadb@6.6.16(@mikro-orm/core@6.6.16)(pg@8.20.0)': dependencies: '@mikro-orm/core': 6.6.16 - '@mikro-orm/knex': 6.6.16(@mikro-orm/core@6.6.16)(mariadb@3.4.5)(pg@8.20.0)(sqlite3@5.1.7) + '@mikro-orm/knex': 6.6.16(@mikro-orm/core@6.6.16)(mariadb@3.4.5)(mysql2@3.20.0(@types/node@22.12.0))(pg@8.20.0) mariadb: 3.4.5 transitivePeerDependencies: - better-sqlite3 @@ -21657,7 +21710,7 @@ snapshots: '@mikro-orm/postgresql@6.6.16(@mikro-orm/core@6.6.16)(mariadb@3.4.5)': dependencies: '@mikro-orm/core': 6.6.16 - '@mikro-orm/knex': 6.6.16(@mikro-orm/core@6.6.16)(mariadb@3.4.5)(pg@8.20.0)(sqlite3@5.1.7) + '@mikro-orm/knex': 6.6.16(@mikro-orm/core@6.6.16)(mariadb@3.4.5)(mysql2@3.20.0(@types/node@22.12.0))(pg@8.20.0) pg: 8.20.0 postgres-array: 3.0.4 postgres-date: 2.1.0 @@ -34677,7 +34730,7 @@ snapshots: typescript: 5.7.3 webpack: 5.108.3(@swc/core@1.10.11(@swc/helpers@0.5.15)) - ts-loader@9.5.2(typescript@5.7.3)(webpack@5.108.4): + ts-loader@9.5.2(typescript@5.7.3)(webpack@5.108.4(@swc/core@1.10.11(@swc/helpers@0.5.15))): dependencies: chalk: 4.1.2 enhanced-resolve: 5.18.0 @@ -34685,9 +34738,9 @@ snapshots: semver: 7.6.3 source-map: 0.7.4 typescript: 5.7.3 - webpack: 5.108.4 + webpack: 5.108.4(@swc/core@1.10.11(@swc/helpers@0.5.15)) - ts-loader@9.5.2(typescript@5.7.3)(webpack@5.97.1(@swc/core@1.10.11(@swc/helpers@0.5.15))): + ts-loader@9.5.2(typescript@5.7.3)(webpack@5.108.4): dependencies: chalk: 4.1.2 enhanced-resolve: 5.18.0 @@ -34695,7 +34748,7 @@ snapshots: semver: 7.6.3 source-map: 0.7.4 typescript: 5.7.3 - webpack: 5.97.1(@swc/core@1.10.11(@swc/helpers@0.5.15)) + webpack: 5.108.4 ts-morph@12.0.0: dependencies: From b33eb87d1ae8a76eff896e419fc4cf6b54e5c306 Mon Sep 17 00:00:00 2001 From: DABH Date: Thu, 1 Oct 2026 01:20:38 -0500 Subject: [PATCH 05/12] Address review: cancellation, concurrency validation, env config, Retry-After dates, reported cost - Rethrow Workflow cancellation instead of recording it as a skipped prompt. - Reject a non-positive maxConcurrency and cap runners at the prompt count. - Worker loads the same env-config connection options as the client. - Parse the HTTP-date form of Retry-After as well as delta-seconds. - A response without usage.cost reports an unknown cost (null), not zero, and the batch total is named reportedCostUsd with its scope documented. - .post-create mentions OPENROUTER_API_KEY; excluded from the shared-file copy. --- .scripts/copy-shared-files.mjs | 1 + openrouter/.post-create | 4 ++++ openrouter/README.md | 4 +++- openrouter/src/activities.ts | 13 ++++++++++--- openrouter/src/client.ts | 7 +++++-- openrouter/src/mocha/activities.test.ts | 17 +++++++++++++++-- openrouter/src/mocha/workflows.test.ts | 22 +++++++++++++++++++++- openrouter/src/shared.ts | 10 ++++++++-- openrouter/src/worker.ts | 8 +++++--- openrouter/src/workflows.ts | 13 ++++++++++--- 10 files changed, 82 insertions(+), 17 deletions(-) diff --git a/.scripts/copy-shared-files.mjs b/.scripts/copy-shared-files.mjs index 36b715151..005ee967b 100644 --- a/.scripts/copy-shared-files.mjs +++ b/.scripts/copy-shared-files.mjs @@ -64,6 +64,7 @@ const ESLINTIGNORE_EXCLUDE = [ const POST_CREATE_EXCLUDE = [ 'openai-agents', + 'openrouter', 'google-adk-agents', 'env-config', 'dsl-interpreter', diff --git a/openrouter/.post-create b/openrouter/.post-create index 055c11e9e..20f776e87 100644 --- a/openrouter/.post-create +++ b/openrouter/.post-create @@ -12,6 +12,10 @@ 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} diff --git a/openrouter/README.md b/openrouter/README.md index d29c3aea5..88afbdbb4 100644 --- a/openrouter/README.md +++ b/openrouter/README.md @@ -31,7 +31,7 @@ Starting openrouter-prompt-batch-... Q: Explain retries in one sentence. A: Retries are the automatic re-attempts of a failed operation ... -Total cost: $0.000547 +Reported cost: $0.000547 (what OpenRouter reported on each prompt's final attempt) Inspect: temporal workflow show -w openrouter-prompt-batch-... ``` @@ -60,6 +60,8 @@ For agents built on the [Vercel AI SDK](../ai-sdk), [`@openrouter/ai-sdk-provide 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. +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`. + 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 diff --git a/openrouter/src/activities.ts b/openrouter/src/activities.ts index 50fa013d0..4ec1594f5 100644 --- a/openrouter/src/activities.ts +++ b/openrouter/src/activities.ts @@ -45,12 +45,14 @@ export function errorType(status: number): string { */ export const OUT_OF_CREDITS = 'OpenRouterOutOfCredits'; +/** Parse Retry-After in either its delta-seconds or HTTP-date form. */ function retryAfter(headers: Headers | undefined): string | undefined { const value = headers?.get('retry-after'); if (value === null || value === undefined) return undefined; const seconds = Number(value); - // HTTP-date form: let the Activity retry policy decide the delay. - return Number.isFinite(seconds) ? `${seconds}s` : undefined; + if (Number.isFinite(seconds)) return `${seconds}s`; + const delayMs = Date.parse(value) - Date.now(); + return Number.isFinite(delayMs) && delayMs > 0 ? `${Math.ceil(delayMs / 1000)}s` : undefined; } /** @@ -153,11 +155,16 @@ async function send(client: OpenAI, request: OpenRouterRequest, attempt: number) } const usage = data.usage as (OpenAI.CompletionUsage & { cost?: number }) | undefined; + if (typeof usage?.cost !== 'number') { + // OpenRouter reports cost on every response; if it is ever missing, say + // so rather than pretending the call was free. + log.warn('OpenRouter response has no usage.cost'); + } const result: OpenRouterResult = { prompt: request.prompt, model: data.model, answer: contentToText(data.choices?.[0]?.message?.content), - costUsd: typeof usage?.cost === 'number' ? usage.cost : 0, + costUsd: typeof usage?.cost === 'number' ? usage.cost : null, generationId: data.id, cacheStatus: response.headers.get('x-openrouter-cache-status') ?? '', }; diff --git a/openrouter/src/client.ts b/openrouter/src/client.ts index 131cc7cb8..c68540cda 100644 --- a/openrouter/src/client.ts +++ b/openrouter/src/client.ts @@ -27,14 +27,17 @@ async function run() { }); for (const r of result.results) { - console.log(`\n[${r.model}] $${r.costUsd.toFixed(6)} cache=${r.cacheStatus || '-'}`); + const cost = r.costUsd === null ? 'unknown' : `$${r.costUsd.toFixed(6)}`; + console.log(`\n[${r.model}] ${cost} cache=${r.cacheStatus || '-'}`); console.log(` Q: ${r.prompt}`); console.log(` A: ${r.answer.trim()}`); } for (const s of result.skipped) { console.log(`\n[skipped: ${s.reason}] ${s.prompt}`); } - console.log(`\nTotal cost: $${result.totalCostUsd.toFixed(6)}`); + console.log( + `\nReported cost: $${result.reportedCostUsd.toFixed(6)} (what OpenRouter reported on each prompt's final attempt)`, + ); console.log(`Inspect: temporal workflow show -w ${workflowId}`); } diff --git a/openrouter/src/mocha/activities.test.ts b/openrouter/src/mocha/activities.test.ts index 5536671a7..306d8c7d2 100644 --- a/openrouter/src/mocha/activities.test.ts +++ b/openrouter/src/mocha/activities.test.ts @@ -143,12 +143,25 @@ describe('callOpenRouter activity', () => { assert.strictEqual(result.costUsd, 0); }); - it('reports a missing cost as zero', async () => { + it('reports a missing cost as unknown', async () => { const body = completion(); delete (body.usage as { cost?: number }).cost; const activities = makeActivities(() => ({ status: 200, body })); const result = (await new MockActivityEnvironment().run(activities.callOpenRouter, request)) as OpenRouterResult; - assert.strictEqual(result.costUsd, 0); + assert.strictEqual(result.costUsd, null); assert.strictEqual(result.cacheStatus, ''); }); + + it('honors an HTTP-date Retry-After', async () => { + const when = new Date(Date.now() + 30_000).toUTCString(); + const activities = makeActivities(() => ({ + status: 503, + body: { error: { code: 503, message: 'No provider available' } }, + headers: { 'Retry-After': when }, + })); + const failure = await expectFailure(() => new MockActivityEnvironment().run(activities.callOpenRouter, request)); + assert.strictEqual(failure.type, 'OpenRouterHTTP503'); + const seconds = Number(String(failure.nextRetryDelay).replace('s', '')); + assert.ok(seconds > 25 && seconds <= 30, `unexpected delay ${failure.nextRetryDelay}`); + }); }); diff --git a/openrouter/src/mocha/workflows.test.ts b/openrouter/src/mocha/workflows.test.ts index 905c67154..dbd3fe644 100644 --- a/openrouter/src/mocha/workflows.test.ts +++ b/openrouter/src/mocha/workflows.test.ts @@ -61,6 +61,26 @@ describe('promptBatch workflow', function () { ['one', 'two'], ); assert.deepStrictEqual(result.skipped, [{ prompt: 'bad', reason: 'OpenRouterHTTP400' }]); - assert.strictEqual(result.totalCostUsd, 0.002); + assert.strictEqual(result.reportedCostUsd, 0.002); + }); + + it('rejects a non-positive maxConcurrency', async () => { + const taskQueue = 'test-openrouter-' + Date.now(); + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + activities: { callOpenRouter: async () => assert.fail('should not run') }, + }); + await assert.rejects( + worker.runUntil( + testEnv.client.workflow.execute(promptBatch, { + args: [{ prompts: ['one'], maxConcurrency: 0 }], + workflowId: 'test-openrouter-' + Date.now(), + taskQueue, + }), + ), + (err: unknown) => /maxConcurrency/.test(String((err as { cause?: Error }).cause?.message)), + ); }); }); diff --git a/openrouter/src/shared.ts b/openrouter/src/shared.ts index a181f5565..09ea22642 100644 --- a/openrouter/src/shared.ts +++ b/openrouter/src/shared.ts @@ -34,7 +34,8 @@ export interface OpenRouterResult { prompt: string; model: string; answer: string; - costUsd: number; + /** What OpenRouter reported for this attempt; null if the response had no usage.cost. */ + costUsd: number | null; generationId: string; /** "HIT" or "MISS" from X-OpenRouter-Cache-Status, or "" when absent. */ cacheStatus: string; @@ -55,5 +56,10 @@ export interface BatchInput { export interface BatchResult { results: OpenRouterResult[]; skipped: SkippedPrompt[]; - totalCostUsd: number; + /** + * Sum of the cost OpenRouter reported on each prompt's final, successful + * attempt. Attempts that were billed but whose result never reached Temporal + * are not included; OpenRouter's dashboard is the source of truth for spend. + */ + reportedCostUsd: number; } diff --git a/openrouter/src/worker.ts b/openrouter/src/worker.ts index 8060c17eb..f6714a69b 100644 --- a/openrouter/src/worker.ts +++ b/openrouter/src/worker.ts @@ -1,11 +1,13 @@ import { NativeConnection, Worker } from '@temporalio/worker'; +import { loadClientConnectConfig } from '@temporalio/envconfig'; import { buildClient, createActivities } from './activities'; import { TASK_QUEUE } from './shared'; async function run() { - const connection = await NativeConnection.connect({ - address: 'localhost:7233', - }); + // Same connection settings as the client, so a profile that points at a + // remote server moves both the starter and the Worker. + const config = loadClientConnectConfig(); + const connection = await NativeConnection.connect(config.connectionOptions); try { // One OpenRouter client for the Worker's lifetime, shared by every // concurrent Activity. Reads OPENROUTER_API_KEY from the environment. diff --git a/openrouter/src/workflows.ts b/openrouter/src/workflows.ts index 47089f863..37f93b472 100644 --- a/openrouter/src/workflows.ts +++ b/openrouter/src/workflows.ts @@ -1,4 +1,4 @@ -import { ActivityFailure, ApplicationFailure, log, proxyActivities } from '@temporalio/workflow'; +import { ActivityFailure, ApplicationFailure, isCancellation, log, proxyActivities } from '@temporalio/workflow'; import type { createActivities } from './activities'; import { BatchInput, @@ -32,6 +32,11 @@ export async function promptBatch(batch: BatchInput): Promise { ); } + const maxConcurrency = batch.maxConcurrency ?? 5; + if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) { + throw ApplicationFailure.nonRetryable('maxConcurrency must be a positive integer'); + } + const outcomes: (OpenRouterResult | SkippedPrompt)[] = new Array(batch.prompts.length); let next = 0; // Bounded concurrency: N runners pull from the shared prompt list. @@ -41,14 +46,14 @@ export async function promptBatch(batch: BatchInput): Promise { outcomes[index] = await answer(batch.prompts[index], batch); } }; - await Promise.all(Array.from({ length: batch.maxConcurrency ?? 5 }, runner)); + await Promise.all(Array.from({ length: Math.min(maxConcurrency, batch.prompts.length) }, runner)); const results = outcomes.filter((o): o is OpenRouterResult => 'answer' in o); const skipped = outcomes.filter((o): o is SkippedPrompt => 'reason' in o); return { results, skipped, - totalCostUsd: Number(results.reduce((sum, r) => sum + r.costUsd, 0).toFixed(6)), + reportedCostUsd: Number(results.reduce((sum, r) => sum + (r.costUsd ?? 0), 0).toFixed(6)), }; } @@ -62,6 +67,8 @@ async function answer(prompt: string, batch: BatchInput): Promise Date: Thu, 1 Oct 2026 13:10:40 -0500 Subject: [PATCH 06/12] Address review: namespace, request abort on cancellation, history wording - Client and Worker pass the namespace from the loaded connection config. - The OpenRouter request carries the Activity's cancellation signal; an abort caused by cancellation surfaces as CancelledFailure. Test added. - README describes what Event History records versus Worker logs. --- openrouter/README.md | 6 +++--- openrouter/src/activities.ts | 19 ++++++++++++++----- openrouter/src/client.ts | 2 +- openrouter/src/mocha/activities.test.ts | 22 +++++++++++++++++++++- openrouter/src/worker.ts | 2 +- 5 files changed, 40 insertions(+), 11 deletions(-) diff --git a/openrouter/README.md b/openrouter/README.md index 88afbdbb4..6da0d6f23 100644 --- a/openrouter/README.md +++ b/openrouter/README.md @@ -1,6 +1,6 @@ # 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. +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. @@ -8,7 +8,7 @@ This is the TypeScript port of the Python [`openrouter/prompt_batch`](https://gi - 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. Running out of money gets its own failure type, `OpenRouterOutOfCredits`, for both 402 (account out of credits) and 403 `Key limit exceeded` (per-key limit), so a Workflow can pause on it. OpenRouter can also return HTTP 200 with an `error` body and no `choices`; the Activity checks for that. +- 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; 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. Running out of money gets its own failure type, `OpenRouterOutOfCredits`, for both 402 (account out of credits) and 403 `Key limit exceeded` (per-key limit), so a Workflow can pause on it. 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`. @@ -48,7 +48,7 @@ npm run workflow -- --fail-once "Explain idempotency in one sentence." Q: Explain idempotency in one sentence. ``` -`temporal workflow show -w ` 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. +`temporal workflow show -w ` 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. ## Using OpenRouter's SDKs instead diff --git a/openrouter/src/activities.ts b/openrouter/src/activities.ts index 4ec1594f5..f9df58762 100644 --- a/openrouter/src/activities.ts +++ b/openrouter/src/activities.ts @@ -1,12 +1,13 @@ -import OpenAI, { APIError } from 'openai'; +import OpenAI, { APIError, APIUserAbortError } from 'openai'; import { ApplicationFailure, Context, log } from '@temporalio/activity'; import { OPENROUTER_BASE_URL, OpenRouterRequest, OpenRouterResult } from './shared'; /** * OpenAI SDK client pointed at OpenRouter. * - * Client-side retries are disabled so that Temporal owns every retry and each - * attempt is visible in Event History. (OpenRouter's official `@openrouter/sdk` + * Client-side retries are disabled so that Temporal owns every retry: the + * attempt count and last failure land in Event History, and each attempt is + * logged below. (OpenRouter's official `@openrouter/sdk` * retries 5xx and connection errors for up to an hour by default; if you use it * instead, pass `retryConfig: { strategy: 'none' }`.) */ @@ -109,7 +110,7 @@ export function createActivities(client: OpenAI) { ? setInterval(() => context.heartbeat(context.info.attempt), heartbeatMs / 2) : undefined; try { - return await send(client, request, context.info.attempt); + return await send(client, request, context); } finally { if (heartbeat) clearInterval(heartbeat); } @@ -117,7 +118,8 @@ export function createActivities(client: OpenAI) { }; } -async function send(client: OpenAI, request: OpenRouterRequest, attempt: number): Promise { +async function send(client: OpenAI, request: OpenRouterRequest, context: Context): Promise { + const attempt = context.info.attempt; const params: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming & { plugins?: unknown } = { model: request.model, messages: [{ role: 'user', content: request.prompt }], @@ -131,6 +133,8 @@ async function send(client: OpenAI, request: OpenRouterRequest, attempt: number) try { ({ data, response } = await client.chat.completions .create(params, { + // Abort the HTTP request if the Activity is cancelled. + signal: context.cancellationSignal, headers: { // Ask OpenRouter to cache the successful response. A retry of the // byte-identical request within the TTL is served from cache and @@ -141,6 +145,11 @@ async function send(client: OpenAI, request: OpenRouterRequest, attempt: number) }) .withResponse()); } catch (e) { + if (e instanceof APIUserAbortError && context.cancellationSignal.aborted) { + // The request was aborted because the Activity was cancelled; surface + // that as a cancellation, not as a failed call. + await context.cancelled; + } if (e instanceof APIError && typeof e.status === 'number') { throwForStatus(e.status, errorMessage(e.error) || e.message, e.headers); } diff --git a/openrouter/src/client.ts b/openrouter/src/client.ts index c68540cda..0c57677cb 100644 --- a/openrouter/src/client.ts +++ b/openrouter/src/client.ts @@ -16,7 +16,7 @@ async function run() { const config = loadClientConnectConfig(); const connection = await Connection.connect(config.connectionOptions); - const client = new Client({ connection }); + const client = new Client({ connection, namespace: config.namespace ?? 'default' }); const workflowId = 'openrouter-prompt-batch-' + nanoid(); console.log(`Starting ${workflowId}`); diff --git a/openrouter/src/mocha/activities.test.ts b/openrouter/src/mocha/activities.test.ts index 306d8c7d2..0644b6767 100644 --- a/openrouter/src/mocha/activities.test.ts +++ b/openrouter/src/mocha/activities.test.ts @@ -1,5 +1,5 @@ import { MockActivityEnvironment } from '@temporalio/testing'; -import { ApplicationFailure } from '@temporalio/activity'; +import { ApplicationFailure, CancelledFailure } from '@temporalio/activity'; import { describe, it } from 'mocha'; import assert from 'assert'; import OpenAI from 'openai'; @@ -143,6 +143,26 @@ describe('callOpenRouter activity', () => { assert.strictEqual(result.costUsd, 0); }); + it('aborts the HTTP request and surfaces cancellation when the Activity is cancelled', async () => { + let seenSignal: AbortSignal | undefined; + const fetch = (input: string | URL | Request, init?: RequestInit): Promise => { + seenSignal = init?.signal ?? undefined; + return new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + }); + }; + const client = new OpenAI({ baseURL: OPENROUTER_BASE_URL, apiKey: 'test-key', maxRetries: 0, fetch }); + const activities = createActivities(client); + const env = new MockActivityEnvironment({ heartbeatTimeoutMs: 1000 }); + + const run = env.run(activities.callOpenRouter, request); + await new Promise((resolve) => setTimeout(resolve, 50)); + env.cancel(); + + await assert.rejects(run, (e: unknown) => e instanceof CancelledFailure); + assert.ok(seenSignal?.aborted, 'the request signal should have been aborted'); + }); + it('reports a missing cost as unknown', async () => { const body = completion(); delete (body.usage as { cost?: number }).cost; diff --git a/openrouter/src/worker.ts b/openrouter/src/worker.ts index f6714a69b..661e0d400 100644 --- a/openrouter/src/worker.ts +++ b/openrouter/src/worker.ts @@ -15,7 +15,7 @@ async function run() { const worker = await Worker.create({ connection, - namespace: 'default', + namespace: config.namespace ?? 'default', taskQueue: TASK_QUEUE, // Workflows are registered using a path as they run in a separate JS context. workflowsPath: require.resolve('./workflows'), From fc54d9066c56bb5ea3929edce252c53bf53ecafb Mon Sep 17 00:00:00 2001 From: DABH Date: Thu, 1 Oct 2026 13:22:38 -0500 Subject: [PATCH 07/12] Self-review fixes: transient 402, error messages, arg parsing, cancellation coverage - A 402 whose error.metadata.limit_source is openrouter_in_flight_budget is transient per OpenRouter's docs; retry it after Retry-After instead of treating it as out of credits. - openai's APIError.error is already the inner error object, so the message path never matched and every failure message carried the status twice. - The client parses its flags explicitly, rejects unknown ones, and accepts --max-concurrency; .env.example removed since nothing read it. - Empty or non-positive Retry-After values are ignored. - Any error while cancellation is pending surfaces as CancelledFailure. - Cancellation test waits for the request to start instead of sleeping, and a Workflow-level cancellation test was added. --- openrouter/.env.example | 4 -- openrouter/README.md | 9 +++- openrouter/src/activities.ts | 70 +++++++++++++++---------- openrouter/src/client.ts | 20 ++++--- openrouter/src/mocha/activities.test.ts | 37 ++++++++++++- openrouter/src/mocha/workflows.test.ts | 42 +++++++++++++-- 6 files changed, 135 insertions(+), 47 deletions(-) delete mode 100644 openrouter/.env.example diff --git a/openrouter/.env.example b/openrouter/.env.example deleted file mode 100644 index 599fc1bd0..000000000 --- a/openrouter/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -OPENROUTER_API_KEY= -# Optional app attribution for OpenRouter's rankings -OPENROUTER_HTTP_REFERER= -OPENROUTER_APP_TITLE= diff --git a/openrouter/README.md b/openrouter/README.md index 6da0d6f23..4f6b044b2 100644 --- a/openrouter/README.md +++ b/openrouter/README.md @@ -8,7 +8,7 @@ This is the TypeScript port of the Python [`openrouter/prompt_batch`](https://gi - 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; 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. Running out of money gets its own failure type, `OpenRouterOutOfCredits`, for both 402 (account out of credits) and 403 `Key limit exceeded` (per-key limit), so a Workflow can pause on it. OpenRouter can also return HTTP 200 with an `error` body and no `choices`; the Activity checks for that. +- 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; 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`; 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`. @@ -48,6 +48,11 @@ npm run workflow -- --fail-once "Explain idempotency in one sentence." Q: Explain idempotency in one sentence. ``` +### Other options + +- `--model `: any OpenRouter model instead of the Auto Router. +- `--max-concurrency `: how many prompts are in flight at once (default 5). + `temporal workflow show -w ` 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. ## Using OpenRouter's SDKs instead @@ -58,7 +63,7 @@ For agents built on the [Vercel AI SDK](../ai-sdk), [`@openrouter/ai-sdk-provide ## 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. +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`. diff --git a/openrouter/src/activities.ts b/openrouter/src/activities.ts index f9df58762..438ffc751 100644 --- a/openrouter/src/activities.ts +++ b/openrouter/src/activities.ts @@ -1,4 +1,4 @@ -import OpenAI, { APIError, APIUserAbortError } from 'openai'; +import OpenAI, { APIError } from 'openai'; import { ApplicationFailure, Context, log } from '@temporalio/activity'; import { OPENROUTER_BASE_URL, OpenRouterRequest, OpenRouterResult } from './shared'; @@ -40,31 +40,51 @@ export function errorType(status: number): string { /** * Thrown instead of an HTTP status type when the call failed for lack of - * money: 402 when the account is out of credits, or 403 "Key limit exceeded" - * when the API key hit its own credit limit. A Workflow can pause on this and - * resume once someone tops up. + * money: a 402 (account or API key out of credits; `error.metadata.limit_source` + * says which) or, as observed in practice, a 403 "Key limit exceeded" for a + * per-key limit. A Workflow can pause on this and resume once someone tops up. */ export const OUT_OF_CREDITS = 'OpenRouterOutOfCredits'; /** Parse Retry-After in either its delta-seconds or HTTP-date form. */ function retryAfter(headers: Headers | undefined): string | undefined { - const value = headers?.get('retry-after'); - if (value === null || value === undefined) return undefined; + const value = headers?.get('retry-after')?.trim(); + if (!value) return undefined; const seconds = Number(value); - if (Number.isFinite(seconds)) return `${seconds}s`; + if (Number.isFinite(seconds)) return seconds > 0 ? `${seconds}s` : undefined; const delayMs = Date.parse(value) - Date.now(); return Number.isFinite(delayMs) && delayMs > 0 ? `${Math.ceil(delayMs / 1000)}s` : undefined; } +/** The `error` object OpenRouter returns, as far as this sample reads it. */ +interface OpenRouterErrorBody { + code?: number; + message?: string; + metadata?: { limit_source?: string }; +} + +function errorBody(body: unknown): OpenRouterErrorBody { + if (!body || typeof body !== 'object') return {}; + // openai's APIError.error is already the inner `error` object; a raw + // response body wraps it as `{ error: {...} }`. Accept both. + const inner = 'error' in body ? (body as { error: unknown }).error : body; + return inner && typeof inner === 'object' ? (inner as OpenRouterErrorBody) : {}; +} + /** * Turn an OpenRouter error into an ApplicationFailure with the right retry - * posture. Retryable: 408, 429 (honoring Retry-After), and any 5xx. - * Non-retryable: other 4xx. 400 is a bad request, 401 a bad key, 403 a - * moderation or permission block. Out of money is its own type - * (OUT_OF_CREDITS): 402 for the account, 403 "Key limit exceeded" for the key. + * posture. Retryable: 408, 429 (honoring Retry-After), any 5xx, and the + * transient in-flight-budget 402. Non-retryable: other 4xx. 400 is a bad + * request, 401 a bad key, 403 a moderation or permission block. Out of money + * is its own type (OUT_OF_CREDITS). */ -export function throwForStatus(status: number, message: string, headers?: Headers): never { - if (status === 402 || (status === 403 && message.toLowerCase().includes('limit exceeded'))) { +export function throwForStatus(status: number, error: OpenRouterErrorBody, headers?: Headers): never { + const message = error.message ?? ''; + // A 402 from the in-flight budget cap is transient: OpenRouter asks you to + // wait for Retry-After and try again. Every other 402, and the legacy 403 + // "Key limit exceeded", means someone has to add credits. + const transient402 = status === 402 && error.metadata?.limit_source === 'openrouter_in_flight_budget'; + if (!transient402 && (status === 402 || (status === 403 && message.toLowerCase().includes('limit exceeded')))) { throw ApplicationFailure.create({ message: `OpenRouter returned HTTP ${status}: ${message}`, type: OUT_OF_CREDITS, @@ -72,7 +92,7 @@ export function throwForStatus(status: number, message: string, headers?: Header details: [{ status }], }); } - const retryable = status === 408 || status === 429 || status >= 500; + const retryable = transient402 || status === 408 || status === 429 || status >= 500; throw ApplicationFailure.create({ message: `OpenRouter returned HTTP ${status}: ${message}`, type: errorType(status), @@ -82,14 +102,6 @@ export function throwForStatus(status: number, message: string, headers?: Header }); } -function errorMessage(body: unknown): string { - if (body && typeof body === 'object' && 'error' in body) { - const error = (body as { error?: { message?: unknown } }).error; - if (error && typeof error.message === 'string') return error.message; - } - return ''; -} - function contentToText(content: unknown): string { if (typeof content === 'string') return content; if (!Array.isArray(content)) return ''; @@ -128,7 +140,7 @@ async function send(client: OpenAI, request: OpenRouterRequest, context: Context params.plugins = [{ id: 'auto-router', cost_tier: request.costTier }]; } - let data: OpenAI.Chat.ChatCompletion & { error?: { code?: number; message?: string } }; + let data: OpenAI.Chat.ChatCompletion & { error?: OpenRouterErrorBody }; let response: Response; try { ({ data, response } = await client.chat.completions @@ -145,13 +157,14 @@ async function send(client: OpenAI, request: OpenRouterRequest, context: Context }) .withResponse()); } catch (e) { - if (e instanceof APIUserAbortError && context.cancellationSignal.aborted) { - // The request was aborted because the Activity was cancelled; surface - // that as a cancellation, not as a failed call. + if (context.cancellationSignal.aborted) { + // Whatever the request did, the Activity was cancelled; surface that as + // a cancellation, not as a failed call. Rejects with CancelledFailure. await context.cancelled; } if (e instanceof APIError && typeof e.status === 'number') { - throwForStatus(e.status, errorMessage(e.error) || e.message, e.headers); + const error = errorBody(e.error); + throwForStatus(e.status, { ...error, message: error.message ?? e.message }, e.headers); } // Connection errors and timeouts propagate as-is: Temporal retries them. throw e; @@ -160,7 +173,8 @@ async function send(client: OpenAI, request: OpenRouterRequest, context: Context if (data.error) { // OpenRouter can return HTTP 200 with an error body and no choices when // the upstream provider failed after the request was accepted. - throwForStatus(data.error.code ?? 500, data.error.message ?? '', response.headers); + const error = errorBody(data); + throwForStatus(error.code ?? 500, error, response.headers); } const usage = data.usage as (OpenAI.CompletionUsage & { cost?: number }) | undefined; diff --git a/openrouter/src/client.ts b/openrouter/src/client.ts index 0c57677cb..6da34501e 100644 --- a/openrouter/src/client.ts +++ b/openrouter/src/client.ts @@ -7,12 +7,20 @@ import { DEFAULT_MODEL, TASK_QUEUE } from './shared'; const DEFAULT_PROMPTS = ['Explain retries in one sentence.', 'Write a haiku about databases.']; async function run() { - // Usage: npm run workflow -- [--fail-once] [--model ] [prompt ...] + // Usage: npm run workflow -- [--fail-once] [--model ] [--max-concurrency ] [prompt ...] const args = process.argv.slice(2); - const failOnceAfterCall = args.includes('--fail-once'); - const modelIndex = args.indexOf('--model'); - const model = modelIndex >= 0 ? args[modelIndex + 1] : DEFAULT_MODEL; - const prompts = args.filter((a, i) => !a.startsWith('--') && (modelIndex < 0 || i !== modelIndex + 1)); + let failOnceAfterCall = false; + let model = DEFAULT_MODEL; + let maxConcurrency = 5; + const prompts: string[] = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--fail-once') failOnceAfterCall = true; + else if (arg === '--model') model = args[++i] ?? model; + else if (arg === '--max-concurrency') maxConcurrency = Number(args[++i]); + else if (arg.startsWith('--')) throw new Error(`Unknown flag: ${arg}`); + else prompts.push(arg); + } const config = loadClientConnectConfig(); const connection = await Connection.connect(config.connectionOptions); @@ -23,7 +31,7 @@ async function run() { const result = await client.workflow.execute(promptBatch, { taskQueue: TASK_QUEUE, workflowId, - args: [{ prompts: prompts.length ? prompts : DEFAULT_PROMPTS, model, failOnceAfterCall }], + args: [{ prompts: prompts.length ? prompts : DEFAULT_PROMPTS, model, maxConcurrency, failOnceAfterCall }], }); for (const r of result.results) { diff --git a/openrouter/src/mocha/activities.test.ts b/openrouter/src/mocha/activities.test.ts index 0644b6767..ce3b2232d 100644 --- a/openrouter/src/mocha/activities.test.ts +++ b/openrouter/src/mocha/activities.test.ts @@ -100,7 +100,37 @@ describe('callOpenRouter activity', () => { const failure = await expectFailure(() => new MockActivityEnvironment().run(activities.callOpenRouter, request)); assert.strictEqual(failure.type, 'OpenRouterOutOfCredits'); assert.strictEqual(failure.nonRetryable, true); - assert.match(failure.message, /Insufficient credits/); + assert.strictEqual(failure.message, 'OpenRouter returned HTTP 402: Insufficient credits'); + }); + + it('retries a transient in-flight-budget 402 after Retry-After', async () => { + const activities = makeActivities(() => ({ + status: 402, + body: { + error: { + code: 402, + message: 'In-flight budget exceeded', + metadata: { limit_source: 'openrouter_in_flight_budget' }, + }, + }, + headers: { 'Retry-After': '3' }, + })); + const failure = await expectFailure(() => new MockActivityEnvironment().run(activities.callOpenRouter, request)); + assert.strictEqual(failure.type, 'OpenRouterHTTP402'); + assert.strictEqual(failure.nonRetryable, false); + assert.strictEqual(failure.nextRetryDelay, '3s'); + }); + + it('ignores an empty or non-positive Retry-After', async () => { + for (const value of ['', '-5', '0']) { + const activities = makeActivities(() => ({ + status: 429, + body: { error: { code: 429, message: 'Rate limited' } }, + headers: { 'Retry-After': value }, + })); + const failure = await expectFailure(() => new MockActivityEnvironment().run(activities.callOpenRouter, request)); + assert.strictEqual(failure.nextRetryDelay, undefined, `Retry-After ${JSON.stringify(value)}`); + } }); it('treats 403 key limit exceeded as out of credits too', async () => { @@ -145,8 +175,11 @@ describe('callOpenRouter activity', () => { it('aborts the HTTP request and surfaces cancellation when the Activity is cancelled', async () => { let seenSignal: AbortSignal | undefined; + let requestStarted!: () => void; + const started = new Promise((resolve) => (requestStarted = resolve)); const fetch = (input: string | URL | Request, init?: RequestInit): Promise => { seenSignal = init?.signal ?? undefined; + requestStarted(); return new Promise((_, reject) => { init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); }); @@ -156,7 +189,7 @@ describe('callOpenRouter activity', () => { const env = new MockActivityEnvironment({ heartbeatTimeoutMs: 1000 }); const run = env.run(activities.callOpenRouter, request); - await new Promise((resolve) => setTimeout(resolve, 50)); + await started; env.cancel(); await assert.rejects(run, (e: unknown) => e instanceof CancelledFailure); diff --git a/openrouter/src/mocha/workflows.test.ts b/openrouter/src/mocha/workflows.test.ts index dbd3fe644..478c04ff6 100644 --- a/openrouter/src/mocha/workflows.test.ts +++ b/openrouter/src/mocha/workflows.test.ts @@ -1,7 +1,9 @@ import { TestWorkflowEnvironment } from '@temporalio/testing'; import { after, before, describe, it } from 'mocha'; import { Worker } from '@temporalio/worker'; -import { ApplicationFailure } from '@temporalio/activity'; +import { ApplicationFailure, CancelledFailure, Context } from '@temporalio/activity'; +import { WorkflowFailedError } from '@temporalio/client'; +import { nanoid } from 'nanoid'; import assert from 'assert'; import { promptBatch } from '../workflows'; import { OpenRouterRequest, OpenRouterResult } from '../shared'; @@ -20,7 +22,7 @@ describe('promptBatch workflow', function () { }); it('collects results and skips prompts that fail with a non-retryable error', async () => { - const taskQueue = 'test-openrouter-' + Date.now(); + const taskQueue = 'test-openrouter-' + nanoid(); const activities = { async callOpenRouter(request: OpenRouterRequest): Promise { if (request.prompt === 'bad') { @@ -51,7 +53,7 @@ describe('promptBatch workflow', function () { const result = await worker.runUntil( testEnv.client.workflow.execute(promptBatch, { args: [{ prompts: ['one', 'bad', 'two'], maxConcurrency: 2 }], - workflowId: 'test-openrouter-' + Date.now(), + workflowId: 'test-openrouter-' + nanoid(), taskQueue, }), ); @@ -64,8 +66,38 @@ describe('promptBatch workflow', function () { assert.strictEqual(result.reportedCostUsd, 0.002); }); + it('propagates Workflow cancellation instead of recording skipped prompts', async () => { + const taskQueue = 'test-openrouter-' + nanoid(); + const activities = { + async callOpenRouter(): Promise { + // Block until cancelled, then surface the cancellation. + await Context.current().cancelled; + throw new Error('unreachable'); + }, + }; + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + activities, + }); + await worker.runUntil(async () => { + const handle = await testEnv.client.workflow.start(promptBatch, { + args: [{ prompts: ['one', 'two'], maxConcurrency: 2 }], + workflowId: 'test-openrouter-' + nanoid(), + taskQueue, + }); + await new Promise((resolve) => setTimeout(resolve, 500)); + await handle.cancel(); + await assert.rejects( + handle.result(), + (err: unknown) => err instanceof WorkflowFailedError && err.cause instanceof CancelledFailure, + ); + }); + }); + it('rejects a non-positive maxConcurrency', async () => { - const taskQueue = 'test-openrouter-' + Date.now(); + const taskQueue = 'test-openrouter-' + nanoid(); const worker = await Worker.create({ connection: testEnv.nativeConnection, taskQueue, @@ -76,7 +108,7 @@ describe('promptBatch workflow', function () { worker.runUntil( testEnv.client.workflow.execute(promptBatch, { args: [{ prompts: ['one'], maxConcurrency: 0 }], - workflowId: 'test-openrouter-' + Date.now(), + workflowId: 'test-openrouter-' + nanoid(), taskQueue, }), ), From fe3e9b6b9176c1f66058895930e391751f490d97 Mon Sep 17 00:00:00 2001 From: DABH Date: Thu, 1 Oct 2026 13:28:19 -0500 Subject: [PATCH 08/12] Treat a provider error on the choice as an error, not an answer --- openrouter/README.md | 2 +- openrouter/src/activities.ts | 6 ++++++ openrouter/src/mocha/activities.test.ts | 11 +++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/openrouter/README.md b/openrouter/README.md index 4f6b044b2..5fc89e97d 100644 --- a/openrouter/README.md +++ b/openrouter/README.md @@ -8,7 +8,7 @@ This is the TypeScript port of the Python [`openrouter/prompt_batch`](https://gi - 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; 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`; the Activity checks for that. +- 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; 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`. diff --git a/openrouter/src/activities.ts b/openrouter/src/activities.ts index 438ffc751..45d09d7d4 100644 --- a/openrouter/src/activities.ts +++ b/openrouter/src/activities.ts @@ -176,6 +176,12 @@ async function send(client: OpenAI, request: OpenRouterRequest, context: Context const error = errorBody(data); throwForStatus(error.code ?? 500, error, response.headers); } + const choiceError = (data.choices?.[0] as { error?: OpenRouterErrorBody } | undefined)?.error; + if (choiceError) { + // Or a 200 with a partial answer and the provider's error on the choice + // itself; a partial answer is not an answer. + throwForStatus(choiceError.code ?? 500, choiceError, response.headers); + } const usage = data.usage as (OpenAI.CompletionUsage & { cost?: number }) | undefined; if (typeof usage?.cost !== 'number') { diff --git a/openrouter/src/mocha/activities.test.ts b/openrouter/src/mocha/activities.test.ts index ce3b2232d..773ea1fe7 100644 --- a/openrouter/src/mocha/activities.test.ts +++ b/openrouter/src/mocha/activities.test.ts @@ -153,6 +153,17 @@ describe('callOpenRouter activity', () => { assert.strictEqual(failure.nonRetryable, true); }); + it('treats a provider error on the choice as an error, not an answer', async () => { + const body = completion() as ReturnType & { choices: Record[] }; + body.choices[0].finish_reason = 'error'; + body.choices[0].error = { code: 502, message: 'Provider died' }; + const activities = makeActivities(() => ({ status: 200, body })); + const failure = await expectFailure(() => new MockActivityEnvironment().run(activities.callOpenRouter, request)); + assert.strictEqual(failure.type, 'OpenRouterHTTP502'); + assert.strictEqual(failure.nonRetryable, false); + assert.match(failure.message, /Provider died/); + }); + it('with failOnceAfterCall, fails the first attempt only', async () => { const activities = makeActivities(() => ({ status: 200, From 0eef8791ff039b57e8fc7a923cfcec34fef966e9 Mon Sep 17 00:00:00 2001 From: DABH Date: Thu, 1 Oct 2026 13:37:10 -0500 Subject: [PATCH 09/12] Self-review round 2: strict flag values, narrower catch, README fixes - --model and --max-concurrency require a value; --max-concurrency must be a positive integer, checked client-side instead of silently defaulting. - Only ActivityFailure is recorded as a skipped prompt; anything else propagates. - Non-numeric error codes fall back to 500 instead of becoming a bogus type. - Tests for a plain 4xx via the APIError path and for connection errors propagating unchanged. - README: 408 listed as retryable, two-prompt output matches its command, Other options moved out of the --fail-once explanation. --- openrouter/README.md | 10 +++++++--- openrouter/src/activities.ts | 4 ++-- openrouter/src/client.ts | 14 +++++++++++--- openrouter/src/mocha/activities.test.ts | 20 ++++++++++++++++++++ openrouter/src/workflows.ts | 7 ++++--- 5 files changed, 44 insertions(+), 11 deletions(-) diff --git a/openrouter/README.md b/openrouter/README.md index 5fc89e97d..a3ab667ae 100644 --- a/openrouter/README.md +++ b/openrouter/README.md @@ -8,7 +8,7 @@ This is the TypeScript port of the Python [`openrouter/prompt_batch`](https://gi - 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; 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. +- 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`. @@ -31,6 +31,10 @@ Starting openrouter-prompt-batch-... 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-... ``` @@ -48,13 +52,13 @@ npm run workflow -- --fail-once "Explain idempotency in one sentence." Q: Explain idempotency in one sentence. ``` +`temporal workflow show -w ` 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 `: any OpenRouter model instead of the Auto Router. - `--max-concurrency `: how many prompts are in flight at once (default 5). -`temporal workflow show -w ` 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. - ## 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. diff --git a/openrouter/src/activities.ts b/openrouter/src/activities.ts index 45d09d7d4..c08d8f05b 100644 --- a/openrouter/src/activities.ts +++ b/openrouter/src/activities.ts @@ -174,13 +174,13 @@ async function send(client: OpenAI, request: OpenRouterRequest, context: Context // OpenRouter can return HTTP 200 with an error body and no choices when // the upstream provider failed after the request was accepted. const error = errorBody(data); - throwForStatus(error.code ?? 500, error, response.headers); + throwForStatus(typeof error.code === 'number' ? error.code : 500, error, response.headers); } const choiceError = (data.choices?.[0] as { error?: OpenRouterErrorBody } | undefined)?.error; if (choiceError) { // Or a 200 with a partial answer and the provider's error on the choice // itself; a partial answer is not an answer. - throwForStatus(choiceError.code ?? 500, choiceError, response.headers); + throwForStatus(typeof choiceError.code === 'number' ? choiceError.code : 500, choiceError, response.headers); } const usage = data.usage as (OpenAI.CompletionUsage & { cost?: number }) | undefined; diff --git a/openrouter/src/client.ts b/openrouter/src/client.ts index 6da34501e..d8180a4cd 100644 --- a/openrouter/src/client.ts +++ b/openrouter/src/client.ts @@ -16,9 +16,17 @@ async function run() { for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--fail-once') failOnceAfterCall = true; - else if (arg === '--model') model = args[++i] ?? model; - else if (arg === '--max-concurrency') maxConcurrency = Number(args[++i]); - else if (arg.startsWith('--')) throw new Error(`Unknown flag: ${arg}`); + else if (arg === '--model' || arg === '--max-concurrency') { + const value = args[++i]; + if (value === undefined || value.startsWith('--')) throw new Error(`${arg} requires a value`); + if (arg === '--model') model = value; + else { + maxConcurrency = Number(value); + if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) { + throw new Error('--max-concurrency must be a positive integer'); + } + } + } else if (arg.startsWith('--')) throw new Error(`Unknown flag: ${arg}`); else prompts.push(arg); } diff --git a/openrouter/src/mocha/activities.test.ts b/openrouter/src/mocha/activities.test.ts index 773ea1fe7..aad0b6a40 100644 --- a/openrouter/src/mocha/activities.test.ts +++ b/openrouter/src/mocha/activities.test.ts @@ -133,6 +133,26 @@ describe('callOpenRouter activity', () => { } }); + it('treats a plain 4xx as non-retryable with the server message', async () => { + const activities = makeActivities(() => ({ status: 400, body: { error: { code: 400, message: 'Bad prompt' } } })); + const failure = await expectFailure(() => new MockActivityEnvironment().run(activities.callOpenRouter, request)); + assert.strictEqual(failure.type, 'OpenRouterHTTP400'); + assert.strictEqual(failure.nonRetryable, true); + assert.strictEqual(failure.message, 'OpenRouter returned HTTP 400: Bad prompt'); + }); + + it('lets a connection error propagate unchanged so Temporal retries it', async () => { + const fetch = async (): Promise => { + throw new TypeError('fetch failed'); + }; + const client = new OpenAI({ baseURL: OPENROUTER_BASE_URL, apiKey: 'test-key', maxRetries: 0, fetch }); + const activities = createActivities(client); + await assert.rejects( + new MockActivityEnvironment().run(activities.callOpenRouter, request), + (e: unknown) => !(e instanceof ApplicationFailure) && e instanceof Error && /Connection error/.test(e.message), + ); + }); + it('treats 403 key limit exceeded as out of credits too', async () => { const activities = makeActivities(() => ({ status: 403, diff --git a/openrouter/src/workflows.ts b/openrouter/src/workflows.ts index 37f93b472..193a43f88 100644 --- a/openrouter/src/workflows.ts +++ b/openrouter/src/workflows.ts @@ -67,11 +67,12 @@ async function answer(prompt: string, batch: BatchInput): Promise Date: Thu, 1 Oct 2026 13:39:18 -0500 Subject: [PATCH 10/12] Cap Retry-After at five minutes; retry a response with no choices --- openrouter/src/activities.ts | 21 +++++++++++++++++---- openrouter/src/mocha/activities.test.ts | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/openrouter/src/activities.ts b/openrouter/src/activities.ts index c08d8f05b..f1d3fea9e 100644 --- a/openrouter/src/activities.ts +++ b/openrouter/src/activities.ts @@ -46,14 +46,23 @@ export function errorType(status: number): string { */ export const OUT_OF_CREDITS = 'OpenRouterOutOfCredits'; +/** Longest Retry-After the Activity passes through as the next retry delay. */ +const MAX_RETRY_AFTER_SECONDS = 300; + /** Parse Retry-After in either its delta-seconds or HTTP-date form. */ function retryAfter(headers: Headers | undefined): string | undefined { const value = headers?.get('retry-after')?.trim(); if (!value) return undefined; - const seconds = Number(value); - if (Number.isFinite(seconds)) return seconds > 0 ? `${seconds}s` : undefined; - const delayMs = Date.parse(value) - Date.now(); - return Number.isFinite(delayMs) && delayMs > 0 ? `${Math.ceil(delayMs / 1000)}s` : undefined; + let seconds = Number(value); + if (!Number.isFinite(seconds)) { + const delayMs = Date.parse(value) - Date.now(); + if (!Number.isFinite(delayMs)) return undefined; + seconds = Math.ceil(delayMs / 1000); + } + if (seconds <= 0) return undefined; + // Honor the server, within reason: nextRetryDelay overrides the retry + // policy's interval, so cap it rather than park a prompt for hours. + return `${Math.min(seconds, MAX_RETRY_AFTER_SECONDS)}s`; } /** The `error` object OpenRouter returns, as far as this sample reads it. */ @@ -182,6 +191,10 @@ async function send(client: OpenAI, request: OpenRouterRequest, context: Context // itself; a partial answer is not an answer. throwForStatus(typeof choiceError.code === 'number' ? choiceError.code : 500, choiceError, response.headers); } + if (!data.choices?.length) { + // No error and no answer: treat like a server error and retry. + throwForStatus(500, { message: 'Response has no choices' }, response.headers); + } const usage = data.usage as (OpenAI.CompletionUsage & { cost?: number }) | undefined; if (typeof usage?.cost !== 'number') { diff --git a/openrouter/src/mocha/activities.test.ts b/openrouter/src/mocha/activities.test.ts index aad0b6a40..9ce9a4d4d 100644 --- a/openrouter/src/mocha/activities.test.ts +++ b/openrouter/src/mocha/activities.test.ts @@ -153,6 +153,24 @@ describe('callOpenRouter activity', () => { ); }); + it('caps a huge Retry-After', async () => { + const activities = makeActivities(() => ({ + status: 429, + body: { error: { code: 429, message: 'Rate limited' } }, + headers: { 'Retry-After': '1000000000' }, + })); + const failure = await expectFailure(() => new MockActivityEnvironment().run(activities.callOpenRouter, request)); + assert.strictEqual(failure.nextRetryDelay, '300s'); + }); + + it('retries a 200 with no choices and no error', async () => { + const body = { ...completion(), choices: [] }; + const activities = makeActivities(() => ({ status: 200, body })); + const failure = await expectFailure(() => new MockActivityEnvironment().run(activities.callOpenRouter, request)); + assert.strictEqual(failure.type, 'OpenRouterHTTP500'); + assert.strictEqual(failure.nonRetryable, false); + }); + it('treats 403 key limit exceeded as out of credits too', async () => { const activities = makeActivities(() => ({ status: 403, From 16cdcf13bfbd55773df6f63a3ce81c12deea1778 Mon Sep 17 00:00:00 2001 From: DABH Date: Sun, 4 Oct 2026 14:55:11 -0500 Subject: [PATCH 11/12] Report how many prompts came back without a cost reportedCostUsd is a subtotal of known costs; unknownCostCount makes that visible in the result and the client output instead of printing $0 for a batch with unknown charges. --- openrouter/README.md | 2 +- openrouter/src/client.ts | 3 +++ openrouter/src/mocha/workflows.test.ts | 32 ++++++++++++++++++++++++++ openrouter/src/shared.ts | 5 ++++ openrouter/src/workflows.ts | 1 + 5 files changed, 42 insertions(+), 1 deletion(-) diff --git a/openrouter/README.md b/openrouter/README.md index a3ab667ae..4c8a69ebd 100644 --- a/openrouter/README.md +++ b/openrouter/README.md @@ -69,7 +69,7 @@ For agents built on the [Vercel AI SDK](../ai-sdk), [`@openrouter/ai-sdk-provide 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`. +The reported cost in the result is the sum of what OpenRouter reported on each prompt's final, successful attempt, and `unknownCostCount` says how many results had no cost at all. 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`. 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. diff --git a/openrouter/src/client.ts b/openrouter/src/client.ts index d8180a4cd..295bb12a0 100644 --- a/openrouter/src/client.ts +++ b/openrouter/src/client.ts @@ -54,6 +54,9 @@ async function run() { console.log( `\nReported cost: $${result.reportedCostUsd.toFixed(6)} (what OpenRouter reported on each prompt's final attempt)`, ); + if (result.unknownCostCount > 0) { + console.log(` ${result.unknownCostCount} prompt(s) came back without a cost`); + } console.log(`Inspect: temporal workflow show -w ${workflowId}`); } diff --git a/openrouter/src/mocha/workflows.test.ts b/openrouter/src/mocha/workflows.test.ts index 478c04ff6..b82170b5e 100644 --- a/openrouter/src/mocha/workflows.test.ts +++ b/openrouter/src/mocha/workflows.test.ts @@ -64,6 +64,38 @@ describe('promptBatch workflow', function () { ); assert.deepStrictEqual(result.skipped, [{ prompt: 'bad', reason: 'OpenRouterHTTP400' }]); assert.strictEqual(result.reportedCostUsd, 0.002); + assert.strictEqual(result.unknownCostCount, 0); + }); + + it('counts prompts whose cost was unknown instead of treating them as free', async () => { + const taskQueue = 'test-openrouter-' + nanoid(); + const activities = { + async callOpenRouter(request: OpenRouterRequest): Promise { + return { + prompt: request.prompt, + model: 'm', + answer: 'ok', + costUsd: request.prompt === 'known' ? 0.001 : null, + generationId: `gen-${request.prompt}`, + cacheStatus: '', + }; + }, + }; + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + activities, + }); + const result = await worker.runUntil( + testEnv.client.workflow.execute(promptBatch, { + args: [{ prompts: ['known', 'unknown'] }], + workflowId: 'test-openrouter-' + nanoid(), + taskQueue, + }), + ); + assert.strictEqual(result.reportedCostUsd, 0.001); + assert.strictEqual(result.unknownCostCount, 1); }); it('propagates Workflow cancellation instead of recording skipped prompts', async () => { diff --git a/openrouter/src/shared.ts b/openrouter/src/shared.ts index 09ea22642..d6c03a402 100644 --- a/openrouter/src/shared.ts +++ b/openrouter/src/shared.ts @@ -62,4 +62,9 @@ export interface BatchResult { * are not included; OpenRouter's dashboard is the source of truth for spend. */ reportedCostUsd: number; + /** + * How many successful prompts came back without a cost. When this is not + * zero, reportedCostUsd is a subtotal of the known costs. + */ + unknownCostCount: number; } diff --git a/openrouter/src/workflows.ts b/openrouter/src/workflows.ts index 193a43f88..1d9882d68 100644 --- a/openrouter/src/workflows.ts +++ b/openrouter/src/workflows.ts @@ -54,6 +54,7 @@ export async function promptBatch(batch: BatchInput): Promise { results, skipped, reportedCostUsd: Number(results.reduce((sum, r) => sum + (r.costUsd ?? 0), 0).toFixed(6)), + unknownCostCount: results.filter((r) => r.costUsd === null).length, }; } From f6cbd0f8ec96da5895d37d03ab3f11cb44d7f32a Mon Sep 17 00:00:00 2001 From: DABH Date: Sun, 4 Oct 2026 15:02:56 -0500 Subject: [PATCH 12/12] README: separate the unknown-cost sentence --- openrouter/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openrouter/README.md b/openrouter/README.md index 4c8a69ebd..ef21f235f 100644 --- a/openrouter/README.md +++ b/openrouter/README.md @@ -69,7 +69,7 @@ For agents built on the [Vercel AI SDK](../ai-sdk), [`@openrouter/ai-sdk-provide 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, and `unknownCostCount` says how many results had no cost at all. 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`. +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.