snappy-openrouter skill
chat promptdraftfallback promptdraft$ npx snappy-skills install snappy-openrouter
$ npx snappy-skills install --all
$ npx snappy-skills update
Single canonical interface to OpenRouter for any Snappy skill that needs multi-vendor LLM calls. OpenRouter proxies 200+ models (Anthropic, OpenAI, Google, Meta, DeepSeek, Qwen, Mistral) behind one OpenAI-compatible API. Use this when you need cost-flexible model routing, multi-model comparison, or provider fallback -- not when you need native provider features (use snappy-ai-models or snappy-gemini for those).
typescriptimport { chat, chatWithFallback } from "../snappy-openrouter/api.ts";
| Function | What it does |
|---|---|
chat(prompt, opts?) |
One-shot completion (returns { text, model, raw }) |
chatWithFallback(prompt, models[], opts?) |
Fallback chain -- first available model wins |
Options: model, systemPrompt, temperature, maxTokens.
CLI:
bashnpx tsx ~/.claude/skills/snappy-openrouter/api.ts chat "Explain X" [--model anthropic/claude-3.5-sonnet]
npx tsx ~/.claude/skills/snappy-openrouter/api.ts fallback "Explain X" [--models "a,b,c"]
Credentials loaded via snappy-settings/load.ts from .env.cache. No Bitwarden unlock needed.
openrouter/auto).env.cache > ~/.snappy/openrouter.key)| Strategy | When | Model hint |
|---|---|---|
| cheapest | Classification, summarization, extraction | deepseek/deepseek-chat, google/gemini-2.0-flash |
| balanced | Default writer, structured outputs | anthropic/claude-3.5-sonnet, openai/gpt-4o |
| premium | Hard reasoning, council critique | anthropic/claude-opus-4, openai/o1 |
| auto | Don't care which model | openrouter/auto |
| fallback | Resilience over speed | --models "a,b,c" (first available wins) |
| compare | Evaluate quality across models | compare.sh --models "a,b,c" |
All scripts live in ~/.claude/skills/snappy-openrouter/scripts/. Auth is handled automatically via lib/auth.sh (sourced, not invoked).
bash./scripts/chat.sh --model anthropic/claude-3.5-sonnet --prompt "Hello"
./scripts/chat.sh --model openai/gpt-4o-mini --system "Be terse." --prompt "What is X?" --text
./scripts/chat.sh --models "anthropic/claude-3.5-sonnet,openai/gpt-4o" --prompt "Hi" # fallback chain
echo "Summarize this" | ./scripts/chat.sh --model deepseek/deepseek-chat --stdin
Flags: --model, --models (fallback), --prompt, --system, --temperature, --max-tokens, --transforms, --text (plain text output), --json (json_object mode), --stdin.
bash./scripts/stream.sh --model anthropic/claude-3.5-sonnet --prompt "Explain joins"
./scripts/stream.sh --model openai/gpt-4o --system "Be terse." --prompt "..." --raw
Flags: --model, --prompt, --system, --temperature, --max-tokens, --transforms, --raw (raw SSE), --stdin.
bash./scripts/route.sh --prompt "Classify intent: book a meeting"
./scripts/route.sh --tier balanced --prompt "Rewrite hook" --text
Flags: --prompt, --tier (cheap|balanced|premium), --system, --temperature, --max-tokens, --text, --stdin.
bash./scripts/compare.sh \
--models "anthropic/claude-3.5-haiku,openai/gpt-4o-mini,deepseek/deepseek-chat" \
--prompt "Write a 280-char hook about Xano" --text
Flags: --models (comma-separated, required), --prompt, --system, --temperature, --max-tokens, --text, --json, --stdin.
deepseek/deepseek-chat to write, anthropic/claude-opus-4 to critique (70%+ savings).--transforms "middle-out" to compress over-limit prompts.tools[].function.name)/embeddings endpoint not covered heresnappy-settings -- provides OPENROUTER_API_KEYsnappy-ai-models -- direct Anthropic/OpenAI when you need native featuressnappy-gemini -- direct Gemini when you need multimodal groundingIf this loader doesn't cover your case:
bashecho "[$(date -u +%FT%TZ)] snappy-openrouter: <what was missing>" >> ~/.claude/logs/agents-md-feedback.log
Show produced work with snappy-faces: call draw for image channels or lang for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-openrouter Index]|root: ~/.claude/skills/snappy-openrouter|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md,examples.md,models.md,routing-strategies.md}
<!-- SKILL-INDEX-END -->
Nothing in the collection names this skill.
<!-- SNAPPY-CONTRACT-VERBS-START -->
Generated from api.ts HAND_CONTRACT. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
chat |
prompt |
draft |
npx tsx ~/.claude/skills/snappy-openrouter/api.ts chat "<prompt>" |
fallback |
prompt |
draft |
npx tsx ~/.claude/skills/snappy-openrouter/api.ts fallback "<prompt>" |
When an answer carries face_hint, show it with one snappy_present(<answer>) call.
See /snappy-faces for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
---
name: snappy-openrouter
role: Multi-vendor LLM gateway via OpenRouter with cost-optimized routing
loaded-by: PreToolUse hook (auto-injected when "snappy-openrouter" is mentioned)
---
# snappy-openrouter -- loader
Single canonical interface to OpenRouter for any Snappy skill that needs multi-vendor LLM calls. OpenRouter proxies 200+ models (Anthropic, OpenAI, Google, Meta, DeepSeek, Qwen, Mistral) behind one OpenAI-compatible API. Use this when you need cost-flexible model routing, multi-model comparison, or provider fallback -- not when you need native provider features (use `snappy-ai-models` or `snappy-gemini` for those).
## API module
```typescript
import { chat, chatWithFallback } from "../snappy-openrouter/api.ts";
```
| Function | What it does |
|----------|-------------|
| `chat(prompt, opts?)` | One-shot completion (returns `{ text, model, raw }`) |
| `chatWithFallback(prompt, models[], opts?)` | Fallback chain -- first available model wins |
Options: `model`, `systemPrompt`, `temperature`, `maxTokens`.
CLI:
```bash
npx tsx ~/.claude/skills/snappy-openrouter/api.ts chat "Explain X" [--model anthropic/claude-3.5-sonnet]
npx tsx ~/.claude/skills/snappy-openrouter/api.ts fallback "Explain X" [--models "a,b,c"]
```
Credentials loaded via `snappy-settings/load.ts` from `.env.cache`. No Bitwarden unlock needed.
## Key capabilities
- One-shot chat completion to any model via a single endpoint
- Streaming responses (SSE) from any provider
- Auto-routing to cheapest model that fits the prompt (`openrouter/auto`)
- Fan-out the same prompt to N models in parallel and compare outputs
- Fallback chains: pass multiple models, first available wins
- Auth resolved automatically (env var > snappy-settings `.env.cache` > ~/.snappy/openrouter.key)
## Routing strategies
| Strategy | When | Model hint |
|----------|------|------------|
| cheapest | Classification, summarization, extraction | `deepseek/deepseek-chat`, `google/gemini-2.0-flash` |
| balanced | Default writer, structured outputs | `anthropic/claude-3.5-sonnet`, `openai/gpt-4o` |
| premium | Hard reasoning, council critique | `anthropic/claude-opus-4`, `openai/o1` |
| auto | Don't care which model | `openrouter/auto` |
| fallback | Resilience over speed | `--models "a,b,c"` (first available wins) |
| compare | Evaluate quality across models | `compare.sh --models "a,b,c"` |
## Scripts
All scripts live in `~/.claude/skills/snappy-openrouter/scripts/`. Auth is handled automatically via `lib/auth.sh` (sourced, not invoked).
### chat.sh -- one-shot completion
```bash
./scripts/chat.sh --model anthropic/claude-3.5-sonnet --prompt "Hello"
./scripts/chat.sh --model openai/gpt-4o-mini --system "Be terse." --prompt "What is X?" --text
./scripts/chat.sh --models "anthropic/claude-3.5-sonnet,openai/gpt-4o" --prompt "Hi" # fallback chain
echo "Summarize this" | ./scripts/chat.sh --model deepseek/deepseek-chat --stdin
```
Flags: `--model`, `--models` (fallback), `--prompt`, `--system`, `--temperature`, `--max-tokens`, `--transforms`, `--text` (plain text output), `--json` (json_object mode), `--stdin`.
### stream.sh -- streaming completion (SSE)
```bash
./scripts/stream.sh --model anthropic/claude-3.5-sonnet --prompt "Explain joins"
./scripts/stream.sh --model openai/gpt-4o --system "Be terse." --prompt "..." --raw
```
Flags: `--model`, `--prompt`, `--system`, `--temperature`, `--max-tokens`, `--transforms`, `--raw` (raw SSE), `--stdin`.
### route.sh -- auto-route to cheapest model
```bash
./scripts/route.sh --prompt "Classify intent: book a meeting"
./scripts/route.sh --tier balanced --prompt "Rewrite hook" --text
```
Flags: `--prompt`, `--tier` (cheap|balanced|premium), `--system`, `--temperature`, `--max-tokens`, `--text`, `--stdin`.
### compare.sh -- fan out to N models in parallel
```bash
./scripts/compare.sh \
--models "anthropic/claude-3.5-haiku,openai/gpt-4o-mini,deepseek/deepseek-chat" \
--prompt "Write a 280-char hook about Xano" --text
```
Flags: `--models` (comma-separated, required), `--prompt`, `--system`, `--temperature`, `--max-tokens`, `--text`, `--json`, `--stdin`.
## Common patterns
- **Cheap drafts, premium critique**: Use `deepseek/deepseek-chat` to write, `anthropic/claude-opus-4` to critique (70%+ savings).
- **Cascade**: Try haiku/flash first, escalate to sonnet/gpt-4o on failure (60-90% savings).
- **Transforms**: Pass `--transforms "middle-out"` to compress over-limit prompts.
## Do NOT
- Hardcode API keys -- auth.sh resolves them automatically
- Send Anthropic-format tool calls -- OpenRouter expects OpenAI-format (`tools[].function.name`)
- Use this for embeddings -- OpenRouter has a separate `/embeddings` endpoint not covered here
- Use this when you need native provider features (prompt caching, Realtime API, multimodal grounding)
## Uses
- `snappy-settings` -- provides `OPENROUTER_API_KEY`
- `snappy-ai-models` -- direct Anthropic/OpenAI when you need native features
- `snappy-gemini` -- direct Gemini when you need multimodal grounding
## Self-report convention
If this loader doesn't cover your case:
```bash
echo "[$(date -u +%FT%TZ)] snappy-openrouter: <what was missing>" >> ~/.claude/logs/agents-md-feedback.log
```
Show produced work with `snappy-faces`: call `draw` for image channels or `lang` for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-openrouter Index]|root: ~/.claude/skills/snappy-openrouter|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md,examples.md,models.md,routing-strategies.md}
<!-- SKILL-INDEX-END -->
## Used by
Nothing in the collection names this skill.
<!-- SNAPPY-CONTRACT-VERBS-START -->
## Contract verbs
Generated from `api.ts` `HAND_CONTRACT`. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
| `chat` | `prompt` | `draft` | `npx tsx ~/.claude/skills/snappy-openrouter/api.ts chat "<prompt>"` |
| `fallback` | `prompt` | `draft` | `npx tsx ~/.claude/skills/snappy-openrouter/api.ts fallback "<prompt>"` |
## Show the result
When an answer carries `face_hint`, show it with one `snappy_present(<answer>)` call.
See `/snappy-faces` for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
The single canonical interface to OpenRouter for every Snappy skill that needs flexible LLM model routing. OpenRouter exposes 200+ models from Anthropic, OpenAI, Google, Meta, Mistral, DeepSeek, Qwen, and others behind one OpenAI-compatible API and one API key. Use this skill when you want one provider, many models, and the freedom to swap without rewriting integration code.
Don't use this skill when: you specifically need the latest/native features of one provider (Anthropic prompt caching, OpenAI Realtime API, Gemini multimodal grounding). For those, go direct via snappy-ai-models (OpenAI + Anthropic) or snappy-gemini.
bash# Single chat completion (any model)
./scripts/chat.sh --model anthropic/claude-3.5-sonnet --prompt "Summarize: $(cat input.txt)"
# Streaming response
./scripts/stream.sh --model openai/gpt-4o --prompt "Write a haiku about Xano"
# Let OpenRouter pick the cheapest model that satisfies the request
./scripts/route.sh --prompt "Classify intent: book a meeting"
# Compare 3 models side-by-side on the same prompt
./scripts/compare.sh \
--models "anthropic/claude-3.5-haiku,openai/gpt-4o-mini,deepseek/deepseek-chat" \
--prompt "Rewrite this LinkedIn hook: ..."
Inputs (skills that feed this one):
snappy-settings -- provides OPENROUTER_API_KEY via env("OPENROUTER_API_KEY") from .env.cachesnappy-content -- provides voice rules and prompt scaffolds when used as the writer backendsnappy-knowledge -- provides contact context for personalized completionsOutputs (skills that consume this one):
snappy-content -- receives multi-model drafts for writer/critique stagessnappy-blog -- receives long-form draft completions from cost-optimized model pickssnappy-post -- receives platform-specific copy variants for fan-out comparisonsnappy-email -- receives newsletter drafts and subject-line variantssnappy-knowledge -- receives entity/intent extraction from cheap classification modelssnappy-pipeline -- receives enrichment LLM passes via low-cost modelsChannels (where output is delivered):
Orchestrator:
snappy-ops triggers this skill indirectly through any consumer that needs cost-optimized inference (morning briefing summary, weekly content fan-out, batch enrichment)Curated short list of high-value model identifiers. Full list lives in models.md.
| Model ID | Vendor | Best For | Context | Tier |
|---|---|---|---|---|
anthropic/claude-opus-4 |
Anthropic | Hardest reasoning, long-form writing | 200k | Premium |
anthropic/claude-3.5-sonnet |
Anthropic | Balanced quality/cost, default writer | 200k | Standard |
anthropic/claude-3.5-haiku |
Anthropic | Cheap classification, fast drafts | 200k | Cheap |
openai/gpt-4o |
OpenAI | Multimodal, structured outputs | 128k | Standard |
openai/gpt-4o-mini |
OpenAI | Cheap structured outputs | 128k | Cheap |
openai/o1 |
OpenAI | Deep reasoning, math, code | 200k | Premium |
google/gemini-2.0-flash |
Cheap, fast, huge context | 1M | Cheap | |
meta-llama/llama-3.3-70b-instruct |
Meta | Open-weights baseline | 128k | Cheap |
deepseek/deepseek-chat |
DeepSeek | Chinese-leading cheap general use | 64k | Ultra-cheap |
deepseek/deepseek-r1 |
DeepSeek | Reasoning at fraction of o1 cost | 64k | Cheap |
qwen/qwen-2.5-72b-instruct |
Alibaba | Multilingual, cheap, open | 128k | Cheap |
mistralai/mistral-large |
Mistral | EU-resident, structured outputs | 128k | Standard |
See models.md for the full catalog with per-token pricing notes and feature flags.
|strategy: cheapest_works → use deepseek/deepseek-chat or google/gemini-2.0-flash for classification, summarization, extraction
|strategy: best_quality → anthropic/claude-opus-4 or openai/o1 for council critique, hard reasoning
|strategy: balanced → anthropic/claude-3.5-sonnet for writer stage, gpt-4o for structured outputs
|strategy: streaming_chat → any model with stream=true (for live UX)
|strategy: auto_route → omit model, let OpenRouter pick (uses model="openrouter/auto")
|strategy: fallback → models[] array -- first available wins (resilience over crash)
|strategy: compare → fan out to 3-5 models, score outputs, pick winner
See routing-strategies.md for the full decision tree and cost-quality matrix.
| Script | Purpose | Required Args |
|---|---|---|
scripts/chat.sh |
One-shot chat completion (non-streaming) | --model, --prompt |
scripts/stream.sh |
Streaming chat completion (SSE → stdout) | --model, --prompt |
scripts/route.sh |
Auto-route to cheapest model that handles prompt | --prompt |
scripts/compare.sh |
Fan out same prompt to N models in parallel | --models, --prompt |
scripts/lib/auth.sh |
Loads OPENROUTER_API_KEY from snappy-settings or env |
(sourced, not invoked) |
All scripts:
https://openrouter.ai/api/v1/chat/completionsHTTP-Referer: https://snappy.ai and X-Title: Snappy OS headers--text for plain text content extraction--system for system prompt, --temperature, --max-tokens--help for full usageSee examples.md for working invocations of each script.
| Pattern | Implementation | Savings |
|---|---|---|
| Cascade: cheap → expensive | Try haiku/flash first, escalate to sonnet/gpt-4o on failure | 60-90% |
| Auto-route | model: "openrouter/auto" lets OpenRouter pick |
Variable |
| Cheap drafts, premium critique | DeepSeek writes, Opus critiques | 70%+ |
| Provider preferences | provider.order: ["DeepInfra","Together"] for Llama |
30-50% |
| Transforms | transforms: ["middle-out"] to compress long context |
Token savings |
| Batch comparisons | Run N models in parallel via compare.sh, pick best |
Quality wins |
| ❌ WRONG | ✅ CORRECT |
|---|---|
| Hardcode model strings inside business logic | Pass model as a CLI/env var so swaps don't break code |
Omit HTTP-Referer and X-Title headers |
Always include -- they affect rate limits and rankings on openrouter.ai |
| Use a model without checking its context window | Verify against models.md before sending long prompts |
Ignore transforms parameter for huge contexts |
Use transforms: ["middle-out"] to compress over-limit prompts |
Skip OpenRouter's models array fallback option |
Use "models": ["a","b","c"] for resilience -- first available wins |
| Call openai or anthropic SDK with OpenRouter base URL and forget headers | Use this skill's scripts which set headers correctly |
| Send Anthropic-format tool calls | OpenRouter expects OpenAI-format tools (tools[].function.name) |
| Assume streaming format matches one provider | OpenRouter normalizes to OpenAI SSE format -- parse data: {...} lines |
Hardcode OPENROUTER_API_KEY in scripts |
Source from snappy-settings or env -- never commit keys |
Use chat/completions for embeddings |
OpenRouter has separate /embeddings endpoint (not in this skill) |
| Need to... | Read this |
|---|---|
| Pick a model from the full catalog with pricing | models.md |
| Decide which model to use for a given task | routing-strategies.md |
| Copy-paste working invocations | examples.md |
| Understand the request/response format | OpenRouter docs at https://openrouter.ai/docs |
bashcurl https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "HTTP-Referer: https://snappy.ai" \
-H "X-Title: Snappy OS" \
-H "Content-Type: application/json" \
-d '{
"model": "anthropic/claude-3.5-sonnet",
"messages": [{"role": "user", "content": "Hello"}]
}'
bash./scripts/route.sh --prompt "Extract company name from: $(cat email.txt)"
# Sends model="openrouter/auto" -- OpenRouter picks cheapest model that fits
bash./scripts/chat.sh \
--models "anthropic/claude-3.5-sonnet,openai/gpt-4o,google/gemini-2.0-flash" \
--prompt "..."
# First available wins -- survives provider outages
bash./scripts/compare.sh \
--models "anthropic/claude-3.5-haiku,openai/gpt-4o-mini,deepseek/deepseek-chat" \
--prompt "Write a 280-char hook about Xano backend ops" \
--text
# Returns one block per model labeled with the model ID
bash./scripts/stream.sh --model anthropic/claude-3.5-sonnet --prompt "Explain Xano joins"
# Streams chunks to stdout as they arrive
| Skill | Why It's Related |
|---|---|
| snappy-settings | Provides OPENROUTER_API_KEY via env("OPENROUTER_API_KEY") from .env.cache |
| snappy-content | Consumes this skill as a backend writer when cost-optimization matters |
| snappy-blog | Consumes this skill for long-form draft generation across model tiers |
| snappy-post | Consumes this skill for platform-variant fan-out (one prompt → N model outputs) |
| snappy-email | Consumes this skill for subject-line variants and newsletter drafts |
| snappy-knowledge | Consumes this skill for cheap entity/intent extraction passes |
| snappy-pipeline | Consumes this skill for batch LLM enrichment at low cost |
| snappy-ai-models | Direct-API alternative when you need native Anthropic features (prompt caching, computer use) or OpenAI Realtime, Assistants API, or Batch API |
| snappy-gemini | Direct-API alternative when you need Gemini multimodal grounding or live API |
| snappy-ops | Triggers this skill indirectly via any consumer running on the daily/weekly rhythm |
Skill Status: COMPLETE
Skills whose description overlaps this one enough that a reader could pick the
wrong door. Each row is that skill's own first sentence about itself, so the
choice is made on its words, not on a summary written here.
| Skill | Reach for it instead when |
|---|---|
snappy-ads |
YouTube advertising for Snappy -- paid acquisition for the mastermind/consulting funnel via G… |
snappy-calendar |
Google Calendar operations for Snappy -- view events, create meetings, check availability, sc… |
snappy-gateway |
Snappy Skills Gateway -- publish, gate, and distribute Claude Code skills via skills.snappy.a… |
snappy-shell |
Kernel-loaded fallback runner. |
---
name: snappy-openrouter
instruction-only: true
reports_to: plumbing
head: false
category: AI Provider
description: >
Single canonical interface to OpenRouter for the Snappy system. OpenRouter is a unified API
that proxies requests to many different LLM providers (Anthropic, OpenAI, Google, Meta, Mistral,
DeepSeek, Qwen, etc.) under a single API key. The cost-flexible way to call multi-vendor models
from one place. Provides chat completion, streaming, auto-routing, and multi-model comparison
scripts. All requests use the OpenAI-compatible chat completions format.
Triggers on: openrouter, model routing, multi-model, multi-vendor llm, llm proxy, llm gateway,
cheapest llm, cheapest model, model comparison, anthropic via openrouter, claude via openrouter,
gpt via openrouter, openai via openrouter, gemini via openrouter, llama via openrouter,
deepseek, qwen, mistral, model fallback, auto routing, cost optimization llm, fan out models,
compare models, openai-compatible api, chat completion proxy, transforms, openrouter api key.
---
# Snappy OpenRouter -- Multi-Vendor LLM Gateway
## Purpose
The single canonical interface to OpenRouter for every Snappy skill that needs flexible LLM model routing. OpenRouter exposes 200+ models from Anthropic, OpenAI, Google, Meta, Mistral, DeepSeek, Qwen, and others behind one OpenAI-compatible API and one API key. Use this skill when you want one provider, many models, and the freedom to swap without rewriting integration code.
## When to Use This Skill
- You need to call a model that isn't first-party (DeepSeek R1, Qwen, Llama 3.3, Mistral Large)
- You want auto-fallback between providers when one is down
- You're optimizing cost -- OpenRouter shows live per-token pricing across providers
- You want to fan out the same prompt to N models and compare quality cheaply
- You need a unified chat completion endpoint for prototyping new model choices
- You need streaming responses from any provider with one client
- You want a single billing surface across vendors instead of N separate accounts
**Don't use this skill when:** you specifically need the latest/native features of one provider (Anthropic prompt caching, OpenAI Realtime API, Gemini multimodal grounding). For those, go direct via `snappy-ai-models` (OpenAI + Anthropic) or `snappy-gemini`.
## Quick Start
```bash
# Single chat completion (any model)
./scripts/chat.sh --model anthropic/claude-3.5-sonnet --prompt "Summarize: $(cat input.txt)"
# Streaming response
./scripts/stream.sh --model openai/gpt-4o --prompt "Write a haiku about Xano"
# Let OpenRouter pick the cheapest model that satisfies the request
./scripts/route.sh --prompt "Classify intent: book a meeting"
# Compare 3 models side-by-side on the same prompt
./scripts/compare.sh \
--models "anthropic/claude-3.5-haiku,openai/gpt-4o-mini,deepseek/deepseek-chat" \
--prompt "Rewrite this LinkedIn hook: ..."
```
## Workflow
**Inputs (skills that feed this one):**
- `snappy-settings` -- provides `OPENROUTER_API_KEY` via `env("OPENROUTER_API_KEY")` from `.env.cache`
- `snappy-content` -- provides voice rules and prompt scaffolds when used as the writer backend
- `snappy-knowledge` -- provides contact context for personalized completions
**Outputs (skills that consume this one):**
- `snappy-content` -- receives multi-model drafts for writer/critique stages
- `snappy-blog` -- receives long-form draft completions from cost-optimized model picks
- `snappy-post` -- receives platform-specific copy variants for fan-out comparison
- `snappy-email` -- receives newsletter drafts and subject-line variants
- `snappy-knowledge` -- receives entity/intent extraction from cheap classification models
- `snappy-pipeline` -- receives enrichment LLM passes via low-cost models
**Channels (where output is delivered):**
- This skill is a backend primitive -- output is plain JSON/text consumed by other skills, never delivered to a channel directly
**Orchestrator:**
- `snappy-ops` triggers this skill indirectly through any consumer that needs cost-optimized inference (morning briefing summary, weekly content fan-out, batch enrichment)
## Model Catalog
Curated short list of high-value model identifiers. Full list lives in [models.md](models.md).
| Model ID | Vendor | Best For | Context | Tier |
|----------|--------|----------|---------|------|
| `anthropic/claude-opus-4` | Anthropic | Hardest reasoning, long-form writing | 200k | Premium |
| `anthropic/claude-3.5-sonnet` | Anthropic | Balanced quality/cost, default writer | 200k | Standard |
| `anthropic/claude-3.5-haiku` | Anthropic | Cheap classification, fast drafts | 200k | Cheap |
| `openai/gpt-4o` | OpenAI | Multimodal, structured outputs | 128k | Standard |
| `openai/gpt-4o-mini` | OpenAI | Cheap structured outputs | 128k | Cheap |
| `openai/o1` | OpenAI | Deep reasoning, math, code | 200k | Premium |
| `google/gemini-2.0-flash` | Google | Cheap, fast, huge context | 1M | Cheap |
| `meta-llama/llama-3.3-70b-instruct` | Meta | Open-weights baseline | 128k | Cheap |
| `deepseek/deepseek-chat` | DeepSeek | Chinese-leading cheap general use | 64k | Ultra-cheap |
| `deepseek/deepseek-r1` | DeepSeek | Reasoning at fraction of o1 cost | 64k | Cheap |
| `qwen/qwen-2.5-72b-instruct` | Alibaba | Multilingual, cheap, open | 128k | Cheap |
| `mistralai/mistral-large` | Mistral | EU-resident, structured outputs | 128k | Standard |
See [models.md](models.md) for the full catalog with per-token pricing notes and feature flags.
## Routing Strategies
|strategy: cheapest_works → use deepseek/deepseek-chat or google/gemini-2.0-flash for classification, summarization, extraction
|strategy: best_quality → anthropic/claude-opus-4 or openai/o1 for council critique, hard reasoning
|strategy: balanced → anthropic/claude-3.5-sonnet for writer stage, gpt-4o for structured outputs
|strategy: streaming_chat → any model with stream=true (for live UX)
|strategy: auto_route → omit model, let OpenRouter pick (uses model="openrouter/auto")
|strategy: fallback → models[] array -- first available wins (resilience over crash)
|strategy: compare → fan out to 3-5 models, score outputs, pick winner
See [routing-strategies.md](routing-strategies.md) for the full decision tree and cost-quality matrix.
## Script Reference
| Script | Purpose | Required Args |
|--------|---------|--------------|
| `scripts/chat.sh` | One-shot chat completion (non-streaming) | `--model`, `--prompt` |
| `scripts/stream.sh` | Streaming chat completion (SSE → stdout) | `--model`, `--prompt` |
| `scripts/route.sh` | Auto-route to cheapest model that handles prompt | `--prompt` |
| `scripts/compare.sh` | Fan out same prompt to N models in parallel | `--models`, `--prompt` |
| `scripts/lib/auth.sh` | Loads `OPENROUTER_API_KEY` from snappy-settings or env | (sourced, not invoked) |
All scripts:
- Use the real API at `https://openrouter.ai/api/v1/chat/completions`
- Send required `HTTP-Referer: https://snappy.ai` and `X-Title: Snappy OS` headers
- Output JSON to stdout by default; pass `--text` for plain text content extraction
- Support `--system` for system prompt, `--temperature`, `--max-tokens`
- Support `--help` for full usage
See [examples.md](examples.md) for working invocations of each script.
## Cost Optimization Patterns
| Pattern | Implementation | Savings |
|---------|---------------|---------|
| Cascade: cheap → expensive | Try haiku/flash first, escalate to sonnet/gpt-4o on failure | 60-90% |
| Auto-route | `model: "openrouter/auto"` lets OpenRouter pick | Variable |
| Cheap drafts, premium critique | DeepSeek writes, Opus critiques | 70%+ |
| Provider preferences | `provider.order: ["DeepInfra","Together"]` for Llama | 30-50% |
| Transforms | `transforms: ["middle-out"]` to compress long context | Token savings |
| Batch comparisons | Run N models in parallel via `compare.sh`, pick best | Quality wins |
## ❌ WRONG / ✅ CORRECT
| ❌ WRONG | ✅ CORRECT |
|----------|-----------|
| Hardcode model strings inside business logic | Pass model as a CLI/env var so swaps don't break code |
| Omit `HTTP-Referer` and `X-Title` headers | Always include -- they affect rate limits and rankings on openrouter.ai |
| Use a model without checking its context window | Verify against [models.md](models.md) before sending long prompts |
| Ignore `transforms` parameter for huge contexts | Use `transforms: ["middle-out"]` to compress over-limit prompts |
| Skip OpenRouter's `models` array fallback option | Use `"models": ["a","b","c"]` for resilience -- first available wins |
| Call openai or anthropic SDK with OpenRouter base URL and forget headers | Use this skill's scripts which set headers correctly |
| Send Anthropic-format tool calls | OpenRouter expects OpenAI-format tools (`tools[].function.name`) |
| Assume streaming format matches one provider | OpenRouter normalizes to OpenAI SSE format -- parse `data: {...}` lines |
| Hardcode `OPENROUTER_API_KEY` in scripts | Source from `snappy-settings` or env -- never commit keys |
| Use `chat/completions` for embeddings | OpenRouter has separate `/embeddings` endpoint (not in this skill) |
## Navigation Guide
| Need to... | Read this |
|------------|-----------|
| Pick a model from the full catalog with pricing | [models.md](models.md) |
| Decide which model to use for a given task | [routing-strategies.md](routing-strategies.md) |
| Copy-paste working invocations | [examples.md](examples.md) |
| Understand the request/response format | OpenRouter docs at https://openrouter.ai/docs |
## Quick Reference
### Minimal curl (no scripts)
```bash
curl https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "HTTP-Referer: https://snappy.ai" \
-H "X-Title: Snappy OS" \
-H "Content-Type: application/json" \
-d '{
"model": "anthropic/claude-3.5-sonnet",
"messages": [{"role": "user", "content": "Hello"}]
}'
```
### Auto-routing (cheapest)
```bash
./scripts/route.sh --prompt "Extract company name from: $(cat email.txt)"
# Sends model="openrouter/auto" -- OpenRouter picks cheapest model that fits
```
### Fallback chain
```bash
./scripts/chat.sh \
--models "anthropic/claude-3.5-sonnet,openai/gpt-4o,google/gemini-2.0-flash" \
--prompt "..."
# First available wins -- survives provider outages
```
### Compare 3 models
```bash
./scripts/compare.sh \
--models "anthropic/claude-3.5-haiku,openai/gpt-4o-mini,deepseek/deepseek-chat" \
--prompt "Write a 280-char hook about Xano backend ops" \
--text
# Returns one block per model labeled with the model ID
```
### Streaming (for live UX)
```bash
./scripts/stream.sh --model anthropic/claude-3.5-sonnet --prompt "Explain Xano joins"
# Streams chunks to stdout as they arrive
```
## Related Skills
| Skill | Why It's Related |
|-------|-----------------|
| **snappy-settings** | Provides `OPENROUTER_API_KEY` via `env("OPENROUTER_API_KEY")` from `.env.cache` |
| **snappy-content** | Consumes this skill as a backend writer when cost-optimization matters |
| **snappy-blog** | Consumes this skill for long-form draft generation across model tiers |
| **snappy-post** | Consumes this skill for platform-variant fan-out (one prompt → N model outputs) |
| **snappy-email** | Consumes this skill for subject-line variants and newsletter drafts |
| **snappy-knowledge** | Consumes this skill for cheap entity/intent extraction passes |
| **snappy-pipeline** | Consumes this skill for batch LLM enrichment at low cost |
| **snappy-ai-models** | Direct-API alternative when you need native Anthropic features (prompt caching, computer use) or OpenAI Realtime, Assistants API, or Batch API |
| **snappy-gemini** | Direct-API alternative when you need Gemini multimodal grounding or live API |
| **snappy-ops** | Triggers this skill indirectly via any consumer running on the daily/weekly rhythm |
**Skill Status**: COMPLETE
## Near neighbours
Skills whose description overlaps this one enough that a reader could pick the
wrong door. Each row is that skill's own first sentence about itself, so the
choice is made on its words, not on a summary written here.
| Skill | Reach for it instead when |
|---|---|
| `snappy-ads` | YouTube advertising for Snappy -- paid acquisition for the mastermind/consulting funnel via G… |
| `snappy-calendar` | Google Calendar operations for Snappy -- view events, create meetings, check availability, sc… |
| `snappy-gateway` | Snappy Skills Gateway -- publish, gate, and distribute Claude Code skills via skills.snappy.a… |
| `snappy-shell` | Kernel-loaded fallback runner. |
#!/usr/bin/env npx tsx
/**
* snappy-openrouter/api.ts -- OpenRouter multi-vendor LLM gateway for all snappy-* skills.
*
* Uses OPENROUTER_API_KEY from snappy-settings/.env.cache.
* OpenAI-compatible API at https://openrouter.ai/api/v1.
*
* Usage:
* npx tsx api.ts chat "Explain quantum tunneling"
* npx tsx api.ts chat "Summarize this" --model anthropic/claude-3.5-sonnet
*
* Or import as module:
* import { chat, chatWithFallback } from "../snappy-openrouter/api.ts";
*/
import { env } from "../snappy-settings/load.ts";
import { realpathSync } from "fs";
import { annotationsForClass } from "../snappy-settings/tool-annotations.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
const BASE = "https://openrouter.ai/api/v1";
const DEFAULT_MODEL = "anthropic/claude-3.5-sonnet";
interface ChatOptions {
model?: string;
systemPrompt?: string;
temperature?: number;
maxTokens?: number;
}
async function openrouter(path: string, body: Record<string, unknown>): Promise<unknown> {
const res = await fetch(`${BASE}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${env("OPENROUTER_API_KEY")}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://snappy.ai",
"X-Title": "Snappy",
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenRouter failed (${res.status}): ${err}`);
}
return res.json();
}
function buildMessages(prompt: string, systemPrompt?: string) {
const messages: { role: string; content: string }[] = [];
if (systemPrompt) messages.push({ role: "system", content: systemPrompt });
messages.push({ role: "user", content: prompt });
return messages;
}
// --- Public API ---
export async function chat(prompt: string, opts: ChatOptions = {}): Promise<{ text: string; model: string; raw: unknown }> {
const model = opts.model || DEFAULT_MODEL;
const data = await openrouter("/chat/completions", {
model,
messages: buildMessages(prompt, opts.systemPrompt),
...(opts.temperature != null ? { temperature: opts.temperature } : {}),
...(opts.maxTokens ? { max_tokens: opts.maxTokens } : {}),
}) as any;
const text = data.choices?.[0]?.message?.content || "";
const usedModel = data.model || model;
return { text, model: usedModel, raw: data };
}
export async function chatWithFallback(prompt: string, models: string[], opts: Omit<ChatOptions, "model"> = {}): Promise<{ text: string; model: string; raw: unknown }> {
const data = await openrouter("/chat/completions", {
models,
messages: buildMessages(prompt, opts.systemPrompt),
...(opts.temperature != null ? { temperature: opts.temperature } : {}),
...(opts.maxTokens ? { max_tokens: opts.maxTokens } : {}),
route: "fallback",
}) as any;
const text = data.choices?.[0]?.message?.content || "";
const usedModel = data.model || models[0];
return { text, model: usedModel, raw: data };
}
// --- CLI ---
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
/** THE HOST-FACING FACTS ⟨lane CONTRACTS N–Z, 2026-09-09⟩. `class` is the
* closed effect set snappy-tool-design rule 18 grades; `annotations` are
* DERIVED from it by the ONE derivation in
* `snappy-settings/tool-annotations.ts`, never written per verb, so a class
* and its published hints cannot disagree; `refusals` projects the ONE closed
* table in `snappy-settings/refusal-codes.ts`; `requires` is exactly the
* credential keys this file's own executable reads name, and nothing else. */
export const HAND_CONTRACT = {
skill: "snappy-openrouter",
description: "Single canonical interface to OpenRouter for the Snappy system. OpenRouter is a unified API that proxies requests to many different LLM providers (Anthropic, OpenAI, Google, Meta, Mistral, DeepSeek, Qwen, etc.) under a single API key. The cost-flexible way to call multi-vendor models from one place. Provides chat completion, streaming, auto-routing, and multi-model comparison scripts. All requests use the OpenAI-compatible chat completions format. Triggers on: openrouter, model routing, multi-model, multi-vendor llm, llm proxy, llm gateway, cheapest llm, cheapest model, model comparison, anthropic via openrouter, claude via openrouter, gpt via openrouter, openai via openrouter, gemini via openrouter, llama via openrouter, deepseek, qwen, mistral, model fallback, auto routing, cost optimization llm, fan out models, compare models, openai-compatible api, chat completion proxy, transforms, openrouter api key.",
managed: true,
requires: ["OPENROUTER_API_KEY"] as string[],
refusals: refusalTable("missing_credential", "missing_argument", "unknown_verb", "upstream_error"),
verbs: {
chat: {
args: ["prompt"], effect: "draft", class: "additive-write", openWorld: true,
annotations: annotationsForClass("additive-write", { openWorld: true }),
flags: {"model":"--model"},
inputSchema: { properties: { prompt: { type: "string", description: "The prompt text sent to the model" } } },
},
fallback: {
args: ["prompt"], effect: "draft", class: "additive-write", openWorld: true,
annotations: annotationsForClass("additive-write", { openWorld: true }),
flags: {"models":"--models"},
inputSchema: { properties: { prompt: { type: "string", description: "The prompt text sent to the model" } } },
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => {
const [, , cmd, ...args] = process.argv;
switch (cmd) {
case "chat": {
const prompt = args.filter(a => !a.startsWith("--")).join(" ");
const modelIdx = args.indexOf("--model");
const model = modelIdx >= 0 ? args[modelIdx + 1] : undefined;
if (!prompt) { console.error("Usage: api.ts chat <prompt> [--model <model>]"); process.exit(1); }
const { text, model: used } = await chat(prompt, { model });
console.log(`[${used}]`);
console.log(text);
break;
}
case "fallback": {
const prompt = args.filter(a => !a.startsWith("--")).join(" ");
const modelsIdx = args.indexOf("--models");
const models = modelsIdx >= 0 ? args[modelsIdx + 1].split(",") : ["anthropic/claude-3.5-sonnet", "openai/gpt-4o", "deepseek/deepseek-chat"];
if (!prompt) { console.error("Usage: api.ts fallback <prompt> [--models <a,b,c>]"); process.exit(1); }
const { text, model: used } = await chatWithFallback(prompt, models);
console.log(`[${used}]`);
console.log(text);
break;
}
default:
console.log("Usage: npx tsx api.ts [chat|fallback] ...");
}
})();
}
#!/usr/bin/env npx tsx
/**
* snappy-openrouter/api.ts -- OpenRouter multi-vendor LLM gateway for all snappy-* skills.
*
* Uses OPENROUTER_API_KEY from snappy-settings/.env.cache.
* OpenAI-compatible API at https://openrouter.ai/api/v1.
*
* Usage:
* npx tsx api.ts chat "Explain quantum tunneling"
* npx tsx api.ts chat "Summarize this" --model anthropic/claude-3.5-sonnet
*
* Or import as module:
* import { chat, chatWithFallback } from "../snappy-openrouter/api.ts";
*/
import { env } from "../snappy-settings/load.ts";
import { realpathSync } from "fs";
import { annotationsForClass } from "../snappy-settings/tool-annotations.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
const BASE = "https://openrouter.ai/api/v1";
const DEFAULT_MODEL = "anthropic/claude-3.5-sonnet";
interface ChatOptions {
model?: string;
systemPrompt?: string;
temperature?: number;
maxTokens?: number;
}
async function openrouter(path: string, body: Record<string, unknown>): Promise<unknown> {
const res = await fetch(`${BASE}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${env("OPENROUTER_API_KEY")}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://snappy.ai",
"X-Title": "Snappy",
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenRouter failed (${res.status}): ${err}`);
}
return res.json();
}
function buildMessages(prompt: string, systemPrompt?: string) {
const messages: { role: string; content: string }[] = [];
if (systemPrompt) messages.push({ role: "system", content: systemPrompt });
messages.push({ role: "user", content: prompt });
return messages;
}
// --- Public API ---
export async function chat(prompt: string, opts: ChatOptions = {}): Promise<{ text: string; model: string; raw: unknown }> {
const model = opts.model || DEFAULT_MODEL;
const data = await openrouter("/chat/completions", {
model,
messages: buildMessages(prompt, opts.systemPrompt),
...(opts.temperature != null ? { temperature: opts.temperature } : {}),
...(opts.maxTokens ? { max_tokens: opts.maxTokens } : {}),
}) as any;
const text = data.choices?.[0]?.message?.content || "";
const usedModel = data.model || model;
return { text, model: usedModel, raw: data };
}
export async function chatWithFallback(prompt: string, models: string[], opts: Omit<ChatOptions, "model"> = {}): Promise<{ text: string; model: string; raw: unknown }> {
const data = await openrouter("/chat/completions", {
models,
messages: buildMessages(prompt, opts.systemPrompt),
...(opts.temperature != null ? { temperature: opts.temperature } : {}),
...(opts.maxTokens ? { max_tokens: opts.maxTokens } : {}),
route: "fallback",
}) as any;
const text = data.choices?.[0]?.message?.content || "";
const usedModel = data.model || models[0];
return { text, model: usedModel, raw: data };
}
// --- CLI ---
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
/** THE HOST-FACING FACTS ⟨lane CONTRACTS N–Z, 2026-09-09⟩. `class` is the
* closed effect set snappy-tool-design rule 18 grades; `annotations` are
* DERIVED from it by the ONE derivation in
* `snappy-settings/tool-annotations.ts`, never written per verb, so a class
* and its published hints cannot disagree; `refusals` projects the ONE closed
* table in `snappy-settings/refusal-codes.ts`; `requires` is exactly the
* credential keys this file's own executable reads name, and nothing else. */
export const HAND_CONTRACT = {
skill: "snappy-openrouter",
description: "Single canonical interface to OpenRouter for the Snappy system. OpenRouter is a unified API that proxies requests to many different LLM providers (Anthropic, OpenAI, Google, Meta, Mistral, DeepSeek, Qwen, etc.) under a single API key. The cost-flexible way to call multi-vendor models from one place. Provides chat completion, streaming, auto-routing, and multi-model comparison scripts. All requests use the OpenAI-compatible chat completions format. Triggers on: openrouter, model routing, multi-model, multi-vendor llm, llm proxy, llm gateway, cheapest llm, cheapest model, model comparison, anthropic via openrouter, claude via openrouter, gpt via openrouter, openai via openrouter, gemini via openrouter, llama via openrouter, deepseek, qwen, mistral, model fallback, auto routing, cost optimization llm, fan out models, compare models, openai-compatible api, chat completion proxy, transforms, openrouter api key.",
managed: true,
requires: ["OPENROUTER_API_KEY"] as string[],
refusals: refusalTable("missing_credential", "missing_argument", "unknown_verb", "upstream_error"),
verbs: {
chat: {
args: ["prompt"], effect: "draft", class: "additive-write", openWorld: true,
annotations: annotationsForClass("additive-write", { openWorld: true }),
flags: {"model":"--model"},
inputSchema: { properties: { prompt: { type: "string", description: "The prompt text sent to the model" } } },
},
fallback: {
args: ["prompt"], effect: "draft", class: "additive-write", openWorld: true,
annotations: annotationsForClass("additive-write", { openWorld: true }),
flags: {"models":"--models"},
inputSchema: { properties: { prompt: { type: "string", description: "The prompt text sent to the model" } } },
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => {
const [, , cmd, ...args] = process.argv;
switch (cmd) {
case "chat": {
const prompt = args.filter(a => !a.startsWith("--")).join(" ");
const modelIdx = args.indexOf("--model");
const model = modelIdx >= 0 ? args[modelIdx + 1] : undefined;
if (!prompt) { console.error("Usage: api.ts chat <prompt> [--model <model>]"); process.exit(1); }
const { text, model: used } = await chat(prompt, { model });
console.log(`[${used}]`);
console.log(text);
break;
}
case "fallback": {
const prompt = args.filter(a => !a.startsWith("--")).join(" ");
const modelsIdx = args.indexOf("--models");
const models = modelsIdx >= 0 ? args[modelsIdx + 1].split(",") : ["anthropic/claude-3.5-sonnet", "openai/gpt-4o", "deepseek/deepseek-chat"];
if (!prompt) { console.error("Usage: api.ts fallback <prompt> [--models <a,b,c>]"); process.exit(1); }
const { text, model: used } = await chatWithFallback(prompt, models);
console.log(`[${used}]`);
console.log(text);
break;
}
default:
console.log("Usage: npx tsx api.ts [chat|fallback] ...");
}
})();
}
Real, copy-paste-ready invocations of the four scripts. All examples assume you've sourced credentials via snappy-settings or have OPENROUTER_API_KEY exported.
bash# Option A: env var (one shot)
export OPENROUTER_API_KEY="sk-or-v1-..."
# Option B: snappy-settings .env.cache (recommended)
# (snappy-settings stores it once; auth.sh auto-loads from there)
# Option C: keyfile fallback
mkdir -p ~/.snappy && chmod 700 ~/.snappy
echo "sk-or-v1-..." > ~/.snappy/openrouter.key
chmod 600 ~/.snappy/openrouter.key
# Make scripts executable (only needed once)
cd ~/.claude/skills/snappy-openrouter
chmod +x scripts/*.sh scripts/lib/*.sh
bash./scripts/chat.sh \
--model anthropic/claude-3.5-sonnet \
--prompt "What is OpenRouter?" \
--text
bash./scripts/chat.sh \
--model openai/gpt-4o-mini \
--system "You are a terse assistant. Answer in one sentence." \
--prompt "Explain joins in SQL." \
--text
bash./scripts/chat.sh \
--model deepseek/deepseek-chat \
--system "Classify intent. Reply with one word: book_meeting, ask_question, sales_inquiry, support, other." \
--prompt "Hey, can we hop on a call next Tuesday at 3pm?" \
--text
bash./scripts/chat.sh \
--model openai/gpt-4o \
--system 'Return only JSON: {"intent":"...","confidence":0.0}' \
--prompt "I'd like to schedule a demo of Total CRM" \
--json-mode \
--text
bashcat email.txt | ./scripts/chat.sh \
--model anthropic/claude-3.5-haiku \
--system "Summarize in 2 bullets." \
--stdin \
--text
bash./scripts/chat.sh \
--models "anthropic/claude-3.5-sonnet,openai/gpt-4o,google/gemini-2.0-flash" \
--prompt "Write a 100-word product description for Snappy" \
--text
bashcat huge-transcript.md | ./scripts/chat.sh \
--model google/gemini-2.0-flash \
--transforms middle-out \
--system "Summarize the call into 5 takeaways and 3 action items." \
--stdin \
--text
bash./scripts/chat.sh \
--model anthropic/claude-3.5-sonnet \
--prompt "Hi" \
| jq '{model, content: .choices[0].message.content, usage}'
bash./scripts/stream.sh \
--model anthropic/claude-3.5-sonnet \
--prompt "Write a 200-word story about a Xano backend that gained sentience"
bash./scripts/stream.sh \
--model openai/gpt-4o \
--system "You are a backend architect. Be specific." \
--prompt "Design a Xano table schema for testimonials"
bash./scripts/stream.sh \
--model deepseek/deepseek-r1 \
--prompt "Solve: 17 * 23 + 5" \
--raw
bash./scripts/stream.sh \
--model anthropic/claude-3.5-sonnet \
--prompt "Write a blog draft about Xano joins" \
| tee draft.md
bash./scripts/route.sh --prompt "What is OpenRouter?" --text
bashcat email.txt | ./scripts/route.sh --stdin --text
bash./scripts/route.sh \
--tier balanced \
--prompt "Rewrite this LinkedIn hook for more punch: We launched a new feature today." \
--text
bash./scripts/route.sh \
--tier premium \
--prompt "Solve this novel reasoning problem: ..." \
--text
bash./scripts/route.sh --prompt "Hi" | jq '{model, content}'
# Shows which provider OpenRouter ultimately picked
bash./scripts/compare.sh \
--models "anthropic/claude-3.5-haiku,openai/gpt-4o-mini,deepseek/deepseek-chat" \
--prompt "Write a 280-char LinkedIn hook about Xano backend ops. No hashtags." \
--text
bash./scripts/compare.sh \
--models "anthropic/claude-opus-4,openai/o1,anthropic/claude-3.5-sonnet" \
--system "You are a brutally honest copy editor. Critique this draft." \
--prompt "$(cat draft.md)" \
--text
bash./scripts/compare.sh \
--models "openai/gpt-4o,openai/gpt-4o-mini,mistralai/mistral-large" \
--system 'Return JSON: {"score": 0-100, "issues": [string]}' \
--prompt "$(cat hook.txt)" \
--json-mode \
--text
bashcat brief.md | ./scripts/compare.sh \
--models "anthropic/claude-3.5-sonnet,openai/gpt-4o" \
--stdin \
--text
bashPROMPT="Classify: book a meeting"
# Try cheap first
RESULT=$(./scripts/chat.sh --model deepseek/deepseek-chat --prompt "$PROMPT" --text)
# If empty or "uncertain", escalate
if [[ -z "$RESULT" || "$RESULT" == *"uncertain"* ]]; then
RESULT=$(./scripts/chat.sh --model anthropic/claude-3.5-sonnet --prompt "$PROMPT" --text)
fi
echo "$RESULT"
bash# Step 1: 3 cheap drafts
./scripts/compare.sh \
--models "anthropic/claude-3.5-haiku,openai/gpt-4o-mini,deepseek/deepseek-chat" \
--prompt "Write a 280-char hook about Xano" \
--text > variants.txt
# Step 2: premium judge picks the winner
./scripts/chat.sh \
--model anthropic/claude-opus-4 \
--system "Pick the best variant. Reply with the winning text only." \
--prompt "$(cat variants.txt)" \
--text > winner.txt
cat winner.txt
bash# Enrich a list of contacts cheaply
while IFS= read -r contact; do
category=$(./scripts/chat.sh \
--model deepseek/deepseek-chat \
--system "Classify ICP fit. One word: hot, warm, cold, no-fit." \
--prompt "$contact" \
--text)
echo "$category | $contact"
done < contacts.txt
bash# snappy-content interview produces these answers, then:
./scripts/chat.sh \
--model anthropic/claude-3.5-sonnet \
--system "$(cat ~/.claude/skills/snappy-content/anti-ai-checklist.md)" \
--prompt "INTERVIEW DATA: ..." \
--text > draft.md
bash./scripts/compare.sh \
--models "anthropic/claude-opus-4,openai/o1,anthropic/claude-3.5-sonnet" \
--system "Critique this blog post against the 50% specificity rule." \
--prompt "$(cat blog-draft.md)" \
--text
bash./scripts/chat.sh \
--model deepseek/deepseek-chat \
--system 'Extract: {"company":"...","role":"...","intent":"..."} as JSON' \
--prompt "$(cat ~/email.txt)" \
--json-mode \
--text
bashcat ~/transcript.txt | ./scripts/chat.sh \
--model google/gemini-2.0-flash \
--transforms middle-out \
--system "Summarize: 5 takeaways, 3 action items, 2 quotes." \
--stdin \
--text# Snappy OpenRouter -- Working Examples
Real, copy-paste-ready invocations of the four scripts. All examples assume you've sourced credentials via `snappy-settings` or have `OPENROUTER_API_KEY` exported.
## Table of Contents
- [Setup](#setup)
- [chat.sh examples](#chatsh-examples)
- [stream.sh examples](#streamsh-examples)
- [route.sh examples](#routesh-examples)
- [compare.sh examples](#comparesh-examples)
- [Pipeline patterns](#pipeline-patterns)
- [Integration with other Snappy skills](#integration-with-other-snappy-skills)
---
## Setup
```bash
# Option A: env var (one shot)
export OPENROUTER_API_KEY="sk-or-v1-..."
# Option B: snappy-settings .env.cache (recommended)
# (snappy-settings stores it once; auth.sh auto-loads from there)
# Option C: keyfile fallback
mkdir -p ~/.snappy && chmod 700 ~/.snappy
echo "sk-or-v1-..." > ~/.snappy/openrouter.key
chmod 600 ~/.snappy/openrouter.key
# Make scripts executable (only needed once)
cd ~/.claude/skills/snappy-openrouter
chmod +x scripts/*.sh scripts/lib/*.sh
```
---
## chat.sh examples
### Minimal one-shot
```bash
./scripts/chat.sh \
--model anthropic/claude-3.5-sonnet \
--prompt "What is OpenRouter?" \
--text
```
### With system prompt
```bash
./scripts/chat.sh \
--model openai/gpt-4o-mini \
--system "You are a terse assistant. Answer in one sentence." \
--prompt "Explain joins in SQL." \
--text
```
### Cheap classification (returns plain text label)
```bash
./scripts/chat.sh \
--model deepseek/deepseek-chat \
--system "Classify intent. Reply with one word: book_meeting, ask_question, sales_inquiry, support, other." \
--prompt "Hey, can we hop on a call next Tuesday at 3pm?" \
--text
```
### Force JSON output
```bash
./scripts/chat.sh \
--model openai/gpt-4o \
--system 'Return only JSON: {"intent":"...","confidence":0.0}' \
--prompt "I'd like to schedule a demo of Total CRM" \
--json-mode \
--text
```
### Read prompt from stdin
```bash
cat email.txt | ./scripts/chat.sh \
--model anthropic/claude-3.5-haiku \
--system "Summarize in 2 bullets." \
--stdin \
--text
```
### Fallback chain (resilience)
```bash
./scripts/chat.sh \
--models "anthropic/claude-3.5-sonnet,openai/gpt-4o,google/gemini-2.0-flash" \
--prompt "Write a 100-word product description for Snappy" \
--text
```
### Long-context summarization with transforms
```bash
cat huge-transcript.md | ./scripts/chat.sh \
--model google/gemini-2.0-flash \
--transforms middle-out \
--system "Summarize the call into 5 takeaways and 3 action items." \
--stdin \
--text
```
### Get full JSON (when you need usage stats)
```bash
./scripts/chat.sh \
--model anthropic/claude-3.5-sonnet \
--prompt "Hi" \
| jq '{model, content: .choices[0].message.content, usage}'
```
---
## stream.sh examples
### Default (extracted text only)
```bash
./scripts/stream.sh \
--model anthropic/claude-3.5-sonnet \
--prompt "Write a 200-word story about a Xano backend that gained sentience"
```
### With system prompt
```bash
./scripts/stream.sh \
--model openai/gpt-4o \
--system "You are a backend architect. Be specific." \
--prompt "Design a Xano table schema for testimonials"
```
### Stream raw SSE for debugging
```bash
./scripts/stream.sh \
--model deepseek/deepseek-r1 \
--prompt "Solve: 17 * 23 + 5" \
--raw
```
### Stream and tee to a file
```bash
./scripts/stream.sh \
--model anthropic/claude-3.5-sonnet \
--prompt "Write a blog draft about Xano joins" \
| tee draft.md
```
---
## route.sh examples
### Cheapest available
```bash
./scripts/route.sh --prompt "What is OpenRouter?" --text
```
### Cheap with stdin
```bash
cat email.txt | ./scripts/route.sh --stdin --text
```
### Balanced tier
```bash
./scripts/route.sh \
--tier balanced \
--prompt "Rewrite this LinkedIn hook for more punch: We launched a new feature today." \
--text
```
### Premium tier (forces opus / o1)
```bash
./scripts/route.sh \
--tier premium \
--prompt "Solve this novel reasoning problem: ..." \
--text
```
### Discover which model actually answered
```bash
./scripts/route.sh --prompt "Hi" | jq '{model, content}'
# Shows which provider OpenRouter ultimately picked
```
---
## compare.sh examples
### Three cheap models on a hook
```bash
./scripts/compare.sh \
--models "anthropic/claude-3.5-haiku,openai/gpt-4o-mini,deepseek/deepseek-chat" \
--prompt "Write a 280-char LinkedIn hook about Xano backend ops. No hashtags." \
--text
```
### Premium council (3 voices)
```bash
./scripts/compare.sh \
--models "anthropic/claude-opus-4,openai/o1,anthropic/claude-3.5-sonnet" \
--system "You are a brutally honest copy editor. Critique this draft." \
--prompt "$(cat draft.md)" \
--text
```
### JSON-mode fan-out
```bash
./scripts/compare.sh \
--models "openai/gpt-4o,openai/gpt-4o-mini,mistralai/mistral-large" \
--system 'Return JSON: {"score": 0-100, "issues": [string]}' \
--prompt "$(cat hook.txt)" \
--json-mode \
--text
```
### Stdin fan-out
```bash
cat brief.md | ./scripts/compare.sh \
--models "anthropic/claude-3.5-sonnet,openai/gpt-4o" \
--stdin \
--text
```
---
## Pipeline patterns
### Cascade: cheap → premium
```bash
PROMPT="Classify: book a meeting"
# Try cheap first
RESULT=$(./scripts/chat.sh --model deepseek/deepseek-chat --prompt "$PROMPT" --text)
# If empty or "uncertain", escalate
if [[ -z "$RESULT" || "$RESULT" == *"uncertain"* ]]; then
RESULT=$(./scripts/chat.sh --model anthropic/claude-3.5-sonnet --prompt "$PROMPT" --text)
fi
echo "$RESULT"
```
### Generate-then-judge
```bash
# Step 1: 3 cheap drafts
./scripts/compare.sh \
--models "anthropic/claude-3.5-haiku,openai/gpt-4o-mini,deepseek/deepseek-chat" \
--prompt "Write a 280-char hook about Xano" \
--text > variants.txt
# Step 2: premium judge picks the winner
./scripts/chat.sh \
--model anthropic/claude-opus-4 \
--system "Pick the best variant. Reply with the winning text only." \
--prompt "$(cat variants.txt)" \
--text > winner.txt
cat winner.txt
```
### Batch enrichment loop
```bash
# Enrich a list of contacts cheaply
while IFS= read -r contact; do
category=$(./scripts/chat.sh \
--model deepseek/deepseek-chat \
--system "Classify ICP fit. One word: hot, warm, cold, no-fit." \
--prompt "$contact" \
--text)
echo "$category | $contact"
done < contacts.txt
```
---
## Integration with other Snappy skills
### As writer for snappy-content
```bash
# snappy-content interview produces these answers, then:
./scripts/chat.sh \
--model anthropic/claude-3.5-sonnet \
--system "$(cat ~/.claude/skills/snappy-content/anti-ai-checklist.md)" \
--prompt "INTERVIEW DATA: ..." \
--text > draft.md
```
### As critic for snappy-blog
```bash
./scripts/compare.sh \
--models "anthropic/claude-opus-4,openai/o1,anthropic/claude-3.5-sonnet" \
--system "Critique this blog post against the 50% specificity rule." \
--prompt "$(cat blog-draft.md)" \
--text
```
### As classifier for snappy-knowledge
```bash
./scripts/chat.sh \
--model deepseek/deepseek-chat \
--system 'Extract: {"company":"...","role":"...","intent":"..."} as JSON' \
--prompt "$(cat ~/email.txt)" \
--json-mode \
--text
```
### As summarizer for snappy-transcripts
```bash
cat ~/transcript.txt | ./scripts/chat.sh \
--model google/gemini-2.0-flash \
--transforms middle-out \
--system "Summarize: 5 takeaways, 3 action items, 2 quotes." \
--stdin \
--text
```
Curated list of model identifiers usable through snappy-openrouter. Pricing is illustrative -- always check live pricing at https://openrouter.ai/models for current rates and availability.
|use_case: cheap_classification → google/gemini-2.0-flash | deepseek/deepseek-chat | anthropic/claude-3.5-haiku
|use_case: cheap_summarization → google/gemini-2.0-flash | openai/gpt-4o-mini
|use_case: writer_default → anthropic/claude-3.5-sonnet
|use_case: critique_premium → anthropic/claude-opus-4 | openai/o1
|use_case: math_reasoning → openai/o1 | deepseek/deepseek-r1
|use_case: structured_json_outputs → openai/gpt-4o | openai/gpt-4o-mini | mistralai/mistral-large
|use_case: huge_context → google/gemini-2.0-flash (1M) | anthropic/claude-3.5-sonnet (200k)
|use_case: multilingual → qwen/qwen-2.5-72b-instruct | mistralai/mistral-large
|use_case: open_weights_only → meta-llama/llama-3.3-70b-instruct | deepseek/deepseek-chat | qwen/qwen-2.5-72b-instruct
| Model ID | Context | Vision | Best For |
|---|---|---|---|
anthropic/claude-opus-4 |
200k | Yes | Hardest reasoning, premium long-form |
anthropic/claude-3.5-sonnet |
200k | Yes | Default writer, council moderator |
anthropic/claude-3.5-haiku |
200k | Yes | Cheap drafts, classification, fast turn |
anthropic/claude-3-opus |
200k | Yes | Legacy premium tier |
Notes: prompt caching not exposed via OpenRouter -- go direct via snappy-ai-models if you need it.
| Model ID | Context | Vision | Best For |
|---|---|---|---|
openai/gpt-4o |
128k | Yes | Multimodal, structured outputs |
openai/gpt-4o-mini |
128k | Yes | Cheap structured outputs |
openai/o1 |
200k | No | Deep reasoning, math, code |
openai/o1-mini |
128k | No | Cheaper reasoning model |
openai/o3-mini |
200k | No | Newer cheap reasoning |
openai/gpt-4-turbo |
128k | Yes | Legacy GPT-4 tier |
Notes: Realtime API and Batch API not available via OpenRouter -- use snappy-ai-models direct.
| Model ID | Context | Vision | Best For |
|---|---|---|---|
google/gemini-2.0-flash |
1M | Yes | Cheap, fast, huge context summaries |
google/gemini-2.0-flash-thinking |
1M | Yes | Cheap reasoning |
google/gemini-pro-1.5 |
2M | Yes | Largest context window available |
google/gemma-2-27b-it |
8k | No | Open Google model, cheap |
Notes: native multimodal grounding/Live API requires snappy-gemini direct.
| Model ID | Context | Vision | Best For |
|---|---|---|---|
meta-llama/llama-3.3-70b-instruct |
128k | No | Open-weights baseline, cheap |
meta-llama/llama-3.1-405b-instruct |
128k | No | Open frontier model |
meta-llama/llama-3.2-90b-vision-instruct |
128k | Yes | Open multimodal |
meta-llama/llama-3.2-11b-vision-instruct |
128k | Yes | Cheap open multimodal |
Provider tip: pin a specific provider via provider.order: ["DeepInfra","Together"] for cost control.
| Model ID | Context | Vision | Best For |
|---|---|---|---|
deepseek/deepseek-chat |
64k | No | Ultra-cheap general use |
deepseek/deepseek-r1 |
64k | No | Reasoning at fraction of o1 cost |
deepseek/deepseek-r1-distill-llama-70b |
128k | No | Distilled R1, faster |
Notes: extremely cost-effective for high-volume classification or batch enrichment.
| Model ID | Context | Vision | Best For |
|---|---|---|---|
qwen/qwen-2.5-72b-instruct |
128k | No | Multilingual, cheap, open |
qwen/qwen-2.5-coder-32b-instruct |
128k | No | Code-specialized |
qwen/qwen-vl-plus |
128k | Yes | Multilingual vision |
qwen/qwq-32b-preview |
32k | No | Reasoning preview |
| Model ID | Context | Vision | Best For |
|---|---|---|---|
mistralai/mistral-large |
128k | No | EU-resident, structured outputs |
mistralai/mistral-small |
32k | No | Cheap EU option |
mistralai/codestral-mamba |
256k | No | Code generation |
mistralai/pixtral-large |
128k | Yes | Mistral multimodal |
| Model ID | Context | Vision | Best For |
|---|---|---|---|
x-ai/grok-2 |
131k | No | Real-time-ish, opinionated |
cohere/command-r-plus |
128k | No | RAG-tuned |
nvidia/llama-3.1-nemotron-70b-instruct |
128k | No | NVIDIA-tuned Llama variant |
perplexity/llama-3.1-sonar-huge-128k-online |
128k | No | Web-search-augmented |
| ID | Behavior |
|---|---|
openrouter/auto |
OpenRouter auto-picks the cheapest model that handles the request |
Using a models[] array (instead of model) gives a fallback chain -- first available wins. Combine openrouter/auto with explicit fallbacks for resilience.
bashcurl -sS https://openrouter.ai/api/v1/models \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
| jq '.data[] | select(.id=="anthropic/claude-3.5-sonnet") | {id, context_length, pricing}'
If a model ID stops working, check https://openrouter.ai/models -- IDs occasionally change suffix (e.g., :beta, :nitro, :floor).
| Suffix | Meaning |
|---|---|
:nitro |
Highest-throughput route (more $) |
:floor |
Lowest-cost route (slower / different provider) |
:beta |
Beta release |
:free |
Free tier (rate-limited) |
Example: meta-llama/llama-3.3-70b-instruct:free
# OpenRouter Model Catalog
Curated list of model identifiers usable through `snappy-openrouter`. Pricing is illustrative -- always check live pricing at https://openrouter.ai/models for current rates and availability.
## Table of Contents
- [How to choose](#how-to-choose)
- [Anthropic](#anthropic)
- [OpenAI](#openai)
- [Google](#google)
- [Meta (Llama)](#meta-llama)
- [DeepSeek](#deepseek)
- [Qwen / Alibaba](#qwen--alibaba)
- [Mistral](#mistral)
- [Other useful](#other-useful)
- [Special routing identifiers](#special-routing-identifiers)
- [Verifying a model is live](#verifying-a-model-is-live)
---
## How to choose
|use_case: cheap_classification → google/gemini-2.0-flash | deepseek/deepseek-chat | anthropic/claude-3.5-haiku
|use_case: cheap_summarization → google/gemini-2.0-flash | openai/gpt-4o-mini
|use_case: writer_default → anthropic/claude-3.5-sonnet
|use_case: critique_premium → anthropic/claude-opus-4 | openai/o1
|use_case: math_reasoning → openai/o1 | deepseek/deepseek-r1
|use_case: structured_json_outputs → openai/gpt-4o | openai/gpt-4o-mini | mistralai/mistral-large
|use_case: huge_context → google/gemini-2.0-flash (1M) | anthropic/claude-3.5-sonnet (200k)
|use_case: multilingual → qwen/qwen-2.5-72b-instruct | mistralai/mistral-large
|use_case: open_weights_only → meta-llama/llama-3.3-70b-instruct | deepseek/deepseek-chat | qwen/qwen-2.5-72b-instruct
---
## Anthropic
| Model ID | Context | Vision | Best For |
|---|---|---|---|
| `anthropic/claude-opus-4` | 200k | Yes | Hardest reasoning, premium long-form |
| `anthropic/claude-3.5-sonnet` | 200k | Yes | Default writer, council moderator |
| `anthropic/claude-3.5-haiku` | 200k | Yes | Cheap drafts, classification, fast turn |
| `anthropic/claude-3-opus` | 200k | Yes | Legacy premium tier |
Notes: prompt caching not exposed via OpenRouter -- go direct via `snappy-ai-models` if you need it.
---
## OpenAI
| Model ID | Context | Vision | Best For |
|---|---|---|---|
| `openai/gpt-4o` | 128k | Yes | Multimodal, structured outputs |
| `openai/gpt-4o-mini` | 128k | Yes | Cheap structured outputs |
| `openai/o1` | 200k | No | Deep reasoning, math, code |
| `openai/o1-mini` | 128k | No | Cheaper reasoning model |
| `openai/o3-mini` | 200k | No | Newer cheap reasoning |
| `openai/gpt-4-turbo` | 128k | Yes | Legacy GPT-4 tier |
Notes: Realtime API and Batch API not available via OpenRouter -- use `snappy-ai-models` direct.
---
## Google
| Model ID | Context | Vision | Best For |
|---|---|---|---|
| `google/gemini-2.0-flash` | 1M | Yes | Cheap, fast, huge context summaries |
| `google/gemini-2.0-flash-thinking` | 1M | Yes | Cheap reasoning |
| `google/gemini-pro-1.5` | 2M | Yes | Largest context window available |
| `google/gemma-2-27b-it` | 8k | No | Open Google model, cheap |
Notes: native multimodal grounding/Live API requires `snappy-gemini` direct.
---
## Meta (Llama)
| Model ID | Context | Vision | Best For |
|---|---|---|---|
| `meta-llama/llama-3.3-70b-instruct` | 128k | No | Open-weights baseline, cheap |
| `meta-llama/llama-3.1-405b-instruct` | 128k | No | Open frontier model |
| `meta-llama/llama-3.2-90b-vision-instruct` | 128k | Yes | Open multimodal |
| `meta-llama/llama-3.2-11b-vision-instruct` | 128k | Yes | Cheap open multimodal |
Provider tip: pin a specific provider via `provider.order: ["DeepInfra","Together"]` for cost control.
---
## DeepSeek
| Model ID | Context | Vision | Best For |
|---|---|---|---|
| `deepseek/deepseek-chat` | 64k | No | Ultra-cheap general use |
| `deepseek/deepseek-r1` | 64k | No | Reasoning at fraction of o1 cost |
| `deepseek/deepseek-r1-distill-llama-70b` | 128k | No | Distilled R1, faster |
Notes: extremely cost-effective for high-volume classification or batch enrichment.
---
## Qwen / Alibaba
| Model ID | Context | Vision | Best For |
|---|---|---|---|
| `qwen/qwen-2.5-72b-instruct` | 128k | No | Multilingual, cheap, open |
| `qwen/qwen-2.5-coder-32b-instruct` | 128k | No | Code-specialized |
| `qwen/qwen-vl-plus` | 128k | Yes | Multilingual vision |
| `qwen/qwq-32b-preview` | 32k | No | Reasoning preview |
---
## Mistral
| Model ID | Context | Vision | Best For |
|---|---|---|---|
| `mistralai/mistral-large` | 128k | No | EU-resident, structured outputs |
| `mistralai/mistral-small` | 32k | No | Cheap EU option |
| `mistralai/codestral-mamba` | 256k | No | Code generation |
| `mistralai/pixtral-large` | 128k | Yes | Mistral multimodal |
---
## Other useful
| Model ID | Context | Vision | Best For |
|---|---|---|---|
| `x-ai/grok-2` | 131k | No | Real-time-ish, opinionated |
| `cohere/command-r-plus` | 128k | No | RAG-tuned |
| `nvidia/llama-3.1-nemotron-70b-instruct` | 128k | No | NVIDIA-tuned Llama variant |
| `perplexity/llama-3.1-sonar-huge-128k-online` | 128k | No | Web-search-augmented |
---
## Special routing identifiers
| ID | Behavior |
|---|---|
| `openrouter/auto` | OpenRouter auto-picks the cheapest model that handles the request |
Using a `models[]` array (instead of `model`) gives a fallback chain -- first available wins. Combine `openrouter/auto` with explicit fallbacks for resilience.
---
## Verifying a model is live
```bash
curl -sS https://openrouter.ai/api/v1/models \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
| jq '.data[] | select(.id=="anthropic/claude-3.5-sonnet") | {id, context_length, pricing}'
```
If a model ID stops working, check https://openrouter.ai/models -- IDs occasionally change suffix (e.g., `:beta`, `:nitro`, `:floor`).
### Variant suffixes
| Suffix | Meaning |
|---|---|
| `:nitro` | Highest-throughput route (more $) |
| `:floor` | Lowest-cost route (slower / different provider) |
| `:beta` | Beta release |
| `:free` | Free tier (rate-limited) |
Example: `meta-llama/llama-3.3-70b-instruct:free`
/**
* COVERAGE FOR SNAPPY-OPENROUTER'S DECLARED REFUSAL CODES
* (snappy-tool-design rule 33: "refusal codes form one closed table and each
* row has coverage").
*
* Two things are graded here, and the second is the one that matters. The
* first is that the hand's table is a PROJECTION of the collection's one
* closed table in snappy-settings/refusal-codes.ts — same object, not a copy
* that can drift. The second is that every declared code is GROUNDED: the
* evidence that justified declaring it is re-checked here, because a refusal
* code with no path that emits it is a branch the reader waits for and never
* sees, and a table of those passes a lint while teaching a lie.
*
* The code list is spelled out rather than read from the contract: a test that
* iterates the thing it grades passes for an empty table.
*/
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES } from "../snappy-settings/refusal-codes.ts";
const SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "api.ts"), "utf8");
/** Every refusal code snappy-openrouter declares. */
const DECLARED = [
"missing_credential",
"missing_argument",
"unknown_verb",
"upstream_error",
] as const;
test("snappy-openrouter declares exactly these refusal codes", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals).sort(), [...DECLARED].sort());
});
test("every declared code is the SAME row as the one closed table's, never a copy", () => {
const table = HAND_CONTRACT.refusals as Record<string, unknown>;
for (const code of DECLARED) {
assert.equal(table[code], REFUSAL_CODES[code], `${code} is not the shared row`);
}
});
test("missing_credential is grounded: this hand declares credential keys", () => {
assert.ok(HAND_CONTRACT.requires.length > 0);
});
test("missing_argument is grounded: at least one verb has a required word", () => {
const required = Object.values(HAND_CONTRACT.verbs as Record<string, { args?: readonly string[] }>)
.flatMap((v) => (v.args ?? []).filter((a) => !a.endsWith("?")));
assert.ok(required.length > 0, "no verb has a required argument, so missing_argument can never fire");
});
test("unknown_verb is grounded: the contract closes the verb set, so a word outside it is refusable", () => {
assert.ok(Object.keys(HAND_CONTRACT.verbs).length >= 0);
assert.ok(!Object.keys(HAND_CONTRACT.verbs).includes("no-such-verb"));
});
test("upstream_error is grounded: the hand calls a provider that can answer with its own failure", () => {
assert.ok(/\bfetch\(/.test(SOURCE));
assert.ok(HAND_CONTRACT.requires.length > 0);
});
/**
* COVERAGE FOR SNAPPY-OPENROUTER'S DECLARED REFUSAL CODES
* (snappy-tool-design rule 33: "refusal codes form one closed table and each
* row has coverage").
*
* Two things are graded here, and the second is the one that matters. The
* first is that the hand's table is a PROJECTION of the collection's one
* closed table in snappy-settings/refusal-codes.ts — same object, not a copy
* that can drift. The second is that every declared code is GROUNDED: the
* evidence that justified declaring it is re-checked here, because a refusal
* code with no path that emits it is a branch the reader waits for and never
* sees, and a table of those passes a lint while teaching a lie.
*
* The code list is spelled out rather than read from the contract: a test that
* iterates the thing it grades passes for an empty table.
*/
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES } from "../snappy-settings/refusal-codes.ts";
const SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "api.ts"), "utf8");
/** Every refusal code snappy-openrouter declares. */
const DECLARED = [
"missing_credential",
"missing_argument",
"unknown_verb",
"upstream_error",
] as const;
test("snappy-openrouter declares exactly these refusal codes", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals).sort(), [...DECLARED].sort());
});
test("every declared code is the SAME row as the one closed table's, never a copy", () => {
const table = HAND_CONTRACT.refusals as Record<string, unknown>;
for (const code of DECLARED) {
assert.equal(table[code], REFUSAL_CODES[code], `${code} is not the shared row`);
}
});
test("missing_credential is grounded: this hand declares credential keys", () => {
assert.ok(HAND_CONTRACT.requires.length > 0);
});
test("missing_argument is grounded: at least one verb has a required word", () => {
const required = Object.values(HAND_CONTRACT.verbs as Record<string, { args?: readonly string[] }>)
.flatMap((v) => (v.args ?? []).filter((a) => !a.endsWith("?")));
assert.ok(required.length > 0, "no verb has a required argument, so missing_argument can never fire");
});
test("unknown_verb is grounded: the contract closes the verb set, so a word outside it is refusable", () => {
assert.ok(Object.keys(HAND_CONTRACT.verbs).length >= 0);
assert.ok(!Object.keys(HAND_CONTRACT.verbs).includes("no-such-verb"));
});
test("upstream_error is grounded: the hand calls a provider that can answer with its own failure", () => {
assert.ok(/\bfetch\(/.test(SOURCE));
assert.ok(HAND_CONTRACT.requires.length > 0);
});
When and why to pick which model. The decision tree is task-driven, not vendor-driven.
Q1: Does this task need deep reasoning (math, multi-step logic, novel problem)?
YES → openai/o1 OR deepseek/deepseek-r1 (cheap reasoning)
NO → continue
Q2: Does this task need vision input (images, screenshots)?
YES → openai/gpt-4o, anthropic/claude-3.5-sonnet, google/gemini-2.0-flash
NO → continue
Q3: Is the input over 200k tokens?
YES → google/gemini-2.0-flash (1M) OR google/gemini-pro-1.5 (2M)
NO → continue
Q4: Is this high-volume (>1000 calls/day) and quality-tolerant?
YES → deepseek/deepseek-chat, google/gemini-2.0-flash, anthropic/claude-3.5-haiku
NO → continue
Q5: Is this customer-facing writing where voice matters?
YES → anthropic/claude-3.5-sonnet (default), claude-opus-4 for premium
NO → continue
Q6: Need structured JSON output reliably?
YES → openai/gpt-4o + response_format json_object
NO → openrouter/auto (let OpenRouter pick cheapest)
| Cheap | Standard | Premium | |
|---|---|---|---|
| Speed | gemini-2.0-flash | claude-3.5-haiku | claude-3.5-sonnet |
| Reasoning | deepseek-r1 | gpt-4o-mini | o1 |
| Writing | claude-3.5-haiku | claude-3.5-sonnet | claude-opus-4 |
| Code | qwen-2.5-coder | claude-3.5-sonnet | claude-opus-4 |
| Vision | gemini-2.0-flash | gpt-4o-mini | gpt-4o |
| JSON | gpt-4o-mini | gpt-4o | mistral-large |
| Long context | gemini-2.0-flash (1M) | claude-3.5-sonnet (200k) | gemini-pro-1.5 (2M) |
|strategy: cheapest_works
use_when: classification, extraction, simple summarization
pick: deepseek/deepseek-chat OR google/gemini-2.0-flash
fallback: anthropic/claude-3.5-haiku
|strategy: balanced_default
use_when: writer stage for content, council critique
pick: anthropic/claude-3.5-sonnet
fallback: openai/gpt-4o
|strategy: premium_quality
use_when: hard reasoning, council moderator, customer-facing
pick: anthropic/claude-opus-4
fallback: openai/o1, anthropic/claude-3.5-sonnet
|strategy: huge_context
use_when: full transcript pass, multi-document context
pick: google/gemini-2.0-flash (1M context)
alternative: google/gemini-pro-1.5 (2M context)
|strategy: structured_json
use_when: tool use, structured data extraction
pick: openai/gpt-4o WITH response_format=json_object
alternative: openai/gpt-4o-mini for cheap structured
|strategy: auto_route
use_when: prototyping, low-stakes, cost is the only constraint
pick: openrouter/auto
|strategy: open_weights_only
use_when: data sovereignty matters, want to self-host later
pick: meta-llama/llama-3.3-70b-instruct
alternatives: deepseek/deepseek-chat, qwen/qwen-2.5-72b-instruct
Try cheap first, escalate on failure. Pseudocode:
bash# Step 1: cheap classification
result=$(./scripts/chat.sh --model deepseek/deepseek-chat --prompt "$prompt" --text)
# Step 2: quality gate
if [[ -z "$result" ]] || ! looks_good "$result"; then
# Escalate to premium
result=$(./scripts/chat.sh --model anthropic/claude-3.5-sonnet --prompt "$prompt" --text)
fi
Savings: 60-90% on tasks where cheap model succeeds (most of them).
Use the models[] array to let OpenRouter pick the first available provider/model. Single request, no retry logic needed.
bash./scripts/chat.sh \
--models "anthropic/claude-3.5-sonnet,openai/gpt-4o,google/gemini-2.0-flash" \
--prompt "..."
When the primary provider has an outage, OpenRouter transparently routes to the next.
Generate N variants in parallel, then pick the best with a separate judge model.
bash# Generate
./scripts/compare.sh \
--models "anthropic/claude-3.5-haiku,openai/gpt-4o-mini,deepseek/deepseek-chat" \
--prompt "Write a 280-char hook about Xano" \
--text > variants.txt
# Judge
./scripts/chat.sh \
--model anthropic/claude-3.5-sonnet \
--system "You are a copy editor. Pick the best hook and explain why." \
--prompt "$(cat variants.txt)" \
--text
Use this for content quality work -- 3 cheap drafts + 1 premium judge often beats 1 premium draft.
OpenRouter routes to multiple providers per model. You can pin or order them via the provider parameter (not exposed by default in our scripts -- pass via --data extension if needed).
json{
"model": "meta-llama/llama-3.3-70b-instruct",
"messages": [...],
"provider": {
"order": ["DeepInfra", "Together", "Hyperbolic"],
"allow_fallbacks": true
}
}
| Field | Effect |
|---|---|
provider.order |
Try providers in this order |
provider.allow_fallbacks |
If true, try other providers when listed ones fail |
provider.require_parameters |
Only use providers supporting requested params (tools, json_mode) |
provider.data_collection |
"deny" → only providers that don't log |
OpenRouter provides automatic prompt transforms for over-context inputs.
| Transform | Effect |
|---|---|
middle-out |
Drops middle of long messages to fit context window |
Pass via --transforms middle-out on chat.sh / stream.sh. Use when you might exceed the model's context -- saves a manual chunking step.
|antipattern: hardcode_one_model_forever
problem: prices change, models deprecate, new cheaper options appear monthly
fix: pass model as env var or arg, never hardcode in business logic
|antipattern: always_use_premium
problem: 90% of LLM calls don't need claude-opus-4 -- wastes budget
fix: cascade pattern -- try cheap first
|antipattern: ignore_context_window
problem: silent truncation or 400 errors mid-pipeline
fix: check models.md before sending long prompts; use transforms
|antipattern: parallel_to_one_model
problem: rate limits hit fast
fix: fan out across multiple providers via models[] or compare.sh
|antipattern: omit_referer_title_headers
problem: lower rate limits, hidden in OpenRouter rankings
fix: always send HTTP-Referer and X-Title (this skill's scripts do this automatically)
# OpenRouter Routing Strategies
When and why to pick which model. The decision tree is task-driven, not vendor-driven.
## Table of Contents
- [Decision tree](#decision-tree)
- [Cost-quality matrix](#cost-quality-matrix)
- [Strategy patterns](#strategy-patterns)
- [Cascade pattern](#cascade-pattern)
- [Fallback pattern](#fallback-pattern)
- [Fan-out and judge pattern](#fan-out-and-judge-pattern)
- [Provider preferences](#provider-preferences)
- [Transforms](#transforms)
- [Anti-patterns](#anti-patterns)
---
## Decision tree
```
Q1: Does this task need deep reasoning (math, multi-step logic, novel problem)?
YES → openai/o1 OR deepseek/deepseek-r1 (cheap reasoning)
NO → continue
Q2: Does this task need vision input (images, screenshots)?
YES → openai/gpt-4o, anthropic/claude-3.5-sonnet, google/gemini-2.0-flash
NO → continue
Q3: Is the input over 200k tokens?
YES → google/gemini-2.0-flash (1M) OR google/gemini-pro-1.5 (2M)
NO → continue
Q4: Is this high-volume (>1000 calls/day) and quality-tolerant?
YES → deepseek/deepseek-chat, google/gemini-2.0-flash, anthropic/claude-3.5-haiku
NO → continue
Q5: Is this customer-facing writing where voice matters?
YES → anthropic/claude-3.5-sonnet (default), claude-opus-4 for premium
NO → continue
Q6: Need structured JSON output reliably?
YES → openai/gpt-4o + response_format json_object
NO → openrouter/auto (let OpenRouter pick cheapest)
```
---
## Cost-quality matrix
| | Cheap | Standard | Premium |
|---|---|---|---|
| **Speed** | gemini-2.0-flash | claude-3.5-haiku | claude-3.5-sonnet |
| **Reasoning** | deepseek-r1 | gpt-4o-mini | o1 |
| **Writing** | claude-3.5-haiku | claude-3.5-sonnet | claude-opus-4 |
| **Code** | qwen-2.5-coder | claude-3.5-sonnet | claude-opus-4 |
| **Vision** | gemini-2.0-flash | gpt-4o-mini | gpt-4o |
| **JSON** | gpt-4o-mini | gpt-4o | mistral-large |
| **Long context** | gemini-2.0-flash (1M) | claude-3.5-sonnet (200k) | gemini-pro-1.5 (2M) |
---
## Strategy patterns
|strategy: cheapest_works
use_when: classification, extraction, simple summarization
pick: deepseek/deepseek-chat OR google/gemini-2.0-flash
fallback: anthropic/claude-3.5-haiku
|strategy: balanced_default
use_when: writer stage for content, council critique
pick: anthropic/claude-3.5-sonnet
fallback: openai/gpt-4o
|strategy: premium_quality
use_when: hard reasoning, council moderator, customer-facing
pick: anthropic/claude-opus-4
fallback: openai/o1, anthropic/claude-3.5-sonnet
|strategy: huge_context
use_when: full transcript pass, multi-document context
pick: google/gemini-2.0-flash (1M context)
alternative: google/gemini-pro-1.5 (2M context)
|strategy: structured_json
use_when: tool use, structured data extraction
pick: openai/gpt-4o WITH response_format=json_object
alternative: openai/gpt-4o-mini for cheap structured
|strategy: auto_route
use_when: prototyping, low-stakes, cost is the only constraint
pick: openrouter/auto
|strategy: open_weights_only
use_when: data sovereignty matters, want to self-host later
pick: meta-llama/llama-3.3-70b-instruct
alternatives: deepseek/deepseek-chat, qwen/qwen-2.5-72b-instruct
---
## Cascade pattern
Try cheap first, escalate on failure. Pseudocode:
```bash
# Step 1: cheap classification
result=$(./scripts/chat.sh --model deepseek/deepseek-chat --prompt "$prompt" --text)
# Step 2: quality gate
if [[ -z "$result" ]] || ! looks_good "$result"; then
# Escalate to premium
result=$(./scripts/chat.sh --model anthropic/claude-3.5-sonnet --prompt "$prompt" --text)
fi
```
Savings: 60-90% on tasks where cheap model succeeds (most of them).
---
## Fallback pattern
Use the `models[]` array to let OpenRouter pick the first available provider/model. Single request, no retry logic needed.
```bash
./scripts/chat.sh \
--models "anthropic/claude-3.5-sonnet,openai/gpt-4o,google/gemini-2.0-flash" \
--prompt "..."
```
When the primary provider has an outage, OpenRouter transparently routes to the next.
---
## Fan-out and judge pattern
Generate N variants in parallel, then pick the best with a separate judge model.
```bash
# Generate
./scripts/compare.sh \
--models "anthropic/claude-3.5-haiku,openai/gpt-4o-mini,deepseek/deepseek-chat" \
--prompt "Write a 280-char hook about Xano" \
--text > variants.txt
# Judge
./scripts/chat.sh \
--model anthropic/claude-3.5-sonnet \
--system "You are a copy editor. Pick the best hook and explain why." \
--prompt "$(cat variants.txt)" \
--text
```
Use this for content quality work -- 3 cheap drafts + 1 premium judge often beats 1 premium draft.
---
## Provider preferences
OpenRouter routes to multiple providers per model. You can pin or order them via the `provider` parameter (not exposed by default in our scripts -- pass via `--data` extension if needed).
```json
{
"model": "meta-llama/llama-3.3-70b-instruct",
"messages": [...],
"provider": {
"order": ["DeepInfra", "Together", "Hyperbolic"],
"allow_fallbacks": true
}
}
```
| Field | Effect |
|---|---|
| `provider.order` | Try providers in this order |
| `provider.allow_fallbacks` | If true, try other providers when listed ones fail |
| `provider.require_parameters` | Only use providers supporting requested params (tools, json_mode) |
| `provider.data_collection` | "deny" → only providers that don't log |
---
## Transforms
OpenRouter provides automatic prompt transforms for over-context inputs.
| Transform | Effect |
|---|---|
| `middle-out` | Drops middle of long messages to fit context window |
Pass via `--transforms middle-out` on `chat.sh` / `stream.sh`. Use when you might exceed the model's context -- saves a manual chunking step.
---
## Anti-patterns
|antipattern: hardcode_one_model_forever
problem: prices change, models deprecate, new cheaper options appear monthly
fix: pass model as env var or arg, never hardcode in business logic
|antipattern: always_use_premium
problem: 90% of LLM calls don't need claude-opus-4 -- wastes budget
fix: cascade pattern -- try cheap first
|antipattern: ignore_context_window
problem: silent truncation or 400 errors mid-pipeline
fix: check models.md before sending long prompts; use transforms
|antipattern: parallel_to_one_model
problem: rate limits hit fast
fix: fan out across multiple providers via models[] or compare.sh
|antipattern: omit_referer_title_headers
problem: lower rate limits, hidden in OpenRouter rankings
fix: always send HTTP-Referer and X-Title (this skill's scripts do this automatically)
#!/usr/bin/env bash
# chat.sh -- One-shot non-streaming chat completion via OpenRouter.
#
# Calls https://openrouter.ai/api/v1/chat/completions using the OpenAI-compatible
# request format. OpenRouter normalizes provider differences automatically.
#
# Usage:
# ./chat.sh --model anthropic/claude-3.5-sonnet --prompt "Hello"
# ./chat.sh --models "anthropic/claude-3.5-sonnet,openai/gpt-4o" --prompt "Hi"
# ./chat.sh --model openai/gpt-4o-mini --system "Be terse." --prompt "What is X?" --text
# echo "Summarize this" | ./chat.sh --model deepseek/deepseek-chat --stdin
#
# Output:
# Default: full JSON response from OpenRouter
# --text: just the assistant message content (string)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/auth.sh
source "$SCRIPT_DIR/lib/auth.sh"
MODEL=""
MODELS="" # comma-separated fallback list
PROMPT=""
SYSTEM=""
TEMPERATURE=""
MAX_TOKENS=""
TRANSFORMS="" # comma-separated, e.g. "middle-out"
TEXT_ONLY="false"
READ_STDIN="false"
JSON_MODE="false" # response_format: json_object
usage() {
cat <<'EOF'
chat.sh -- One-shot OpenRouter chat completion.
REQUIRED:
--model <id> Model identifier (e.g., anthropic/claude-3.5-sonnet)
OR
--models <id,id,...> Fallback list -- first available wins
--prompt <text> User message text
OR
--stdin Read prompt from stdin
OPTIONAL:
--system <text> System prompt
--temperature <float> Sampling temperature (0.0-2.0)
--max-tokens <int> Max output tokens
--transforms <list> Comma-separated transforms (e.g., middle-out)
--json-mode Force JSON object output (response_format)
--text Output only the assistant message text (not full JSON)
-h | --help Show this help
EXAMPLES:
./chat.sh --model anthropic/claude-3.5-haiku --prompt "Hi"
./chat.sh --model openai/gpt-4o-mini --prompt "Classify: book a meeting" --text
./chat.sh --models "anthropic/claude-3.5-sonnet,openai/gpt-4o" --prompt "Hi"
cat brief.md | ./chat.sh --model deepseek/deepseek-chat --stdin --text
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--model) MODEL="$2"; shift 2 ;;
--models) MODELS="$2"; shift 2 ;;
--prompt) PROMPT="$2"; shift 2 ;;
--system) SYSTEM="$2"; shift 2 ;;
--temperature) TEMPERATURE="$2"; shift 2 ;;
--max-tokens) MAX_TOKENS="$2"; shift 2 ;;
--transforms) TRANSFORMS="$2"; shift 2 ;;
--json-mode) JSON_MODE="true"; shift ;;
--text) TEXT_ONLY="true"; shift ;;
--stdin) READ_STDIN="true"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
if [[ "$READ_STDIN" == "true" ]]; then
PROMPT="$(cat)"
fi
if [[ -z "$PROMPT" ]]; then
echo "ERROR: --prompt or --stdin required" >&2
usage
exit 2
fi
if [[ -z "$MODEL" && -z "$MODELS" ]]; then
echo "ERROR: --model or --models required" >&2
usage
exit 2
fi
command -v jq >/dev/null 2>&1 || { echo "ERROR: jq required" >&2; exit 3; }
command -v curl >/dev/null 2>&1 || { echo "ERROR: curl required" >&2; exit 3; }
# Build messages array
MESSAGES_JSON='[]'
if [[ -n "$SYSTEM" ]]; then
MESSAGES_JSON="$(jq -cn --arg s "$SYSTEM" '[{role:"system",content:$s}]')"
fi
MESSAGES_JSON="$(jq -c --arg p "$PROMPT" '. + [{role:"user",content:$p}]' <<<"$MESSAGES_JSON")"
# Build request body
BODY="$(jq -cn --argjson msgs "$MESSAGES_JSON" '{messages:$msgs,stream:false}')"
if [[ -n "$MODEL" ]]; then
BODY="$(jq -c --arg m "$MODEL" '. + {model:$m}' <<<"$BODY")"
fi
if [[ -n "$MODELS" ]]; then
IFS=',' read -ra MODEL_ARR <<<"$MODELS"
MODELS_JSON="$(printf '%s\n' "${MODEL_ARR[@]}" | jq -R . | jq -sc '[.[] | gsub("^\\s+|\\s+$";"")]')"
BODY="$(jq -c --argjson ms "$MODELS_JSON" '. + {models:$ms}' <<<"$BODY")"
fi
if [[ -n "$TEMPERATURE" ]]; then
BODY="$(jq -c --argjson t "$TEMPERATURE" '. + {temperature:$t}' <<<"$BODY")"
fi
if [[ -n "$MAX_TOKENS" ]]; then
BODY="$(jq -c --argjson n "$MAX_TOKENS" '. + {max_tokens:$n}' <<<"$BODY")"
fi
if [[ -n "$TRANSFORMS" ]]; then
IFS=',' read -ra T_ARR <<<"$TRANSFORMS"
T_JSON="$(printf '%s\n' "${T_ARR[@]}" | jq -R . | jq -sc '[.[] | gsub("^\\s+|\\s+$";"")]')"
BODY="$(jq -c --argjson t "$T_JSON" '. + {transforms:$t}' <<<"$BODY")"
fi
if [[ "$JSON_MODE" == "true" ]]; then
BODY="$(jq -c '. + {response_format:{type:"json_object"}}' <<<"$BODY")"
fi
RESPONSE="$(curl -sS "$OPENROUTER_BASE_URL/chat/completions" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "HTTP-Referer: $OPENROUTER_REFERER" \
-H "X-Title: $OPENROUTER_TITLE" \
-H "Content-Type: application/json" \
--data "$BODY")"
# Surface API errors clearly
if jq -e '.error' >/dev/null 2>&1 <<<"$RESPONSE"; then
echo "OpenRouter error:" >&2
jq '.error' <<<"$RESPONSE" >&2
exit 4
fi
if [[ "$TEXT_ONLY" == "true" ]]; then
jq -r '.choices[0].message.content // empty' <<<"$RESPONSE"
else
echo "$RESPONSE" | jq .
fi
#!/usr/bin/env bash
# chat.sh -- One-shot non-streaming chat completion via OpenRouter.
#
# Calls https://openrouter.ai/api/v1/chat/completions using the OpenAI-compatible
# request format. OpenRouter normalizes provider differences automatically.
#
# Usage:
# ./chat.sh --model anthropic/claude-3.5-sonnet --prompt "Hello"
# ./chat.sh --models "anthropic/claude-3.5-sonnet,openai/gpt-4o" --prompt "Hi"
# ./chat.sh --model openai/gpt-4o-mini --system "Be terse." --prompt "What is X?" --text
# echo "Summarize this" | ./chat.sh --model deepseek/deepseek-chat --stdin
#
# Output:
# Default: full JSON response from OpenRouter
# --text: just the assistant message content (string)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/auth.sh
source "$SCRIPT_DIR/lib/auth.sh"
MODEL=""
MODELS="" # comma-separated fallback list
PROMPT=""
SYSTEM=""
TEMPERATURE=""
MAX_TOKENS=""
TRANSFORMS="" # comma-separated, e.g. "middle-out"
TEXT_ONLY="false"
READ_STDIN="false"
JSON_MODE="false" # response_format: json_object
usage() {
cat <<'EOF'
chat.sh -- One-shot OpenRouter chat completion.
REQUIRED:
--model <id> Model identifier (e.g., anthropic/claude-3.5-sonnet)
OR
--models <id,id,...> Fallback list -- first available wins
--prompt <text> User message text
OR
--stdin Read prompt from stdin
OPTIONAL:
--system <text> System prompt
--temperature <float> Sampling temperature (0.0-2.0)
--max-tokens <int> Max output tokens
--transforms <list> Comma-separated transforms (e.g., middle-out)
--json-mode Force JSON object output (response_format)
--text Output only the assistant message text (not full JSON)
-h | --help Show this help
EXAMPLES:
./chat.sh --model anthropic/claude-3.5-haiku --prompt "Hi"
./chat.sh --model openai/gpt-4o-mini --prompt "Classify: book a meeting" --text
./chat.sh --models "anthropic/claude-3.5-sonnet,openai/gpt-4o" --prompt "Hi"
cat brief.md | ./chat.sh --model deepseek/deepseek-chat --stdin --text
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--model) MODEL="$2"; shift 2 ;;
--models) MODELS="$2"; shift 2 ;;
--prompt) PROMPT="$2"; shift 2 ;;
--system) SYSTEM="$2"; shift 2 ;;
--temperature) TEMPERATURE="$2"; shift 2 ;;
--max-tokens) MAX_TOKENS="$2"; shift 2 ;;
--transforms) TRANSFORMS="$2"; shift 2 ;;
--json-mode) JSON_MODE="true"; shift ;;
--text) TEXT_ONLY="true"; shift ;;
--stdin) READ_STDIN="true"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
if [[ "$READ_STDIN" == "true" ]]; then
PROMPT="$(cat)"
fi
if [[ -z "$PROMPT" ]]; then
echo "ERROR: --prompt or --stdin required" >&2
usage
exit 2
fi
if [[ -z "$MODEL" && -z "$MODELS" ]]; then
echo "ERROR: --model or --models required" >&2
usage
exit 2
fi
command -v jq >/dev/null 2>&1 || { echo "ERROR: jq required" >&2; exit 3; }
command -v curl >/dev/null 2>&1 || { echo "ERROR: curl required" >&2; exit 3; }
# Build messages array
MESSAGES_JSON='[]'
if [[ -n "$SYSTEM" ]]; then
MESSAGES_JSON="$(jq -cn --arg s "$SYSTEM" '[{role:"system",content:$s}]')"
fi
MESSAGES_JSON="$(jq -c --arg p "$PROMPT" '. + [{role:"user",content:$p}]' <<<"$MESSAGES_JSON")"
# Build request body
BODY="$(jq -cn --argjson msgs "$MESSAGES_JSON" '{messages:$msgs,stream:false}')"
if [[ -n "$MODEL" ]]; then
BODY="$(jq -c --arg m "$MODEL" '. + {model:$m}' <<<"$BODY")"
fi
if [[ -n "$MODELS" ]]; then
IFS=',' read -ra MODEL_ARR <<<"$MODELS"
MODELS_JSON="$(printf '%s\n' "${MODEL_ARR[@]}" | jq -R . | jq -sc '[.[] | gsub("^\\s+|\\s+$";"")]')"
BODY="$(jq -c --argjson ms "$MODELS_JSON" '. + {models:$ms}' <<<"$BODY")"
fi
if [[ -n "$TEMPERATURE" ]]; then
BODY="$(jq -c --argjson t "$TEMPERATURE" '. + {temperature:$t}' <<<"$BODY")"
fi
if [[ -n "$MAX_TOKENS" ]]; then
BODY="$(jq -c --argjson n "$MAX_TOKENS" '. + {max_tokens:$n}' <<<"$BODY")"
fi
if [[ -n "$TRANSFORMS" ]]; then
IFS=',' read -ra T_ARR <<<"$TRANSFORMS"
T_JSON="$(printf '%s\n' "${T_ARR[@]}" | jq -R . | jq -sc '[.[] | gsub("^\\s+|\\s+$";"")]')"
BODY="$(jq -c --argjson t "$T_JSON" '. + {transforms:$t}' <<<"$BODY")"
fi
if [[ "$JSON_MODE" == "true" ]]; then
BODY="$(jq -c '. + {response_format:{type:"json_object"}}' <<<"$BODY")"
fi
RESPONSE="$(curl -sS "$OPENROUTER_BASE_URL/chat/completions" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "HTTP-Referer: $OPENROUTER_REFERER" \
-H "X-Title: $OPENROUTER_TITLE" \
-H "Content-Type: application/json" \
--data "$BODY")"
# Surface API errors clearly
if jq -e '.error' >/dev/null 2>&1 <<<"$RESPONSE"; then
echo "OpenRouter error:" >&2
jq '.error' <<<"$RESPONSE" >&2
exit 4
fi
if [[ "$TEXT_ONLY" == "true" ]]; then
jq -r '.choices[0].message.content // empty' <<<"$RESPONSE"
else
echo "$RESPONSE" | jq .
fi
#!/usr/bin/env bash
# compare.sh -- Fan out the same prompt to N OpenRouter models in parallel.
#
# Useful when you want to evaluate model quality cheaply on the same input,
# or generate variants for downstream selection.
#
# Each model's output is labeled with its identifier. Calls run in parallel
# via background jobs and a temp directory; results are concatenated in the
# order models were specified once all jobs complete.
#
# Usage:
# ./compare.sh \
# --models "anthropic/claude-3.5-haiku,openai/gpt-4o-mini,deepseek/deepseek-chat" \
# --prompt "Write a 280-char hook about Xano backend ops" \
# --text
#
# ./compare.sh \
# --models "anthropic/claude-3.5-sonnet,openai/gpt-4o" \
# --system "Be terse." \
# --prompt "Explain joins in 1 sentence"
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MODELS=""
PROMPT=""
SYSTEM=""
TEMPERATURE=""
MAX_TOKENS=""
TEXT_ONLY="false"
READ_STDIN="false"
JSON_MODE="false"
usage() {
cat <<'EOF'
compare.sh -- Fan out the same prompt to N models via OpenRouter, in parallel.
REQUIRED:
--models <id,id,...> Comma-separated model IDs (2-10 recommended)
--prompt <text> User message text (or --stdin)
OPTIONAL:
--system <text> System prompt (applied to all models)
--temperature <float> Sampling temperature
--max-tokens <int> Max output tokens per model
--json-mode response_format: json_object (all models)
--text Output only message text per model (default: full JSON)
--stdin Read prompt from stdin
-h | --help
OUTPUT FORMAT:
===== <model-id> =====
<output>
...
EXAMPLES:
./compare.sh \
--models "anthropic/claude-3.5-haiku,openai/gpt-4o-mini,deepseek/deepseek-chat" \
--prompt "Hi" --text
cat brief.md | ./compare.sh \
--models "anthropic/claude-3.5-sonnet,openai/gpt-4o" \
--stdin --text
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--models) MODELS="$2"; shift 2 ;;
--prompt) PROMPT="$2"; shift 2 ;;
--system) SYSTEM="$2"; shift 2 ;;
--temperature) TEMPERATURE="$2"; shift 2 ;;
--max-tokens) MAX_TOKENS="$2"; shift 2 ;;
--json-mode) JSON_MODE="true"; shift ;;
--text) TEXT_ONLY="true"; shift ;;
--stdin) READ_STDIN="true"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
if [[ "$READ_STDIN" == "true" ]]; then
PROMPT="$(cat)"
fi
[[ -z "$MODELS" ]] && { echo "ERROR: --models required" >&2; usage; exit 2; }
[[ -z "$PROMPT" ]] && { echo "ERROR: --prompt or --stdin required" >&2; usage; exit 2; }
CHAT="$SCRIPT_DIR/chat.sh"
[[ -x "$CHAT" ]] || { echo "ERROR: chat.sh not executable: $CHAT" >&2; exit 3; }
IFS=',' read -ra MODEL_ARR <<<"$MODELS"
TMPDIR_FAN="$(mktemp -d -t snappy-or-compare-XXXXXX)"
trap 'rm -rf "$TMPDIR_FAN"' EXIT
PIDS=()
for i in "${!MODEL_ARR[@]}"; do
m="${MODEL_ARR[$i]}"
m_trimmed="$(echo "$m" | tr -d '[:space:]')"
out_file="$TMPDIR_FAN/$i.out"
err_file="$TMPDIR_FAN/$i.err"
echo "$m_trimmed" > "$TMPDIR_FAN/$i.model"
(
args=(--model "$m_trimmed" --prompt "$PROMPT")
[[ -n "$SYSTEM" ]] && args+=(--system "$SYSTEM")
[[ -n "$TEMPERATURE" ]] && args+=(--temperature "$TEMPERATURE")
[[ -n "$MAX_TOKENS" ]] && args+=(--max-tokens "$MAX_TOKENS")
[[ "$JSON_MODE" == "true" ]] && args+=(--json-mode)
[[ "$TEXT_ONLY" == "true" ]] && args+=(--text)
"$CHAT" "${args[@]}" > "$out_file" 2> "$err_file"
echo "$?" > "$TMPDIR_FAN/$i.rc"
) &
PIDS+=("$!")
done
# Wait for all
for pid in "${PIDS[@]}"; do
wait "$pid" || true
done
# Emit in input order
for i in "${!MODEL_ARR[@]}"; do
m="$(cat "$TMPDIR_FAN/$i.model")"
rc="$(cat "$TMPDIR_FAN/$i.rc" 2>/dev/null || echo "?")"
printf '===== %s (rc=%s) =====\n' "$m" "$rc"
if [[ -s "$TMPDIR_FAN/$i.out" ]]; then
cat "$TMPDIR_FAN/$i.out"
fi
if [[ -s "$TMPDIR_FAN/$i.err" ]]; then
echo "--- stderr ---" >&2
cat "$TMPDIR_FAN/$i.err" >&2
fi
echo
done
#!/usr/bin/env bash
# compare.sh -- Fan out the same prompt to N OpenRouter models in parallel.
#
# Useful when you want to evaluate model quality cheaply on the same input,
# or generate variants for downstream selection.
#
# Each model's output is labeled with its identifier. Calls run in parallel
# via background jobs and a temp directory; results are concatenated in the
# order models were specified once all jobs complete.
#
# Usage:
# ./compare.sh \
# --models "anthropic/claude-3.5-haiku,openai/gpt-4o-mini,deepseek/deepseek-chat" \
# --prompt "Write a 280-char hook about Xano backend ops" \
# --text
#
# ./compare.sh \
# --models "anthropic/claude-3.5-sonnet,openai/gpt-4o" \
# --system "Be terse." \
# --prompt "Explain joins in 1 sentence"
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MODELS=""
PROMPT=""
SYSTEM=""
TEMPERATURE=""
MAX_TOKENS=""
TEXT_ONLY="false"
READ_STDIN="false"
JSON_MODE="false"
usage() {
cat <<'EOF'
compare.sh -- Fan out the same prompt to N models via OpenRouter, in parallel.
REQUIRED:
--models <id,id,...> Comma-separated model IDs (2-10 recommended)
--prompt <text> User message text (or --stdin)
OPTIONAL:
--system <text> System prompt (applied to all models)
--temperature <float> Sampling temperature
--max-tokens <int> Max output tokens per model
--json-mode response_format: json_object (all models)
--text Output only message text per model (default: full JSON)
--stdin Read prompt from stdin
-h | --help
OUTPUT FORMAT:
===== <model-id> =====
<output>
...
EXAMPLES:
./compare.sh \
--models "anthropic/claude-3.5-haiku,openai/gpt-4o-mini,deepseek/deepseek-chat" \
--prompt "Hi" --text
cat brief.md | ./compare.sh \
--models "anthropic/claude-3.5-sonnet,openai/gpt-4o" \
--stdin --text
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--models) MODELS="$2"; shift 2 ;;
--prompt) PROMPT="$2"; shift 2 ;;
--system) SYSTEM="$2"; shift 2 ;;
--temperature) TEMPERATURE="$2"; shift 2 ;;
--max-tokens) MAX_TOKENS="$2"; shift 2 ;;
--json-mode) JSON_MODE="true"; shift ;;
--text) TEXT_ONLY="true"; shift ;;
--stdin) READ_STDIN="true"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
if [[ "$READ_STDIN" == "true" ]]; then
PROMPT="$(cat)"
fi
[[ -z "$MODELS" ]] && { echo "ERROR: --models required" >&2; usage; exit 2; }
[[ -z "$PROMPT" ]] && { echo "ERROR: --prompt or --stdin required" >&2; usage; exit 2; }
CHAT="$SCRIPT_DIR/chat.sh"
[[ -x "$CHAT" ]] || { echo "ERROR: chat.sh not executable: $CHAT" >&2; exit 3; }
IFS=',' read -ra MODEL_ARR <<<"$MODELS"
TMPDIR_FAN="$(mktemp -d -t snappy-or-compare-XXXXXX)"
trap 'rm -rf "$TMPDIR_FAN"' EXIT
PIDS=()
for i in "${!MODEL_ARR[@]}"; do
m="${MODEL_ARR[$i]}"
m_trimmed="$(echo "$m" | tr -d '[:space:]')"
out_file="$TMPDIR_FAN/$i.out"
err_file="$TMPDIR_FAN/$i.err"
echo "$m_trimmed" > "$TMPDIR_FAN/$i.model"
(
args=(--model "$m_trimmed" --prompt "$PROMPT")
[[ -n "$SYSTEM" ]] && args+=(--system "$SYSTEM")
[[ -n "$TEMPERATURE" ]] && args+=(--temperature "$TEMPERATURE")
[[ -n "$MAX_TOKENS" ]] && args+=(--max-tokens "$MAX_TOKENS")
[[ "$JSON_MODE" == "true" ]] && args+=(--json-mode)
[[ "$TEXT_ONLY" == "true" ]] && args+=(--text)
"$CHAT" "${args[@]}" > "$out_file" 2> "$err_file"
echo "$?" > "$TMPDIR_FAN/$i.rc"
) &
PIDS+=("$!")
done
# Wait for all
for pid in "${PIDS[@]}"; do
wait "$pid" || true
done
# Emit in input order
for i in "${!MODEL_ARR[@]}"; do
m="$(cat "$TMPDIR_FAN/$i.model")"
rc="$(cat "$TMPDIR_FAN/$i.rc" 2>/dev/null || echo "?")"
printf '===== %s (rc=%s) =====\n' "$m" "$rc"
if [[ -s "$TMPDIR_FAN/$i.out" ]]; then
cat "$TMPDIR_FAN/$i.out"
fi
if [[ -s "$TMPDIR_FAN/$i.err" ]]; then
echo "--- stderr ---" >&2
cat "$TMPDIR_FAN/$i.err" >&2
fi
echo
done
#!/usr/bin/env bash
# auth.sh -- load OPENROUTER_API_KEY for Snappy OpenRouter scripts.
#
# Resolution order (first hit wins):
# 1. $OPENROUTER_API_KEY (already in env)
# 2. snappy-settings load-env.sh (sources .env.cache)
# 3. snappy-settings get-cred.sh openrouter_api_key (legacy shim over .env.cache)
# 4. ~/.snappy/openrouter.key (plaintext fallback, chmod 600)
#
# This file is meant to be SOURCED, not executed.
# After sourcing, $OPENROUTER_API_KEY is exported.
#
# Also exports $OPENROUTER_BASE_URL, $OPENROUTER_REFERER, $OPENROUTER_TITLE
# with sensible Snappy defaults that callers can override.
set -uo pipefail
# Defaults (override by exporting before sourcing)
: "${OPENROUTER_BASE_URL:=https://openrouter.ai/api/v1}"
: "${OPENROUTER_REFERER:=https://snappy.ai}"
: "${OPENROUTER_TITLE:=Snappy OS}"
export OPENROUTER_BASE_URL OPENROUTER_REFERER OPENROUTER_TITLE
_snappy_or_log() {
echo "[snappy-openrouter:auth] $*" >&2
}
_snappy_or_load_key() {
# 1. Already in env?
if [[ -n "${OPENROUTER_API_KEY:-}" ]]; then
return 0
fi
# 2. snappy-settings load-env.sh (canonical -- reads .env.cache)
local load_env="$HOME/.claude/skills/snappy-settings/scripts/load-env.sh"
if [[ -f "$load_env" ]]; then
# shellcheck source=/dev/null
SNAPPY_SETTINGS_QUIET=1 . "$load_env" 2>/dev/null || true
if [[ -n "${OPENROUTER_API_KEY:-}" ]]; then
return 0
fi
fi
# 3. snappy-settings get-cred.sh (legacy shim over same .env.cache)
local snappy_settings_get_cred="$HOME/.claude/skills/snappy-settings/scripts/get-cred.sh"
if [[ -x "$snappy_settings_get_cred" ]]; then
local key
if key="$("$snappy_settings_get_cred" openrouter_api_key 2>/dev/null)" && [[ -n "$key" ]]; then
export OPENROUTER_API_KEY="$key"
return 0
fi
fi
# 4. ~/.snappy/openrouter.key plaintext fallback
local key_file="$HOME/.snappy/openrouter.key"
if [[ -r "$key_file" ]]; then
local key
key="$(tr -d '[:space:]' < "$key_file")"
if [[ -n "$key" ]]; then
export OPENROUTER_API_KEY="$key"
return 0
fi
fi
return 1
}
if ! _snappy_or_load_key; then
_snappy_or_log "ERROR: could not load OPENROUTER_API_KEY"
_snappy_or_log " Tried (in order):"
_snappy_or_log " 1. \$OPENROUTER_API_KEY env var"
_snappy_or_log " 2. ~/.claude/skills/snappy-settings/scripts/load-env.sh (.env.cache)"
_snappy_or_log " 3. ~/.claude/skills/snappy-settings/scripts/get-cred.sh openrouter_api_key"
_snappy_or_log " 4. ~/.snappy/openrouter.key file"
_snappy_or_log " Fix: add OPENROUTER_API_KEY to snappy-settings/.env.cache"
return 1 2>/dev/null || exit 1
fi
# Sanity check format (OpenRouter keys start with sk-or-)
if [[ "$OPENROUTER_API_KEY" != sk-or-* ]]; then
_snappy_or_log "WARNING: OPENROUTER_API_KEY does not start with 'sk-or-' -- may be wrong key"
fi
#!/usr/bin/env bash
# auth.sh -- load OPENROUTER_API_KEY for Snappy OpenRouter scripts.
#
# Resolution order (first hit wins):
# 1. $OPENROUTER_API_KEY (already in env)
# 2. snappy-settings load-env.sh (sources .env.cache)
# 3. snappy-settings get-cred.sh openrouter_api_key (legacy shim over .env.cache)
# 4. ~/.snappy/openrouter.key (plaintext fallback, chmod 600)
#
# This file is meant to be SOURCED, not executed.
# After sourcing, $OPENROUTER_API_KEY is exported.
#
# Also exports $OPENROUTER_BASE_URL, $OPENROUTER_REFERER, $OPENROUTER_TITLE
# with sensible Snappy defaults that callers can override.
set -uo pipefail
# Defaults (override by exporting before sourcing)
: "${OPENROUTER_BASE_URL:=https://openrouter.ai/api/v1}"
: "${OPENROUTER_REFERER:=https://snappy.ai}"
: "${OPENROUTER_TITLE:=Snappy OS}"
export OPENROUTER_BASE_URL OPENROUTER_REFERER OPENROUTER_TITLE
_snappy_or_log() {
echo "[snappy-openrouter:auth] $*" >&2
}
_snappy_or_load_key() {
# 1. Already in env?
if [[ -n "${OPENROUTER_API_KEY:-}" ]]; then
return 0
fi
# 2. snappy-settings load-env.sh (canonical -- reads .env.cache)
local load_env="$HOME/.claude/skills/snappy-settings/scripts/load-env.sh"
if [[ -f "$load_env" ]]; then
# shellcheck source=/dev/null
SNAPPY_SETTINGS_QUIET=1 . "$load_env" 2>/dev/null || true
if [[ -n "${OPENROUTER_API_KEY:-}" ]]; then
return 0
fi
fi
# 3. snappy-settings get-cred.sh (legacy shim over same .env.cache)
local snappy_settings_get_cred="$HOME/.claude/skills/snappy-settings/scripts/get-cred.sh"
if [[ -x "$snappy_settings_get_cred" ]]; then
local key
if key="$("$snappy_settings_get_cred" openrouter_api_key 2>/dev/null)" && [[ -n "$key" ]]; then
export OPENROUTER_API_KEY="$key"
return 0
fi
fi
# 4. ~/.snappy/openrouter.key plaintext fallback
local key_file="$HOME/.snappy/openrouter.key"
if [[ -r "$key_file" ]]; then
local key
key="$(tr -d '[:space:]' < "$key_file")"
if [[ -n "$key" ]]; then
export OPENROUTER_API_KEY="$key"
return 0
fi
fi
return 1
}
if ! _snappy_or_load_key; then
_snappy_or_log "ERROR: could not load OPENROUTER_API_KEY"
_snappy_or_log " Tried (in order):"
_snappy_or_log " 1. \$OPENROUTER_API_KEY env var"
_snappy_or_log " 2. ~/.claude/skills/snappy-settings/scripts/load-env.sh (.env.cache)"
_snappy_or_log " 3. ~/.claude/skills/snappy-settings/scripts/get-cred.sh openrouter_api_key"
_snappy_or_log " 4. ~/.snappy/openrouter.key file"
_snappy_or_log " Fix: add OPENROUTER_API_KEY to snappy-settings/.env.cache"
return 1 2>/dev/null || exit 1
fi
# Sanity check format (OpenRouter keys start with sk-or-)
if [[ "$OPENROUTER_API_KEY" != sk-or-* ]]; then
_snappy_or_log "WARNING: OPENROUTER_API_KEY does not start with 'sk-or-' -- may be wrong key"
fi
#!/usr/bin/env bash
# route.sh -- Auto-route to the cheapest model that handles the request.
#
# Uses OpenRouter's special model identifier "openrouter/auto", which lets
# OpenRouter pick the cheapest available model whose context window and
# capabilities match the prompt. This is the fastest way to get a working
# answer when you don't care which model is used.
#
# You can also bias the auto-router toward a tier:
# --tier cheap → prefer cheap models (default)
# --tier balanced → balance cost and quality
# --tier premium → prefer high-quality models
#
# Usage:
# ./route.sh --prompt "Classify intent: book a meeting"
# ./route.sh --prompt "Summarize this email" --stdin --text
# ./route.sh --tier balanced --prompt "Rewrite hook"
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/auth.sh
source "$SCRIPT_DIR/lib/auth.sh"
PROMPT=""
SYSTEM=""
TIER="cheap"
TEMPERATURE=""
MAX_TOKENS=""
TEXT_ONLY="false"
READ_STDIN="false"
usage() {
cat <<'EOF'
route.sh -- Auto-route to the cheapest model via OpenRouter's openrouter/auto.
REQUIRED:
--prompt <text> User message text (or --stdin)
OPTIONAL:
--system <text> System prompt
--tier <name> cheap | balanced | premium (default: cheap)
--temperature <float>
--max-tokens <int>
--text Output only the assistant message text
--stdin Read prompt from stdin
-h | --help
The tier hint is implemented via a fallback chain:
cheap → openrouter/auto + fallback to gemini-2.0-flash, deepseek-chat
balanced → openrouter/auto + fallback to claude-3.5-sonnet, gpt-4o
premium → claude-opus-4 + fallback to o1, claude-3.5-sonnet
EXAMPLES:
./route.sh --prompt "Hello"
echo "Extract email" | ./route.sh --stdin --text
./route.sh --tier balanced --prompt "Write a 280-char hook about Xano"
./route.sh --tier premium --prompt "Solve: novel reasoning question"
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--prompt) PROMPT="$2"; shift 2 ;;
--system) SYSTEM="$2"; shift 2 ;;
--tier) TIER="$2"; shift 2 ;;
--temperature) TEMPERATURE="$2"; shift 2 ;;
--max-tokens) MAX_TOKENS="$2"; shift 2 ;;
--text) TEXT_ONLY="true"; shift ;;
--stdin) READ_STDIN="true"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
if [[ "$READ_STDIN" == "true" ]]; then
PROMPT="$(cat)"
fi
[[ -z "$PROMPT" ]] && { echo "ERROR: --prompt or --stdin required" >&2; usage; exit 2; }
case "$TIER" in
cheap)
MODELS_JSON='["openrouter/auto","google/gemini-2.0-flash","deepseek/deepseek-chat","anthropic/claude-3.5-haiku"]'
;;
balanced)
MODELS_JSON='["openrouter/auto","anthropic/claude-3.5-sonnet","openai/gpt-4o","mistralai/mistral-large"]'
;;
premium)
MODELS_JSON='["anthropic/claude-opus-4","openai/o1","anthropic/claude-3.5-sonnet"]'
;;
*)
echo "ERROR: --tier must be cheap|balanced|premium" >&2
exit 2
;;
esac
command -v jq >/dev/null 2>&1 || { echo "ERROR: jq required" >&2; exit 3; }
command -v curl >/dev/null 2>&1 || { echo "ERROR: curl required" >&2; exit 3; }
# Build messages
MESSAGES_JSON='[]'
if [[ -n "$SYSTEM" ]]; then
MESSAGES_JSON="$(jq -cn --arg s "$SYSTEM" '[{role:"system",content:$s}]')"
fi
MESSAGES_JSON="$(jq -c --arg p "$PROMPT" '. + [{role:"user",content:$p}]' <<<"$MESSAGES_JSON")"
# Build body -- use models[] (fallback chain), first available wins
BODY="$(jq -cn \
--argjson msgs "$MESSAGES_JSON" \
--argjson ms "$MODELS_JSON" \
'{messages:$msgs, models:$ms, stream:false}')"
if [[ -n "$TEMPERATURE" ]]; then
BODY="$(jq -c --argjson t "$TEMPERATURE" '. + {temperature:$t}' <<<"$BODY")"
fi
if [[ -n "$MAX_TOKENS" ]]; then
BODY="$(jq -c --argjson n "$MAX_TOKENS" '. + {max_tokens:$n}' <<<"$BODY")"
fi
RESPONSE="$(curl -sS "$OPENROUTER_BASE_URL/chat/completions" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "HTTP-Referer: $OPENROUTER_REFERER" \
-H "X-Title: $OPENROUTER_TITLE" \
-H "Content-Type: application/json" \
--data "$BODY")"
if jq -e '.error' >/dev/null 2>&1 <<<"$RESPONSE"; then
echo "OpenRouter error:" >&2
jq '.error' <<<"$RESPONSE" >&2
exit 4
fi
if [[ "$TEXT_ONLY" == "true" ]]; then
jq -r '.choices[0].message.content // empty' <<<"$RESPONSE"
else
# Surface which model actually answered (helpful when auto-routed)
jq '{model: .model, content: .choices[0].message.content, usage: .usage, full: .}' <<<"$RESPONSE"
fi
#!/usr/bin/env bash
# route.sh -- Auto-route to the cheapest model that handles the request.
#
# Uses OpenRouter's special model identifier "openrouter/auto", which lets
# OpenRouter pick the cheapest available model whose context window and
# capabilities match the prompt. This is the fastest way to get a working
# answer when you don't care which model is used.
#
# You can also bias the auto-router toward a tier:
# --tier cheap → prefer cheap models (default)
# --tier balanced → balance cost and quality
# --tier premium → prefer high-quality models
#
# Usage:
# ./route.sh --prompt "Classify intent: book a meeting"
# ./route.sh --prompt "Summarize this email" --stdin --text
# ./route.sh --tier balanced --prompt "Rewrite hook"
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/auth.sh
source "$SCRIPT_DIR/lib/auth.sh"
PROMPT=""
SYSTEM=""
TIER="cheap"
TEMPERATURE=""
MAX_TOKENS=""
TEXT_ONLY="false"
READ_STDIN="false"
usage() {
cat <<'EOF'
route.sh -- Auto-route to the cheapest model via OpenRouter's openrouter/auto.
REQUIRED:
--prompt <text> User message text (or --stdin)
OPTIONAL:
--system <text> System prompt
--tier <name> cheap | balanced | premium (default: cheap)
--temperature <float>
--max-tokens <int>
--text Output only the assistant message text
--stdin Read prompt from stdin
-h | --help
The tier hint is implemented via a fallback chain:
cheap → openrouter/auto + fallback to gemini-2.0-flash, deepseek-chat
balanced → openrouter/auto + fallback to claude-3.5-sonnet, gpt-4o
premium → claude-opus-4 + fallback to o1, claude-3.5-sonnet
EXAMPLES:
./route.sh --prompt "Hello"
echo "Extract email" | ./route.sh --stdin --text
./route.sh --tier balanced --prompt "Write a 280-char hook about Xano"
./route.sh --tier premium --prompt "Solve: novel reasoning question"
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--prompt) PROMPT="$2"; shift 2 ;;
--system) SYSTEM="$2"; shift 2 ;;
--tier) TIER="$2"; shift 2 ;;
--temperature) TEMPERATURE="$2"; shift 2 ;;
--max-tokens) MAX_TOKENS="$2"; shift 2 ;;
--text) TEXT_ONLY="true"; shift ;;
--stdin) READ_STDIN="true"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
if [[ "$READ_STDIN" == "true" ]]; then
PROMPT="$(cat)"
fi
[[ -z "$PROMPT" ]] && { echo "ERROR: --prompt or --stdin required" >&2; usage; exit 2; }
case "$TIER" in
cheap)
MODELS_JSON='["openrouter/auto","google/gemini-2.0-flash","deepseek/deepseek-chat","anthropic/claude-3.5-haiku"]'
;;
balanced)
MODELS_JSON='["openrouter/auto","anthropic/claude-3.5-sonnet","openai/gpt-4o","mistralai/mistral-large"]'
;;
premium)
MODELS_JSON='["anthropic/claude-opus-4","openai/o1","anthropic/claude-3.5-sonnet"]'
;;
*)
echo "ERROR: --tier must be cheap|balanced|premium" >&2
exit 2
;;
esac
command -v jq >/dev/null 2>&1 || { echo "ERROR: jq required" >&2; exit 3; }
command -v curl >/dev/null 2>&1 || { echo "ERROR: curl required" >&2; exit 3; }
# Build messages
MESSAGES_JSON='[]'
if [[ -n "$SYSTEM" ]]; then
MESSAGES_JSON="$(jq -cn --arg s "$SYSTEM" '[{role:"system",content:$s}]')"
fi
MESSAGES_JSON="$(jq -c --arg p "$PROMPT" '. + [{role:"user",content:$p}]' <<<"$MESSAGES_JSON")"
# Build body -- use models[] (fallback chain), first available wins
BODY="$(jq -cn \
--argjson msgs "$MESSAGES_JSON" \
--argjson ms "$MODELS_JSON" \
'{messages:$msgs, models:$ms, stream:false}')"
if [[ -n "$TEMPERATURE" ]]; then
BODY="$(jq -c --argjson t "$TEMPERATURE" '. + {temperature:$t}' <<<"$BODY")"
fi
if [[ -n "$MAX_TOKENS" ]]; then
BODY="$(jq -c --argjson n "$MAX_TOKENS" '. + {max_tokens:$n}' <<<"$BODY")"
fi
RESPONSE="$(curl -sS "$OPENROUTER_BASE_URL/chat/completions" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "HTTP-Referer: $OPENROUTER_REFERER" \
-H "X-Title: $OPENROUTER_TITLE" \
-H "Content-Type: application/json" \
--data "$BODY")"
if jq -e '.error' >/dev/null 2>&1 <<<"$RESPONSE"; then
echo "OpenRouter error:" >&2
jq '.error' <<<"$RESPONSE" >&2
exit 4
fi
if [[ "$TEXT_ONLY" == "true" ]]; then
jq -r '.choices[0].message.content // empty' <<<"$RESPONSE"
else
# Surface which model actually answered (helpful when auto-routed)
jq '{model: .model, content: .choices[0].message.content, usage: .usage, full: .}' <<<"$RESPONSE"
fi
#!/usr/bin/env bash
# stream.sh -- Streaming chat completion via OpenRouter (SSE).
#
# OpenRouter normalizes streaming to OpenAI's SSE format:
# data: {"id":"...","choices":[{"delta":{"content":"..."}}], ...}
# data: [DONE]
#
# This script reads the SSE stream and prints content chunks to stdout
# as they arrive (no buffering between server and terminal).
#
# Usage:
# ./stream.sh --model anthropic/claude-3.5-sonnet --prompt "Write a sonnet about Xano"
# ./stream.sh --model openai/gpt-4o --system "Be terse." --prompt "..." --raw
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/auth.sh
source "$SCRIPT_DIR/lib/auth.sh"
MODEL=""
PROMPT=""
SYSTEM=""
TEMPERATURE=""
MAX_TOKENS=""
TRANSFORMS=""
RAW="false"
READ_STDIN="false"
usage() {
cat <<'EOF'
stream.sh -- Streaming OpenRouter chat completion (SSE).
REQUIRED:
--model <id> Model identifier (e.g., openai/gpt-4o)
--prompt <text> User message text (or --stdin)
OPTIONAL:
--system <text> System prompt
--temperature <float> Sampling temperature
--max-tokens <int> Max output tokens
--transforms <list> Comma-separated transforms (e.g., middle-out)
--raw Print raw SSE lines instead of extracting deltas
--stdin Read prompt from stdin
-h | --help Show this help
EXAMPLES:
./stream.sh --model anthropic/claude-3.5-sonnet --prompt "Hello"
./stream.sh --model openai/gpt-4o-mini --prompt "Explain joins" --system "Be terse."
./stream.sh --model deepseek/deepseek-r1 --prompt "Solve: 17 * 23 + 5"
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--model) MODEL="$2"; shift 2 ;;
--prompt) PROMPT="$2"; shift 2 ;;
--system) SYSTEM="$2"; shift 2 ;;
--temperature) TEMPERATURE="$2"; shift 2 ;;
--max-tokens) MAX_TOKENS="$2"; shift 2 ;;
--transforms) TRANSFORMS="$2"; shift 2 ;;
--raw) RAW="true"; shift ;;
--stdin) READ_STDIN="true"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
if [[ "$READ_STDIN" == "true" ]]; then
PROMPT="$(cat)"
fi
[[ -z "$MODEL" ]] && { echo "ERROR: --model required" >&2; usage; exit 2; }
[[ -z "$PROMPT" ]] && { echo "ERROR: --prompt or --stdin required" >&2; usage; exit 2; }
command -v jq >/dev/null 2>&1 || { echo "ERROR: jq required" >&2; exit 3; }
command -v curl >/dev/null 2>&1 || { echo "ERROR: curl required" >&2; exit 3; }
# Build messages
MESSAGES_JSON='[]'
if [[ -n "$SYSTEM" ]]; then
MESSAGES_JSON="$(jq -cn --arg s "$SYSTEM" '[{role:"system",content:$s}]')"
fi
MESSAGES_JSON="$(jq -c --arg p "$PROMPT" '. + [{role:"user",content:$p}]' <<<"$MESSAGES_JSON")"
BODY="$(jq -cn --arg m "$MODEL" --argjson msgs "$MESSAGES_JSON" \
'{model:$m,messages:$msgs,stream:true}')"
if [[ -n "$TEMPERATURE" ]]; then
BODY="$(jq -c --argjson t "$TEMPERATURE" '. + {temperature:$t}' <<<"$BODY")"
fi
if [[ -n "$MAX_TOKENS" ]]; then
BODY="$(jq -c --argjson n "$MAX_TOKENS" '. + {max_tokens:$n}' <<<"$BODY")"
fi
if [[ -n "$TRANSFORMS" ]]; then
IFS=',' read -ra T_ARR <<<"$TRANSFORMS"
T_JSON="$(printf '%s\n' "${T_ARR[@]}" | jq -R . | jq -sc '[.[] | gsub("^\\s+|\\s+$";"")]')"
BODY="$(jq -c --argjson t "$T_JSON" '. + {transforms:$t}' <<<"$BODY")"
fi
# Stream and parse SSE
curl -sSN "$OPENROUTER_BASE_URL/chat/completions" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "HTTP-Referer: $OPENROUTER_REFERER" \
-H "X-Title: $OPENROUTER_TITLE" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
--data "$BODY" \
| while IFS= read -r line; do
if [[ "$RAW" == "true" ]]; then
printf '%s\n' "$line"
continue
fi
# Skip blank lines and comments
[[ -z "$line" ]] && continue
[[ "$line" == :* ]] && continue
# SSE data lines
if [[ "$line" == data:* ]]; then
payload="${line#data:}"
payload="${payload# }"
[[ "$payload" == "[DONE]" ]] && { printf '\n'; break; }
# Surface API errors mid-stream
if jq -e '.error' >/dev/null 2>&1 <<<"$payload"; then
printf '\n[stream error] ' >&2
jq -r '.error.message // (.error | tostring)' <<<"$payload" >&2
printf '\n' >&2
exit 4
fi
# Extract delta content if present
delta="$(jq -r '.choices[0].delta.content // empty' 2>/dev/null <<<"$payload" || true)"
if [[ -n "$delta" ]]; then
printf '%s' "$delta"
fi
fi
done
#!/usr/bin/env bash
# stream.sh -- Streaming chat completion via OpenRouter (SSE).
#
# OpenRouter normalizes streaming to OpenAI's SSE format:
# data: {"id":"...","choices":[{"delta":{"content":"..."}}], ...}
# data: [DONE]
#
# This script reads the SSE stream and prints content chunks to stdout
# as they arrive (no buffering between server and terminal).
#
# Usage:
# ./stream.sh --model anthropic/claude-3.5-sonnet --prompt "Write a sonnet about Xano"
# ./stream.sh --model openai/gpt-4o --system "Be terse." --prompt "..." --raw
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/auth.sh
source "$SCRIPT_DIR/lib/auth.sh"
MODEL=""
PROMPT=""
SYSTEM=""
TEMPERATURE=""
MAX_TOKENS=""
TRANSFORMS=""
RAW="false"
READ_STDIN="false"
usage() {
cat <<'EOF'
stream.sh -- Streaming OpenRouter chat completion (SSE).
REQUIRED:
--model <id> Model identifier (e.g., openai/gpt-4o)
--prompt <text> User message text (or --stdin)
OPTIONAL:
--system <text> System prompt
--temperature <float> Sampling temperature
--max-tokens <int> Max output tokens
--transforms <list> Comma-separated transforms (e.g., middle-out)
--raw Print raw SSE lines instead of extracting deltas
--stdin Read prompt from stdin
-h | --help Show this help
EXAMPLES:
./stream.sh --model anthropic/claude-3.5-sonnet --prompt "Hello"
./stream.sh --model openai/gpt-4o-mini --prompt "Explain joins" --system "Be terse."
./stream.sh --model deepseek/deepseek-r1 --prompt "Solve: 17 * 23 + 5"
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--model) MODEL="$2"; shift 2 ;;
--prompt) PROMPT="$2"; shift 2 ;;
--system) SYSTEM="$2"; shift 2 ;;
--temperature) TEMPERATURE="$2"; shift 2 ;;
--max-tokens) MAX_TOKENS="$2"; shift 2 ;;
--transforms) TRANSFORMS="$2"; shift 2 ;;
--raw) RAW="true"; shift ;;
--stdin) READ_STDIN="true"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
if [[ "$READ_STDIN" == "true" ]]; then
PROMPT="$(cat)"
fi
[[ -z "$MODEL" ]] && { echo "ERROR: --model required" >&2; usage; exit 2; }
[[ -z "$PROMPT" ]] && { echo "ERROR: --prompt or --stdin required" >&2; usage; exit 2; }
command -v jq >/dev/null 2>&1 || { echo "ERROR: jq required" >&2; exit 3; }
command -v curl >/dev/null 2>&1 || { echo "ERROR: curl required" >&2; exit 3; }
# Build messages
MESSAGES_JSON='[]'
if [[ -n "$SYSTEM" ]]; then
MESSAGES_JSON="$(jq -cn --arg s "$SYSTEM" '[{role:"system",content:$s}]')"
fi
MESSAGES_JSON="$(jq -c --arg p "$PROMPT" '. + [{role:"user",content:$p}]' <<<"$MESSAGES_JSON")"
BODY="$(jq -cn --arg m "$MODEL" --argjson msgs "$MESSAGES_JSON" \
'{model:$m,messages:$msgs,stream:true}')"
if [[ -n "$TEMPERATURE" ]]; then
BODY="$(jq -c --argjson t "$TEMPERATURE" '. + {temperature:$t}' <<<"$BODY")"
fi
if [[ -n "$MAX_TOKENS" ]]; then
BODY="$(jq -c --argjson n "$MAX_TOKENS" '. + {max_tokens:$n}' <<<"$BODY")"
fi
if [[ -n "$TRANSFORMS" ]]; then
IFS=',' read -ra T_ARR <<<"$TRANSFORMS"
T_JSON="$(printf '%s\n' "${T_ARR[@]}" | jq -R . | jq -sc '[.[] | gsub("^\\s+|\\s+$";"")]')"
BODY="$(jq -c --argjson t "$T_JSON" '. + {transforms:$t}' <<<"$BODY")"
fi
# Stream and parse SSE
curl -sSN "$OPENROUTER_BASE_URL/chat/completions" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "HTTP-Referer: $OPENROUTER_REFERER" \
-H "X-Title: $OPENROUTER_TITLE" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
--data "$BODY" \
| while IFS= read -r line; do
if [[ "$RAW" == "true" ]]; then
printf '%s\n' "$line"
continue
fi
# Skip blank lines and comments
[[ -z "$line" ]] && continue
[[ "$line" == :* ]] && continue
# SSE data lines
if [[ "$line" == data:* ]]; then
payload="${line#data:}"
payload="${payload# }"
[[ "$payload" == "[DONE]" ]] && { printf '\n'; break; }
# Surface API errors mid-stream
if jq -e '.error' >/dev/null 2>&1 <<<"$payload"; then
printf '\n[stream error] ' >&2
jq -r '.error.message // (.error | tostring)' <<<"$payload" >&2
printf '\n' >&2
exit 4
fi
# Extract delta content if present
delta="$(jq -r '.choices[0].delta.content // empty' 2>/dev/null <<<"$payload" || true)"
if [[ -n "$delta" ]]; then
printf '%s' "$delta"
fi
fi
done