From d01c63e055cbcf9306bac99bf87187a04a9f6f12 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 10 Mar 2026 16:24:22 -0400 Subject: [PATCH 01/11] chore: add biome linting, root package.json, and CI workflow Add biome.json (adapted from openrouter-web, stripped of monorepo/React specifics), root package.json with lint/typecheck scripts, and GitHub Actions CI for lint + typecheck on PR and push to main. --- .github/workflows/ci.yaml | 29 +++++++++++++++++ biome.json | 66 +++++++++++++++++++++++++++++++++++++++ bun.lock | 31 ++++++++++++++++++ package.json | 15 +++++++++ 4 files changed, 141 insertions(+) create mode 100644 .github/workflows/ci.yaml create mode 100644 biome.json create mode 100644 bun.lock create mode 100644 package.json diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..b836589 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,29 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + - run: bun install + - name: Biome lint + run: bunx biome check . + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + - run: bun install + - name: Install skill dependencies + run: | + cd skills/openrouter-models/scripts && bun install && cd ../../.. + cd skills/openrouter-images/scripts && bun install && cd ../../.. + - name: Type check + run: bun run typecheck diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..bc88dbd --- /dev/null +++ b/biome.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.0/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true, + "defaultBranch": "main" + }, + "files": { + "includes": ["**", "!**/*.json", "!!**/biome.json", "!**/node_modules/**"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100, + "expand": "always" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "useLiteralKeys": "off", + "noForEach": "off", + "noBannedTypes": "error" + }, + "style": { + "noNonNullAssertion": "off", + "useNodejsImportProtocol": "error", + "useBlockStatements": "error", + "noParameterAssign": "error", + "useConst": "error", + "useImportType": { "level": "on", "options": { "style": "separatedType" } }, + "noInferrableTypes": "error", + "noUselessElse": "error" + }, + "correctness": { + "noUnusedImports": "error" + }, + "suspicious": { + "noExplicitAny": "error", + "noAssignInExpressions": "error", + "noConsole": "off", + "noDoubleEquals": { "level": "error", "options": { "ignoreNull": false } } + }, + "performance": { + "recommended": true, + "noAccumulatingSpread": "error" + }, + "security": { + "recommended": true + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "all", + "semicolons": "always", + "arrowParentheses": "always", + "bracketSpacing": true, + "lineWidth": 100 + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..05bdd76 --- /dev/null +++ b/bun.lock @@ -0,0 +1,31 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "openrouter-skills", + "devDependencies": { + "@biomejs/biome": "2.4.0", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.4.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.0", "@biomejs/cli-darwin-x64": "2.4.0", "@biomejs/cli-linux-arm64": "2.4.0", "@biomejs/cli-linux-arm64-musl": "2.4.0", "@biomejs/cli-linux-x64": "2.4.0", "@biomejs/cli-linux-x64-musl": "2.4.0", "@biomejs/cli-win32-arm64": "2.4.0", "@biomejs/cli-win32-x64": "2.4.0" }, "bin": { "biome": "bin/biome" } }, "sha512-iluT61cORUDIC5i/y42ljyQraCemmmcgbMLLCnYO+yh+2hjTmcMFcwY8G0zTzWCsPb3t3AyKc+0t/VuhPZULUg=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-L+YpOtPSuU0etomfvFTPWRsa7+8ejaJL3yaROEoT/96HDJbR6OsvZQk0C8JUYou+XFdP+JcGxqZknkp4n934RA=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Aq+S7ffpb5ynTyLgtnEjG+W6xuTd2F7FdC7J6ShpvRhZwJhjzwITGF9vrqoOnw0sv1XWkt2Q1Rpg+hleg/Xg7Q=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-u2p54IhvNAWB+h7+rxCZe3reNfQYFK+ppDw+q0yegrGclFYnDPZAntv/PqgUacpC3uxTeuWFgWW7RFe3lHuxOA=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1rhDUq8sf7xX3tg7vbnU3WVfanKCKi40OXc4VleBMzRStmQHdeBY46aFP6VdwEomcVjyNiu+Zcr3LZtAdrZrjQ=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WVFOhsnzhrbMGOSIcB9yFdRV2oG2KkRRhIZiunI9gJqSU3ax9ErdnTxRfJUxZUI9NbzVxC60OCXNcu+mXfF/Tw=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Omo0xhl63z47X+CrE5viEWKJhejJyndl577VoXg763U/aoATrK3r5+8DPh02GokWPeODX1Hek00OtjjooGan9w=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-aqRwW0LJLV1v1NzyLvLWQhdLmDSAV1vUh+OBdYJaa8f28XBn5BZavo+WTfqgEzALZxlNfBmu6NGO6Al3MbCULw=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-g47s+V+OqsGxbSZN3lpav6WYOk0PIc3aCBAq+p6dwSynL3K5MA6Cg6nkzDOlu28GEHwbakW+BllzHCJCxnfK5Q=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d649ca7 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "openrouter-skills", + "private": true, + "scripts": { + "lint": "biome check .", + "lint:fix": "biome check --fix .", + "format": "biome format --fix .", + "typecheck": "bun run typecheck:models && bun run typecheck:images", + "typecheck:models": "cd skills/openrouter-models/scripts && bunx tsc --noEmit", + "typecheck:images": "cd skills/openrouter-images/scripts && bunx tsc --noEmit" + }, + "devDependencies": { + "@biomejs/biome": "2.4.0" + } +} From 3cf1d9c6cf89b4982a2411c08195e662d98c69c2 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 10 Mar 2026 16:34:50 -0400 Subject: [PATCH 02/11] feat(models): migrate to @openrouter/sdk with strict types and bun --- skills/openrouter-models/SKILL.md | 39 +++--- skills/openrouter-models/scripts/bun.lock | 31 +++++ .../scripts/compare-models.ts | 101 ++++++++------ .../scripts/get-endpoints.ts | 125 ++++++++++------- skills/openrouter-models/scripts/lib.ts | 120 ++++++++++------- .../openrouter-models/scripts/list-models.ts | 46 ++++--- skills/openrouter-models/scripts/package.json | 16 ++- .../scripts/resolve-model.ts | 127 +++++++++++------- .../scripts/search-models.ts | 41 +++--- .../openrouter-models/scripts/tsconfig.json | 15 +++ skills/openrouter-models/scripts/types.ts | 39 ++++++ 11 files changed, 451 insertions(+), 249 deletions(-) create mode 100644 skills/openrouter-models/scripts/bun.lock create mode 100644 skills/openrouter-models/scripts/tsconfig.json create mode 100644 skills/openrouter-models/scripts/types.ts diff --git a/skills/openrouter-models/SKILL.md b/skills/openrouter-models/SKILL.md index 7d0314c..626b084 100644 --- a/skills/openrouter-models/SKILL.md +++ b/skills/openrouter-models/SKILL.md @@ -9,12 +9,13 @@ Discover, search, and compare the 300+ AI models available on OpenRouter. Query ## Prerequisites -The `OPENROUTER_API_KEY` environment variable is optional for most scripts. It is only required for `get-endpoints.ts` (provider performance data). Get a key at https://openrouter.ai/keys +- `bun` runtime installed +- The `OPENROUTER_API_KEY` environment variable is optional for most scripts. It is only required for `get-endpoints.ts` (provider performance data). Get a key at https://openrouter.ai/keys ## First-Time Setup ```bash -cd /scripts && npm install +cd /scripts && bun install ``` ## Decision Tree @@ -43,9 +44,9 @@ Pick the right script based on what the user is asking: Resolve an informal or vague model name to an exact OpenRouter model ID using fuzzy matching: ```bash -cd /scripts && npx tsx resolve-model.ts "claude sonnet" -cd /scripts && npx tsx resolve-model.ts "gpt 4o mini" -cd /scripts && npx tsx resolve-model.ts "llama 3.1" +cd /scripts && bun run resolve-model.ts "claude sonnet" +cd /scripts && bun run resolve-model.ts "gpt 4o mini" +cd /scripts && bun run resolve-model.ts "llama 3.1" ``` Results include a `confidence` level and `score`: @@ -61,7 +62,7 @@ Results include a `confidence` level and `score`: ## List Models ```bash -cd /scripts && npx tsx list-models.ts +cd /scripts && bun run list-models.ts ``` ### Filter by Category @@ -69,7 +70,7 @@ cd /scripts && npx tsx list-models.ts Server-side category filtering: ```bash -cd /scripts && npx tsx list-models.ts --category programming +cd /scripts && bun run list-models.ts --category programming ``` Categories: `programming`, `roleplay`, `marketing`, `marketing/seo`, `technology`, `science`, `translation`, `legal`, `finance`, `health`, `trivia`, `academia` @@ -77,10 +78,10 @@ Categories: `programming`, `roleplay`, `marketing`, `marketing/seo`, `technology ### Sort Results ```bash -cd /scripts && npx tsx list-models.ts --sort newest # Recently added first -cd /scripts && npx tsx list-models.ts --sort price # Cheapest first -cd /scripts && npx tsx list-models.ts --sort context # Largest context first -cd /scripts && npx tsx list-models.ts --sort throughput # Most output tokens first +cd /scripts && bun run list-models.ts --sort newest # Recently added first +cd /scripts && bun run list-models.ts --sort price # Cheapest first +cd /scripts && bun run list-models.ts --sort context # Largest context first +cd /scripts && bun run list-models.ts --sort throughput # Most output tokens first ``` Models with upcoming `expiration_date` values trigger a stderr warning. @@ -88,9 +89,9 @@ Models with upcoming `expiration_date` values trigger a stderr warning. ## Search Models ```bash -cd /scripts && npx tsx search-models.ts "claude" -cd /scripts && npx tsx search-models.ts --modality image -cd /scripts && npx tsx search-models.ts "gpt" --modality text +cd /scripts && bun run search-models.ts "claude" +cd /scripts && bun run search-models.ts --modality image +cd /scripts && bun run search-models.ts "gpt" --modality text ``` Modalities: `text`, `image`, `audio`, `file` @@ -100,8 +101,8 @@ Modalities: `text`, `image`, `audio`, `file` Compare two or more models side-by-side with pricing in per-million-tokens format. Uses exact ID matching — `openai/gpt-4o` matches only that model, not variants like `gpt-4o-mini`. ```bash -cd /scripts && npx tsx compare-models.ts "anthropic/claude-sonnet-4" "openai/gpt-4o" -cd /scripts && npx tsx compare-models.ts "anthropic/claude-sonnet-4" "openai/gpt-4o" "google/gemini-2.5-pro" --sort price +cd /scripts && bun run compare-models.ts "anthropic/claude-sonnet-4" "openai/gpt-4o" +cd /scripts && bun run compare-models.ts "anthropic/claude-sonnet-4" "openai/gpt-4o" "google/gemini-2.5-pro" --sort price ``` Sort options: `price` (cheapest first), `context` (largest first), `speed`/`throughput` (most output tokens first) @@ -111,9 +112,9 @@ Sort options: `price` (cheapest first), `context` (largest first), `speed`/`thro Get per-provider latency, uptime, and throughput for any model: ```bash -cd /scripts && npx tsx get-endpoints.ts "anthropic/claude-sonnet-4" -cd /scripts && npx tsx get-endpoints.ts "anthropic/claude-sonnet-4" --sort throughput -cd /scripts && npx tsx get-endpoints.ts "openai/gpt-4o" --sort latency +cd /scripts && bun run get-endpoints.ts "anthropic/claude-sonnet-4" +cd /scripts && bun run get-endpoints.ts "anthropic/claude-sonnet-4" --sort throughput +cd /scripts && bun run get-endpoints.ts "openai/gpt-4o" --sort latency ``` Sort options: `throughput` (fastest tokens/sec first), `latency` (lowest p50 ms first), `uptime` (most reliable first), `price` (cheapest first) diff --git a/skills/openrouter-models/scripts/bun.lock b/skills/openrouter-models/scripts/bun.lock new file mode 100644 index 0000000..2ac52fc --- /dev/null +++ b/skills/openrouter-models/scripts/bun.lock @@ -0,0 +1,31 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "openrouter-models-scripts", + "dependencies": { + "@openrouter/sdk": "^0.9.11", + }, + "devDependencies": { + "@types/bun": "^1.3.10", + "typescript": "^5.8.0", + }, + }, + }, + "packages": { + "@openrouter/sdk": ["@openrouter/sdk@0.9.11", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-BgFu6NcIJO4a9aVjr04y3kZ8pyM71j15I+bzfVAGEvxnj+KQNIkBYQGgwrG3D+aT1QpDKLki8btcQmpaxUas6A=="], + + "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + + "@types/node": ["@types/node@25.4.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="], + + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + } +} diff --git a/skills/openrouter-models/scripts/compare-models.ts b/skills/openrouter-models/scripts/compare-models.ts index e03c1ec..15e7d7f 100644 --- a/skills/openrouter-models/scripts/compare-models.ts +++ b/skills/openrouter-models/scripts/compare-models.ts @@ -1,44 +1,48 @@ -import { optionalApiKey, fetchApi, parseArgs } from "./lib.js"; +import { createClient, optionalApiKey, parseArgs } from './lib.js'; +import type { Model } from './types.js'; const apiKey = optionalApiKey(); const args = parseArgs(process.argv.slice(2)); -const sortBy = args.get("sort") as string | undefined; +const sortBy = args.get('sort') as string | undefined; // Collect positional args as model IDs const modelIds: string[] = []; for (let i = 0; ; i++) { const val = args.get(`_${i}`); - if (val === undefined) break; + if (val === undefined) { + break; + } modelIds.push(val as string); } if (modelIds.length < 2) { console.error( - "Usage: compare-models.ts [...] [--sort price|context|speed|throughput]\n\n" + - "Examples:\n" + - ' npx tsx compare-models.ts "anthropic/claude-sonnet-4" "openai/gpt-4o"\n' + - ' npx tsx compare-models.ts "anthropic/claude-sonnet-4" "google/gemini-2.5-pro" --sort price\n\n' + - "Sort options:\n" + - " price - Sort by prompt cost (cheapest first)\n" + - " context - Sort by context length (largest first)\n" + - " speed - Sort by max completion tokens (largest first)\n" + - " throughput - Alias for speed" + 'Usage: compare-models.ts [...] [--sort price|context|speed|throughput]\n\n' + + 'Examples:\n' + + ' bun run compare-models.ts "anthropic/claude-sonnet-4" "openai/gpt-4o"\n' + + ' bun run compare-models.ts "anthropic/claude-sonnet-4" "google/gemini-2.5-pro" --sort price\n\n' + + 'Sort options:\n' + + ' price - Sort by prompt cost (cheapest first)\n' + + ' context - Sort by context length (largest first)\n' + + ' speed - Sort by max completion tokens (largest first)\n' + + ' throughput - Alias for speed', ); process.exit(1); } -const json = await fetchApi("/models", apiKey); -const allModels = json.data ?? []; +const client = createClient(apiKey); +const response = await client.models.list({}); +const allModels: Model[] = response.data ?? []; // For each requested ID, prefer exact match, fall back to partial -let matched: any[] = []; +const matched: Model[] = []; for (const id of modelIds) { const lowerId = id.toLowerCase(); - const exact = allModels.find((m: any) => m.id.toLowerCase() === lowerId); + const exact = allModels.find((m) => m.id.toLowerCase() === lowerId); if (exact) { matched.push(exact); } else { - const partial = allModels.filter((m: any) => m.id.toLowerCase().includes(lowerId)); + const partial = allModels.filter((m) => m.id.toLowerCase().includes(lowerId)); if (partial.length === 0) { console.error(`Warning: No model found matching "${id}". Skipping.`); } else if (partial.length === 1) { @@ -46,7 +50,10 @@ for (const id of modelIds) { } else { console.error( `Warning: "${id}" matched ${partial.length} models. Using closest match: ${partial[0].id}\n` + - ` Other matches: ${partial.slice(1, 4).map((m: any) => m.id).join(", ")}${partial.length > 4 ? "..." : ""}` + ` Other matches: ${partial + .slice(1, 4) + .map((m) => m.id) + .join(', ')}${partial.length > 4 ? '...' : ''}`, ); matched.push(partial[0]); } @@ -54,46 +61,60 @@ for (const id of modelIds) { } if (matched.length < 2) { - console.error("Need at least 2 models to compare. Use list-models.ts to find valid IDs."); + console.error('Need at least 2 models to compare. Use list-models.ts to find valid IDs.'); process.exit(1); } -if (sortBy === "price") { - matched.sort((a: any, b: any) => parseFloat(a.pricing?.prompt ?? "0") - parseFloat(b.pricing?.prompt ?? "0")); -} else if (sortBy === "context") { - matched.sort((a: any, b: any) => (b.context_length ?? 0) - (a.context_length ?? 0)); -} else if (sortBy === "speed" || sortBy === "throughput") { +if (sortBy === 'price') { + matched.sort( + (a, b) => parseFloat(a.pricing?.prompt ?? '0') - parseFloat(b.pricing?.prompt ?? '0'), + ); +} else if (sortBy === 'context') { + matched.sort((a, b) => (b.contextLength ?? 0) - (a.contextLength ?? 0)); +} else if (sortBy === 'speed' || sortBy === 'throughput') { matched.sort( - (a: any, b: any) => - (b.top_provider?.max_completion_tokens ?? 0) - (a.top_provider?.max_completion_tokens ?? 0) + (a, b) => (b.topProvider?.maxCompletionTokens ?? 0) - (a.topProvider?.maxCompletionTokens ?? 0), ); } -const comparison = matched.map((m: any) => { - const promptCost = parseFloat(m.pricing?.prompt ?? "0") * 1_000_000; - const completionCost = parseFloat(m.pricing?.completion ?? "0") * 1_000_000; - const cacheCost = m.pricing?.input_cache_read - ? parseFloat(m.pricing.input_cache_read) * 1_000_000 +const comparison = matched.map((m) => { + const promptCost = parseFloat(m.pricing?.prompt ?? '0') * 1_000_000; + const completionCost = parseFloat(m.pricing?.completion ?? '0') * 1_000_000; + const cacheCost = m.pricing?.inputCacheRead + ? parseFloat(m.pricing.inputCacheRead) * 1_000_000 : null; return { id: m.id, name: m.name, - context_length: m.context_length, - max_completion_tokens: m.top_provider?.max_completion_tokens ?? null, - per_request_limits: m.per_request_limits, + context_length: m.contextLength, + max_completion_tokens: m.topProvider?.maxCompletionTokens ?? null, + per_request_limits: m.perRequestLimits + ? { + prompt_tokens: m.perRequestLimits.promptTokens, + completion_tokens: m.perRequestLimits.completionTokens, + } + : null, pricing_per_million_tokens: { prompt: `$${promptCost.toFixed(2)}`, completion: `$${completionCost.toFixed(2)}`, - ...(cacheCost !== null ? { cached_input: `$${cacheCost.toFixed(2)}` } : {}), + ...(cacheCost !== null + ? { + cached_input: `$${cacheCost.toFixed(2)}`, + } + : {}), }, modalities: { - input: m.architecture?.input_modalities ?? [], - output: m.architecture?.output_modalities ?? [], + input: m.architecture?.inputModalities?.map(String) ?? [], + output: m.architecture?.outputModalities?.map(String) ?? [], }, - supported_parameters: m.supported_parameters ?? [], - is_moderated: m.top_provider?.is_moderated ?? null, - ...(m.expiration_date ? { expiration_date: m.expiration_date } : {}), + supported_parameters: m.supportedParameters?.map(String) ?? [], + is_moderated: m.topProvider?.isModerated ?? null, + ...(m.expirationDate + ? { + expiration_date: m.expirationDate, + } + : {}), }; }); diff --git a/skills/openrouter-models/scripts/get-endpoints.ts b/skills/openrouter-models/scripts/get-endpoints.ts index acabf83..7627168 100644 --- a/skills/openrouter-models/scripts/get-endpoints.ts +++ b/skills/openrouter-models/scripts/get-endpoints.ts @@ -1,56 +1,68 @@ -import { requireApiKey, fetchApi, parseArgs } from "./lib.js"; +import { createClient, parseArgs, requireApiKey } from './lib.js'; +import type { PublicEndpoint } from './types.js'; const apiKey = requireApiKey(); const args = parseArgs(process.argv.slice(2)); -const modelId = args.get("_0") as string | undefined; -const sortBy = args.get("sort") as string | undefined; +const modelId = args.get('_0') as string | undefined; +const sortBy = args.get('sort') as string | undefined; if (!modelId) { console.error( - "Usage: get-endpoints.ts [--sort throughput|latency|uptime|price]\n\n" + - "Shows per-provider performance data for a model:\n" + - " - Latency percentiles (p50/p75/p90/p99) in ms\n" + - " - Uptime % over last 30 minutes\n" + - " - Throughput (tokens/sec) percentiles\n" + - " - Provider-specific pricing and limits\n\n" + - "Sort options:\n" + - " throughput - Fastest generation speed first (highest p50 tokens/sec)\n" + - " latency - Lowest response latency first (lowest p50 ms)\n" + - " uptime - Most reliable first (highest uptime %)\n" + - " price - Cheapest first (lowest prompt cost)\n\n" + - "Examples:\n" + - ' npx tsx get-endpoints.ts "anthropic/claude-sonnet-4"\n' + - ' npx tsx get-endpoints.ts "anthropic/claude-sonnet-4" --sort throughput\n' + - ' npx tsx get-endpoints.ts "openai/gpt-4o" --sort latency' + 'Usage: get-endpoints.ts [--sort throughput|latency|uptime|price]\n\n' + + 'Shows per-provider performance data for a model:\n' + + ' - Latency percentiles (p50/p75/p90/p99) in ms\n' + + ' - Uptime % over last 30 minutes\n' + + ' - Throughput (tokens/sec) percentiles\n' + + ' - Provider-specific pricing and limits\n\n' + + 'Sort options:\n' + + ' throughput - Fastest generation speed first (highest p50 tokens/sec)\n' + + ' latency - Lowest response latency first (lowest p50 ms)\n' + + ' uptime - Most reliable first (highest uptime %)\n' + + ' price - Cheapest first (lowest prompt cost)\n\n' + + 'Examples:\n' + + ' bun run get-endpoints.ts "anthropic/claude-sonnet-4"\n' + + ' bun run get-endpoints.ts "anthropic/claude-sonnet-4" --sort throughput\n' + + ' bun run get-endpoints.ts "openai/gpt-4o" --sort latency', ); process.exit(1); } -const json = await fetchApi(`/models/${modelId}/endpoints`, apiKey); -const data = json.data; +const slashIndex = modelId.indexOf('/'); +if (slashIndex < 0) { + console.error( + `Error: Model ID must be in "author/slug" format (e.g., "anthropic/claude-sonnet-4"). Got: "${modelId}"`, + ); + process.exit(1); +} + +const author = modelId.slice(0, slashIndex); +const slug = modelId.slice(slashIndex + 1); + +const client = createClient(apiKey); +const response = await client.endpoints.list({ + author, + slug, +}); +const data = response.data; if (!data?.endpoints?.length) { console.error(`No provider endpoints found for model: ${modelId}`); process.exit(1); } -let endpoints = data.endpoints; +const endpoints: PublicEndpoint[] = data.endpoints; -if (sortBy === "throughput") { - endpoints.sort((a: any, b: any) => - (b.throughput_last_30m?.p50 ?? 0) - (a.throughput_last_30m?.p50 ?? 0) - ); -} else if (sortBy === "latency") { - endpoints.sort((a: any, b: any) => - (a.latency_last_30m?.p50 ?? Infinity) - (b.latency_last_30m?.p50 ?? Infinity) +if (sortBy === 'throughput') { + endpoints.sort((a, b) => (b.throughputLast30m?.p50 ?? 0) - (a.throughputLast30m?.p50 ?? 0)); +} else if (sortBy === 'latency') { + endpoints.sort( + (a, b) => (a.latencyLast30m?.p50 ?? Infinity) - (b.latencyLast30m?.p50 ?? Infinity), ); -} else if (sortBy === "uptime") { - endpoints.sort((a: any, b: any) => - (b.uptime_last_30m ?? 0) - (a.uptime_last_30m ?? 0) - ); -} else if (sortBy === "price") { - endpoints.sort((a: any, b: any) => - parseFloat(a.pricing?.prompt ?? "0") - parseFloat(b.pricing?.prompt ?? "0") +} else if (sortBy === 'uptime') { + endpoints.sort((a, b) => (b.uptimeLast30m ?? 0) - (a.uptimeLast30m ?? 0)); +} else if (sortBy === 'price') { + endpoints.sort( + (a, b) => parseFloat(a.pricing?.prompt ?? '0') - parseFloat(b.pricing?.prompt ?? '0'), ); } @@ -58,27 +70,36 @@ const output = { model_id: data.id, model_name: data.name, total_providers: endpoints.length, - endpoints: endpoints.map((ep: any) => ({ - provider: ep.provider_name, + endpoints: endpoints.map((ep) => ({ + provider: String(ep.providerName), tag: ep.tag, - status: ep.status === 0 ? "operational" : `degraded (${ep.status})`, - uptime_30m: ep.uptime_last_30m != null ? `${ep.uptime_last_30m.toFixed(2)}%` : null, - latency_30m_ms: ep.latency_last_30m ?? null, - throughput_30m_tokens_per_sec: ep.throughput_last_30m ?? null, - context_length: ep.context_length, - max_completion_tokens: ep.max_completion_tokens, - max_prompt_tokens: ep.max_prompt_tokens, + status: ep.status === 0 ? 'operational' : `degraded (${ep.status})`, + uptime_30m: + ep.uptimeLast30m !== null && ep.uptimeLast30m !== undefined + ? `${ep.uptimeLast30m.toFixed(2)}%` + : null, + latency_30m_ms: ep.latencyLast30m ?? null, + throughput_30m_tokens_per_sec: ep.throughputLast30m ?? null, + context_length: ep.contextLength, + max_completion_tokens: ep.maxCompletionTokens, + max_prompt_tokens: ep.maxPromptTokens, pricing_per_million_tokens: { - prompt: `$${(parseFloat(ep.pricing?.prompt ?? "0") * 1_000_000).toFixed(2)}`, - completion: `$${(parseFloat(ep.pricing?.completion ?? "0") * 1_000_000).toFixed(2)}`, - ...(ep.pricing?.input_cache_read - ? { cached_input: `$${(parseFloat(ep.pricing.input_cache_read) * 1_000_000).toFixed(2)}` } + prompt: `$${(parseFloat(ep.pricing?.prompt ?? '0') * 1_000_000).toFixed(2)}`, + completion: `$${(parseFloat(ep.pricing?.completion ?? '0') * 1_000_000).toFixed(2)}`, + ...(ep.pricing?.inputCacheRead + ? { + cached_input: `$${(parseFloat(ep.pricing.inputCacheRead) * 1_000_000).toFixed(2)}`, + } + : {}), + ...(ep.pricing?.discount + ? { + discount: `${(ep.pricing.discount * 100).toFixed(0)}%`, + } : {}), - ...(ep.pricing?.discount ? { discount: `${(ep.pricing.discount * 100).toFixed(0)}%` } : {}), }, - quantization: ep.quantization !== "unknown" ? ep.quantization : null, - supports_implicit_caching: ep.supports_implicit_caching, - supported_parameters: ep.supported_parameters, + quantization: ep.quantization !== 'unknown' ? ep.quantization : null, + supports_implicit_caching: ep.supportsImplicitCaching, + supported_parameters: ep.supportedParameters?.map(String) ?? [], })), }; diff --git a/skills/openrouter-models/scripts/lib.ts b/skills/openrouter-models/scripts/lib.ts index 9218e55..6c78f52 100644 --- a/skills/openrouter-models/scripts/lib.ts +++ b/skills/openrouter-models/scripts/lib.ts @@ -1,9 +1,13 @@ +import { OpenRouter } from '@openrouter/sdk'; +import type { Model } from '@openrouter/sdk/models'; +import type { FormattedModel } from './types.js'; + export function requireApiKey(): string { const apiKey = process.env.OPENROUTER_API_KEY; if (!apiKey) { console.error( - "Error: OPENROUTER_API_KEY environment variable is not set.\n" + - "Get your API key at https://openrouter.ai/keys" + 'Error: OPENROUTER_API_KEY environment variable is not set.\n' + + 'Get your API key at https://openrouter.ai/keys', ); process.exit(1); } @@ -14,49 +18,71 @@ export function optionalApiKey(): string | undefined { return process.env.OPENROUTER_API_KEY; } -export async function fetchApi(path: string, apiKey?: string): Promise { - const url = `https://openrouter.ai/api/v1${path}`; - const headers: Record = {}; - if (apiKey) { - headers.Authorization = `Bearer ${apiKey}`; - } - const res = await fetch(url, { headers }); - - if (!res.ok) { - const body = await res.text().catch(() => ""); - switch (res.status) { - case 401: - console.error("Error 401: Invalid API key. Check your OPENROUTER_API_KEY."); - break; - case 404: - console.error(`Error 404: Not found — ${url}`); - console.error("Use list-models.ts to see available model IDs."); - break; - case 429: - console.error("Error 429: Rate limited. Wait a moment and try again."); - break; - default: - console.error(`Error ${res.status}: ${body || res.statusText}`); - } - process.exit(1); - } - - return res.json(); +export function createClient(apiKey?: string): OpenRouter { + return new OpenRouter({ + apiKey: apiKey ?? '', + }); } -export function formatModel(m: any) { +export function formatModel(m: Model): FormattedModel { return { id: m.id, name: m.name, description: m.description, created: m.created, - context_length: m.context_length, - pricing: m.pricing, - architecture: m.architecture, - top_provider: m.top_provider, - per_request_limits: m.per_request_limits, - supported_parameters: m.supported_parameters, - ...(m.expiration_date ? { expiration_date: m.expiration_date } : {}), + context_length: m.contextLength, + pricing: { + prompt: m.pricing.prompt, + completion: m.pricing.completion, + ...(m.pricing.request !== undefined + ? { + request: m.pricing.request, + } + : {}), + ...(m.pricing.image !== undefined + ? { + image: m.pricing.image, + } + : {}), + ...(m.pricing.inputCacheRead !== undefined + ? { + input_cache_read: m.pricing.inputCacheRead, + } + : {}), + ...(m.pricing.inputCacheWrite !== undefined + ? { + input_cache_write: m.pricing.inputCacheWrite, + } + : {}), + ...(m.pricing.discount !== undefined + ? { + discount: m.pricing.discount, + } + : {}), + }, + architecture: { + tokenizer: m.architecture.tokenizer ?? null, + modality: m.architecture.modality, + input_modalities: m.architecture.inputModalities.map(String), + output_modalities: m.architecture.outputModalities.map(String), + }, + top_provider: { + context_length: m.topProvider.contextLength ?? null, + max_completion_tokens: m.topProvider.maxCompletionTokens ?? null, + is_moderated: m.topProvider.isModerated, + }, + per_request_limits: m.perRequestLimits + ? { + prompt_tokens: m.perRequestLimits.promptTokens, + completion_tokens: m.perRequestLimits.completionTokens, + } + : null, + supported_parameters: m.supportedParameters.map(String), + ...(m.expirationDate + ? { + expiration_date: m.expirationDate, + } + : {}), }; } @@ -65,17 +91,21 @@ export function parseArgs(argv: string[]): Map { const positional: string[] = []; for (let i = 0; i < argv.length; i++) { - if (argv[i].startsWith("--") && argv[i + 1] && !argv[i + 1].startsWith("--")) { - result.set(argv[i].slice(2), argv[i + 1]); + const current = argv[i]; + const next = argv[i + 1]; + if (current.startsWith('--') && next && !next.startsWith('--')) { + result.set(current.slice(2), next); i++; - } else if (argv[i].startsWith("--")) { - result.set(argv[i].slice(2), true); + } else if (current.startsWith('--')) { + result.set(current.slice(2), true); } else { - positional.push(argv[i]); + positional.push(current); } } - positional.forEach((v, i) => result.set(`_${i}`, v)); - result.set("_count", String(positional.length)); + for (let j = 0; j < positional.length; j++) { + result.set(`_${j}`, positional[j]); + } + result.set('_count', String(positional.length)); return result; } diff --git a/skills/openrouter-models/scripts/list-models.ts b/skills/openrouter-models/scripts/list-models.ts index acb7efc..4fc1652 100644 --- a/skills/openrouter-models/scripts/list-models.ts +++ b/skills/openrouter-models/scripts/list-models.ts @@ -1,34 +1,36 @@ -import { optionalApiKey, fetchApi, formatModel, parseArgs } from "./lib.js"; +import type { Category } from '@openrouter/sdk/models/operations'; +import { createClient, formatModel, optionalApiKey, parseArgs } from './lib.js'; +import type { FormattedModel } from './types.js'; const apiKey = optionalApiKey(); const args = parseArgs(process.argv.slice(2)); -const category = args.get("category") as string | undefined; -const sort = args.get("sort") as string | undefined; +const category = args.get('category') as Category | undefined; +const sort = args.get('sort') as string | undefined; -const path = category - ? `/models?category=${encodeURIComponent(category)}` - : "/models"; - -const json = await fetchApi(path, apiKey); -let models = (json.data ?? []).map(formatModel); +const client = createClient(apiKey); +const response = await client.models.list({ + category, +}); +const models: FormattedModel[] = (response.data ?? []).map(formatModel); // Warn about expiring models -const expiring = models.filter((m: any) => m.expiration_date); +const expiring = models.filter((m) => m.expiration_date); if (expiring.length > 0) { - console.error( - `Warning: ${expiring.length} model(s) have upcoming expiration dates.\n` - ); + console.error(`Warning: ${expiring.length} model(s) have upcoming expiration dates.\n`); } -if (sort === "newest") { - models.sort((a: any, b: any) => (b.created ?? 0) - (a.created ?? 0)); -} else if (sort === "price") { - models.sort((a: any, b: any) => parseFloat(a.pricing?.prompt ?? "0") - parseFloat(b.pricing?.prompt ?? "0")); -} else if (sort === "context") { - models.sort((a: any, b: any) => (b.context_length ?? 0) - (a.context_length ?? 0)); -} else if (sort === "throughput" || sort === "speed") { - models.sort((a: any, b: any) => - (b.top_provider?.max_completion_tokens ?? 0) - (a.top_provider?.max_completion_tokens ?? 0) +if (sort === 'newest') { + models.sort((a, b) => (b.created ?? 0) - (a.created ?? 0)); +} else if (sort === 'price') { + models.sort( + (a, b) => parseFloat(a.pricing?.prompt ?? '0') - parseFloat(b.pricing?.prompt ?? '0'), + ); +} else if (sort === 'context') { + models.sort((a, b) => (b.context_length ?? 0) - (a.context_length ?? 0)); +} else if (sort === 'throughput' || sort === 'speed') { + models.sort( + (a, b) => + (b.top_provider?.max_completion_tokens ?? 0) - (a.top_provider?.max_completion_tokens ?? 0), ); } diff --git a/skills/openrouter-models/scripts/package.json b/skills/openrouter-models/scripts/package.json index dd44a74..3f85be5 100644 --- a/skills/openrouter-models/scripts/package.json +++ b/skills/openrouter-models/scripts/package.json @@ -1,8 +1,12 @@ { - "name": "openrouter-models-scripts", - "type": "module", - "private": true, - "devDependencies": { - "tsx": "^4.0.0" - } + "name": "openrouter-models-scripts", + "type": "module", + "private": true, + "dependencies": { + "@openrouter/sdk": "^0.9.11" + }, + "devDependencies": { + "@types/bun": "^1.3.10", + "typescript": "^5.8.0" + } } diff --git a/skills/openrouter-models/scripts/resolve-model.ts b/skills/openrouter-models/scripts/resolve-model.ts index 52adc8d..93ddcdc 100644 --- a/skills/openrouter-models/scripts/resolve-model.ts +++ b/skills/openrouter-models/scripts/resolve-model.ts @@ -1,13 +1,23 @@ -import { optionalApiKey, fetchApi, formatModel, parseArgs } from "./lib.js"; +import { createClient, formatModel, optionalApiKey, parseArgs } from './lib.js'; +import type { Model } from './types.js'; const STOP_WORDS = new Set([ - "the", "a", "an", "model", "latest", "best", "from", "by", "most", "for", + 'the', + 'a', + 'an', + 'model', + 'latest', + 'best', + 'from', + 'by', + 'most', + 'for', ]); function tokenize(text: string): string[] { return text .toLowerCase() - .split(/[\s\-_\/:.]+/) + .split(/[\s\-_/:.]+/) .filter((t) => t.length > 0); } @@ -16,7 +26,7 @@ function removeStopWords(tokens: string[]): string[] { } function collapse(text: string): string { - return text.toLowerCase().replace(/[\s\-_\/:.]+/g, ""); + return text.toLowerCase().replace(/[\s\-_/:.]+/g, ''); } function bigrams(text: string): Set { @@ -30,32 +40,40 @@ function bigrams(text: string): Set { function bigramDice(a: string, b: string): number { const ba = bigrams(a); const bb = bigrams(b); - if (ba.size === 0 && bb.size === 0) return 0; + if (ba.size === 0 && bb.size === 0) { + return 0; + } let intersection = 0; for (const g of ba) { - if (bb.has(g)) intersection++; + if (bb.has(g)) { + intersection++; + } } return (2 * intersection) / (ba.size + bb.size); } function stripProvider(id: string): string { - const slash = id.indexOf("/"); + const slash = id.indexOf('/'); return slash >= 0 ? id.slice(slash + 1) : id; } function tokenOverlapScore(queryTokens: string[], targetTokens: string[]): number { - if (queryTokens.length === 0) return 0; + if (queryTokens.length === 0) { + return 0; + } let matched = 0; let lastIndex = -1; let orderBonus = 0; for (const qt of queryTokens) { const idx = targetTokens.findIndex( - (tt, i) => i > lastIndex - 1 && (tt === qt || tt.includes(qt) || qt.includes(tt)) + (tt, i) => i > lastIndex - 1 && (tt === qt || tt.includes(qt) || qt.includes(tt)), ); if (idx >= 0) { matched++; - if (idx > lastIndex) orderBonus++; + if (idx > lastIndex) { + orderBonus++; + } lastIndex = idx; } } @@ -69,24 +87,24 @@ function substringScore(collapsedQuery: string, modelId: string, modelName: stri const collapsedId = collapse(modelId); const collapsedName = collapse(modelName); - if (collapsedId.includes(collapsedQuery)) return 1.0; - if (collapsedName.includes(collapsedQuery)) return 1.0; + if (collapsedId.includes(collapsedQuery)) { + return 1.0; + } + if (collapsedName.includes(collapsedQuery)) { + return 1.0; + } return 0; } interface ScoredModel { score: number; - confidence: "high" | "medium" | "low"; - model: any; + confidence: 'high' | 'medium' | 'low'; + model: Model; } -function scoreModel( - queryTokens: string[], - collapsedQuery: string, - model: any -): number { - const modelId = (model.id ?? "").toLowerCase(); - const modelName = (model.name ?? "").toLowerCase(); +function scoreModel(queryTokens: string[], collapsedQuery: string, model: Model): number { + const modelId = (model.id ?? '').toLowerCase(); + const modelName = (model.name ?? '').toLowerCase(); const targetTokens = tokenize(`${modelId} ${modelName}`); const tokenScore = tokenOverlapScore(queryTokens, targetTokens); @@ -95,52 +113,63 @@ function scoreModel( const strippedId = stripProvider(modelId); const bigramScore = Math.max( bigramDice(collapsedQuery, collapse(strippedId)), - bigramDice(collapsedQuery, collapse(modelName)) + bigramDice(collapsedQuery, collapse(modelName)), ); return tokenScore * 0.5 + subScore * 0.3 + bigramScore * 0.2; } -function confidence(score: number): "high" | "medium" | "low" { - if (score >= 0.85) return "high"; - if (score >= 0.55) return "medium"; - return "low"; +function confidence(score: number): 'high' | 'medium' | 'low' { + if (score >= 0.85) { + return 'high'; + } + if (score >= 0.55) { + return 'medium'; + } + return 'low'; } // --- main --- const apiKey = optionalApiKey(); const args = parseArgs(process.argv.slice(2)); -const rawQuery = args.get("_0") as string | undefined; +const rawQuery = args.get('_0') as string | undefined; if (!rawQuery || rawQuery.trim().length === 0) { console.error( - "Usage: resolve-model.ts \n\n" + - "Resolves an informal model name to an exact OpenRouter model ID.\n\n" + - "Examples:\n" + - ' npx tsx resolve-model.ts "claude sonnet"\n' + - ' npx tsx resolve-model.ts "gpt 4o mini"\n' + - ' npx tsx resolve-model.ts "llama 3.1"' + 'Usage: resolve-model.ts \n\n' + + 'Resolves an informal model name to an exact OpenRouter model ID.\n\n' + + 'Examples:\n' + + ' bun run resolve-model.ts "claude sonnet"\n' + + ' bun run resolve-model.ts "gpt 4o mini"\n' + + ' bun run resolve-model.ts "llama 3.1"', ); process.exit(1); } -const query = rawQuery as string; +const query = rawQuery; -const json = await fetchApi("/models", apiKey); -const models: any[] = json.data ?? []; +const client = createClient(apiKey); +const response = await client.models.list({}); +const models: Model[] = response.data ?? []; // Exact ID match short-circuit -const exactMatch = models.find( - (m: any) => (m.id ?? "").toLowerCase() === query.toLowerCase() -); +const exactMatch = models.find((m) => (m.id ?? '').toLowerCase() === query.toLowerCase()); if (exactMatch) { const result = { ...formatModel(exactMatch), - confidence: "high" as const, + confidence: 'high' as const, score: 1.0, }; - console.log(JSON.stringify([result], null, 2)); + console.log( + JSON.stringify( + [ + result, + ], + null, + 2, + ), + ); process.exit(0); } @@ -148,19 +177,23 @@ const queryTokens = removeStopWords(tokenize(query)); if (queryTokens.length === 0) { console.error( - "Query contains only stop words. Try a more specific model name.\n" + - "Examples: \"claude sonnet\", \"gpt 4o\", \"llama 3.1\"" + 'Query contains only stop words. Try a more specific model name.\n' + + 'Examples: "claude sonnet", "gpt 4o", "llama 3.1"', ); console.log(JSON.stringify([])); process.exit(0); } -const collapsedQuery = collapse(queryTokens.join(" ")); +const collapsedQuery = collapse(queryTokens.join(' ')); const scored: ScoredModel[] = models - .map((m: any) => { + .map((m) => { const score = scoreModel(queryTokens, collapsedQuery, m); - return { score, confidence: confidence(score), model: m }; + return { + score, + confidence: confidence(score), + model: m, + }; }) .filter((s) => s.score >= 0.3) .sort((a, b) => b.score - a.score) @@ -168,7 +201,7 @@ const scored: ScoredModel[] = models if (scored.length === 0) { console.error( - `No models matched "${query}". Try a more specific name or use search-models.ts for substring matching.` + `No models matched "${query}". Try a more specific name or use search-models.ts for substring matching.`, ); console.log(JSON.stringify([])); process.exit(0); diff --git a/skills/openrouter-models/scripts/search-models.ts b/skills/openrouter-models/scripts/search-models.ts index e6181bc..22933ca 100644 --- a/skills/openrouter-models/scripts/search-models.ts +++ b/skills/openrouter-models/scripts/search-models.ts @@ -1,40 +1,45 @@ -import { optionalApiKey, fetchApi, formatModel, parseArgs } from "./lib.js"; +import { createClient, formatModel, optionalApiKey, parseArgs } from './lib.js'; +import type { Model } from './types.js'; const apiKey = optionalApiKey(); const args = parseArgs(process.argv.slice(2)); -const query = args.get("_0") as string | undefined; -const modality = args.get("modality") as string | undefined; +const query = args.get('_0') as string | undefined; +const modality = args.get('modality') as string | undefined; if (!query && !modality) { console.error( - "Usage: search-models.ts [--modality ]\n\n" + - "Examples:\n" + - ' npx tsx search-models.ts "claude"\n' + - ' npx tsx search-models.ts --modality image\n' + - ' npx tsx search-models.ts "gpt" --modality text' + 'Usage: search-models.ts [--modality ]\n\n' + + 'Examples:\n' + + ' bun run search-models.ts "claude"\n' + + ' bun run search-models.ts --modality image\n' + + ' bun run search-models.ts "gpt" --modality text', ); process.exit(1); } -const json = await fetchApi("/models", apiKey); -let models = json.data ?? []; +const client = createClient(apiKey); +const response = await client.models.list({}); +let models: Model[] = response.data ?? []; if (query) { const lowerQuery = query.toLowerCase(); - models = models.filter((m: any) => { - const id = (m.id ?? "").toLowerCase(); - const name = (m.name ?? "").toLowerCase(); + models = models.filter((m) => { + const id = (m.id ?? '').toLowerCase(); + const name = (m.name ?? '').toLowerCase(); return id.includes(lowerQuery) || name.includes(lowerQuery); }); } if (modality) { const lowerModality = modality.toLowerCase(); - models = models.filter((m: any) => { - const inputMods: string[] = m.architecture?.input_modalities ?? []; - const outputMods: string[] = m.architecture?.output_modalities ?? []; - return [...inputMods, ...outputMods] - .map((mod: string) => mod.toLowerCase()) + models = models.filter((m) => { + const inputMods: string[] = m.architecture?.inputModalities?.map(String) ?? []; + const outputMods: string[] = m.architecture?.outputModalities?.map(String) ?? []; + return [ + ...inputMods, + ...outputMods, + ] + .map((mod) => mod.toLowerCase()) .includes(lowerModality); }); } diff --git a/skills/openrouter-models/scripts/tsconfig.json b/skills/openrouter-models/scripts/tsconfig.json new file mode 100644 index 0000000..0395297 --- /dev/null +++ b/skills/openrouter-models/scripts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true + }, + "include": ["*.ts"] +} diff --git a/skills/openrouter-models/scripts/types.ts b/skills/openrouter-models/scripts/types.ts new file mode 100644 index 0000000..16410de --- /dev/null +++ b/skills/openrouter-models/scripts/types.ts @@ -0,0 +1,39 @@ +import type { Model, PublicEndpoint } from '@openrouter/sdk/models'; + +// Re-export SDK types for convenience +export type { Model, PublicEndpoint }; + +// Output format types (snake_case, matching current JSON output) +export interface FormattedModel { + id: string; + name: string; + description: string | undefined; + created: number; + context_length: number | null; + pricing: { + prompt: string; + completion: string; + request?: string; + image?: string; + input_cache_read?: string; + input_cache_write?: string; + discount?: number; + }; + architecture: { + tokenizer?: string | null; + modality: string | null; + input_modalities: string[]; + output_modalities: string[]; + }; + top_provider: { + context_length?: number | null; + max_completion_tokens?: number | null; + is_moderated: boolean; + }; + per_request_limits: { + prompt_tokens: number; + completion_tokens: number; + } | null; + supported_parameters: string[]; + expiration_date?: string | null; +} From 15d44a9bc146155d70f88b9d2fd3fca3cbef8da7 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 10 Mar 2026 16:49:36 -0400 Subject: [PATCH 03/11] improve openrouter-models SKILL.md triggering and guidance Expand description with many more trigger phrases so Claude activates the skill even when users don't mention OpenRouter explicitly. Add "why" explanations to Presenting Results guidelines. Add Common Workflows table showing how to chain scripts for typical user intents. --- skills/openrouter-models/SKILL.md | 32 ++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/skills/openrouter-models/SKILL.md b/skills/openrouter-models/SKILL.md index 626b084..bb77da7 100644 --- a/skills/openrouter-models/SKILL.md +++ b/skills/openrouter-models/SKILL.md @@ -1,6 +1,16 @@ --- name: openrouter-models -description: Query OpenRouter for available AI models, pricing, capabilities, throughput, and provider performance. Use when the user asks about available OpenRouter models, model pricing, model context lengths, model capabilities, provider latency or uptime, throughput limits, supported parameters, wants to search/filter/compare models, or find the fastest provider for a model. +description: >- + Query OpenRouter for available AI models, pricing, capabilities, throughput, and provider performance. + Use this skill whenever the user asks about AI models — even if they don't explicitly mention OpenRouter. + Triggers include: "what models are available", "which model should I use", "recommend a model for X", + "cheapest/fastest/best model for Y", "how much does Claude/GPT/Llama cost", "what's the context length of X", + "what models support tool use/images/reasoning/audio", "is model X available on OpenRouter", + "compare Claude vs GPT vs Gemini", "model deprecation or expiration dates", "which provider is fastest", + "lowest latency provider", "provider uptime", "throughput limits", "find me a model that can do X", + searching/filtering/comparing models, or any question about model pricing, capabilities, or availability. + Also use when the user mentions a model by informal name (e.g. "sonnet", "4o mini", "llama 3") — + resolve it first, then answer their question with live data. --- # OpenRouter Models @@ -223,10 +233,22 @@ Returns for each provider: ## Presenting Results - When a user mentions a model by informal name, use `resolve-model.ts` first, then feed the resolved `id` into other scripts -- Convert pricing to per-million-tokens format for readability -- When comparing, use a markdown table with models as columns +- Convert pricing to per-million-tokens format — raw per-token numbers are tiny decimals that humans can't parse at a glance +- When comparing, use a markdown table with models as columns — side-by-side layout is far easier to scan than sequential blocks - For provider endpoints, highlight the fastest (lowest p50 latency) and most reliable (highest uptime) providers - Call out notable supported parameters: `tools`, `structured_outputs`, `reasoning`, `web_search_options` -- Note cache pricing when available — it can cut input costs 90%+ -- Flag models with `expiration_date` as deprecated +- Highlight cache pricing when available — it can cut input costs by 90%+, which often changes the cost-optimal choice entirely +- Flag models with `expiration_date` as deprecated — users need to plan migration before removal - When a model has multiple providers at different prices, mention the cheapest option + +## Common Workflows + +Chain scripts together based on what the user actually needs: + +| User intent | Workflow | +|---|---| +| "Which model should I use for X?" | `resolve-model.ts` (if informal name) → `search-models.ts --modality` or `list-models.ts --category` → `compare-models.ts` → recommend based on results | +| "What's the cheapest model for Y?" | `list-models.ts --sort price` or `compare-models.ts A B --sort price` → highlight cache pricing if relevant | +| "Fastest provider for model X?" | `resolve-model.ts` (if needed) → `get-endpoints.ts --sort throughput` → call out the top provider | +| "Compare Claude vs GPT" | `resolve-model.ts` for each informal name → `compare-models.ts` with resolved IDs → present markdown table | +| "Is model X being deprecated?" | `search-models.ts "X"` → check `expiration_date` field → advise on timeline and alternatives | From 858d761fae9cb0c0ac7f0eddb1acfe7b18614e43 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 10 Mar 2026 16:32:46 -0400 Subject: [PATCH 04/11] feat(images): migrate to @openrouter/sdk with strict types and bun --- skills/openrouter-images/SKILL.md | 21 ++-- skills/openrouter-images/scripts/bun.lock | 31 +++++ skills/openrouter-images/scripts/edit.ts | 112 +++++++++++------- skills/openrouter-images/scripts/generate.ts | 92 ++++++++------ skills/openrouter-images/scripts/lib.ts | 78 +++++------- skills/openrouter-images/scripts/package.json | 6 +- .../openrouter-images/scripts/tsconfig.json | 15 +++ 7 files changed, 220 insertions(+), 135 deletions(-) create mode 100644 skills/openrouter-images/scripts/bun.lock create mode 100644 skills/openrouter-images/scripts/tsconfig.json diff --git a/skills/openrouter-images/SKILL.md b/skills/openrouter-images/SKILL.md index 28112b3..b13586a 100644 --- a/skills/openrouter-images/SKILL.md +++ b/skills/openrouter-images/SKILL.md @@ -9,12 +9,13 @@ Generate images from text prompts and edit existing images via OpenRouter's chat ## Prerequisites -The `OPENROUTER_API_KEY` environment variable must be set. Get a key at https://openrouter.ai/keys +- The `OPENROUTER_API_KEY` environment variable must be set. Get a key at https://openrouter.ai/keys +- `bun` must be installed ## First-Time Setup ```bash -cd /scripts && npm install +cd /scripts && bun install ``` ## Decision Tree @@ -34,10 +35,10 @@ Pick the right script based on what the user is asking: Create a new image from a text prompt: ```bash -cd /scripts && npx tsx generate.ts "a red panda wearing sunglasses" -cd /scripts && npx tsx generate.ts "a futuristic cityscape at night" --aspect-ratio 16:9 -cd /scripts && npx tsx generate.ts "pixel art of a dragon" --output dragon.png -cd /scripts && npx tsx generate.ts "a watercolor painting" --model google/gemini-2.5-flash-image +cd /scripts && bun run generate.ts "a red panda wearing sunglasses" +cd /scripts && bun run generate.ts "a futuristic cityscape at night" --aspect-ratio 16:9 +cd /scripts && bun run generate.ts "pixel art of a dragon" --output dragon.png +cd /scripts && bun run generate.ts "a watercolor painting" --model google/gemini-2.5-flash-image ``` ### Options @@ -54,9 +55,9 @@ cd /scripts && npx tsx generate.ts "a watercolor painting" --model g Modify an existing image with a text prompt: ```bash -cd /scripts && npx tsx edit.ts photo.png "make the sky purple" -cd /scripts && npx tsx edit.ts avatar.jpg "add a party hat" --output avatar-hat.png -cd /scripts && npx tsx edit.ts scene.png "convert to watercolor style" --model google/gemini-2.5-flash-image +cd /scripts && bun run edit.ts photo.png "make the sky purple" +cd /scripts && bun run edit.ts avatar.jpg "add a party hat" --output avatar-hat.png +cd /scripts && bun run edit.ts scene.png "convert to watercolor style" --model google/gemini-2.5-flash-image ``` ### Options @@ -102,7 +103,7 @@ The default model is `google/gemini-3.1-flash-image-preview` (Nano Banana 2). To Use the `openrouter-models` skill to discover image-capable models: ```bash -cd /scripts && npx tsx search-models.ts --modality image +cd /scripts && bun run search-models.ts --modality image ``` ## Presenting Results diff --git a/skills/openrouter-images/scripts/bun.lock b/skills/openrouter-images/scripts/bun.lock new file mode 100644 index 0000000..7ee79f5 --- /dev/null +++ b/skills/openrouter-images/scripts/bun.lock @@ -0,0 +1,31 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "openrouter-images-scripts", + "dependencies": { + "@openrouter/sdk": "^0.9.11", + }, + "devDependencies": { + "@types/bun": "^1.2.0", + "typescript": "^5.8.0", + }, + }, + }, + "packages": { + "@openrouter/sdk": ["@openrouter/sdk@0.9.11", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-BgFu6NcIJO4a9aVjr04y3kZ8pyM71j15I+bzfVAGEvxnj+KQNIkBYQGgwrG3D+aT1QpDKLki8btcQmpaxUas6A=="], + + "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + + "@types/node": ["@types/node@25.4.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="], + + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + } +} diff --git a/skills/openrouter-images/scripts/edit.ts b/skills/openrouter-images/scripts/edit.ts index 37199b1..6365139 100644 --- a/skills/openrouter-images/scripts/edit.ts +++ b/skills/openrouter-images/scripts/edit.ts @@ -1,78 +1,102 @@ +import type { ChatResponse, Modality } from '@openrouter/sdk/models'; import { + createClient, DEFAULT_MODEL, - requireApiKey, + defaultOutputPath, parseArgs, - postChatCompletion, readImageAsDataUrl, saveImage, - defaultOutputPath, -} from "./lib.js"; +} from './lib.js'; -const apiKey = requireApiKey(); +const client = createClient(); const args = parseArgs(process.argv.slice(2)); -const imagePath = args.get("_0") as string | undefined; -const prompt = args.get("_1") as string | undefined; +const imagePath = args.get('_0') as string | undefined; +const prompt = args.get('_1') as string | undefined; if (!imagePath || !prompt) { - console.error("Usage: npx tsx edit.ts \"prompt\" [--model ] [--output ] [--aspect-ratio ] [--image-size ]"); + console.error( + 'Usage: bun run edit.ts "prompt" [--model ] [--output ] [--aspect-ratio ] [--image-size ]', + ); process.exit(1); } -const model = (args.get("model") as string) || DEFAULT_MODEL; -const outputBase = (args.get("output") as string) || defaultOutputPath(); -const aspectRatio = args.get("aspect-ratio") as string | undefined; -const imageSize = args.get("image-size") as string | undefined; +const model = (args.get('model') as string) || DEFAULT_MODEL; +const outputBase = (args.get('output') as string) || defaultOutputPath(); +const aspectRatio = args.get('aspect-ratio') as string | undefined; +const imageSize = args.get('image-size') as string | undefined; -const dataUrl = readImageAsDataUrl(imagePath as string); +const dataUrl = readImageAsDataUrl(imagePath); const imageConfig: Record = {}; -if (aspectRatio) imageConfig.aspect_ratio = aspectRatio; -if (imageSize) imageConfig.image_size = imageSize; +if (aspectRatio) { + imageConfig.aspect_ratio = aspectRatio; +} +if (imageSize) { + imageConfig.image_size = imageSize; +} -const body: any = { - model, - messages: [ - { - role: "user", - content: [ - { type: "image_url", image_url: { url: dataUrl } }, - { type: "text", text: prompt }, - ], - }, - ], - modalities: ["image", "text"], - ...(Object.keys(imageConfig).length > 0 ? { image_config: imageConfig } : {}), -}; +const modalities: Modality[] = [ + 'image', + 'text', +]; + +const response = (await client.chat.send({ + chatGenerationParams: { + model, + messages: [ + { + role: 'user' as const, + content: [ + { + type: 'image_url' as const, + imageUrl: { + url: dataUrl, + }, + }, + { + type: 'text' as const, + text: prompt, + }, + ], + }, + ], + modalities, + ...(Object.keys(imageConfig).length > 0 + ? { + imageConfig, + } + : {}), + }, +})) as ChatResponse; -const json = await postChatCompletion(apiKey, body); -const message = json.choices?.[0]?.message; +const message = response.choices?.[0]?.message; if (!message) { - console.error("Error: No response from model."); + console.error('Error: No response from model.'); process.exit(1); } -if (message.content) { +if (message.content && typeof message.content === 'string') { console.error(`Model: ${message.content}`); } -const images: string[] = message.images ?? []; +const images: string[] = message.images?.map((img) => img.imageUrl.url) ?? []; if (images.length === 0) { - console.error("Error: No images returned by model."); + console.error('Error: No images returned by model.'); process.exit(1); } const saved: string[] = []; for (let i = 0; i < images.length; i++) { - const img = images[i].startsWith("data:") ? images[i] : `data:image/png;base64,${images[i]}`; + const img = images[i].startsWith('data:') ? images[i] : `data:image/png;base64,${images[i]}`; let outPath: string; if (images.length === 1) { outPath = outputBase; } else { - const dotIdx = outputBase.lastIndexOf("."); + const dotIdx = outputBase.lastIndexOf('.'); const base = dotIdx > 0 ? outputBase.slice(0, dotIdx) : outputBase; - const ext = dotIdx > 0 ? outputBase.slice(dotIdx) : ".png"; + const ext = dotIdx > 0 ? outputBase.slice(dotIdx) : '.png'; outPath = `${base}-${i + 1}${ext}`; } const abs = saveImage(img, outPath); @@ -81,8 +105,14 @@ for (let i = 0; i < images.length; i++) { console.log( JSON.stringify( - { model, source_image: imagePath, prompt, images_saved: saved, count: saved.length }, + { + model, + source_image: imagePath, + prompt, + images_saved: saved, + count: saved.length, + }, null, - 2 - ) + 2, + ), ); diff --git a/skills/openrouter-images/scripts/generate.ts b/skills/openrouter-images/scripts/generate.ts index 50e7f72..9ac0dee 100644 --- a/skills/openrouter-images/scripts/generate.ts +++ b/skills/openrouter-images/scripts/generate.ts @@ -1,69 +1,95 @@ -import { - DEFAULT_MODEL, - requireApiKey, - parseArgs, - postChatCompletion, - saveImage, - defaultOutputPath, -} from "./lib.js"; +import type { ChatResponse, Modality } from '@openrouter/sdk/models'; +import { createClient, DEFAULT_MODEL, defaultOutputPath, parseArgs, saveImage } from './lib.js'; -const apiKey = requireApiKey(); +const client = createClient(); const args = parseArgs(process.argv.slice(2)); -const prompt = args.get("_0") as string | undefined; +const prompt = args.get('_0') as string | undefined; if (!prompt) { - console.error("Usage: npx tsx generate.ts \"prompt\" [--model ] [--output ] [--aspect-ratio ] [--image-size ]"); + console.error( + 'Usage: bun run generate.ts "prompt" [--model ] [--output ] [--aspect-ratio ] [--image-size ]', + ); process.exit(1); } -const model = (args.get("model") as string) || DEFAULT_MODEL; -const outputBase = (args.get("output") as string) || defaultOutputPath(); -const aspectRatio = args.get("aspect-ratio") as string | undefined; -const imageSize = args.get("image-size") as string | undefined; +const model = (args.get('model') as string) || DEFAULT_MODEL; +const outputBase = (args.get('output') as string) || defaultOutputPath(); +const aspectRatio = args.get('aspect-ratio') as string | undefined; +const imageSize = args.get('image-size') as string | undefined; const imageConfig: Record = {}; -if (aspectRatio) imageConfig.aspect_ratio = aspectRatio; -if (imageSize) imageConfig.image_size = imageSize; +if (aspectRatio) { + imageConfig.aspect_ratio = aspectRatio; +} +if (imageSize) { + imageConfig.image_size = imageSize; +} + +const modalities: Modality[] = [ + 'image', + 'text', +]; -const body: any = { - model, - messages: [{ role: "user", content: prompt }], - modalities: ["image", "text"], - ...(Object.keys(imageConfig).length > 0 ? { image_config: imageConfig } : {}), -}; +const response = (await client.chat.send({ + chatGenerationParams: { + model, + messages: [ + { + role: 'user' as const, + content: prompt, + }, + ], + modalities, + ...(Object.keys(imageConfig).length > 0 + ? { + imageConfig, + } + : {}), + }, +})) as ChatResponse; -const json = await postChatCompletion(apiKey, body); -const message = json.choices?.[0]?.message; +const message = response.choices?.[0]?.message; if (!message) { - console.error("Error: No response from model."); + console.error('Error: No response from model.'); process.exit(1); } -if (message.content) { +if (message.content && typeof message.content === 'string') { console.error(`Model: ${message.content}`); } -const images: string[] = message.images ?? []; +const images: string[] = message.images?.map((img) => img.imageUrl.url) ?? []; if (images.length === 0) { - console.error("Error: No images returned by model."); + console.error('Error: No images returned by model.'); process.exit(1); } const saved: string[] = []; for (let i = 0; i < images.length; i++) { - const dataUrl = images[i].startsWith("data:") ? images[i] : `data:image/png;base64,${images[i]}`; + const dataUrl = images[i].startsWith('data:') ? images[i] : `data:image/png;base64,${images[i]}`; let outPath: string; if (images.length === 1) { outPath = outputBase; } else { - const dotIdx = outputBase.lastIndexOf("."); + const dotIdx = outputBase.lastIndexOf('.'); const base = dotIdx > 0 ? outputBase.slice(0, dotIdx) : outputBase; - const ext = dotIdx > 0 ? outputBase.slice(dotIdx) : ".png"; + const ext = dotIdx > 0 ? outputBase.slice(dotIdx) : '.png'; outPath = `${base}-${i + 1}${ext}`; } const abs = saveImage(dataUrl, outPath); saved.push(abs); } -console.log(JSON.stringify({ model, prompt, images_saved: saved, count: saved.length }, null, 2)); +console.log( + JSON.stringify( + { + model, + prompt, + images_saved: saved, + count: saved.length, + }, + null, + 2, + ), +); diff --git a/skills/openrouter-images/scripts/lib.ts b/skills/openrouter-images/scripts/lib.ts index 143aea4..1d7d374 100644 --- a/skills/openrouter-images/scripts/lib.ts +++ b/skills/openrouter-images/scripts/lib.ts @@ -1,18 +1,21 @@ -import { readFileSync, writeFileSync } from "node:fs"; -import { resolve, extname } from "node:path"; +import { readFileSync, writeFileSync } from 'node:fs'; +import { extname, resolve } from 'node:path'; +import { OpenRouter } from '@openrouter/sdk'; -export const DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview"; +export const DEFAULT_MODEL = 'google/gemini-3.1-flash-image-preview'; -export function requireApiKey(): string { +export function createClient(): OpenRouter { const apiKey = process.env.OPENROUTER_API_KEY; if (!apiKey) { console.error( - "Error: OPENROUTER_API_KEY environment variable is not set.\n" + - "Get your API key at https://openrouter.ai/keys" + 'Error: OPENROUTER_API_KEY environment variable is not set.\n' + + 'Get your API key at https://openrouter.ai/keys', ); process.exit(1); } - return apiKey; + return new OpenRouter({ + apiKey, + }); } export function parseArgs(argv: string[]): Map { @@ -20,56 +23,29 @@ export function parseArgs(argv: string[]): Map { const positional: string[] = []; for (let i = 0; i < argv.length; i++) { - if (argv[i].startsWith("--") && argv[i + 1] && !argv[i + 1].startsWith("--")) { + if (argv[i].startsWith('--') && argv[i + 1] && !argv[i + 1].startsWith('--')) { result.set(argv[i].slice(2), argv[i + 1]); i++; - } else if (argv[i].startsWith("--")) { + } else if (argv[i].startsWith('--')) { result.set(argv[i].slice(2), true); } else { positional.push(argv[i]); } } - positional.forEach((v, i) => result.set(`_${i}`, v)); - result.set("_count", String(positional.length)); - return result; -} - -export async function postChatCompletion(apiKey: string, body: any): Promise { - const url = "https://openrouter.ai/api/v1/chat/completions"; - const res = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), + positional.forEach((v, i) => { + result.set(`_${i}`, v); }); - - if (!res.ok) { - const text = await res.text().catch(() => ""); - switch (res.status) { - case 401: - console.error("Error 401: Invalid API key. Check your OPENROUTER_API_KEY."); - break; - case 429: - console.error("Error 429: Rate limited. Wait a moment and try again."); - break; - default: - console.error(`Error ${res.status}: ${text || res.statusText}`); - } - process.exit(1); - } - - return res.json(); + result.set('_count', String(positional.length)); + return result; } const MIME_MAP: Record = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".webp": "image/webp", - ".gif": "image/gif", + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.gif': 'image/gif', }; export function readImageAsDataUrl(filePath: string): string { @@ -77,27 +53,29 @@ export function readImageAsDataUrl(filePath: string): string { const ext = extname(abs).toLowerCase(); const mime = MIME_MAP[ext]; if (!mime) { - console.error(`Error: Unsupported image format "${ext}". Use .png, .jpg, .jpeg, .webp, or .gif`); + console.error( + `Error: Unsupported image format "${ext}". Use .png, .jpg, .jpeg, .webp, or .gif`, + ); process.exit(1); } const data = readFileSync(abs); - return `data:${mime};base64,${data.toString("base64")}`; + return `data:${mime};base64,${data.toString('base64')}`; } export function saveImage(dataUrl: string, outputPath: string): string { const match = dataUrl.match(/^data:[^;]+;base64,(.+)$/); if (!match) { - console.error("Error: Invalid data URL format in response."); + console.error('Error: Invalid data URL format in response.'); process.exit(1); } const abs = resolve(outputPath); - writeFileSync(abs, Buffer.from(match[1], "base64")); + writeFileSync(abs, Buffer.from(match[1], 'base64')); return abs; } export function defaultOutputPath(): string { const now = new Date(); - const pad = (n: number) => String(n).padStart(2, "0"); + const pad = (n: number) => String(n).padStart(2, '0'); const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` + `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; diff --git a/skills/openrouter-images/scripts/package.json b/skills/openrouter-images/scripts/package.json index 56962cf..c54e858 100644 --- a/skills/openrouter-images/scripts/package.json +++ b/skills/openrouter-images/scripts/package.json @@ -2,7 +2,11 @@ "name": "openrouter-images-scripts", "type": "module", "private": true, + "dependencies": { + "@openrouter/sdk": "^0.9.11" + }, "devDependencies": { - "tsx": "^4.0.0" + "@types/bun": "^1.2.0", + "typescript": "^5.8.0" } } diff --git a/skills/openrouter-images/scripts/tsconfig.json b/skills/openrouter-images/scripts/tsconfig.json new file mode 100644 index 0000000..396355e --- /dev/null +++ b/skills/openrouter-images/scripts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true + }, + "include": ["*.ts"] +} From 08e81f4e924bf97e53e3c8b773d6814ba5e052dc Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 10 Mar 2026 16:49:55 -0400 Subject: [PATCH 05/11] docs(images): improve SKILL.md triggering, prompt tips, and result guidance Expand description to cover more trigger phrases (logo, sketch, mockup, etc.) so the skill activates in more relevant contexts. Add prompt tips section for better image results. Add "why" explanations to presenting results section. Cross-reference openrouter-models skill for model discovery. --- skills/openrouter-images/SKILL.md | 33 ++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/skills/openrouter-images/SKILL.md b/skills/openrouter-images/SKILL.md index b13586a..f4295d3 100644 --- a/skills/openrouter-images/SKILL.md +++ b/skills/openrouter-images/SKILL.md @@ -1,6 +1,15 @@ --- name: openrouter-images -description: Generate images from text prompts and edit existing images using OpenRouter's image generation models. Use when the user asks to create, generate, or make an image, picture, or illustration from a description, or wants to edit, modify, transform, or alter an existing image with a text prompt. +description: > + Generate images from text prompts and edit existing images using OpenRouter's image generation models. + Use this skill whenever the user wants to create visual content of any kind: generate an image, picture, + photo, artwork, illustration, logo, icon, banner, thumbnail, mockup, diagram, or sketch from a description. + Also use when the user wants to edit, modify, transform, or alter an existing image — changing colors, + adding or removing elements, converting style (watercolor, pixel art, oil painting, etc.), or fixing + something in a photo. Trigger on phrases like "make me a picture", "draw/sketch/paint this", "visualize + this concept", "create a logo", "generate a mockup", "change/fix/update this image", "add/remove something + from this photo", or any request that implies producing or modifying a visual. Even if the user doesn't + say "image generation" explicitly, use this skill whenever the output should be an image file. --- # OpenRouter Images @@ -96,11 +105,20 @@ Supported input formats: `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif` } ``` +## Prompt Tips + +Better prompts produce better images. A few specifics go a long way: + +- **State the medium** — "a watercolor painting", "a 35mm photograph", "pixel art", "3D render". This anchors the model's style. +- **Describe style and mood** — lighting, color palette, atmosphere ("warm golden-hour light", "moody noir shadows", "vibrant pop-art colors"). +- **Be specific about composition** — what's in the foreground vs background, camera angle, framing ("close-up portrait", "wide aerial shot"). +- **For edits, be precise** — say exactly what to change and what to preserve ("make the sky sunset-orange but keep the buildings unchanged"). + ## Using a Different Model The default model is `google/gemini-3.1-flash-image-preview` (Nano Banana 2). To use a different model, pass `--model ` with any OpenRouter model ID that supports image output modalities. -Use the `openrouter-models` skill to discover image-capable models: +To discover which models support image generation, use the `openrouter-models` skill to search by modality — it can filter and compare image-capable models by price, speed, and quality: ```bash cd /scripts && bun run search-models.ts --modality image @@ -108,8 +126,9 @@ cd /scripts && bun run search-models.ts --modality ## Presenting Results -- After generating or editing, display the saved image to the user -- Include the model used and any text response the model provided (printed to stderr) -- If multiple images are returned, show all of them -- When the user doesn't specify an output path, tell them where the file was saved -- For edit operations, mention the source image that was modified +- **Display the saved image** to the user immediately — they need to evaluate quality and may want iterations. +- **Mention the model used** — different models have different strengths, and the user may want to retry with another model. +- **Tell them the file path** — they'll need it for further edits, to include in other work, or to share. +- Include any text response the model provided (printed to stderr). +- If multiple images are returned, show all of them. +- For edit operations, mention the source image that was modified. From 6dbdb6e3e92aec9886612f77655e1604f238b267 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 10 Mar 2026 16:50:35 -0400 Subject: [PATCH 06/11] improve openrouter-images SKILL.md model guidance and presentation Replace generic "Using a Different Model" section with a Model Selection table showing when to pick different models. Add "why" explanations to Presenting Results and suggest iterative refinement workflow. --- skills/openrouter-images/SKILL.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/skills/openrouter-images/SKILL.md b/skills/openrouter-images/SKILL.md index f4295d3..28f5a1e 100644 --- a/skills/openrouter-images/SKILL.md +++ b/skills/openrouter-images/SKILL.md @@ -114,11 +114,20 @@ Better prompts produce better images. A few specifics go a long way: - **Be specific about composition** — what's in the foreground vs background, camera angle, framing ("close-up portrait", "wide aerial shot"). - **For edits, be precise** — say exactly what to change and what to preserve ("make the sky sunset-orange but keep the buildings unchanged"). -## Using a Different Model +## Model Selection -The default model is `google/gemini-3.1-flash-image-preview` (Nano Banana 2). To use a different model, pass `--model ` with any OpenRouter model ID that supports image output modalities. +The default model is `google/gemini-3.1-flash-image-preview` (Nano Banana 2) — it's fast, free-tier eligible, and handles most requests well. -To discover which models support image generation, use the `openrouter-models` skill to search by modality — it can filter and compare image-capable models by price, speed, and quality: +Pass `--model ` to use a different model. Choose based on what the user needs: + +| Need | Recommended approach | +|---|---| +| Quick drafts, iteration | Default model — fast turnaround for exploring ideas | +| Highest quality / artistic style | Try a dedicated image model (e.g. DALL-E, Stable Diffusion variants) | +| Photo-realistic edits | Gemini models handle edit instructions well since they understand both text and images natively | +| Budget-conscious | Stick with the default or check pricing via the `openrouter-models` skill | + +To discover all available image-generation models, use the `openrouter-models` skill: ```bash cd /scripts && bun run search-models.ts --modality image @@ -126,9 +135,10 @@ cd /scripts && bun run search-models.ts --modality ## Presenting Results -- **Display the saved image** to the user immediately — they need to evaluate quality and may want iterations. -- **Mention the model used** — different models have different strengths, and the user may want to retry with another model. +- **Display the saved image** to the user immediately — they need to see the result to decide if it's good or needs another iteration. +- **Mention the model used** — so the user can switch models if the style doesn't match what they wanted. - **Tell them the file path** — they'll need it for further edits, to include in other work, or to share. +- **Suggest refinements** — if the result isn't perfect, offer to tweak the prompt or try a different model. Image generation is inherently iterative. - Include any text response the model provided (printed to stderr). - If multiple images are returned, show all of them. - For edit operations, mention the source image that was modified. From 3f207377ab2edb83af8bd8602636207a8d5b895f Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 10 Mar 2026 16:30:35 -0400 Subject: [PATCH 07/11] docs(sdk): update SKILL.md event names to match current SDK --- skills/openrouter-typescript-sdk/SKILL.md | 108 +++++++++++++++++----- 1 file changed, 87 insertions(+), 21 deletions(-) diff --git a/skills/openrouter-typescript-sdk/SKILL.md b/skills/openrouter-typescript-sdk/SKILL.md index 1389155..580c8b0 100644 --- a/skills/openrouter-typescript-sdk/SKILL.md +++ b/skills/openrouter-typescript-sdk/SKILL.md @@ -883,48 +883,95 @@ The `getFullResponsesStream()` method yields these event types: type EnhancedResponseStreamEvent = | ResponseCreatedEvent | ResponseInProgressEvent + | ResponseCompletedEvent + | ResponseIncompleteEvent + | ResponseFailedEvent + | ErrorEvent + | OutputItemAddedEvent + | OutputItemDoneEvent + | ContentPartAddedEvent + | ContentPartDoneEvent | OutputTextDeltaEvent | OutputTextDoneEvent - | ReasoningDeltaEvent - | ReasoningDoneEvent + | OutputTextAnnotationAddedEvent + | RefusalDeltaEvent + | RefusalDoneEvent | FunctionCallArgumentsDeltaEvent | FunctionCallArgumentsDoneEvent - | ResponseCompletedEvent - | ToolPreliminaryResultEvent; + | ReasoningTextDeltaEvent + | ReasoningTextDoneEvent + | ReasoningSummaryPartAddedEvent + | ReasoningSummaryPartDoneEvent + | ReasoningSummaryTextDeltaEvent + | ReasoningSummaryTextDoneEvent + | ImageGenCallInProgressEvent + | ImageGenCallGeneratingEvent + | ImageGenCallPartialImageEvent + | ImageGenCallCompletedEvent + | ToolPreliminaryResultEvent + | ToolResultEvent; ``` ### Event Type Reference -| Event Type | Description | Payload | +| Event Type | Description | Key Payload Fields | |------------|-------------|---------| -| `response.created` | Response object initialized | `{ response: ResponseObject }` | -| `response.in_progress` | Generation has started | `{}` | -| `response.output_text.delta` | Text chunk received | `{ delta: string }` | -| `response.output_text.done` | Text generation complete | `{ text: string }` | -| `response.reasoning.delta` | Reasoning chunk (o1 models) | `{ delta: string }` | -| `response.reasoning.done` | Reasoning complete | `{ reasoning: string }` | -| `response.function_call_arguments.delta` | Tool argument chunk | `{ delta: string }` | -| `response.function_call_arguments.done` | Tool arguments complete | `{ arguments: string }` | -| `response.completed` | Full response complete | `{ response: ResponseObject }` | -| `tool.preliminary_result` | Generator tool progress | `{ toolCallId: string; result: unknown }` | +| `response.created` | Response object initialized | `response`, `sequenceNumber` | +| `response.in_progress` | Generation has started | `response`, `sequenceNumber` | +| `response.completed` | Full response complete | `response`, `sequenceNumber` | +| `response.incomplete` | Response incomplete | `response`, `sequenceNumber` | +| `response.failed` | Response generation failed | `response`, `sequenceNumber` | +| `error` | Streaming error occurred | `code`, `message`, `param`, `sequenceNumber` | +| `response.output_item.added` | New output item added | `outputIndex`, `item`, `sequenceNumber` | +| `response.output_item.done` | Output item complete | `outputIndex`, `item`, `sequenceNumber` | +| `response.content_part.added` | New content part added | `outputIndex`, `itemId`, `contentIndex`, `part`, `sequenceNumber` | +| `response.content_part.done` | Content part complete | `outputIndex`, `itemId`, `contentIndex`, `part`, `sequenceNumber` | +| `response.output_text.delta` | Text chunk received | `delta`, `outputIndex`, `itemId`, `contentIndex`, `logprobs`, `sequenceNumber` | +| `response.output_text.done` | Text generation complete | `text`, `outputIndex`, `itemId`, `contentIndex`, `logprobs`, `sequenceNumber` | +| `response.output_text.annotation.added` | Text annotation added | `outputIndex`, `itemId`, `contentIndex`, `annotationIndex`, `annotation`, `sequenceNumber` | +| `response.refusal.delta` | Refusal chunk streamed | `delta`, `outputIndex`, `itemId`, `contentIndex`, `sequenceNumber` | +| `response.refusal.done` | Refusal complete | `refusal`, `outputIndex`, `itemId`, `contentIndex`, `sequenceNumber` | +| `response.function_call_arguments.delta` | Tool argument chunk | `delta`, `itemId`, `outputIndex`, `sequenceNumber` | +| `response.function_call_arguments.done` | Tool arguments complete | `arguments`, `name`, `itemId`, `outputIndex`, `sequenceNumber` | +| `response.reasoning_text.delta` | Reasoning chunk (reasoning models) | `delta`, `outputIndex`, `itemId`, `contentIndex`, `sequenceNumber` | +| `response.reasoning_text.done` | Reasoning complete | `text`, `outputIndex`, `itemId`, `contentIndex`, `sequenceNumber` | +| `response.reasoning_summary_part.added` | Reasoning summary part added | `outputIndex`, `itemId`, `summaryIndex`, `part`, `sequenceNumber` | +| `response.reasoning_summary_part.done` | Reasoning summary part complete | `outputIndex`, `itemId`, `summaryIndex`, `part`, `sequenceNumber` | +| `response.reasoning_summary_text.delta` | Reasoning summary text chunk | `delta`, `itemId`, `outputIndex`, `summaryIndex`, `sequenceNumber` | +| `response.reasoning_summary_text.done` | Reasoning summary text complete | `text`, `itemId`, `outputIndex`, `summaryIndex`, `sequenceNumber` | +| `response.image_generation_call.in_progress` | Image generation started | `itemId`, `outputIndex`, `sequenceNumber` | +| `response.image_generation_call.generating` | Image generation in progress | `itemId`, `outputIndex`, `sequenceNumber` | +| `response.image_generation_call.partial_image` | Partial image available | `itemId`, `outputIndex`, `partialImageB64`, `partialImageIndex`, `sequenceNumber` | +| `response.image_generation_call.completed` | Image generation complete | `itemId`, `outputIndex`, `sequenceNumber` | +| `tool.preliminary_result` | Generator tool progress | `toolCallId`, `result`, `timestamp` | +| `tool.result` | Tool execution complete | `toolCallId`, `result`, `timestamp`, `preliminaryResults?` | ### Text Delta Event ```typescript interface OutputTextDeltaEvent { type: 'response.output_text.delta'; + logprobs: Array; + outputIndex: number; + itemId: string; + contentIndex: number; delta: string; + sequenceNumber: number; } ``` -### Reasoning Delta Event +### Reasoning Text Delta Event For reasoning models (o1, etc.): ```typescript -interface ReasoningDeltaEvent { - type: 'response.reasoning.delta'; +interface ReasoningTextDeltaEvent { + type: 'response.reasoning_text.delta'; + outputIndex: number; + itemId: string; + contentIndex: number; delta: string; + sequenceNumber: number; } ``` @@ -933,7 +980,10 @@ interface ReasoningDeltaEvent { ```typescript interface FunctionCallArgumentsDeltaEvent { type: 'response.function_call_arguments.delta'; + itemId: string; + outputIndex: number; delta: string; + sequenceNumber: number; } ``` @@ -942,10 +992,25 @@ interface FunctionCallArgumentsDeltaEvent { From generator tools that yield progress: ```typescript -interface ToolPreliminaryResultEvent { +interface ToolPreliminaryResultEvent { type: 'tool.preliminary_result'; toolCallId: string; - result: unknown; // Matches the tool's eventSchema + result: TEvent; // Matches the tool's eventSchema + timestamp: number; +} +``` + +### Tool Result Event + +Emitted when a tool execution completes: + +```typescript +interface ToolResultEvent { + type: 'tool.result'; + toolCallId: string; + result: TResult; + timestamp: number; + preliminaryResults?: TPreliminaryResults[]; } ``` @@ -955,6 +1020,7 @@ interface ToolPreliminaryResultEvent { interface ResponseCompletedEvent { type: 'response.completed'; response: OpenResponsesNonStreamingResponse; + sequenceNumber: number; } ``` @@ -983,7 +1049,7 @@ for await (const event of result.getFullResponsesStream()) { process.stdout.write(event.delta); break; - case 'response.reasoning.delta': + case 'response.reasoning_text.delta': console.log('[Reasoning]', event.delta); break; From 2cb12226fc84dab93207052a5ebbf92a7a12e085 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 10 Mar 2026 16:52:18 -0400 Subject: [PATCH 08/11] refactor: restructure openrouter-typescript-sdk SKILL.md for progressive disclosure Reduce SKILL.md from 1316 to 401 lines by extracting detailed content into reference files: - references/authentication.md: OAuth PKCE flow, API key management - references/event-shapes.md: full stream event types and interfaces - references/message-shapes.md: request/response type interfaces - references/advanced-patterns.md: format conversion, dynamic params, generator/manual tools Updated description to be more discoverable with trigger phrases. Added clear pointers in SKILL.md to each reference file. --- skills/openrouter-typescript-sdk/SKILL.md | 1096 ++--------------- .../references/advanced-patterns.md | 163 +++ .../references/authentication.md | 199 +++ .../references/event-shapes.md | 254 ++++ .../references/message-shapes.md | 211 ++++ 5 files changed, 918 insertions(+), 1005 deletions(-) create mode 100644 skills/openrouter-typescript-sdk/references/advanced-patterns.md create mode 100644 skills/openrouter-typescript-sdk/references/authentication.md create mode 100644 skills/openrouter-typescript-sdk/references/event-shapes.md create mode 100644 skills/openrouter-typescript-sdk/references/message-shapes.md diff --git a/skills/openrouter-typescript-sdk/SKILL.md b/skills/openrouter-typescript-sdk/SKILL.md index 580c8b0..3bc1221 100644 --- a/skills/openrouter-typescript-sdk/SKILL.md +++ b/skills/openrouter-typescript-sdk/SKILL.md @@ -1,23 +1,21 @@ --- name: openrouter-typescript-sdk -description: Complete reference for integrating with 300+ AI models through the OpenRouter TypeScript SDK using the callModel pattern +description: "Complete reference for integrating with 300+ AI models through the OpenRouter TypeScript SDK using the callModel pattern. Use when writing TypeScript or JavaScript code that calls AI models via OpenRouter, building agents with tool use, implementing streaming responses, handling multi-turn conversations, or setting up OAuth for OpenRouter. Also use when the user mentions @openrouter/sdk, callModel, OpenRouter client, or needs to integrate any LLM into a TypeScript project through OpenRouter's unified API — even if they don't explicitly ask for SDK documentation." version: 1.0.0 --- # OpenRouter TypeScript SDK -A comprehensive TypeScript SDK for interacting with OpenRouter's unified API, providing access to 300+ AI models through a single, type-safe interface. This skill enables AI agents to leverage the `callModel` pattern for text generation, tool usage, streaming, and multi-turn conversations. +A TypeScript SDK for interacting with OpenRouter's unified API, providing access to 300+ AI models through a single, type-safe interface using the `callModel` pattern. --- -## Installation +## Installation & Setup ```bash npm install @openrouter/sdk ``` -## Setup - Get your API key from [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys), then initialize: ```typescript @@ -28,228 +26,7 @@ const client = new OpenRouter({ }); ``` ---- - -## Authentication - -The SDK supports two authentication methods: API keys for server-side applications and OAuth PKCE flow for user-facing applications. - -### API Key Authentication - -The primary authentication method uses API keys from your OpenRouter account. - -#### Obtaining an API Key - -1. Visit [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) -2. Create a new API key -3. Store securely in an environment variable - -#### Environment Setup - -```bash -export OPENROUTER_API_KEY=sk-or-v1-your-key-here -``` - -#### Client Initialization - -```typescript -import OpenRouter from '@openrouter/sdk'; - -const client = new OpenRouter({ - apiKey: process.env.OPENROUTER_API_KEY -}); -``` - -The client automatically uses this key for all subsequent requests: - -```typescript -// API key is automatically included -const result = client.callModel({ - model: 'openai/gpt-5-nano', - input: 'Hello!' -}); -``` - -#### Get Current Key Metadata - -Retrieve information about the currently configured API key: - -```typescript -const keyInfo = await client.apiKeys.getCurrentKeyMetadata(); -console.log('Key name:', keyInfo.name); -console.log('Created:', keyInfo.createdAt); -``` - -#### API Key Management - -Programmatically manage API keys: - -```typescript -// List all keys -const keys = await client.apiKeys.list(); - -// Create a new key -const newKey = await client.apiKeys.create({ - name: 'Production API Key' -}); - -// Get a specific key by hash -const key = await client.apiKeys.get({ - hash: 'sk-or-v1-...' -}); - -// Update a key -await client.apiKeys.update({ - hash: 'sk-or-v1-...', - requestBody: { - name: 'Updated Key Name' - } -}); - -// Delete a key -await client.apiKeys.delete({ - hash: 'sk-or-v1-...' -}); -``` - -### OAuth Authentication (PKCE Flow) - -For user-facing applications where users should control their own API keys, OpenRouter supports OAuth with PKCE (Proof Key for Code Exchange). This flow allows users to generate API keys through a browser authorization flow without your application handling their credentials. - -#### createAuthCode - -Generate an authorization code and URL to start the OAuth flow: - -```typescript -const authResponse = await client.oAuth.createAuthCode({ - callbackUrl: 'https://myapp.com/auth/callback' -}); - -// authResponse contains: -// - authorizationUrl: URL to redirect the user to -// - code: The authorization code for later exchange - -console.log('Redirect user to:', authResponse.authorizationUrl); -``` - -**Parameters:** - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `callbackUrl` | `string` | Yes | Your application's callback URL after user authorization | - -**Browser Redirect:** - -```typescript -// In a browser environment -window.location.href = authResponse.authorizationUrl; - -// Or in a server-rendered app, return a redirect response -res.redirect(authResponse.authorizationUrl); -``` - -#### exchangeAuthCodeForAPIKey - -After the user authorizes your application, they are redirected back to your callback URL with an authorization code. Exchange this code for an API key: - -```typescript -// In your callback handler -const code = req.query.code; // From the redirect URL - -const apiKeyResponse = await client.oAuth.exchangeAuthCodeForAPIKey({ - code: code -}); - -// apiKeyResponse contains: -// - key: The user's API key -// - Additional metadata about the key - -const userApiKey = apiKeyResponse.key; - -// Store securely for this user's future requests -await saveUserApiKey(userId, userApiKey); -``` - -**Parameters:** - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `code` | `string` | Yes | The authorization code from the OAuth redirect | - -#### Complete OAuth Flow Example - -```typescript -import OpenRouter from '@openrouter/sdk'; -import express from 'express'; - -const app = express(); -const client = new OpenRouter({ - apiKey: process.env.OPENROUTER_API_KEY // Your app's key for OAuth operations -}); - -// Step 1: Initiate OAuth flow -app.get('/auth/start', async (req, res) => { - const authResponse = await client.oAuth.createAuthCode({ - callbackUrl: 'https://myapp.com/auth/callback' - }); - - // Store any state needed for the callback - req.session.oauthState = { /* ... */ }; - - // Redirect user to OpenRouter authorization page - res.redirect(authResponse.authorizationUrl); -}); - -// Step 2: Handle callback and exchange code -app.get('/auth/callback', async (req, res) => { - const { code } = req.query; - - if (!code) { - return res.status(400).send('Authorization code missing'); - } - - try { - const apiKeyResponse = await client.oAuth.exchangeAuthCodeForAPIKey({ - code: code as string - }); - - // Store the user's API key securely - await saveUserApiKey(req.session.userId, apiKeyResponse.key); - - res.redirect('/dashboard?auth=success'); - } catch (error) { - console.error('OAuth exchange failed:', error); - res.redirect('/auth/error'); - } -}); - -// Step 3: Use the user's API key for their requests -app.post('/api/chat', async (req, res) => { - const userApiKey = await getUserApiKey(req.session.userId); - - // Create a client with the user's key - const userClient = new OpenRouter({ - apiKey: userApiKey - }); - - const result = userClient.callModel({ - model: 'openai/gpt-5-nano', - input: req.body.message - }); - - const text = await result.getText(); - res.json({ response: text }); -}); -``` - -### Security Best Practices - -1. **Environment Variables**: Store API keys in environment variables, never in code -2. **Key Rotation**: Rotate keys periodically using the key management API -3. **Environment Separation**: Use different keys for development, staging, and production -4. **OAuth for Users**: Use the OAuth PKCE flow for user-facing apps to avoid handling user credentials -5. **Secure Storage**: Store user API keys encrypted in your database -6. **Minimal Scope**: Create keys with only the permissions needed +For the full OAuth PKCE flow, API key management, and security best practices, read `references/authentication.md`. --- @@ -257,8 +34,6 @@ app.post('/api/chat', async (req, res) => { The `callModel` function is the primary interface for text generation. It provides a unified, type-safe way to interact with any supported model. -### Basic Usage - ```typescript const result = client.callModel({ model: 'openai/gpt-5-nano', @@ -268,34 +43,18 @@ const result = client.callModel({ const text = await result.getText(); ``` -### Key Benefits - -- **Type-safe parameters** with full IDE autocomplete -- **Auto-generated from OpenAPI specs** - automatically updates with new models -- **Multiple consumption patterns** - text, streaming, structured data -- **Automatic tool execution** with multi-turn support - ---- - -## Input Formats +### Input Formats -The SDK accepts flexible input types for the `input` parameter: - -### String Input -A simple string becomes a user message: +**String input** — becomes a user message: ```typescript -const result = client.callModel({ - model: 'openai/gpt-5-nano', - input: 'Hello, how are you?' -}); +client.callModel({ model: 'openai/gpt-5-nano', input: 'Hello!' }); ``` -### Message Arrays -For multi-turn conversations: +**Message arrays** — for multi-turn conversations: ```typescript -const result = client.callModel({ +client.callModel({ model: 'openai/gpt-5-nano', input: [ { role: 'user', content: 'What is the capital of France?' }, @@ -305,40 +64,32 @@ const result = client.callModel({ }); ``` -### Multimodal Content -Including images and text: +**Multimodal content** — images and text: ```typescript -const result = client.callModel({ +client.callModel({ model: 'openai/gpt-5-nano', - input: [ - { - role: 'user', - content: [ - { type: 'text', text: 'What is in this image?' }, - { type: 'image_url', image_url: { url: 'https://example.com/image.png' } } - ] - } - ] + input: [{ + role: 'user', + content: [ + { type: 'text', text: 'What is in this image?' }, + { type: 'image_url', image_url: { url: 'https://example.com/image.png' } } + ] + }] }); ``` -### System Instructions -Use the `instructions` parameter for system-level guidance: +**System instructions** — via the `instructions` parameter: ```typescript -const result = client.callModel({ +client.callModel({ model: 'openai/gpt-5-nano', instructions: 'You are a helpful coding assistant. Be concise.', input: 'How do I reverse a string in Python?' }); ``` ---- - -## Response Methods - -The result object provides multiple methods for consuming the response: +### Response Methods | Method | Purpose | |--------|---------| @@ -348,44 +99,22 @@ The result object provides multiple methods for consuming the response: | `getReasoningStream()` | Stream reasoning tokens (for o1/reasoning models) | | `getToolCallsStream()` | Stream tool calls as they complete | -### getText() - ```typescript -const result = client.callModel({ - model: 'openai/gpt-5-nano', - input: 'Write a haiku about coding' -}); - +// Get text const text = await result.getText(); -console.log(text); -``` - -### getResponse() - -```typescript -const result = client.callModel({ - model: 'openai/gpt-5-nano', - input: 'Hello!' -}); +// Get full response with usage const response = await result.getResponse(); -console.log('Text:', response.text); -console.log('Token usage:', response.usage); -``` - -### getTextStream() - -```typescript -const result = client.callModel({ - model: 'openai/gpt-5-nano', - input: 'Write a short story' -}); +console.log('Tokens:', response.usage); +// Stream text for await (const delta of result.getTextStream()) { process.stdout.write(delta); } ``` +For detailed message/response type interfaces, read `references/message-shapes.md`. + --- ## Tool System @@ -411,12 +140,7 @@ const weatherTool = tool({ humidity: z.number() }), execute: async (params) => { - // Implement weather fetching logic - return { - temperature: 22, - conditions: 'Sunny', - humidity: 45 - }; + return { temperature: 22, conditions: 'Sunny', humidity: 45 }; } }); ``` @@ -434,60 +158,11 @@ const text = await result.getText(); // The SDK automatically executes the tool and continues the conversation ``` -### Tool Types - -#### Regular Tools -Standard execute functions that return a result: - -```typescript -const calculatorTool = tool({ - name: 'calculate', - description: 'Perform mathematical calculations', - inputSchema: z.object({ - expression: z.string() - }), - execute: async ({ expression }) => { - return { result: eval(expression) }; - } -}); -``` - -#### Generator Tools -Yield progress events using `eventSchema`: - -```typescript -const searchTool = tool({ - name: 'web_search', - description: 'Search the web', - inputSchema: z.object({ query: z.string() }), - eventSchema: z.object({ - type: z.literal('progress'), - message: z.string() - }), - outputSchema: z.object({ results: z.array(z.string()) }), - execute: async function* ({ query }) { - yield { type: 'progress', message: 'Searching...' }; - yield { type: 'progress', message: 'Processing results...' }; - return { results: ['Result 1', 'Result 2'] }; - } -}); -``` - -#### Manual Tools -Set `execute: false` to handle tool calls yourself: - -```typescript -const manualTool = tool({ - name: 'user_confirmation', - description: 'Request user confirmation', - inputSchema: z.object({ message: z.string() }), - execute: false -}); -``` +For generator tools (yielding progress events) and manual tools (`execute: false`), read `references/advanced-patterns.md`. --- -## Multi-Turn Conversations with Stop Conditions +## Multi-Turn with Stop Conditions Control automatic tool execution with stop conditions: @@ -506,102 +181,13 @@ const result = client.callModel({ }); ``` -### Available Stop Conditions - | Condition | Description | |-----------|-------------| | `stepCountIs(n)` | Stop after n turns | | `maxCost(amount)` | Stop when cost exceeds amount | | `hasToolCall(name)` | Stop when specific tool is called | -### Custom Stop Conditions - -```typescript -const customStop = (context) => { - return context.messages.length > 20; -}; - -const result = client.callModel({ - model: 'openai/gpt-5-nano', - input: 'Complex task', - tools: [myTool], - stopWhen: customStop -}); -``` - ---- - -## Dynamic Parameters - -Compute parameters based on conversation context: - -```typescript -const result = client.callModel({ - model: (ctx) => ctx.numberOfTurns > 3 ? 'openai/gpt-4' : 'openai/gpt-4o-mini', - temperature: (ctx) => ctx.numberOfTurns > 1 ? 0.3 : 0.7, - input: 'Hello!' -}); -``` - -### Context Object Properties - -| Property | Type | Description | -|----------|------|-------------| -| `numberOfTurns` | number | Current turn count | -| `messages` | array | All messages so far | -| `instructions` | string | Current system instructions | -| `totalCost` | number | Accumulated cost | - ---- - -## nextTurnParams: Context Injection - -Tools can modify parameters for subsequent turns, enabling skills and context-aware behavior: - -```typescript -const skillTool = tool({ - name: 'load_skill', - description: 'Load a specialized skill', - inputSchema: z.object({ - skill: z.string().describe('Name of the skill to load') - }), - nextTurnParams: { - instructions: (params, context) => { - const skillInstructions = loadSkillInstructions(params.skill); - return `${context.instructions}\n\n${skillInstructions}`; - } - }, - execute: async ({ skill }) => { - return { loaded: skill }; - } -}); -``` - -### Use Cases for nextTurnParams - -- **Skill Systems**: Dynamically load specialized capabilities -- **Context Accumulation**: Build up context over multiple turns -- **Mode Switching**: Change model behavior mid-conversation -- **Memory Injection**: Add retrieved context to instructions - ---- - -## Generation Parameters - -Control model behavior with these parameters: - -```typescript -const result = client.callModel({ - model: 'openai/gpt-5-nano', - input: 'Write a creative story', - temperature: 0.7, // Creativity (0-2, default varies by model) - maxOutputTokens: 1000, // Maximum tokens to generate - topP: 0.9, // Nucleus sampling parameter - frequencyPenalty: 0.5, // Reduce repetition - presencePenalty: 0.5, // Encourage new topics - stop: ['\n\n'] // Stop sequences -}); -``` +For custom stop conditions, dynamic parameters, and nextTurnParams context injection, read `references/advanced-patterns.md`. --- @@ -615,503 +201,64 @@ const result = client.callModel({ input: 'Write a detailed explanation' }); -// Consumer 1: Stream text to console -const textPromise = (async () => { - for await (const delta of result.getTextStream()) { - process.stdout.write(delta); - } -})(); - -// Consumer 2: Get full response simultaneously -const responsePromise = result.getResponse(); - -// Both run concurrently -const [, response] = await Promise.all([textPromise, responsePromise]); -console.log('\n\nTotal tokens:', response.usage.totalTokens); +// Stream text to console +for await (const delta of result.getTextStream()) { + process.stdout.write(delta); +} ``` ### Streaming Tool Calls ```typescript -const result = client.callModel({ - model: 'openai/gpt-5-nano', - input: 'Search for information about TypeScript', - tools: [searchTool] -}); - for await (const toolCall of result.getToolCallsStream()) { console.log(`Tool called: ${toolCall.name}`); - console.log(`Arguments: ${JSON.stringify(toolCall.arguments)}`); console.log(`Result: ${JSON.stringify(toolCall.result)}`); } ``` ---- - -## Format Conversion - -Convert between ecosystem formats for interoperability: - -### OpenAI Format - -```typescript -import { fromChatMessages, toChatMessage } from '@openrouter/sdk'; - -// OpenAI messages → OpenRouter format -const result = client.callModel({ - model: 'openai/gpt-5-nano', - input: fromChatMessages(openaiMessages) -}); - -// Response → OpenAI chat message format -const response = await result.getResponse(); -const chatMsg = toChatMessage(response); -``` - -### Claude Format - -```typescript -import { fromClaudeMessages, toClaudeMessage } from '@openrouter/sdk'; - -// Claude messages → OpenRouter format -const result = client.callModel({ - model: 'anthropic/claude-3-opus', - input: fromClaudeMessages(claudeMessages) -}); - -// Response → Claude message format -const response = await result.getResponse(); -const claudeMsg = toClaudeMessage(response); -``` - ---- - -## Responses API Message Shapes - -The SDK uses the **OpenResponses** format for messages. Understanding these shapes is essential for building robust agents. - -### Message Roles - -Messages contain a `role` property that determines the message type: - -| Role | Description | -|------|-------------| -| `user` | User-provided input | -| `assistant` | Model-generated responses | -| `system` | System instructions | -| `developer` | Developer-level directives | -| `tool` | Tool execution results | - -### Text Message - -Simple text content from user or assistant: - -```typescript -interface TextMessage { - role: 'user' | 'assistant'; - content: string; -} -``` - -### Multimodal Message (Array Content) - -Messages with mixed content types: - -```typescript -interface MultimodalMessage { - role: 'user'; - content: Array< - | { type: 'input_text'; text: string } - | { type: 'input_image'; imageUrl: string; detail?: 'auto' | 'low' | 'high' } - | { - type: 'image'; - source: { - type: 'url' | 'base64'; - url?: string; - media_type?: string; - data?: string - } - } - >; -} -``` - -### Tool Function Call Message - -When the model requests a tool execution: +### Concurrent Consumers ```typescript -interface ToolCallMessage { - role: 'assistant'; - content?: null; - tool_calls?: Array<{ - id: string; - type: 'function'; - function: { - name: string; - arguments: string; // JSON-encoded arguments - }; - }>; -} -``` - -### Tool Result Message - -Result returned after tool execution: - -```typescript -interface ToolResultMessage { - role: 'tool'; - tool_call_id: string; - content: string; // JSON-encoded result -} -``` - -### Non-Streaming Response Structure - -The complete response object from `getResponse()`: - -```typescript -interface OpenResponsesNonStreamingResponse { - output: Array; - usage?: { - inputTokens: number; - outputTokens: number; - cachedTokens?: number; - }; - finishReason?: string; - warnings?: Array<{ - type: string; - message: string - }>; - experimental_providerMetadata?: Record; -} -``` - -### Response Message Types - -Output messages in the response array: - -```typescript -// Text/content message -interface ResponseOutputMessage { - type: 'message'; - role: 'assistant'; - content: string | Array; - reasoning?: string; // For reasoning models (o1, etc.) -} - -// Tool result in output -interface FunctionCallOutputMessage { - type: 'function_call_output'; - call_id: string; - output: string; -} -``` - -### Parsed Tool Call - -When tool calls are parsed from the response: - -```typescript -interface ParsedToolCall { - id: string; - name: string; - arguments: unknown; // Validated against inputSchema -} -``` - -### Tool Execution Result - -After a tool completes execution: - -```typescript -interface ToolExecutionResult { - toolCallId: string; - toolName: string; - result: unknown; // Validated against outputSchema - preliminaryResults?: unknown[]; // From generator tools - error?: Error; -} -``` - -### Step Result (for Stop Conditions) +const textPromise = (async () => { + for await (const delta of result.getTextStream()) { + process.stdout.write(delta); + } +})(); -Available in custom stop condition callbacks: +const responsePromise = result.getResponse(); -```typescript -interface StepResult { - stepType: 'initial' | 'continue'; - text: string; - toolCalls: ParsedToolCall[]; - toolResults: ToolExecutionResult[]; - response: OpenResponsesNonStreamingResponse; - usage?: { - inputTokens: number; - outputTokens: number; - cachedTokens?: number; - }; - finishReason?: string; - warnings?: Array<{ type: string; message: string }>; - experimental_providerMetadata?: Record; -} +const [, response] = await Promise.all([textPromise, responsePromise]); +console.log('Total tokens:', response.usage.totalTokens); ``` -### TurnContext - -Available to tools and dynamic parameter functions: - -```typescript -interface TurnContext { - numberOfTurns: number; // Turn count (1-indexed) - turnRequest?: OpenResponsesRequest; // Current request being made - toolCall?: OpenResponsesFunctionToolCall; // Current tool call (in tool context) -} -``` +For the full `EnhancedResponseStreamEvent` type union, individual event interfaces, and raw stream processing with `getFullResponsesStream()`, read `references/event-shapes.md`. --- -## Event Shapes - -The SDK provides multiple streaming methods that yield different event types. - -### Response Stream Events - -The `getFullResponsesStream()` method yields these event types: - -```typescript -type EnhancedResponseStreamEvent = - | ResponseCreatedEvent - | ResponseInProgressEvent - | ResponseCompletedEvent - | ResponseIncompleteEvent - | ResponseFailedEvent - | ErrorEvent - | OutputItemAddedEvent - | OutputItemDoneEvent - | ContentPartAddedEvent - | ContentPartDoneEvent - | OutputTextDeltaEvent - | OutputTextDoneEvent - | OutputTextAnnotationAddedEvent - | RefusalDeltaEvent - | RefusalDoneEvent - | FunctionCallArgumentsDeltaEvent - | FunctionCallArgumentsDoneEvent - | ReasoningTextDeltaEvent - | ReasoningTextDoneEvent - | ReasoningSummaryPartAddedEvent - | ReasoningSummaryPartDoneEvent - | ReasoningSummaryTextDeltaEvent - | ReasoningSummaryTextDoneEvent - | ImageGenCallInProgressEvent - | ImageGenCallGeneratingEvent - | ImageGenCallPartialImageEvent - | ImageGenCallCompletedEvent - | ToolPreliminaryResultEvent - | ToolResultEvent; -``` - -### Event Type Reference - -| Event Type | Description | Key Payload Fields | -|------------|-------------|---------| -| `response.created` | Response object initialized | `response`, `sequenceNumber` | -| `response.in_progress` | Generation has started | `response`, `sequenceNumber` | -| `response.completed` | Full response complete | `response`, `sequenceNumber` | -| `response.incomplete` | Response incomplete | `response`, `sequenceNumber` | -| `response.failed` | Response generation failed | `response`, `sequenceNumber` | -| `error` | Streaming error occurred | `code`, `message`, `param`, `sequenceNumber` | -| `response.output_item.added` | New output item added | `outputIndex`, `item`, `sequenceNumber` | -| `response.output_item.done` | Output item complete | `outputIndex`, `item`, `sequenceNumber` | -| `response.content_part.added` | New content part added | `outputIndex`, `itemId`, `contentIndex`, `part`, `sequenceNumber` | -| `response.content_part.done` | Content part complete | `outputIndex`, `itemId`, `contentIndex`, `part`, `sequenceNumber` | -| `response.output_text.delta` | Text chunk received | `delta`, `outputIndex`, `itemId`, `contentIndex`, `logprobs`, `sequenceNumber` | -| `response.output_text.done` | Text generation complete | `text`, `outputIndex`, `itemId`, `contentIndex`, `logprobs`, `sequenceNumber` | -| `response.output_text.annotation.added` | Text annotation added | `outputIndex`, `itemId`, `contentIndex`, `annotationIndex`, `annotation`, `sequenceNumber` | -| `response.refusal.delta` | Refusal chunk streamed | `delta`, `outputIndex`, `itemId`, `contentIndex`, `sequenceNumber` | -| `response.refusal.done` | Refusal complete | `refusal`, `outputIndex`, `itemId`, `contentIndex`, `sequenceNumber` | -| `response.function_call_arguments.delta` | Tool argument chunk | `delta`, `itemId`, `outputIndex`, `sequenceNumber` | -| `response.function_call_arguments.done` | Tool arguments complete | `arguments`, `name`, `itemId`, `outputIndex`, `sequenceNumber` | -| `response.reasoning_text.delta` | Reasoning chunk (reasoning models) | `delta`, `outputIndex`, `itemId`, `contentIndex`, `sequenceNumber` | -| `response.reasoning_text.done` | Reasoning complete | `text`, `outputIndex`, `itemId`, `contentIndex`, `sequenceNumber` | -| `response.reasoning_summary_part.added` | Reasoning summary part added | `outputIndex`, `itemId`, `summaryIndex`, `part`, `sequenceNumber` | -| `response.reasoning_summary_part.done` | Reasoning summary part complete | `outputIndex`, `itemId`, `summaryIndex`, `part`, `sequenceNumber` | -| `response.reasoning_summary_text.delta` | Reasoning summary text chunk | `delta`, `itemId`, `outputIndex`, `summaryIndex`, `sequenceNumber` | -| `response.reasoning_summary_text.done` | Reasoning summary text complete | `text`, `itemId`, `outputIndex`, `summaryIndex`, `sequenceNumber` | -| `response.image_generation_call.in_progress` | Image generation started | `itemId`, `outputIndex`, `sequenceNumber` | -| `response.image_generation_call.generating` | Image generation in progress | `itemId`, `outputIndex`, `sequenceNumber` | -| `response.image_generation_call.partial_image` | Partial image available | `itemId`, `outputIndex`, `partialImageB64`, `partialImageIndex`, `sequenceNumber` | -| `response.image_generation_call.completed` | Image generation complete | `itemId`, `outputIndex`, `sequenceNumber` | -| `tool.preliminary_result` | Generator tool progress | `toolCallId`, `result`, `timestamp` | -| `tool.result` | Tool execution complete | `toolCallId`, `result`, `timestamp`, `preliminaryResults?` | - -### Text Delta Event - -```typescript -interface OutputTextDeltaEvent { - type: 'response.output_text.delta'; - logprobs: Array; - outputIndex: number; - itemId: string; - contentIndex: number; - delta: string; - sequenceNumber: number; -} -``` - -### Reasoning Text Delta Event - -For reasoning models (o1, etc.): - -```typescript -interface ReasoningTextDeltaEvent { - type: 'response.reasoning_text.delta'; - outputIndex: number; - itemId: string; - contentIndex: number; - delta: string; - sequenceNumber: number; -} -``` - -### Function Call Arguments Delta Event - -```typescript -interface FunctionCallArgumentsDeltaEvent { - type: 'response.function_call_arguments.delta'; - itemId: string; - outputIndex: number; - delta: string; - sequenceNumber: number; -} -``` - -### Tool Preliminary Result Event - -From generator tools that yield progress: - -```typescript -interface ToolPreliminaryResultEvent { - type: 'tool.preliminary_result'; - toolCallId: string; - result: TEvent; // Matches the tool's eventSchema - timestamp: number; -} -``` - -### Tool Result Event - -Emitted when a tool execution completes: - -```typescript -interface ToolResultEvent { - type: 'tool.result'; - toolCallId: string; - result: TResult; - timestamp: number; - preliminaryResults?: TPreliminaryResults[]; -} -``` - -### Response Completed Event - -```typescript -interface ResponseCompletedEvent { - type: 'response.completed'; - response: OpenResponsesNonStreamingResponse; - sequenceNumber: number; -} -``` - -### Tool Stream Events - -The `getToolStream()` method yields: - -```typescript -type ToolStreamEvent = - | { type: 'delta'; content: string } - | { type: 'preliminary_result'; toolCallId: string; result: unknown }; -``` - -### Example: Processing Stream Events - -```typescript -const result = client.callModel({ - model: 'openai/gpt-5-nano', - input: 'Analyze this data', - tools: [analysisTool] -}); - -for await (const event of result.getFullResponsesStream()) { - switch (event.type) { - case 'response.output_text.delta': - process.stdout.write(event.delta); - break; - - case 'response.reasoning_text.delta': - console.log('[Reasoning]', event.delta); - break; - - case 'response.function_call_arguments.delta': - console.log('[Tool Args]', event.delta); - break; - - case 'tool.preliminary_result': - console.log(`[Progress: ${event.toolCallId}]`, event.result); - break; - - case 'response.completed': - console.log('\n[Complete]', event.response.usage); - break; - } -} -``` - -### Message Stream Events - -The `getNewMessagesStream()` yields OpenResponses format updates: - -```typescript -type MessageStreamUpdate = - | ResponsesOutputMessage // Text/content updates - | OpenResponsesFunctionCallOutput; // Tool results -``` - -### Example: Tracking New Messages +## Generation Parameters ```typescript const result = client.callModel({ model: 'openai/gpt-5-nano', - input: 'Research this topic', - tools: [searchTool] + input: 'Write a creative story', + temperature: 0.7, // Creativity (0-2, default varies by model) + maxOutputTokens: 1000, // Maximum tokens to generate + topP: 0.9, // Nucleus sampling parameter + frequencyPenalty: 0.5, // Reduce repetition + presencePenalty: 0.5, // Encourage new topics + stop: ['\n\n'] // Stop sequences }); - -const allMessages: MessageStreamUpdate[] = []; - -for await (const message of result.getNewMessagesStream()) { - allMessages.push(message); - - if (message.type === 'message') { - console.log('Assistant:', message.content); - } else if (message.type === 'function_call_output') { - console.log('Tool result:', message.output); - } -} ``` --- ## API Reference -### Client Methods - -Beyond `callModel`, the client provides access to other API endpoints: - ```typescript -const client = new OpenRouter({ - apiKey: process.env.OPENROUTER_API_KEY -}); +const client = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }); + +// Primary interface +client.callModel({ model, input, tools?, stopWhen?, ...params }); // List available models const models = await client.models.list(); @@ -1122,8 +269,8 @@ const completion = await client.chat.send({ messages: [{ role: 'user', content: 'Hello!' }] }); -// Legacy completions format -const legacyCompletion = await client.completions.generate({ +// Legacy completions +const legacy = await client.completions.generate({ model: 'openai/gpt-5-nano', prompt: 'Once upon a time' }); @@ -1136,38 +283,34 @@ const credits = await client.credits.getCredits(); // API key management const keys = await client.apiKeys.list(); + +// OAuth +const auth = await client.oAuth.createAuthCode({ callbackUrl: '...' }); ``` --- ## Error Handling -The SDK provides specific error types with actionable messages: - ```typescript try { - const result = await client.callModel({ + const text = await client.callModel({ model: 'openai/gpt-5-nano', input: 'Hello!' - }); - const text = await result.getText(); + }).getText(); } catch (error) { if (error.statusCode === 401) { - console.error('Invalid API key - check your OPENROUTER_API_KEY'); + console.error('Invalid API key'); } else if (error.statusCode === 402) { - console.error('Insufficient credits - add credits at openrouter.ai'); + console.error('Insufficient credits'); } else if (error.statusCode === 429) { - console.error('Rate limited - implement backoff retry'); + console.error('Rate limited - implement backoff'); } else if (error.statusCode === 503) { - console.error('Model temporarily unavailable - try again or use fallback'); - } else { - console.error('Unexpected error:', error.message); + console.error('Model unavailable - try fallback'); } } ``` -### Error Status Codes - | Code | Meaning | Action | |------|---------|--------| | 400 | Bad request | Check request parameters | @@ -1182,60 +325,41 @@ try { ## Complete Example: Agent with Tools ```typescript -import OpenRouter, { tool, stepCountIs } from '@openrouter/sdk'; +import OpenRouter, { tool, stepCountIs, hasToolCall } from '@openrouter/sdk'; import { z } from 'zod'; -const client = new OpenRouter({ - apiKey: process.env.OPENROUTER_API_KEY -}); +const client = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }); -// Define tools const searchTool = tool({ name: 'web_search', description: 'Search the web for information', - inputSchema: z.object({ - query: z.string().describe('Search query') - }), + inputSchema: z.object({ query: z.string().describe('Search query') }), outputSchema: z.object({ results: z.array(z.object({ - title: z.string(), - snippet: z.string(), - url: z.string() + title: z.string(), snippet: z.string(), url: z.string() })) }), - execute: async ({ query }) => { - // Implement actual search - return { - results: [ - { title: 'Example', snippet: 'Example result', url: 'https://example.com' } - ] - }; - } + execute: async ({ query }) => ({ + results: [{ title: 'Example', snippet: 'Result', url: 'https://example.com' }] + }) }); const finishTool = tool({ name: 'finish', description: 'Complete the task with final answer', - inputSchema: z.object({ - answer: z.string().describe('The final answer') - }), + inputSchema: z.object({ answer: z.string() }), execute: async ({ answer }) => ({ answer }) }); -// Run agent async function runAgent(task: string) { const result = client.callModel({ model: 'openai/gpt-5-nano', - instructions: 'You are a helpful research assistant. Use web_search to find information, then use finish to provide your final answer.', + instructions: 'Use web_search to find information, then use finish to provide your final answer.', input: task, tools: [searchTool, finishTool], - stopWhen: [ - stepCountIs(10), - hasToolCall('finish') - ] + stopWhen: [stepCountIs(10), hasToolCall('finish')] }); - // Stream progress for await (const toolCall of result.getToolCallsStream()) { console.log(`[${toolCall.name}] ${JSON.stringify(toolCall.arguments)}`); } @@ -1243,7 +367,6 @@ async function runAgent(task: string) { return await result.getText(); } -// Usage const answer = await runAgent('What are the latest developments in quantum computing?'); console.log('Final answer:', answer); ``` @@ -1252,55 +375,22 @@ console.log('Final answer:', answer); ## Best Practices -### 1. Prefer callModel Over Direct API Calls -The `callModel` pattern provides automatic tool execution, type safety, and multi-turn handling. - -### 2. Use Zod for Tool Schemas -Zod provides runtime validation and excellent TypeScript inference: - -```typescript -import { z } from 'zod'; - -const schema = z.object({ - name: z.string().min(1), - age: z.number().int().positive() -}); -``` - -### 3. Implement Stop Conditions -Always set reasonable limits to prevent runaway costs: - -```typescript -stopWhen: [stepCountIs(20), maxCost(5.00)] -``` - -### 4. Handle Errors Gracefully -Implement retry logic for transient failures: +1. **Prefer callModel** over direct API calls for automatic tool execution, type safety, and multi-turn handling +2. **Use Zod for tool schemas** for runtime validation and TypeScript inference +3. **Set stop conditions** to prevent runaway costs: `stopWhen: [stepCountIs(20), maxCost(5.00)]` +4. **Use streaming** for long responses for better UX and early termination +5. **Handle errors** with retry logic for transient failures (429, 5xx) -```typescript -async function callWithRetry(params, maxRetries = 3) { - for (let i = 0; i < maxRetries; i++) { - try { - return await client.callModel(params).getText(); - } catch (error) { - if (error.statusCode === 429 || error.statusCode >= 500) { - await sleep(Math.pow(2, i) * 1000); - continue; - } - throw error; - } - } -} -``` +--- -### 5. Use Streaming for Long Responses -Streaming provides better UX and allows early termination: +## Reference Files -```typescript -for await (const delta of result.getTextStream()) { - // Process incrementally -} -``` +| Reference | When to read | +|-----------|-------------| +| `references/authentication.md` | OAuth PKCE flow, API key management, security best practices | +| `references/event-shapes.md` | Full stream event type union, individual event interfaces, raw stream processing | +| `references/message-shapes.md` | Message/response type interfaces, StepResult, TurnContext, tool call parsing | +| `references/advanced-patterns.md` | Format conversion (OpenAI/Claude), dynamic parameters, nextTurnParams, generator/manual tools, custom stop conditions | --- @@ -1309,7 +399,3 @@ for await (const delta of result.getTextStream()) { - **API Keys**: [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) - **Model List**: [openrouter.ai/models](https://openrouter.ai/models) - **GitHub Issues**: [github.com/OpenRouterTeam/typescript-sdk/issues](https://github.com/OpenRouterTeam/typescript-sdk/issues) - ---- - -*SDK Status: Beta - Report issues on GitHub* diff --git a/skills/openrouter-typescript-sdk/references/advanced-patterns.md b/skills/openrouter-typescript-sdk/references/advanced-patterns.md new file mode 100644 index 0000000..529ec3d --- /dev/null +++ b/skills/openrouter-typescript-sdk/references/advanced-patterns.md @@ -0,0 +1,163 @@ +# Advanced Patterns Reference + +> Read this when you need format conversion between OpenAI/Claude message formats, dynamic parameters based on conversation context, nextTurnParams for context injection, detailed stop condition customization, or advanced tool types (generator/manual). + +## Table of Contents + +- [Format Conversion](#format-conversion) +- [Dynamic Parameters](#dynamic-parameters) +- [nextTurnParams: Context Injection](#nextturnparams-context-injection) +- [Custom Stop Conditions](#custom-stop-conditions) +- [Generator Tools](#generator-tools) +- [Manual Tools](#manual-tools) + +--- + +## Format Conversion + +Convert between ecosystem formats for interoperability: + +### OpenAI Format + +```typescript +import { fromChatMessages, toChatMessage } from '@openrouter/sdk'; + +// OpenAI messages -> OpenRouter format +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: fromChatMessages(openaiMessages) +}); + +// Response -> OpenAI chat message format +const response = await result.getResponse(); +const chatMsg = toChatMessage(response); +``` + +### Claude Format + +```typescript +import { fromClaudeMessages, toClaudeMessage } from '@openrouter/sdk'; + +// Claude messages -> OpenRouter format +const result = client.callModel({ + model: 'anthropic/claude-3-opus', + input: fromClaudeMessages(claudeMessages) +}); + +// Response -> Claude message format +const response = await result.getResponse(); +const claudeMsg = toClaudeMessage(response); +``` + +--- + +## Dynamic Parameters + +Compute parameters based on conversation context: + +```typescript +const result = client.callModel({ + model: (ctx) => ctx.numberOfTurns > 3 ? 'openai/gpt-4' : 'openai/gpt-4o-mini', + temperature: (ctx) => ctx.numberOfTurns > 1 ? 0.3 : 0.7, + input: 'Hello!' +}); +``` + +### Context Object Properties + +| Property | Type | Description | +|----------|------|-------------| +| `numberOfTurns` | number | Current turn count | +| `messages` | array | All messages so far | +| `instructions` | string | Current system instructions | +| `totalCost` | number | Accumulated cost | + +--- + +## nextTurnParams: Context Injection + +Tools can modify parameters for subsequent turns, enabling skills and context-aware behavior: + +```typescript +const skillTool = tool({ + name: 'load_skill', + description: 'Load a specialized skill', + inputSchema: z.object({ + skill: z.string().describe('Name of the skill to load') + }), + nextTurnParams: { + instructions: (params, context) => { + const skillInstructions = loadSkillInstructions(params.skill); + return `${context.instructions}\n\n${skillInstructions}`; + } + }, + execute: async ({ skill }) => { + return { loaded: skill }; + } +}); +``` + +### Use Cases for nextTurnParams + +- **Skill Systems**: Dynamically load specialized capabilities +- **Context Accumulation**: Build up context over multiple turns +- **Mode Switching**: Change model behavior mid-conversation +- **Memory Injection**: Add retrieved context to instructions + +--- + +## Custom Stop Conditions + +Beyond the built-in `stepCountIs`, `maxCost`, and `hasToolCall`, you can create custom stop conditions: + +```typescript +const customStop = (context) => { + return context.messages.length > 20; +}; + +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: 'Complex task', + tools: [myTool], + stopWhen: customStop +}); +``` + +--- + +## Generator Tools + +Generator tools yield progress events using `eventSchema`: + +```typescript +const searchTool = tool({ + name: 'web_search', + description: 'Search the web', + inputSchema: z.object({ query: z.string() }), + eventSchema: z.object({ + type: z.literal('progress'), + message: z.string() + }), + outputSchema: z.object({ results: z.array(z.string()) }), + execute: async function* ({ query }) { + yield { type: 'progress', message: 'Searching...' }; + yield { type: 'progress', message: 'Processing results...' }; + return { results: ['Result 1', 'Result 2'] }; + } +}); +``` + +--- + +## Manual Tools + +Set `execute: false` to handle tool calls yourself. Useful for user confirmations or external workflows: + +```typescript +const manualTool = tool({ + name: 'user_confirmation', + description: 'Request user confirmation', + inputSchema: z.object({ message: z.string() }), + execute: false +}); +``` diff --git a/skills/openrouter-typescript-sdk/references/authentication.md b/skills/openrouter-typescript-sdk/references/authentication.md new file mode 100644 index 0000000..ae95b40 --- /dev/null +++ b/skills/openrouter-typescript-sdk/references/authentication.md @@ -0,0 +1,199 @@ +# Authentication Reference + +> Read this when you need to implement OAuth PKCE flow for user-facing apps, manage API keys programmatically, or review security best practices. + +## Table of Contents + +- [API Key Management](#api-key-management) +- [OAuth Authentication (PKCE Flow)](#oauth-authentication-pkce-flow) +- [Complete OAuth Flow Example](#complete-oauth-flow-example) +- [Security Best Practices](#security-best-practices) + +--- + +## API Key Management + +### Get Current Key Metadata + +Retrieve information about the currently configured API key: + +```typescript +const keyInfo = await client.apiKeys.getCurrentKeyMetadata(); +console.log('Key name:', keyInfo.name); +console.log('Created:', keyInfo.createdAt); +``` + +### Programmatic Key Management + +```typescript +// List all keys +const keys = await client.apiKeys.list(); + +// Create a new key +const newKey = await client.apiKeys.create({ + name: 'Production API Key' +}); + +// Get a specific key by hash +const key = await client.apiKeys.get({ + hash: 'sk-or-v1-...' +}); + +// Update a key +await client.apiKeys.update({ + hash: 'sk-or-v1-...', + requestBody: { + name: 'Updated Key Name' + } +}); + +// Delete a key +await client.apiKeys.delete({ + hash: 'sk-or-v1-...' +}); +``` + +--- + +## OAuth Authentication (PKCE Flow) + +For user-facing applications where users should control their own API keys, OpenRouter supports OAuth with PKCE (Proof Key for Code Exchange). This flow allows users to generate API keys through a browser authorization flow without your application handling their credentials. + +### createAuthCode + +Generate an authorization code and URL to start the OAuth flow: + +```typescript +const authResponse = await client.oAuth.createAuthCode({ + callbackUrl: 'https://myapp.com/auth/callback' +}); + +// authResponse contains: +// - authorizationUrl: URL to redirect the user to +// - code: The authorization code for later exchange + +console.log('Redirect user to:', authResponse.authorizationUrl); +``` + +**Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `callbackUrl` | `string` | Yes | Your application's callback URL after user authorization | + +**Browser Redirect:** + +```typescript +// In a browser environment +window.location.href = authResponse.authorizationUrl; + +// Or in a server-rendered app, return a redirect response +res.redirect(authResponse.authorizationUrl); +``` + +### exchangeAuthCodeForAPIKey + +After the user authorizes your application, they are redirected back to your callback URL with an authorization code. Exchange this code for an API key: + +```typescript +// In your callback handler +const code = req.query.code; // From the redirect URL + +const apiKeyResponse = await client.oAuth.exchangeAuthCodeForAPIKey({ + code: code +}); + +// apiKeyResponse contains: +// - key: The user's API key +// - Additional metadata about the key + +const userApiKey = apiKeyResponse.key; + +// Store securely for this user's future requests +await saveUserApiKey(userId, userApiKey); +``` + +**Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `code` | `string` | Yes | The authorization code from the OAuth redirect | + +--- + +## Complete OAuth Flow Example + +```typescript +import OpenRouter from '@openrouter/sdk'; +import express from 'express'; + +const app = express(); +const client = new OpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY // Your app's key for OAuth operations +}); + +// Step 1: Initiate OAuth flow +app.get('/auth/start', async (req, res) => { + const authResponse = await client.oAuth.createAuthCode({ + callbackUrl: 'https://myapp.com/auth/callback' + }); + + // Store any state needed for the callback + req.session.oauthState = { /* ... */ }; + + // Redirect user to OpenRouter authorization page + res.redirect(authResponse.authorizationUrl); +}); + +// Step 2: Handle callback and exchange code +app.get('/auth/callback', async (req, res) => { + const { code } = req.query; + + if (!code) { + return res.status(400).send('Authorization code missing'); + } + + try { + const apiKeyResponse = await client.oAuth.exchangeAuthCodeForAPIKey({ + code: code as string + }); + + // Store the user's API key securely + await saveUserApiKey(req.session.userId, apiKeyResponse.key); + + res.redirect('/dashboard?auth=success'); + } catch (error) { + console.error('OAuth exchange failed:', error); + res.redirect('/auth/error'); + } +}); + +// Step 3: Use the user's API key for their requests +app.post('/api/chat', async (req, res) => { + const userApiKey = await getUserApiKey(req.session.userId); + + // Create a client with the user's key + const userClient = new OpenRouter({ + apiKey: userApiKey + }); + + const result = userClient.callModel({ + model: 'openai/gpt-5-nano', + input: req.body.message + }); + + const text = await result.getText(); + res.json({ response: text }); +}); +``` + +--- + +## Security Best Practices + +1. **Environment Variables**: Store API keys in environment variables, never in code +2. **Key Rotation**: Rotate keys periodically using the key management API +3. **Environment Separation**: Use different keys for development, staging, and production +4. **OAuth for Users**: Use the OAuth PKCE flow for user-facing apps to avoid handling user credentials +5. **Secure Storage**: Store user API keys encrypted in your database +6. **Minimal Scope**: Create keys with only the permissions needed diff --git a/skills/openrouter-typescript-sdk/references/event-shapes.md b/skills/openrouter-typescript-sdk/references/event-shapes.md new file mode 100644 index 0000000..504057b --- /dev/null +++ b/skills/openrouter-typescript-sdk/references/event-shapes.md @@ -0,0 +1,254 @@ +# Event Shapes Reference + +> Read this when you need to process raw stream events from `getFullResponsesStream()`, handle specific event types like reasoning or image generation, or build custom stream consumers. + +## Table of Contents + +- [EnhancedResponseStreamEvent Type](#enhancedresponsestreameevent-type) +- [Event Type Reference](#event-type-reference) +- [Individual Event Interfaces](#individual-event-interfaces) +- [Tool Stream Events](#tool-stream-events) +- [Message Stream Events](#message-stream-events) +- [Example: Processing Stream Events](#example-processing-stream-events) +- [Example: Tracking New Messages](#example-tracking-new-messages) + +--- + +## EnhancedResponseStreamEvent Type + +The `getFullResponsesStream()` method yields these event types: + +```typescript +type EnhancedResponseStreamEvent = + | ResponseCreatedEvent + | ResponseInProgressEvent + | ResponseCompletedEvent + | ResponseIncompleteEvent + | ResponseFailedEvent + | ErrorEvent + | OutputItemAddedEvent + | OutputItemDoneEvent + | ContentPartAddedEvent + | ContentPartDoneEvent + | OutputTextDeltaEvent + | OutputTextDoneEvent + | OutputTextAnnotationAddedEvent + | RefusalDeltaEvent + | RefusalDoneEvent + | FunctionCallArgumentsDeltaEvent + | FunctionCallArgumentsDoneEvent + | ReasoningTextDeltaEvent + | ReasoningTextDoneEvent + | ReasoningSummaryPartAddedEvent + | ReasoningSummaryPartDoneEvent + | ReasoningSummaryTextDeltaEvent + | ReasoningSummaryTextDoneEvent + | ImageGenCallInProgressEvent + | ImageGenCallGeneratingEvent + | ImageGenCallPartialImageEvent + | ImageGenCallCompletedEvent + | ToolPreliminaryResultEvent + | ToolResultEvent; +``` + +--- + +## Event Type Reference + +| Event Type | Description | Key Payload Fields | +|------------|-------------|---------| +| `response.created` | Response object initialized | `response`, `sequenceNumber` | +| `response.in_progress` | Generation has started | `response`, `sequenceNumber` | +| `response.completed` | Full response complete | `response`, `sequenceNumber` | +| `response.incomplete` | Response incomplete | `response`, `sequenceNumber` | +| `response.failed` | Response generation failed | `response`, `sequenceNumber` | +| `error` | Streaming error occurred | `code`, `message`, `param`, `sequenceNumber` | +| `response.output_item.added` | New output item added | `outputIndex`, `item`, `sequenceNumber` | +| `response.output_item.done` | Output item complete | `outputIndex`, `item`, `sequenceNumber` | +| `response.content_part.added` | New content part added | `outputIndex`, `itemId`, `contentIndex`, `part`, `sequenceNumber` | +| `response.content_part.done` | Content part complete | `outputIndex`, `itemId`, `contentIndex`, `part`, `sequenceNumber` | +| `response.output_text.delta` | Text chunk received | `delta`, `outputIndex`, `itemId`, `contentIndex`, `logprobs`, `sequenceNumber` | +| `response.output_text.done` | Text generation complete | `text`, `outputIndex`, `itemId`, `contentIndex`, `logprobs`, `sequenceNumber` | +| `response.output_text.annotation.added` | Text annotation added | `outputIndex`, `itemId`, `contentIndex`, `annotationIndex`, `annotation`, `sequenceNumber` | +| `response.refusal.delta` | Refusal chunk streamed | `delta`, `outputIndex`, `itemId`, `contentIndex`, `sequenceNumber` | +| `response.refusal.done` | Refusal complete | `refusal`, `outputIndex`, `itemId`, `contentIndex`, `sequenceNumber` | +| `response.function_call_arguments.delta` | Tool argument chunk | `delta`, `itemId`, `outputIndex`, `sequenceNumber` | +| `response.function_call_arguments.done` | Tool arguments complete | `arguments`, `name`, `itemId`, `outputIndex`, `sequenceNumber` | +| `response.reasoning_text.delta` | Reasoning chunk (reasoning models) | `delta`, `outputIndex`, `itemId`, `contentIndex`, `sequenceNumber` | +| `response.reasoning_text.done` | Reasoning complete | `text`, `outputIndex`, `itemId`, `contentIndex`, `sequenceNumber` | +| `response.reasoning_summary_part.added` | Reasoning summary part added | `outputIndex`, `itemId`, `summaryIndex`, `part`, `sequenceNumber` | +| `response.reasoning_summary_part.done` | Reasoning summary part complete | `outputIndex`, `itemId`, `summaryIndex`, `part`, `sequenceNumber` | +| `response.reasoning_summary_text.delta` | Reasoning summary text chunk | `delta`, `itemId`, `outputIndex`, `summaryIndex`, `sequenceNumber` | +| `response.reasoning_summary_text.done` | Reasoning summary text complete | `text`, `itemId`, `outputIndex`, `summaryIndex`, `sequenceNumber` | +| `response.image_generation_call.in_progress` | Image generation started | `itemId`, `outputIndex`, `sequenceNumber` | +| `response.image_generation_call.generating` | Image generation in progress | `itemId`, `outputIndex`, `sequenceNumber` | +| `response.image_generation_call.partial_image` | Partial image available | `itemId`, `outputIndex`, `partialImageB64`, `partialImageIndex`, `sequenceNumber` | +| `response.image_generation_call.completed` | Image generation complete | `itemId`, `outputIndex`, `sequenceNumber` | +| `tool.preliminary_result` | Generator tool progress | `toolCallId`, `result`, `timestamp` | +| `tool.result` | Tool execution complete | `toolCallId`, `result`, `timestamp`, `preliminaryResults?` | + +--- + +## Individual Event Interfaces + +### Text Delta Event + +```typescript +interface OutputTextDeltaEvent { + type: 'response.output_text.delta'; + logprobs: Array; + outputIndex: number; + itemId: string; + contentIndex: number; + delta: string; + sequenceNumber: number; +} +``` + +### Reasoning Text Delta Event + +For reasoning models (o1, etc.): + +```typescript +interface ReasoningTextDeltaEvent { + type: 'response.reasoning_text.delta'; + outputIndex: number; + itemId: string; + contentIndex: number; + delta: string; + sequenceNumber: number; +} +``` + +### Function Call Arguments Delta Event + +```typescript +interface FunctionCallArgumentsDeltaEvent { + type: 'response.function_call_arguments.delta'; + itemId: string; + outputIndex: number; + delta: string; + sequenceNumber: number; +} +``` + +### Tool Preliminary Result Event + +From generator tools that yield progress: + +```typescript +interface ToolPreliminaryResultEvent { + type: 'tool.preliminary_result'; + toolCallId: string; + result: TEvent; // Matches the tool's eventSchema + timestamp: number; +} +``` + +### Tool Result Event + +Emitted when a tool execution completes: + +```typescript +interface ToolResultEvent { + type: 'tool.result'; + toolCallId: string; + result: TResult; + timestamp: number; + preliminaryResults?: TPreliminaryResults[]; +} +``` + +### Response Completed Event + +```typescript +interface ResponseCompletedEvent { + type: 'response.completed'; + response: OpenResponsesNonStreamingResponse; + sequenceNumber: number; +} +``` + +--- + +## Tool Stream Events + +The `getToolStream()` method yields: + +```typescript +type ToolStreamEvent = + | { type: 'delta'; content: string } + | { type: 'preliminary_result'; toolCallId: string; result: unknown }; +``` + +--- + +## Message Stream Events + +The `getNewMessagesStream()` yields OpenResponses format updates: + +```typescript +type MessageStreamUpdate = + | ResponsesOutputMessage // Text/content updates + | OpenResponsesFunctionCallOutput; // Tool results +``` + +--- + +## Example: Processing Stream Events + +```typescript +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: 'Analyze this data', + tools: [analysisTool] +}); + +for await (const event of result.getFullResponsesStream()) { + switch (event.type) { + case 'response.output_text.delta': + process.stdout.write(event.delta); + break; + + case 'response.reasoning_text.delta': + console.log('[Reasoning]', event.delta); + break; + + case 'response.function_call_arguments.delta': + console.log('[Tool Args]', event.delta); + break; + + case 'tool.preliminary_result': + console.log(`[Progress: ${event.toolCallId}]`, event.result); + break; + + case 'response.completed': + console.log('\n[Complete]', event.response.usage); + break; + } +} +``` + +--- + +## Example: Tracking New Messages + +```typescript +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: 'Research this topic', + tools: [searchTool] +}); + +const allMessages: MessageStreamUpdate[] = []; + +for await (const message of result.getNewMessagesStream()) { + allMessages.push(message); + + if (message.type === 'message') { + console.log('Assistant:', message.content); + } else if (message.type === 'function_call_output') { + console.log('Tool result:', message.output); + } +} +``` diff --git a/skills/openrouter-typescript-sdk/references/message-shapes.md b/skills/openrouter-typescript-sdk/references/message-shapes.md new file mode 100644 index 0000000..068770d --- /dev/null +++ b/skills/openrouter-typescript-sdk/references/message-shapes.md @@ -0,0 +1,211 @@ +# Message Shapes Reference + +> Read this when you need to understand the structure of request/response messages, work with multimodal content, parse tool call results, or implement custom stop conditions using StepResult. + +## Table of Contents + +- [Message Roles](#message-roles) +- [Input Message Types](#input-message-types) +- [Non-Streaming Response Structure](#non-streaming-response-structure) +- [Response Message Types](#response-message-types) +- [Parsed Tool Call](#parsed-tool-call) +- [Tool Execution Result](#tool-execution-result) +- [Step Result](#step-result) +- [TurnContext](#turncontext) + +--- + +## Message Roles + +Messages contain a `role` property that determines the message type: + +| Role | Description | +|------|-------------| +| `user` | User-provided input | +| `assistant` | Model-generated responses | +| `system` | System instructions | +| `developer` | Developer-level directives | +| `tool` | Tool execution results | + +--- + +## Input Message Types + +### Text Message + +Simple text content from user or assistant: + +```typescript +interface TextMessage { + role: 'user' | 'assistant'; + content: string; +} +``` + +### Multimodal Message (Array Content) + +Messages with mixed content types: + +```typescript +interface MultimodalMessage { + role: 'user'; + content: Array< + | { type: 'input_text'; text: string } + | { type: 'input_image'; imageUrl: string; detail?: 'auto' | 'low' | 'high' } + | { + type: 'image'; + source: { + type: 'url' | 'base64'; + url?: string; + media_type?: string; + data?: string + } + } + >; +} +``` + +### Tool Function Call Message + +When the model requests a tool execution: + +```typescript +interface ToolCallMessage { + role: 'assistant'; + content?: null; + tool_calls?: Array<{ + id: string; + type: 'function'; + function: { + name: string; + arguments: string; // JSON-encoded arguments + }; + }>; +} +``` + +### Tool Result Message + +Result returned after tool execution: + +```typescript +interface ToolResultMessage { + role: 'tool'; + tool_call_id: string; + content: string; // JSON-encoded result +} +``` + +--- + +## Non-Streaming Response Structure + +The complete response object from `getResponse()`: + +```typescript +interface OpenResponsesNonStreamingResponse { + output: Array; + usage?: { + inputTokens: number; + outputTokens: number; + cachedTokens?: number; + }; + finishReason?: string; + warnings?: Array<{ + type: string; + message: string + }>; + experimental_providerMetadata?: Record; +} +``` + +--- + +## Response Message Types + +Output messages in the response array: + +```typescript +// Text/content message +interface ResponseOutputMessage { + type: 'message'; + role: 'assistant'; + content: string | Array; + reasoning?: string; // For reasoning models (o1, etc.) +} + +// Tool result in output +interface FunctionCallOutputMessage { + type: 'function_call_output'; + call_id: string; + output: string; +} +``` + +--- + +## Parsed Tool Call + +When tool calls are parsed from the response: + +```typescript +interface ParsedToolCall { + id: string; + name: string; + arguments: unknown; // Validated against inputSchema +} +``` + +--- + +## Tool Execution Result + +After a tool completes execution: + +```typescript +interface ToolExecutionResult { + toolCallId: string; + toolName: string; + result: unknown; // Validated against outputSchema + preliminaryResults?: unknown[]; // From generator tools + error?: Error; +} +``` + +--- + +## Step Result + +Available in custom stop condition callbacks: + +```typescript +interface StepResult { + stepType: 'initial' | 'continue'; + text: string; + toolCalls: ParsedToolCall[]; + toolResults: ToolExecutionResult[]; + response: OpenResponsesNonStreamingResponse; + usage?: { + inputTokens: number; + outputTokens: number; + cachedTokens?: number; + }; + finishReason?: string; + warnings?: Array<{ type: string; message: string }>; + experimental_providerMetadata?: Record; +} +``` + +--- + +## TurnContext + +Available to tools and dynamic parameter functions: + +```typescript +interface TurnContext { + numberOfTurns: number; // Turn count (1-indexed) + turnRequest?: OpenResponsesRequest; // Current request being made + toolCall?: OpenResponsesFunctionToolCall; // Current tool call (in tool context) +} +``` From 08d42e74b793529ca422aa8fc9d9bd5f0a09eb61 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 10 Mar 2026 16:55:36 -0400 Subject: [PATCH 09/11] docs(sdk): emphasize callModel as primary interface over chat/completions Position callModel as a high-level framework comparable to Vercel AI SDK, explaining why it should be the default choice and when to use lower-level methods like client.chat.send() as escape hatches. --- skills/openrouter-typescript-sdk/SKILL.md | 37 +++++++++++------------ 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/skills/openrouter-typescript-sdk/SKILL.md b/skills/openrouter-typescript-sdk/SKILL.md index 3bc1221..bcf9d21 100644 --- a/skills/openrouter-typescript-sdk/SKILL.md +++ b/skills/openrouter-typescript-sdk/SKILL.md @@ -30,9 +30,19 @@ For the full OAuth PKCE flow, API key management, and security best practices, r --- -## Core Concepts: callModel +## Why callModel -The `callModel` function is the primary interface for text generation. It provides a unified, type-safe way to interact with any supported model. +The SDK exposes lower-level methods like `client.chat.send()` and `client.completions.generate()`, but `callModel` is the right choice for almost everything. Think of it like the Vercel AI SDK's `generateText`/`streamText` — a high-level framework that handles the plumbing so you can focus on the logic: + +- **Automatic tool execution** — define tools with Zod schemas, and `callModel` calls them, feeds results back to the model, and loops until done. With `chat.send()` you'd hand-roll that loop yourself. +- **Multi-turn management** — stop conditions (`stepCountIs`, `maxCost`, `hasToolCall`) give you declarative control over agentic loops. No manual message array bookkeeping. +- **Type-safe from end to end** — input schemas, output schemas, and tool definitions are all validated at compile time and runtime. +- **Streaming built in** — `.getTextStream()`, `.getToolCallsStream()`, and `.getFullResponsesStream()` all work from the same result object, with concurrent consumer support. +- **Dynamic parameters** — model, temperature, and instructions can be functions of turn context, so agents can adapt as conversations progress. + +Only reach for `client.chat.send()` when you need raw control over the request/response cycle (e.g., proxy use cases, custom message formats, or non-standard parameters the framework doesn't expose). + +## Basic Usage ```typescript const result = client.callModel({ @@ -252,29 +262,16 @@ const result = client.callModel({ --- -## API Reference +## Other Client Methods + +Beyond `callModel`, the client exposes utility endpoints for account and model management: ```typescript const client = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }); -// Primary interface -client.callModel({ model, input, tools?, stopWhen?, ...params }); - // List available models const models = await client.models.list(); -// Chat completions (alternative to callModel) -const completion = await client.chat.send({ - model: 'openai/gpt-5-nano', - messages: [{ role: 'user', content: 'Hello!' }] -}); - -// Legacy completions -const legacy = await client.completions.generate({ - model: 'openai/gpt-5-nano', - prompt: 'Once upon a time' -}); - // Usage analytics const activity = await client.analytics.getUserActivity(); @@ -288,6 +285,8 @@ const keys = await client.apiKeys.list(); const auth = await client.oAuth.createAuthCode({ callbackUrl: '...' }); ``` +The client also has `client.chat.send()` and `client.completions.generate()` for raw request/response access. These are lower-level escape hatches — prefer `callModel` for all standard text generation, tool use, and streaming workflows because it handles tool loops, multi-turn state, and type validation automatically. + --- ## Error Handling @@ -375,7 +374,7 @@ console.log('Final answer:', answer); ## Best Practices -1. **Prefer callModel** over direct API calls for automatic tool execution, type safety, and multi-turn handling +1. **Always use callModel** unless you need raw request/response control — it handles tool loops, streaming, and multi-turn state automatically 2. **Use Zod for tool schemas** for runtime validation and TypeScript inference 3. **Set stop conditions** to prevent runaway costs: `stopWhen: [stepCountIs(20), maxCost(5.00)]` 4. **Use streaming** for long responses for better UX and early termination From 85eb1f8cc8c934b9b5671d225b2f0f04f3fca6ef Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 10 Mar 2026 17:03:28 -0400 Subject: [PATCH 10/11] feat(images): migrate from chat.send() to callModel() for Responses API Switch image generation and editing scripts from client.chat.send() (Chat Completions API) to client.callModel() (Responses API), as recommended in the Slack review. callModel provides automatic tool execution, type safety, and is the SDK's primary high-level interface. Also fixes biome formatting in biome.json. --- biome.json | 21 ++++- skills/openrouter-images/scripts/edit.ts | 82 +++++++++++--------- skills/openrouter-images/scripts/generate.ts | 56 ++++++------- 3 files changed, 91 insertions(+), 68 deletions(-) diff --git a/biome.json b/biome.json index bc88dbd..194e522 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,12 @@ "defaultBranch": "main" }, "files": { - "includes": ["**", "!**/*.json", "!!**/biome.json", "!**/node_modules/**"] + "includes": [ + "**", + "!**/*.json", + "!!**/biome.json", + "!**/node_modules" + ] }, "formatter": { "enabled": true, @@ -31,7 +36,12 @@ "useBlockStatements": "error", "noParameterAssign": "error", "useConst": "error", - "useImportType": { "level": "on", "options": { "style": "separatedType" } }, + "useImportType": { + "level": "on", + "options": { + "style": "separatedType" + } + }, "noInferrableTypes": "error", "noUselessElse": "error" }, @@ -42,7 +52,12 @@ "noExplicitAny": "error", "noAssignInExpressions": "error", "noConsole": "off", - "noDoubleEquals": { "level": "error", "options": { "ignoreNull": false } } + "noDoubleEquals": { + "level": "error", + "options": { + "ignoreNull": false + } + } }, "performance": { "recommended": true, diff --git a/skills/openrouter-images/scripts/edit.ts b/skills/openrouter-images/scripts/edit.ts index 6365139..56f0abb 100644 --- a/skills/openrouter-images/scripts/edit.ts +++ b/skills/openrouter-images/scripts/edit.ts @@ -1,4 +1,4 @@ -import type { ChatResponse, Modality } from '@openrouter/sdk/models'; +import type { ResponsesOutputModality } from '@openrouter/sdk/models'; import { createClient, DEFAULT_MODEL, @@ -36,52 +36,58 @@ if (imageSize) { imageConfig.image_size = imageSize; } -const modalities: Modality[] = [ +const modalities: ResponsesOutputModality[] = [ 'image', 'text', ]; -const response = (await client.chat.send({ - chatGenerationParams: { - model, - messages: [ - { - role: 'user' as const, - content: [ - { - type: 'image_url' as const, - imageUrl: { - url: dataUrl, - }, - }, - { - type: 'text' as const, - text: prompt, - }, - ], - }, - ], - modalities, - ...(Object.keys(imageConfig).length > 0 - ? { - imageConfig, - } - : {}), - }, -})) as ChatResponse; +const result = client.callModel({ + model, + input: [ + { + role: 'user' as const, + content: [ + { + type: 'input_image' as const, + detail: 'auto' as const, + imageUrl: dataUrl, + }, + { + type: 'input_text' as const, + text: prompt, + }, + ], + }, + ], + modalities, + ...(Object.keys(imageConfig).length > 0 + ? { + imageConfig, + } + : {}), +}); -const message = response.choices?.[0]?.message; +const response = await result.getResponse(); -if (!message) { - console.error('Error: No response from model.'); - process.exit(1); +// Extract text from message output items +for (const item of response.output) { + if (item.type === 'message' && typeof item.content === 'string' && item.content) { + console.error(`Model: ${item.content}`); + } } -if (message.content && typeof message.content === 'string') { - console.error(`Model: ${message.content}`); -} +// Extract images from image_generation_call output items +const images: string[] = response.output + .filter( + ( + item, + ): item is typeof item & { + type: 'image_generation_call'; + result: string; + } => item.type === 'image_generation_call' && typeof item.result === 'string', + ) + .map((item) => item.result); -const images: string[] = message.images?.map((img) => img.imageUrl.url) ?? []; if (images.length === 0) { console.error('Error: No images returned by model.'); process.exit(1); diff --git a/skills/openrouter-images/scripts/generate.ts b/skills/openrouter-images/scripts/generate.ts index 9ac0dee..e6b87e6 100644 --- a/skills/openrouter-images/scripts/generate.ts +++ b/skills/openrouter-images/scripts/generate.ts @@ -1,4 +1,4 @@ -import type { ChatResponse, Modality } from '@openrouter/sdk/models'; +import type { ResponsesOutputModality } from '@openrouter/sdk/models'; import { createClient, DEFAULT_MODEL, defaultOutputPath, parseArgs, saveImage } from './lib.js'; const client = createClient(); @@ -25,41 +25,43 @@ if (imageSize) { imageConfig.image_size = imageSize; } -const modalities: Modality[] = [ +const modalities: ResponsesOutputModality[] = [ 'image', 'text', ]; -const response = (await client.chat.send({ - chatGenerationParams: { - model, - messages: [ - { - role: 'user' as const, - content: prompt, - }, - ], - modalities, - ...(Object.keys(imageConfig).length > 0 - ? { - imageConfig, - } - : {}), - }, -})) as ChatResponse; +const result = client.callModel({ + model, + input: prompt, + modalities, + ...(Object.keys(imageConfig).length > 0 + ? { + imageConfig, + } + : {}), +}); -const message = response.choices?.[0]?.message; +const response = await result.getResponse(); -if (!message) { - console.error('Error: No response from model.'); - process.exit(1); +// Extract text from message output items +for (const item of response.output) { + if (item.type === 'message' && typeof item.content === 'string' && item.content) { + console.error(`Model: ${item.content}`); + } } -if (message.content && typeof message.content === 'string') { - console.error(`Model: ${message.content}`); -} +// Extract images from image_generation_call output items +const images: string[] = response.output + .filter( + ( + item, + ): item is typeof item & { + type: 'image_generation_call'; + result: string; + } => item.type === 'image_generation_call' && typeof item.result === 'string', + ) + .map((item) => item.result); -const images: string[] = message.images?.map((img) => img.imageUrl.url) ?? []; if (images.length === 0) { console.error('Error: No images returned by model.'); process.exit(1); From 9736b3e206739ebc9e5b553a7ff229e8fad99693 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 10 Mar 2026 17:21:23 -0400 Subject: [PATCH 11/11] docs(sdk): update openrouter-typescript-sdk to match latest callModel API Align SKILL.md and reference files with @openrouter/sdk v0.9.11: - Fix multimodal input to Responses API format (input_text/input_image) - Add getItemsStream, getToolCalls, getFullResponsesStream, cancel methods - Add Model Fallback, Image Generation, Structured Output, Reasoning sections - Remove invalid stop parameter from Generation Parameters - Fix context object properties in advanced-patterns.md - Add requireApproval, type inference utilities, additional stop conditions - Add deprecation note for getNewMessagesStream in event-shapes.md - Add reasoning/image_generation/web_search/file_search output types --- skills/openrouter-typescript-sdk/SKILL.md | 144 +++++++++++++++++- .../references/advanced-patterns.md | 49 +++++- .../references/event-shapes.md | 2 + .../references/message-shapes.md | 30 ++++ 4 files changed, 214 insertions(+), 11 deletions(-) diff --git a/skills/openrouter-typescript-sdk/SKILL.md b/skills/openrouter-typescript-sdk/SKILL.md index bcf9d21..0dc4619 100644 --- a/skills/openrouter-typescript-sdk/SKILL.md +++ b/skills/openrouter-typescript-sdk/SKILL.md @@ -82,8 +82,8 @@ client.callModel({ input: [{ role: 'user', content: [ - { type: 'text', text: 'What is in this image?' }, - { type: 'image_url', image_url: { url: 'https://example.com/image.png' } } + { type: 'input_text', text: 'What is in this image?' }, + { type: 'input_image', imageUrl: 'https://example.com/image.png', detail: 'auto' } ] }] }); @@ -106,8 +106,12 @@ client.callModel({ | `getText()` | Get complete text after all tools complete | | `getResponse()` | Full response object with token usage | | `getTextStream()` | Stream text deltas as they arrive | -| `getReasoningStream()` | Stream reasoning tokens (for o1/reasoning models) | +| `getItemsStream()` | Stream complete output items (recommended for UIs) | +| `getReasoningStream()` | Stream reasoning tokens (for reasoning models) | | `getToolCallsStream()` | Stream tool calls as they complete | +| `getToolCalls()` | Get all tool calls after completion (non-streaming) | +| `getFullResponsesStream()` | Raw stream events for custom processing | +| `cancel()` | Cancel an in-progress request | ```typescript // Get text @@ -217,6 +221,38 @@ for await (const delta of result.getTextStream()) { } ``` +### Items Streaming (Recommended for UIs) + +`getItemsStream()` yields complete output items (text, tool calls, reasoning, images) that update in place by ID — ideal for rendering in UIs: + +```typescript +for await (const item of result.getItemsStream()) { + switch (item.type) { + case 'message': + // item.content updates progressively — same item.id, growing text + renderMessage(item.id, item.content); + break; + case 'function_call': + renderToolCall(item.id, item.name, item.arguments); + break; + case 'image_generation_call': + if (item.result) renderImage(item.result); + break; + } +} +``` + +Items share a stable `id` — re-emitted with progressively more content. Use item ID as a React/UI key and replace-in-place on each emission. + +### Cancellation + +```typescript +const result = client.callModel({ model: 'openai/gpt-5-nano', input: 'Long task...' }); + +// Cancel after 5 seconds +setTimeout(() => result.cancel(), 5000); +``` + ### Streaming Tool Calls ```typescript @@ -256,12 +292,104 @@ const result = client.callModel({ topP: 0.9, // Nucleus sampling parameter frequencyPenalty: 0.5, // Reduce repetition presencePenalty: 0.5, // Encourage new topics - stop: ['\n\n'] // Stop sequences }); ``` --- +## Model Fallback + +Provide multiple models — the SDK tries each in order: + +```typescript +const result = client.callModel({ + models: ['anthropic/claude-sonnet-4', 'openai/gpt-4o', 'google/gemini-2.0-flash'], + input: 'Summarize this document' +}); +``` + +--- + +## Image Generation + +Generate images via `callModel` with `modalities` and `imageConfig`: + +```typescript +const result = client.callModel({ + model: 'openai/dall-e-3', + input: 'A sunset over mountains', + modalities: ['image', 'text'], + imageConfig: { + aspect_ratio: '16:9', + image_size: '1024x1024' + } +}); + +const response = await result.getResponse(); +for (const item of response.output) { + if (item.type === 'image_generation_call' && item.result) { + // item.result is base64-encoded image data + saveImage(item.result); + } +} +``` + +--- + +## Structured Output + +Force JSON output with a schema: + +```typescript +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: 'List 3 programming languages with pros and cons', + text: { + format: { + type: 'json_schema', + name: 'languages', + schema: { + type: 'object', + properties: { + languages: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + pros: { type: 'array', items: { type: 'string' } }, + cons: { type: 'array', items: { type: 'string' } } + } + } + } + } + } + } + } +}); +``` + +--- + +## Reasoning Models + +Enable extended thinking for reasoning models: + +```typescript +const result = client.callModel({ + model: 'openai/o3', + input: 'Solve this step by step: ...', + reasoning: { effort: 'high' } +}); + +// Stream reasoning tokens +for await (const delta of result.getReasoningStream()) { + process.stdout.write(delta); +} +``` + +--- + ## Other Client Methods Beyond `callModel`, the client exposes utility endpoints for account and model management: @@ -377,8 +505,10 @@ console.log('Final answer:', answer); 1. **Always use callModel** unless you need raw request/response control — it handles tool loops, streaming, and multi-turn state automatically 2. **Use Zod for tool schemas** for runtime validation and TypeScript inference 3. **Set stop conditions** to prevent runaway costs: `stopWhen: [stepCountIs(20), maxCost(5.00)]` -4. **Use streaming** for long responses for better UX and early termination -5. **Handle errors** with retry logic for transient failures (429, 5xx) +4. **Use `getItemsStream()`** for UI rendering — items update in place by ID, no manual state management +5. **Use `models` array** for automatic fallback across providers +6. **Use streaming** for long responses for better UX and early termination +7. **Handle errors** with retry logic for transient failures (429, 5xx) --- @@ -389,7 +519,7 @@ console.log('Final answer:', answer); | `references/authentication.md` | OAuth PKCE flow, API key management, security best practices | | `references/event-shapes.md` | Full stream event type union, individual event interfaces, raw stream processing | | `references/message-shapes.md` | Message/response type interfaces, StepResult, TurnContext, tool call parsing | -| `references/advanced-patterns.md` | Format conversion (OpenAI/Claude), dynamic parameters, nextTurnParams, generator/manual tools, custom stop conditions | +| `references/advanced-patterns.md` | Format conversion (OpenAI/Claude), dynamic parameters, nextTurnParams, generator/manual tools, custom stop conditions, requireApproval, state persistence, type inference utilities | --- diff --git a/skills/openrouter-typescript-sdk/references/advanced-patterns.md b/skills/openrouter-typescript-sdk/references/advanced-patterns.md index 529ec3d..5d22e5d 100644 --- a/skills/openrouter-typescript-sdk/references/advanced-patterns.md +++ b/skills/openrouter-typescript-sdk/references/advanced-patterns.md @@ -67,10 +67,12 @@ const result = client.callModel({ | Property | Type | Description | |----------|------|-------------| -| `numberOfTurns` | number | Current turn count | -| `messages` | array | All messages so far | -| `instructions` | string | Current system instructions | -| `totalCost` | number | Accumulated cost | +| `numberOfTurns` | `number` | Current turn count (1-indexed) | +| `turnRequest` | `OpenResponsesRequest` | The current request being made | +| `toolCall` | `OpenResponsesFunctionToolCall` | Current tool call (in tool context) | +| `messageHistory` | `array` | All messages so far | +| `model` | `string` | Current model being used | +| `models` | `string[]` | Fallback models list | --- @@ -123,6 +125,13 @@ const result = client.callModel({ }); ``` +Additional built-in stop conditions: + +| Condition | Description | +|-----------|-------------| +| `maxTokensUsed(n)` | Stop when total token usage exceeds n | +| `finishReasonIs(reason)` | Stop on a specific finish reason | + --- ## Generator Tools @@ -161,3 +170,35 @@ const manualTool = tool({ execute: false }); ``` + +--- + +## Tool Approval + +Require human approval before tool execution: + +```typescript +const dangerousTool = tool({ + name: 'delete_file', + description: 'Delete a file', + inputSchema: z.object({ path: z.string() }), + requireApproval: true, + execute: async ({ path }) => { /* ... */ } +}); +``` + +When `requireApproval` is set, the tool call is returned without execution — the caller must approve and re-submit. + +--- + +## Type Inference Utilities + +Extract input/output/event types from tool definitions: + +```typescript +import type { InferToolInput, InferToolOutput, InferToolEvent } from '@openrouter/sdk'; + +type WeatherInput = InferToolInput; +type WeatherOutput = InferToolOutput; +type SearchEvent = InferToolEvent; +``` diff --git a/skills/openrouter-typescript-sdk/references/event-shapes.md b/skills/openrouter-typescript-sdk/references/event-shapes.md index 504057b..28c0d28 100644 --- a/skills/openrouter-typescript-sdk/references/event-shapes.md +++ b/skills/openrouter-typescript-sdk/references/event-shapes.md @@ -185,6 +185,8 @@ type ToolStreamEvent = ## Message Stream Events +> **Deprecated**: Prefer `getItemsStream()` which yields complete output items that update in place by ID. `getNewMessagesStream()` still works but `getItemsStream()` provides a better pattern for UI rendering. + The `getNewMessagesStream()` yields OpenResponses format updates: ```typescript diff --git a/skills/openrouter-typescript-sdk/references/message-shapes.md b/skills/openrouter-typescript-sdk/references/message-shapes.md index 068770d..e3b7f58 100644 --- a/skills/openrouter-typescript-sdk/references/message-shapes.md +++ b/skills/openrouter-typescript-sdk/references/message-shapes.md @@ -140,6 +140,36 @@ interface FunctionCallOutputMessage { call_id: string; output: string; } + +// Reasoning output +interface ResponseReasoningItem { + type: 'reasoning'; + id: string; + summary?: Array<{ type: 'summary_text'; text: string }>; +} + +// Image generation output +interface ResponseImageGenerationCall { + type: 'image_generation_call'; + id: string; + result?: string; // base64 image data + status: 'in_progress' | 'generating' | 'completed'; +} + +// Web search output (plugin) +interface ResponseWebSearchCall { + type: 'web_search_call'; + id: string; + status: string; +} + +// File search output (plugin) +interface ResponseFileSearchCall { + type: 'file_search_call'; + id: string; + status: string; + results?: Array<{ text: string; file_id: string }>; +} ``` ---