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..194e522 --- /dev/null +++ b/biome.json @@ -0,0 +1,81 @@ +{ + "$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" + } +} diff --git a/skills/openrouter-images/SKILL.md b/skills/openrouter-images/SKILL.md index 28112b3..28f5a1e 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 @@ -9,12 +18,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 +44,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 +64,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 @@ -95,20 +105,40 @@ Supported input formats: `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif` } ``` -## Using a Different Model +## Prompt Tips -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. +Better prompts produce better images. A few specifics go a long way: -Use the `openrouter-models` skill to discover image-capable models: +- **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"). + +## Model Selection + +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. + +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 && npx tsx search-models.ts --modality image +cd /scripts && bun run search-models.ts --modality image ``` ## 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 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. 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..56f0abb 100644 --- a/skills/openrouter-images/scripts/edit.ts +++ b/skills/openrouter-images/scripts/edit.ts @@ -1,78 +1,108 @@ +import type { ResponsesOutputModality } 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 modalities: ResponsesOutputModality[] = [ + 'image', + 'text', +]; -const body: any = { +const result = client.callModel({ model, - messages: [ + input: [ { - role: "user", + role: 'user' as const, content: [ - { type: "image_url", image_url: { url: dataUrl } }, - { type: "text", text: prompt }, + { + type: 'input_image' as const, + detail: 'auto' as const, + imageUrl: dataUrl, + }, + { + type: 'input_text' as const, + text: prompt, + }, ], }, ], - modalities: ["image", "text"], - ...(Object.keys(imageConfig).length > 0 ? { image_config: imageConfig } : {}), -}; + modalities, + ...(Object.keys(imageConfig).length > 0 + ? { + imageConfig, + } + : {}), +}); -const json = await postChatCompletion(apiKey, body); -const message = json.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) { - 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 ?? []; 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 +111,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..e6b87e6 100644 --- a/skills/openrouter-images/scripts/generate.ts +++ b/skills/openrouter-images/scripts/generate.ts @@ -1,69 +1,97 @@ -import { - DEFAULT_MODEL, - requireApiKey, - parseArgs, - postChatCompletion, - saveImage, - defaultOutputPath, -} from "./lib.js"; +import type { ResponsesOutputModality } 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: ResponsesOutputModality[] = [ + 'image', + 'text', +]; -const body: any = { +const result = client.callModel({ model, - messages: [{ role: "user", content: prompt }], - modalities: ["image", "text"], - ...(Object.keys(imageConfig).length > 0 ? { image_config: imageConfig } : {}), -}; + input: prompt, + modalities, + ...(Object.keys(imageConfig).length > 0 + ? { + imageConfig, + } + : {}), +}); -const json = await postChatCompletion(apiKey, body); -const message = json.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) { - 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 ?? []; 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"] +} diff --git a/skills/openrouter-models/SKILL.md b/skills/openrouter-models/SKILL.md index 7d0314c..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 @@ -9,12 +19,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 +54,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 +72,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 +80,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 +88,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 +99,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 +111,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 +122,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) @@ -222,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 | 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; +} diff --git a/skills/openrouter-typescript-sdk/SKILL.md b/skills/openrouter-typescript-sdk/SKILL.md index 1389155..0dc4619 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,236 +26,23 @@ 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' -}); +For the full OAuth PKCE flow, API key management, and security best practices, read `references/authentication.md`. -// 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 +## Why callModel ---- +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: -## Core Concepts: callModel +- **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. -The `callModel` function is the primary interface for text generation. It provides a unified, type-safe way to interact with any supported model. +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 +## Basic Usage ```typescript const result = client.callModel({ @@ -268,34 +53,18 @@ const result = client.callModel({ const text = await result.getText(); ``` -### Key Benefits +### Input Formats -- **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 - -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,87 +74,61 @@ 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: 'input_text', text: 'What is in this image?' }, + { type: 'input_image', imageUrl: 'https://example.com/image.png', detail: 'auto' } + ] + }] }); ``` -### 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 | |--------|---------| | `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 | - -### getText() +| `getToolCalls()` | Get all tool calls after completion (non-streaming) | +| `getFullResponsesStream()` | Raw stream events for custom processing | +| `cancel()` | Cancel an in-progress request | ```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 +154,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 +172,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,562 +195,211 @@ 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 -}); -``` +For custom stop conditions, dynamic parameters, and nextTurnParams context injection, read `references/advanced-patterns.md`. --- -## Dynamic Parameters +## Streaming -Compute parameters based on conversation context: +All streaming methods support concurrent consumers from a single result object: ```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!' + model: 'openai/gpt-5-nano', + input: 'Write a detailed explanation' }); -``` -### 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 | - ---- +// Stream text to console +for await (const delta of result.getTextStream()) { + process.stdout.write(delta); +} +``` -## nextTurnParams: Context Injection +### Items Streaming (Recommended for UIs) -Tools can modify parameters for subsequent turns, enabling skills and context-aware behavior: +`getItemsStream()` yields complete output items (text, tool calls, reasoning, images) that update in place by ID — ideal for rendering in UIs: ```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 }; +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; } -}); +} ``` -### Use Cases for nextTurnParams +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. -- **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 +### Cancellation ---- +```typescript +const result = client.callModel({ model: 'openai/gpt-5-nano', input: 'Long task...' }); -## Generation Parameters +// Cancel after 5 seconds +setTimeout(() => result.cancel(), 5000); +``` -Control model behavior with these parameters: +### Streaming Tool Calls ```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 await (const toolCall of result.getToolCallsStream()) { + console.log(`Tool called: ${toolCall.name}`); + console.log(`Result: ${JSON.stringify(toolCall.result)}`); +} ``` ---- - -## Streaming - -All streaming methods support concurrent consumers from a single result object: +### Concurrent Consumers ```typescript -const result = client.callModel({ - model: 'openai/gpt-5-nano', - 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); +console.log('Total tokens:', response.usage.totalTokens); ``` -### 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)}`); -} -``` +For the full `EnhancedResponseStreamEvent` type union, individual event interfaces, and raw stream processing with `getFullResponsesStream()`, read `references/event-shapes.md`. --- -## Format Conversion - -Convert between ecosystem formats for interoperability: - -### OpenAI Format +## Generation Parameters ```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) + 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 }); - -// 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: - -```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 +## Model Fallback -When tool calls are parsed from the response: +Provide multiple models — the SDK tries each in order: ```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) - -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) -} +const result = client.callModel({ + models: ['anthropic/claude-sonnet-4', 'openai/gpt-4o', 'google/gemini-2.0-flash'], + input: 'Summarize this document' +}); ``` --- -## 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 - | OutputTextDeltaEvent - | OutputTextDoneEvent - | ReasoningDeltaEvent - | ReasoningDoneEvent - | FunctionCallArgumentsDeltaEvent - | FunctionCallArgumentsDoneEvent - | ResponseCompletedEvent - | ToolPreliminaryResultEvent; -``` - -### Event Type Reference - -| Event Type | Description | Payload | -|------------|-------------|---------| -| `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 }` | - -### Text Delta Event - -```typescript -interface OutputTextDeltaEvent { - type: 'response.output_text.delta'; - delta: string; -} -``` - -### Reasoning Delta Event +## Image Generation -For reasoning models (o1, etc.): +Generate images via `callModel` with `modalities` and `imageConfig`: ```typescript -interface ReasoningDeltaEvent { - type: 'response.reasoning.delta'; - delta: string; -} -``` - -### Function Call Arguments Delta Event - -```typescript -interface FunctionCallArgumentsDeltaEvent { - type: 'response.function_call_arguments.delta'; - delta: string; -} -``` - -### Tool Preliminary Result Event - -From generator tools that yield progress: - -```typescript -interface ToolPreliminaryResultEvent { - type: 'tool.preliminary_result'; - toolCallId: string; - result: unknown; // Matches the tool's eventSchema -} -``` - -### Response Completed Event +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' + } +}); -```typescript -interface ResponseCompletedEvent { - type: 'response.completed'; - response: OpenResponsesNonStreamingResponse; +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); + } } ``` -### Tool Stream Events +--- -The `getToolStream()` method yields: +## Structured Output -```typescript -type ToolStreamEvent = - | { type: 'delta'; content: string } - | { type: 'preliminary_result'; toolCallId: string; result: unknown }; -``` - -### Example: Processing Stream Events +Force JSON output with a schema: ```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.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; + 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' } } + } + } + } + } + } + } } -} +}); ``` -### Message Stream Events - -The `getNewMessagesStream()` yields OpenResponses format updates: +--- -```typescript -type MessageStreamUpdate = - | ResponsesOutputMessage // Text/content updates - | OpenResponsesFunctionCallOutput; // Tool results -``` +## Reasoning Models -### Example: Tracking New Messages +Enable extended thinking for reasoning models: ```typescript const result = client.callModel({ - model: 'openai/gpt-5-nano', - input: 'Research this topic', - tools: [searchTool] + model: 'openai/o3', + input: 'Solve this step by step: ...', + reasoning: { effort: 'high' } }); -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); - } +// Stream reasoning tokens +for await (const delta of result.getReasoningStream()) { + process.stdout.write(delta); } ``` --- -## API Reference - -### Client Methods +## Other Client Methods -Beyond `callModel`, the client provides access to other API endpoints: +Beyond `callModel`, the client exposes utility endpoints for account and model management: ```typescript -const client = new OpenRouter({ - apiKey: process.env.OPENROUTER_API_KEY -}); +const client = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }); // 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 format -const legacyCompletion = await client.completions.generate({ - model: 'openai/gpt-5-nano', - prompt: 'Once upon a time' -}); - // Usage analytics const activity = await client.analytics.getUserActivity(); @@ -1070,38 +408,36 @@ const credits = await client.credits.getCredits(); // API key management const keys = await client.apiKeys.list(); + +// OAuth +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 -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 | @@ -1116,60 +452,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)}`); } @@ -1177,7 +494,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); ``` @@ -1186,55 +502,24 @@ 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. +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 `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) -### 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: - -```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, requireApproval, state persistence, type inference utilities | --- @@ -1243,7 +528,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..5d22e5d --- /dev/null +++ b/skills/openrouter-typescript-sdk/references/advanced-patterns.md @@ -0,0 +1,204 @@ +# 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 (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 | + +--- + +## 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 +}); +``` + +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 + +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 +}); +``` + +--- + +## 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/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..28c0d28 --- /dev/null +++ b/skills/openrouter-typescript-sdk/references/event-shapes.md @@ -0,0 +1,256 @@ +# 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 + +> **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 +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..e3b7f58 --- /dev/null +++ b/skills/openrouter-typescript-sdk/references/message-shapes.md @@ -0,0 +1,241 @@ +# 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; +} + +// 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 }>; +} +``` + +--- + +## 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) +} +```