From 577ff60128d3dbbac0d89acc30843387669efbe1 Mon Sep 17 00:00:00 2001 From: DABH Date: Fri, 11 Sep 2026 01:13:19 -0500 Subject: [PATCH 1/2] 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 | 262 +++++++++++++++++++----- 23 files changed, 1021 insertions(+), 50 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 52926ea57..6bf41ba08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,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 c738e0c90..9e086050c 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 a208b855f..f347ffab6 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,7 @@ and you'll be given the list of sample options. - [**Human in the Loop**](./google-adk-agents/src/human-in-the-loop): A `LongRunningFunctionTool` whose completion is gated by a Temporal Signal or Update. - [**Structured Output**](./google-adk-agents/src/structured-output): Schema-constrained agent output validated at the Workflow boundary. - [**Observability**](./google-adk-agents/src/observability): Token usage, latency, and call counts from the agent loop's OpenTelemetry spans, by composing `OpenTelemetryPlugin` onto the Worker alongside `GoogleAdkPlugin`. +- [**OpenRouter**](./openrouter): Call [OpenRouter](https://openrouter.ai/) from an Activity and fan a prompt batch out with bounded concurrency. Temporal owns the retries, `Retry-After` becomes the next retry delay, and OpenRouter's response cache makes a retried call free. ### Full-stack apps 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..237aa3e56 --- /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.20.0", + "@temporalio/client": "^1.20.0", + "@temporalio/envconfig": "^1.20.0", + "@temporalio/worker": "^1.20.0", + "@temporalio/workflow": "^1.20.0", + "nanoid": "3.x", + "openai": "^6.0.0" + }, + "devDependencies": { + "@temporalio/testing": "^1.20.0", + "@tsconfig/node22": "^22.0.0", + "@types/mocha": "10.x", + "@types/node": "^22.9.1", + "@typescript-eslint/eslint-plugin": "^8.18.0", + "@typescript-eslint/parser": "^8.18.0", + "eslint": "^8.57.1", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-deprecation": "^3.0.0", + "mocha": "10.x", + "nodemon": "^3.1.7", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "ts-node": "^10.9.2", + "typescript": "^5.6.3" + } +} 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 569c5f848..1e59f1e45 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: @@ -12,7 +12,7 @@ importers: devDependencies: '@temporalio/testing': specifier: ^1.23.0 - version: 1.23.0(@swc/helpers@0.5.15) + version: 1.23.0 doctoc: specifier: ^2.1.0 version: 2.2.1 @@ -228,7 +228,7 @@ importers: version: 0.5.21 ts-jest: specifier: ^28.0.2 - version: 28.0.8(@babel/core@7.29.7)(@jest/types@28.1.3)(babel-jest@28.1.3(@babel/core@7.29.7))(jest@28.1.3(@types/node@22.12.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) + version: 28.0.8(@babel/core@7.26.7)(@jest/types@28.1.3)(babel-jest@28.1.3(@babel/core@7.26.7))(jest@28.1.3(@types/node@22.12.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-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) @@ -1230,7 +1230,7 @@ importers: version: 10.45.2(@trpc/server@10.45.2) '@trpc/next': specifier: ^10.0.0-rc.8 - version: 10.45.2(@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/react-query@10.45.2(@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/server@10.45.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/server@10.45.2)(next@15.1.6(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 10.45.2(@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/react-query@10.45.2(@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/server@10.45.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/server@10.45.2)(next@15.1.6(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@trpc/react-query': specifier: ^10.0.0-rc.8 version: 10.45.2(@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/server@10.45.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1318,7 +1318,7 @@ importers: version: 10.45.2(@trpc/server@10.45.2) '@trpc/next': specifier: ^10.0.0-rc.8 - version: 10.45.2(@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/react-query@10.45.2(@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/server@10.45.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/server@10.45.2)(next@15.1.6(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 10.45.2(@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/react-query@10.45.2(@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/server@10.45.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/server@10.45.2)(next@15.1.6(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@trpc/react-query': specifier: ^10.0.0-rc.8 version: 10.45.2(@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/server@10.45.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2316,7 +2316,7 @@ importers: version: link:../temporal-workflows 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) + version: 10.9.2(@swc/core@1.10.11(@swc/helpers@0.5.15))(@types/node@24.13.3)(typescript@5.7.3) devDependencies: '@types/express': specifier: ^4.17.13 @@ -2384,7 +2384,7 @@ importers: version: link:../temporal-workflows 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) + version: 10.9.2(@swc/core@1.10.11(@swc/helpers@0.5.15))(@types/node@24.13.3)(typescript@5.7.3) devDependencies: nodemon: specifier: ^3.1.7 @@ -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.20.0 + version: 1.23.0 + '@temporalio/client': + specifier: ^1.20.0 + version: 1.23.0 + '@temporalio/envconfig': + specifier: ^1.20.0 + version: 1.23.0 + '@temporalio/worker': + specifier: ^1.20.0 + version: 1.23.0(@swc/helpers@0.5.15) + '@temporalio/workflow': + specifier: ^1.20.0 + version: 1.23.0 + nanoid: + specifier: 3.x + version: 3.3.12 + openai: + specifier: ^6.0.0 + version: 6.45.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.20.0 + version: 1.23.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': @@ -9570,11 +9640,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -21474,7 +21539,7 @@ snapshots: '@jsdoc/salty@0.2.9': dependencies: - lodash: 4.17.21 + lodash: 4.18.1 '@jsep-plugin/assignment@1.3.0(jsep@1.4.0)': dependencies: @@ -21592,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)(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 @@ -21645,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)(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 @@ -23480,6 +23545,30 @@ snapshots: web-streams-polyfill: 4.2.0 zod: 4.4.3 + '@temporalio/testing@1.23.0': + dependencies: + '@temporalio/activity': 1.23.0 + '@temporalio/client': 1.23.0 + '@temporalio/common': 1.23.0 + '@temporalio/core-bridge': 1.23.0 + '@temporalio/proto': 1.23.0 + '@temporalio/worker': 1.23.0 + '@temporalio/workflow': 1.23.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/helpers' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + - webpack-cli + '@temporalio/testing@1.23.0(@swc/helpers@0.5.15)': dependencies: '@temporalio/activity': 1.23.0 @@ -23504,6 +23593,43 @@ snapshots: - uglify-js - webpack-cli + '@temporalio/worker@1.23.0': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@swc/core': 1.10.11(@swc/helpers@0.5.15) + '@temporalio/activity': 1.23.0 + '@temporalio/client': 1.23.0 + '@temporalio/common': 1.23.0 + '@temporalio/core-bridge': 1.23.0 + '@temporalio/nexus': 1.23.0 + '@temporalio/proto': 1.23.0 + '@temporalio/workflow': 1.23.0 + heap-js: 2.6.0 + memfs: 4.17.0 + nexus-rpc: 0.0.3 + protobufjs: 8.7.2 + rxjs: 7.8.2 + source-map: 0.7.6 + source-map-loader: 5.0.0(webpack@5.108.4(@swc/core@1.10.11)) + supports-color: 8.1.1 + swc-loader: 0.2.6(@swc/core@1.10.11)(webpack@5.108.4(@swc/core@1.10.11)) + unionfs: 4.5.4 + webpack: 5.108.4(@swc/core@1.10.11) + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/helpers' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + - webpack-cli + '@temporalio/worker@1.23.0(@swc/helpers@0.5.15)': dependencies: '@grpc/grpc-js': 1.14.4 @@ -23662,7 +23788,7 @@ snapshots: dependencies: '@trpc/server': 10.45.2 - '@trpc/next@10.45.2(@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/react-query@10.45.2(@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/server@10.45.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/server@10.45.2)(next@15.1.6(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@trpc/next@10.45.2(@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/react-query@10.45.2(@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/server@10.45.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/server@10.45.2)(next@15.1.6(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/react-query': 4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@trpc/client': 10.45.2(@trpc/server@10.45.2) @@ -24166,7 +24292,7 @@ snapshots: '@typescript-eslint/types': 8.22.0 '@typescript-eslint/typescript-estree': 8.22.0(typescript@5.7.3) '@typescript-eslint/visitor-keys': 8.22.0 - debug: 4.4.0 + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 typescript: 5.7.3 transitivePeerDependencies: @@ -24409,7 +24535,7 @@ snapshots: dependencies: '@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13) '@rollup/pluginutils': 4.2.1 - acorn: 8.15.0 + acorn: 8.16.0 async-sema: 3.1.1 bindings: 1.5.0 estree-walker: 2.0.2 @@ -24712,8 +24838,6 @@ snapshots: acorn@7.4.1: {} - acorn@8.15.0: {} - acorn@8.16.0: {} address@1.2.2: {} @@ -25092,20 +25216,6 @@ snapshots: transitivePeerDependencies: - supports-color - babel-jest@28.1.3(@babel/core@7.29.7): - dependencies: - '@babel/core': 7.29.7 - '@jest/transform': 28.1.3 - '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 28.1.3(@babel/core@7.29.7) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 - transitivePeerDependencies: - - supports-color - optional: true - babel-jest@29.7.0(@babel/core@7.26.7): dependencies: '@babel/core': 7.26.7 @@ -25289,13 +25399,6 @@ snapshots: babel-plugin-jest-hoist: 28.1.3 babel-preset-current-node-syntax: 1.1.0(@babel/core@7.26.7) - babel-preset-jest@28.1.3(@babel/core@7.29.7): - dependencies: - '@babel/core': 7.29.7 - babel-plugin-jest-hoist: 28.1.3 - babel-preset-current-node-syntax: 1.1.0(@babel/core@7.29.7) - optional: true - babel-preset-jest@29.6.3(@babel/core@7.26.7): dependencies: '@babel/core': 7.26.7 @@ -31085,6 +31188,16 @@ snapshots: optionalDependencies: '@swc/core': 1.10.11(@swc/helpers@0.5.15) + minimizer-webpack-plugin@5.6.1(@swc/core@1.10.11)(webpack@5.108.4(@swc/core@1.10.11)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.37.0 + webpack: 5.108.4(@swc/core@1.10.11) + optionalDependencies: + '@swc/core': 1.10.11(@swc/helpers@0.5.15) + minimizer-webpack-plugin@5.6.1(webpack@5.108.4): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -33790,6 +33903,12 @@ snapshots: source-map-js: 1.2.1 webpack: 5.108.4(@swc/core@1.10.11(@swc/helpers@0.5.15)) + source-map-loader@5.0.0(webpack@5.108.4(@swc/core@1.10.11)): + dependencies: + iconv-lite: 0.6.3 + source-map-js: 1.2.1 + webpack: 5.108.4(@swc/core@1.10.11) + source-map-support@0.5.13: dependencies: buffer-from: 1.1.2 @@ -34189,6 +34308,12 @@ snapshots: '@swc/counter': 0.1.3 webpack: 5.108.4(@swc/core@1.10.11(@swc/helpers@0.5.15)) + swc-loader@0.2.6(@swc/core@1.10.11)(webpack@5.108.4(@swc/core@1.10.11)): + dependencies: + '@swc/core': 1.10.11(@swc/helpers@0.5.15) + '@swc/counter': 0.1.3 + webpack: 5.108.4(@swc/core@1.10.11) + swr@1.3.0(react@18.3.1): dependencies: react: 18.3.1 @@ -34536,7 +34661,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@28.0.8(@babel/core@7.29.7)(@jest/types@28.1.3)(babel-jest@28.1.3(@babel/core@7.29.7))(jest@28.1.3(@types/node@22.12.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-jest@28.0.8(@babel/core@7.26.7)(@jest/types@28.1.3)(babel-jest@28.1.3(@babel/core@7.26.7))(jest@28.1.3(@types/node@22.12.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): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -34549,9 +34674,9 @@ snapshots: typescript: 5.7.3 yargs-parser: 21.1.1 optionalDependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.26.7 '@jest/types': 28.1.3 - babel-jest: 28.1.3(@babel/core@7.29.7) + babel-jest: 28.1.3(@babel/core@7.26.7) ts-jest@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): dependencies: @@ -34582,7 +34707,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 @@ -34590,9 +34715,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 @@ -34600,7 +34725,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: @@ -34671,7 +34796,6 @@ snapshots: yn: 3.1.1 optionalDependencies: '@swc/core': 1.10.11(@swc/helpers@0.5.15) - optional: true ts-toolbelt@6.15.5: {} @@ -35375,6 +35499,44 @@ snapshots: - postcss - uglify-js + webpack@5.108.4(@swc/core@1.10.11): + dependencies: + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.23.0 + es-module-lexer: 2.3.0 + eslint-scope: 5.1.1 + events: 3.3.0 + graceful-fs: 4.2.11 + loader-runner: 4.3.2 + mime-db: 1.54.0 + minimizer-webpack-plugin: 5.6.1(@swc/core@1.10.11)(webpack@5.108.4(@swc/core@1.10.11)) + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.3 + watchpack: 2.5.2 + webpack-sources: 3.5.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + webpack@5.96.1(@swc/core@1.10.11(@swc/helpers@0.5.15)): dependencies: '@types/eslint-scope': 3.7.7 From e05f98d2d0652b0d287bd86dd103d313f950c8f7 Mon Sep 17 00:00:00 2001 From: DABH Date: Fri, 11 Sep 2026 01:17:35 -0500 Subject: [PATCH 2/2] 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,