Files

1 line
246 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "abeermeer/switchboard", "files": {"README.md": {"064b131eade44e11a850656fd7e10297e21ecfd6": {"t": 1785747223.4640794, "n": 21655, "h": "a05da898173423d4", "c": "# Switchboard\n\n[![CI](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml/badge.svg)](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml)\n[![Tests](https://img.shields.io/badge/tests-1035-3ecf8e.svg)](#tests)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n[![Node](https://img.shields.io/badge/node-%E2%89%A522.13-f0912f.svg)](https://nodejs.org)\n\n**One endpoint. Every provider. Free tiers first, automatic fallback, everything local.**\n\nSwitchboard is an AI gateway that runs on your own machine. Point any OpenAI-compatible\ntool at it and it decides — per request — which provider and model should serve the call,\npreferring free tiers, routing around outages, and recording exactly why it chose what it\nchose.\n\n```\nyour tools ──▶ http://127.0.0.1:7272/v1 ──▶ Groq · Cerebras · Google · OpenRouter\n DeepSeek · Mistral · OpenAI · Anthropic …\n```\n\nNothing leaves your machine except the upstream provider calls themselves. No telemetry,\nno account, no cloud control plane.\n\n---\n\n## Demo\n\n[![Switchboard — every candidate, every factor](brag-output/brag.jpg)](https://github.com/abeermeer/switchboard/releases/download/v0.4.1/brag.mp4)\n\n**[▶ Watch the 20-second walkthrough](https://github.com/abeermeer/switchboard/releases/download/v0.4.1/brag.mp4)**\n\nA provider 429s mid-request, the fallback lands on a different one, and the decision trace\nexplains exactly why the replacement won. Every number on screen is real output from a\nrunning instance — the response headers, the four candidate scores, the factor notes, and\nthe exclusion reason.\n\nThat still above is the routing decision for one request: four candidates ranked, the\nwinner in copper, the price spread across providers serving the same class of model, and\n`17 eligible · 5 excluded`. Every other gateway gives you a log line; this is the part\nworth showing.\n\n---\n\n## Why\n\nRunning six provider SDKs across four tools means six sets of keys, six failure modes, and\nno idea what any of it costs. Switchboard collapses that into one endpoint with one key,\nand answers three questions the raw SDKs cannot:\n\n- **Which provider should serve this?** Scored live on health, latency, price and tier.\n- **What happens when it is down?** The next candidate takes it, transparently.\n- **What is this actually costing me?** Per request, per model, per key, against a\n frontier-pricing baseline.\n\n### What makes it different\n\nPlenty of tools proxy several providers. The distinction here is that Switchboard **shows\nits work**.\n\n| | Typical gateway | Switchboard |\n| --- | --- | --- |\n| Why a provider was chosen | A log line, if anything | Every candidate stored with its eight weighted score factors |\n| Why the others lost | Not recorded | An explicit exclusion reason per candidate, rendered in the UI |\n| Tuning a routing policy | Change it and send real traffic | Simulate it — score the ranking without sending anything upstream |\n| Free-tier requests | Billed at list price | Settled at **$0**, so the ladder ranks correctly and spend is honest |\n| Install | Native module, needs a toolchain | `node:sqlite` — no node-gyp, identical on Windows |\n| Where a failure shows up | In your client, as a 500 | Absorbed by the fallback walk; the client sees a 200 |\n\n---\n\n## Quickstart\n\n**1) Install and run**\n\n```bash\ngit clone https://github.com/abeermeer/switchboard.git\ncd switchboard\nnpm install && npm run build\nnpm install -g . # puts `switchboard` and `sb` on your PATH\nswitchboard\n```\n\nThat's it. The dashboard is on **http://localhost:7272**, the gateway on\n**http://localhost:7272/v1**.\n\n> **Not on npm yet.** The package name is **`switchboard-gateway`** and\n> `npm install -g switchboard-gateway` will be the one-liner once it is published — plain\n> `switchboard` on npm is an unrelated 2022 event-listener library with no executable, so\n> installing *that* leaves you with no command at all. Until the publish lands, use the four\n> lines above; they install the same artefact CI verifies on every commit.\n\nYour database and encryption key live in your OS application-data directory\n(`%APPDATA%\\Switchboard`, `~/Library/Application Support/Switchboard`, or\n`~/.local/share/Switchboard`), so reinstalling or upgrading never touches them.\n\n**2) Connect a free provider**\n\nOpen the dashboard and pick one. Groq, Cerebras and Google all give real free tiers with\nno payment method. Or from the terminal:\n\n```bash\nsb providers add groq # prompts for the key, echo off\n```\n\n**3) Create a key and point a tool at it**\n\n```bash\nsb keys new \"my laptop\"\n```\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n<details>\n<summary><b>Other ways to run it</b></summary>\n\n**From a checkout, without installing globally**\n\n```bash\nnpm install\nnpm run build\nnpm start\n```\n\n**Docker**\n\n```bash\ndocker build -t switchboard .\ndocker run -d -p 7272:7272 -v switchboard-data:/data switchboard\n```\n\n**Desktop app** — `npm run electron:build:win` (or `:mac` / `:linux`) puts an installer in\n`release/`. Runs in the tray with start-at-login.\n\n**Pin to a release line** — every version is branched and tagged, so you can track one\nwithout waiting on npm:\n\n```bash\ngit clone -b release/v0.4.1 https://github.com/abeermeer/switchboard.git\n```\n\nOnce the npm publish lands, `npm install -g switchboard-gateway@0.4.1` does the same.\n\n</details>\n\n`\"model\": \"auto\"` hands the decision to the router. You can also send a bare model id\n(`llama-3.3-70b-versatile`), a pinned provider (`groq/llama-3.3-70b-versatile`), or a\npolicy slug you have defined.\n\n---\n\n## Point your tools at it\n\n**Claude Code**\n\n```bash\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:7272/v1\"\nexport ANTHROPIC_API_KEY=\"sb-live-your-key\"\n```\n\n**Cursor** — Settings → Models → Override OpenAI Base URL\n\n```\nBase URL: http://127.0.0.1:7272/v1\nAPI Key: sb-live-your-key\nModel: auto\n```\n\n**OpenAI SDK (Python)**\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(base_url=\"http://127.0.0.1:7272/v1\", api_key=\"sb-live-your-key\")\nresponse = client.chat.completions.create(\n model=\"auto\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\n```\n\n**OpenAI SDK (Node)**\n\n```js\nimport OpenAI from 'openai';\n\nconst client = new OpenAI({\n baseURL: 'http://127.0.0.1:7272/v1',\n apiKey: 'sb-live-your-key',\n});\n```\n\n**Cline / Continue / Aider** — any \"OpenAI-compatible endpoint\" field takes the same base\nURL and key.\n\n---\n\n## Endpoints\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `POST` | `/v1/chat/completions` | Streaming and non-streaming |\n| `POST` | `/v1/completions` | Legacy, shimmed onto chat |\n| `POST` | `/v1/embeddings` | |\n| `POST` | `/v1/images/generations` | |\n| `POST` | `/v1/audio/speech` | Returns audio bytes |\n| `POST` | `/v1/audio/transcriptions` | Multipart upload |\n| `POST` | `/v1/rerank` | |\n| `POST` | `/v1/moderations` | |\n| `GET` | `/v1/models` | Every model across every connected provider |\n| `GET` | `/v1/models/{model}` | |\n\nEvery response carries routing telemetry, so you can debug from curl alone:\n\n```\nx-switchboard-request-id: req_6751a555d0264a6aa7e0\nx-switchboard-provider: groq\nx-switchboard-model: llama-3.3-70b-versatile\nx-switchboard-tier: free\nx-switchboard-attempts: 2\nx-switchboard-cost-usd: 0.000083\nx-switchboard-ttft-ms: 340\nx-switchboard-fallback-reason: Rate limited (429), retry after 12s\n```\n\n`x-switchboard-fallback-reason` only appears when a fallback actually happened, so its\npresence is itself the signal.\n\n---\n\n## Routing\n\nA **policy** (called a combo in the API) is a model name your clients can send. Four ship\nby default:\n\n| Slug | Strategy | Optimises for |\n| --- | --- | --- |\n| `auto` | free-first | Exhaust free tiers before spending anything |\n| `free-only` | free-first, $0 ceiling | Never spends money, fails instead |\n| `fast` | fastest | Lowest observed time to first token |\n| `quality` | quality-first | Strongest model available, price ignored |\n\nSeven strategies are available:\n\n| Strategy | Behaviour |\n| --- | --- |\n| `free-first` | Free tier, then cheap, then paid. The default. |\n| `cost-optimized` | Lowest projected USD for this specific request. |\n| `fastest` | Lowest measured p50 time-to-first-token. |\n| `quality-first` | Inverts the cost factor — price becomes a quality signal. |\n| `priority` | Strict chain order. Predictable, ignores live health. |\n| `round-robin` | Spreads load across healthy members to stretch rate limits. |\n| `failover` | First member always; others only on outright failure. |\n\nEvery candidate is scored on eight factors (health, latency, cost, tier, priority, quota,\nthroughput, stability), weighted per strategy. The weights are visible in the dashboard,\nand the full arithmetic is stored with every request.\n\n### The simulator\n\nThe routing page runs the exact expand-and-score path a live request takes, but stops\nbefore sending anything upstream. Drag a member, move the cost ceiling, and watch the\nranking reshuffle — including which candidates were excluded and for what reason.\n\n---\n\n## Architecture\n\n```mermaid\nflowchart LR\n A[Client] --> B[/v1 endpoint/]\n B --> C[Authenticate<br/>key · rate limit · budget]\n C --> D[Expand candidates<br/>connections × models]\n D --> E[Filter<br/>health · context · features · budget]\n E --> F[Score<br/>8 weighted factors]\n F --> G[Execute]\n G -->|success| H[Log + usage rollup]\n G -->|failure| I{Retryable?}\n I -->|yes| G\n I -->|no| H\n H --> A\n```\n\n| Layer | Location |\n| --- | --- |\n| Domain contract | `src/types/core.ts` |\n| Persistence | `src/lib/db/` — `node:sqlite`, WAL, append-only migrations |\n| Providers | `src/lib/providers/` — catalog + 4 wire adapters |\n| Resilience | `src/lib/resilience/`, `src/lib/health/` |\n| Router | `src/lib/router/` — candidates → score → execute |\n| Gateway API | `src/app/v1/` |\n| Management API | `src/app/api/` |\n| Dashboard | `src/app/(dashboard)/`, `src/components/` |\n\nStorage is Node's built-in `node:sqlite` — no native module, no `node-gyp`, no prebuilt\nbinary roulette. `npm install` works the same on Windows as it does on Linux.\n\n---\n\n## Resilience\n\nThree independent layers, so one failing provider never becomes a failing gateway:\n\n1. **Circuit breaker**, per connection. Opens after N consecutive failures — immediately\n on a `401`, since a bad credential will not fix itself. Cooldown doubles on repeat\n opens, capped at 15 minutes, with jitter so recovering providers are not stampeded.\n2. **Model lockout**, per model. A `429` locks out that one model rather than the whole\n connection, honouring `Retry-After` when the provider sends it.\n3. **Fallback walk**, per request. Up to `maxAttempts` candidates, stopping early when\n retrying elsewhere cannot help — a malformed request produces the same `400` on every\n provider.\n\nA streaming response that has already sent its first byte is **never** retried. Once bytes\nare on the wire the client has committed, and a silent provider switch mid-stream would\ncorrupt the response.\n\n---\n\n## Configuration\n\nEverything is optional; sane defaults are generated on first run.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `PORT` | `7272` | Gateway and dashboard |\n| `SWITCHBOARD_DATA_DIR` | `./data` | Database, key vault, logs |\n| `SWITCHBOARD_MASTER_KEY` | generated | Base64 32-byte key for credential encryption |\n| `SWITCHBOARD_ALLOW_REMOTE` | `0` | Allow non-loopback dashboard access |\n| `SWITCHBOARD_CORS_ORIGINS` | *(none)* | Origins allowed to call `/v1` from a browser |\n| `SWITCHBOARD_LOG_LEVEL` | `info` / `debug` | `debug` · `info` · `warn` · `error` · `silent` |\n| `SWITCHBOARD_LOG_FORMAT` | auto | `json`, or `pretty` in a TTY |\n| `SWITCHBOARD_PACKAGED` | `0` | Set by the desktop build |\n\nProvider credentials are sealed with **AES-256-GCM** before they touch disk. The master key\nlives in `<data-dir>/master.key` with `0600` permissions. No endpoint returns a stored\ncredential — only a four-character hint.\n\n### CORS\n\n`/v1` sends **no** `Access-Control-Allow-Origin` by default. The endpoint is built for\nserver-side SDK clients, which send no `Origin` header and are unaffected by CORS — while a\nwildcard would let any page you happen to visit spend your provider credentials the moment\nit learned a key.\n\nIf a browser app calls the gateway directly, list its origins:\n\n```bash\nSWITCHBOARD_CORS_ORIGINS=http://localhost:3000,https://myapp.example\n```\n\nAllowed origins are reflected rather than wildcarded, with `Vary: Origin` so a shared cache\ncannot hand one origin's response to another. `*` still works if you want the old\nbehaviour, but it is now a decision rather than a default.\n\n### Logging\n\nOne JSON object per line on stdout, or a human-readable line in a TTY. Values that look\nlike credentials are redacted before anything is written — by key name (`authorization`,\n`api_key`, `token`, …) and by shape, so a key pasted into free text is caught too.\n\n```bash\nswitchboard 2>&1 | jq 'select(.level == \"warn\")'\n```\n\nThe same redaction runs on the way **into** the database. Every request log row stores the\nrequest body, the response and the routing decision, so a client that sends its own provider\nkey in a header — or pastes one into a prompt — would otherwise have it written to disk in\nplain text and kept for as long as the row lives. Redacting at the storage boundary rather\nthan on the dashboard's read path means nothing else that can reach the table sees the\noriginal either.\n\nRows are pruned to the **Log retention** setting on each health tick. `0` means keep\nforever, which is a legitimate choice rather than an unconfigured one.\n\n---\n\n## CLI\n\n```bash\nsb start [--port] [--open] # start the gateway\nsb status # health at a glance\nsb doctor # diagnose the install\nsb providers list # connections with status and spend\nsb providers add groq # prompts for the key, echo off\nsb providers test <id> # force a probe\nsb models [--free] # available models with pricing\nsb combos # routing policies\nsb keys new <name> # create an API key\nsb chat [--model] # streaming REPL\nsb logs [--errors] # recent requests\nsb usage [--days 7] # spend summary\nsb open # open the dashboard\n```\n\n---\n\n## Desktop app\n\n```bash\nnpm run electron:build:win # or :mac / :linux\n```\n\nProduces an installer in `release/`. The desktop build runs the gateway as a child process,\nwrites to a real app-data directory, and lives in the system tray with start-at-login,\ncopy-endpoint and restart controls.\n\nIcons are generated from the product mark rather than committed by hand:\n\n```bash\nnpm run icons # icon.png/.ico/.icns + tray at 1x and 2x\nnpm run verify:electron # structural check, no toolchain download\n```\n\n`verify:electron` runs on every CI leg and is the cheap half of the packaging check: the\nbuilder config loads, every referenced icon exists with the right magic bytes *and*\ndimensions, `main.cjs` and `preload.cjs` parse, the preload goes through `contextBridge`\nwithout handing over `ipcRenderer`, and `contextIsolation` / `sandbox` are still on. The\nexpensive half is a real `electron-builder` run on Windows and Linux in\n[desktop.yml](.github/workflows/desktop.yml), which also asserts the installer is larger\nthan 20 MB — electron-builder exits 0 having shipped only the shell if the `files` globs are\nwrong.\n\n---\n\n## Development\n\n```bash\nnpm run dev # dev server on :7272\nnpm run typecheck # tsc --noEmit, strict\nnpm run build # production build\nnpm run electron:dev # desktop shell against the dev server\nnpm run verify:name # the npm name still resolves to this repository\nnpm run verify:install # pack, install globally, run the command\n```\n\n`verify:install` is the check that matters before a release: it packs the tarball, installs\nit into a throwaway global prefix, and runs `switchboard --version` and `sb --version`. Every\nother check in CI runs against the repository, so none of them could tell that the published\ninstall instruction pointed at a different package entirely.\n\nRequires **Node 22.13+** — `node:sqlite` appeared in 22.5 but was flag-gated until 22.13.\nBuilt with Next.js 16, React 19,\nTypeScript 5.9 in strict mode with `noUncheckedIndexedAccess`, and Tailwind CSS v4.\n\nAdding a provider is a single entry in `src/lib/providers/catalog.ts` when it speaks the\nOpenAI wire format — see [docs/development.md](docs/development.md).\n\nConventions for humans and agents are in [CLAUDE.md](CLAUDE.md) and [AGENTS.md](AGENTS.md);\ncontribution workflow in [CONTRIBUTING.md](CONTRIBUTING.md).\n\n### Tests\n\n```bash\nnpm run test:run # ~1,035 tests\nnpm run test:ci # with coverage\n```\n\nVitest, running on every CI leg. Roughly 75% overall, concentrated where a silent bug costs\nmoney or loses a request:\n\n| Area | Coverage |\n| --- | --- |\n| Scoring, cost and free-tier settlement, rate limiting | 100% |\n| Adapter helpers — the error taxonomy every fallback depends on | 99% |\n| Candidate expansion and exclusion reasons | 95% |\n| Circuit breaker state machine | 100% statements |\n| Wire translation for Anthropic, Google and Cohere | ~80% |\n| Credential vault and the redaction layer | ~94% |\n\nThe adapter tests run through the real `execute()` path with `fetch` stubbed, so each one\ncovers the URL, the auth headers, both translation directions and the error classification\ntogether. There are integration tests for the gateway and the management API, and component\ntests for the UI primitives, the live-event transport's reconnection, and the policy\neditor.\n\nTests assert the behaviour the code's comments claim, so the deliberate decisions\n(`bad_request` never retries, a started stream is never retried, free tiers settle at $0)\nfail loudly if anyone \"simplifies\" them away.\n\n### Releases\n\n`main` is the development line. Every version is also cut as a `release/vX.Y.Z` branch with\na matching tag and GitHub release, so you can pin to one and keep receiving fixes for that\nline without inheriting whatever landed on `main` today.\n\nBumping `version` in `package.json` on `main` triggers it: CI typechecks, tests, builds,\nboots the gateway, verifies the publish tarball carries no database or key, then branches,\ntags and publishes the release.\n\nA running record of what changed and why is in [SESSION_SUMMARY.md](SESSION_SUMMARY.md),\nincluding the findings that are known and deliberately unfixed.\n\n### Status\n\nPre-1.0, and specific about what that means.\n\nAn external audit put it at **7.2/10** — strong on architecture, resilience and type\nsafety, with test coverage scored **0/10** as the single largest gap, alongside five\nsmaller findings. A second pass raised four more once those were closed. All ten are now\nclosed:\n\n| Finding | Resolution |\n| --- | --- |\n| No test suite | 1,035 tests; the routing core at 95–100% |\n| Rate limiting reset on restart | Persisted in SQLite, counted per second so it stays cheap on the hot path |\n| `/v1` sent `Access-Control-Allow-Origin: *` | No CORS by default; explicit origin allowlist |\n| No structured logging | JSON logs with a tested redaction layer |\n| Desktop build shipped a stock icon | Icons generated from the product mark |\n| Failed requests skewed latency | Filtered to successes only |\n| Adapter translation barely covered (27%) | 121 tests across the Anthropic, Google and Cohere wire formats; adapters at 80% |\n| Management API routes untested | 47 integration tests driving the real handlers |\n| Log retention setting was never applied | Enforced on the health tick; `0` still means keep forever |\n| Request payloads stored unredacted | Redacted at the storage boundary, not on the read path |\n| Desktop packaging unverified beyond local dev | `npm run verify:electron` on every CI leg, plus a real `electron-builder` run on Windows and Linux |\n| `npm install -g switchboard` installed someone else's package | Renamed to `switchboard-gateway`; CI now checks the registry name and installs the packed tarball to prove the command works |\n| `/api/system/status` reported a hardcoded `0.1.0` | Read from the manifest, with a test pinning it |\n\nWriting the tests found three real bugs that had survived every manual check: a non-ASCII\ncharacter in a provider's error message crashed the response instead of returning the\nclient's error; a rate limit of `0.5` bricked a key permanently; and a provider that was\nreliably *down* posted the fastest p50 in the fleet, so the `fastest` strategy actively\npreferred it.\n\n**Still open**, and worth knowing before you rely on it:\n\n- **Not published to npm yet.** The name is reserved and the release workflow publishes as\n soon as an `NPM_TOKEN` secret exists; until then the install is the four-line clone above.\n- No tests for the DB repositories or most dashboard pages, and no end-to-end tests.\n- No audit log of administrative actions.\n- No graceful shutdown — `SIGTERM` kills in-flight streams rather than draining them.\n- `context_length` counts toward the circuit breaker, unlike `bad_request`, so a client\n looping oversized prompts can open the breaker on a healthy provider.\n- A half-open breaker trial that gets a 429 never reports back, so the trial slot stays\n held until its window expires.\n\n**Do not run it exposed to the internet** without a reverse proxy and authentication in\nfront. It is built for a machine you control. See [SECURITY.md](SECURITY.md) for the threat\nmodel.\n\n---\n\n## License\n\nMIT © 2026 Abeer Meer\n"}, "9b6562e35f8a2495d4871db1602ff41a835da07e": {"t": 1785771466.7931104, "n": 21081, "h": "e6dbe207ce171016", "c": "# Switchboard\n\n[![CI](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml/badge.svg)](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml)\n[![Tests](https://img.shields.io/badge/tests-1035-3ecf8e.svg)](#tests)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n[![Node](https://img.shields.io/badge/node-%E2%89%A522.13-f0912f.svg)](https://nodejs.org)\n\n**One endpoint. Every provider. Free tiers first, automatic fallback, everything local.**\n\nSwitchboard is an AI gateway that runs on your own machine. Point any OpenAI-compatible\ntool at it and it decides — per request — which provider and model should serve the call,\npreferring free tiers, routing around outages, and recording exactly why it chose what it\nchose.\n\n```\nyour tools ──▶ http://127.0.0.1:7272/v1 ──▶ Groq · Cerebras · Google · OpenRouter\n DeepSeek · Mistral · OpenAI · Anthropic …\n```\n\nNothing leaves your machine except the upstream provider calls themselves. No telemetry,\nno account, no cloud control plane.\n\n---\n\n## Demo\n\n[![Switchboard — every candidate, every factor](brag-output/brag.jpg)](https://github.com/abeermeer/switchboard/releases/download/v0.4.1/brag.mp4)\n\n**[▶ Watch the 20-second walkthrough](https://github.com/abeermeer/switchboard/releases/download/v0.4.1/brag.mp4)**\n\nA provider 429s mid-request, the fallback lands on a different one, and the decision trace\nexplains exactly why the replacement won. Every number on screen is real output from a\nrunning instance — the response headers, the four candidate scores, the factor notes, and\nthe exclusion reason.\n\nThat still above is the routing decision for one request: four candidates ranked, the\nwinner in copper, the price spread across providers serving the same class of model, and\n`17 eligible · 5 excluded`. Every other gateway gives you a log line; this is the part\nworth showing.\n\n---\n\n## Why\n\nRunning six provider SDKs across four tools means six sets of keys, six failure modes, and\nno idea what any of it costs. Switchboard collapses that into one endpoint with one key,\nand answers three questions the raw SDKs cannot:\n\n- **Which provider should serve this?** Scored live on health, latency, price and tier.\n- **What happens when it is down?** The next candidate takes it, transparently.\n- **What is this actually costing me?** Per request, per model, per key, against a\n frontier-pricing baseline.\n\n### What makes it different\n\nPlenty of tools proxy several providers. The distinction here is that Switchboard **shows\nits work**.\n\n| | Typical gateway | Switchboard |\n| --- | --- | --- |\n| Why a provider was chosen | A log line, if anything | Every candidate stored with its eight weighted score factors |\n| Why the others lost | Not recorded | An explicit exclusion reason per candidate, rendered in the UI |\n| Tuning a routing policy | Change it and send real traffic | Simulate it — score the ranking without sending anything upstream |\n| Free-tier requests | Billed at list price | Settled at **$0**, so the ladder ranks correctly and spend is honest |\n| Install | Native module, needs a toolchain | `node:sqlite` — no node-gyp, identical on Windows |\n| Where a failure shows up | In your client, as a 500 | Absorbed by the fallback walk; the client sees a 200 |\n\n---\n\n## Quickstart\n\n**1) Install and run**\n\n```bash\nnpm install -g switchboard-gateway\nswitchboard\n```\n\nThat's it. The dashboard is on **http://localhost:7272**, the gateway on\n**http://localhost:7272/v1**.\n\n> The npm package is **`switchboard-gateway`** — plain `switchboard` on npm is an unrelated\n> 2022 event-listener library with no executable, so installing that gives you no command.\n> The commands this package installs are still `switchboard` and `sb`.\n\nYour database and encryption key live in your OS application-data directory\n(`%APPDATA%\\Switchboard`, `~/Library/Application Support/Switchboard`, or\n`~/.local/share/Switchboard`), so upgrading with `npm update -g switchboard-gateway` never\ntouches them.\n\n**2) Connect a free provider**\n\nOpen the dashboard and pick one. Groq, Cerebras and Google all give real free tiers with\nno payment method. Or from the terminal:\n\n```bash\nsb providers add groq # prompts for the key, echo off\n```\n\n**3) Create a key and point a tool at it**\n\n```bash\nsb keys new \"my laptop\"\n```\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n<details>\n<summary><b>Other ways to run it</b></summary>\n\n**From source**\n\n```bash\ngit clone https://github.com/abeermeer/switchboard.git\ncd switchboard\nnpm install\nnpm run build\nnpm start\n```\n\n**Docker**\n\n```bash\ndocker build -t switchboard .\ndocker run -d -p 7272:7272 -v switchboard-data:/data switchboard\n```\n\n**Desktop app** — `npm run electron:build:win` (or `:mac` / `:linux`) puts an installer in\n`release/`. Runs in the tray with start-at-login.\n\n**Pin to a release line** — every version is branched, so you can track one:\n\n```bash\nnpm install -g switchboard-gateway@0.4.1\n```\n\n</details>\n\n`\"model\": \"auto\"` hands the decision to the router. You can also send a bare model id\n(`llama-3.3-70b-versatile`), a pinned provider (`groq/llama-3.3-70b-versatile`), or a\npolicy slug you have defined.\n\n---\n\n## Point your tools at it\n\n**Claude Code**\n\n```bash\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:7272/v1\"\nexport ANTHROPIC_API_KEY=\"sb-live-your-key\"\n```\n\n**Cursor** — Settings → Models → Override OpenAI Base URL\n\n```\nBase URL: http://127.0.0.1:7272/v1\nAPI Key: sb-live-your-key\nModel: auto\n```\n\n**OpenAI SDK (Python)**\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(base_url=\"http://127.0.0.1:7272/v1\", api_key=\"sb-live-your-key\")\nresponse = client.chat.completions.create(\n model=\"auto\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\n```\n\n**OpenAI SDK (Node)**\n\n```js\nimport OpenAI from 'openai';\n\nconst client = new OpenAI({\n baseURL: 'http://127.0.0.1:7272/v1',\n apiKey: 'sb-live-your-key',\n});\n```\n\n**Cline / Continue / Aider** — any \"OpenAI-compatible endpoint\" field takes the same base\nURL and key.\n\n---\n\n## Endpoints\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `POST` | `/v1/chat/completions` | Streaming and non-streaming |\n| `POST` | `/v1/completions` | Legacy, shimmed onto chat |\n| `POST` | `/v1/embeddings` | |\n| `POST` | `/v1/images/generations` | |\n| `POST` | `/v1/audio/speech` | Returns audio bytes |\n| `POST` | `/v1/audio/transcriptions` | Multipart upload |\n| `POST` | `/v1/rerank` | |\n| `POST` | `/v1/moderations` | |\n| `GET` | `/v1/models` | Every model across every connected provider |\n| `GET` | `/v1/models/{model}` | |\n\nEvery response carries routing telemetry, so you can debug from curl alone:\n\n```\nx-switchboard-request-id: req_6751a555d0264a6aa7e0\nx-switchboard-provider: groq\nx-switchboard-model: llama-3.3-70b-versatile\nx-switchboard-tier: free\nx-switchboard-attempts: 2\nx-switchboard-cost-usd: 0.000083\nx-switchboard-ttft-ms: 340\nx-switchboard-fallback-reason: Rate limited (429), retry after 12s\n```\n\n`x-switchboard-fallback-reason` only appears when a fallback actually happened, so its\npresence is itself the signal.\n\n---\n\n## Routing\n\nA **policy** (called a combo in the API) is a model name your clients can send. Four ship\nby default:\n\n| Slug | Strategy | Optimises for |\n| --- | --- | --- |\n| `auto` | free-first | Exhaust free tiers before spending anything |\n| `free-only` | free-first, $0 ceiling | Never spends money, fails instead |\n| `fast` | fastest | Lowest observed time to first token |\n| `quality` | quality-first | Strongest model available, price ignored |\n\nSeven strategies are available:\n\n| Strategy | Behaviour |\n| --- | --- |\n| `free-first` | Free tier, then cheap, then paid. The default. |\n| `cost-optimized` | Lowest projected USD for this specific request. |\n| `fastest` | Lowest measured p50 time-to-first-token. |\n| `quality-first` | Inverts the cost factor — price becomes a quality signal. |\n| `priority` | Strict chain order. Predictable, ignores live health. |\n| `round-robin` | Spreads load across healthy members to stretch rate limits. |\n| `failover` | First member always; others only on outright failure. |\n\nEvery candidate is scored on eight factors (health, latency, cost, tier, priority, quota,\nthroughput, stability), weighted per strategy. The weights are visible in the dashboard,\nand the full arithmetic is stored with every request.\n\n### The simulator\n\nThe routing page runs the exact expand-and-score path a live request takes, but stops\nbefore sending anything upstream. Drag a member, move the cost ceiling, and watch the\nranking reshuffle — including which candidates were excluded and for what reason.\n\n---\n\n## Architecture\n\n```mermaid\nflowchart LR\n A[Client] --> B[/v1 endpoint/]\n B --> C[Authenticate<br/>key · rate limit · budget]\n C --> D[Expand candidates<br/>connections × models]\n D --> E[Filter<br/>health · context · features · budget]\n E --> F[Score<br/>8 weighted factors]\n F --> G[Execute]\n G -->|success| H[Log + usage rollup]\n G -->|failure| I{Retryable?}\n I -->|yes| G\n I -->|no| H\n H --> A\n```\n\n| Layer | Location |\n| --- | --- |\n| Domain contract | `src/types/core.ts` |\n| Persistence | `src/lib/db/` — `node:sqlite`, WAL, append-only migrations |\n| Providers | `src/lib/providers/` — catalog + 4 wire adapters |\n| Resilience | `src/lib/resilience/`, `src/lib/health/` |\n| Router | `src/lib/router/` — candidates → score → execute |\n| Gateway API | `src/app/v1/` |\n| Management API | `src/app/api/` |\n| Dashboard | `src/app/(dashboard)/`, `src/components/` |\n\nStorage is Node's built-in `node:sqlite` — no native module, no `node-gyp`, no prebuilt\nbinary roulette. `npm install` works the same on Windows as it does on Linux.\n\n---\n\n## Resilience\n\nThree independent layers, so one failing provider never becomes a failing gateway:\n\n1. **Circuit breaker**, per connection. Opens after N consecutive failures — immediately\n on a `401`, since a bad credential will not fix itself. Cooldown doubles on repeat\n opens, capped at 15 minutes, with jitter so recovering providers are not stampeded.\n2. **Model lockout**, per model. A `429` locks out that one model rather than the whole\n connection, honouring `Retry-After` when the provider sends it.\n3. **Fallback walk**, per request. Up to `maxAttempts` candidates, stopping early when\n retrying elsewhere cannot help — a malformed request produces the same `400` on every\n provider.\n\nA streaming response that has already sent its first byte is **never** retried. Once bytes\nare on the wire the client has committed, and a silent provider switch mid-stream would\ncorrupt the response.\n\n---\n\n## Configuration\n\nEverything is optional; sane defaults are generated on first run.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `PORT` | `7272` | Gateway and dashboard |\n| `SWITCHBOARD_DATA_DIR` | `./data` | Database, key vault, logs |\n| `SWITCHBOARD_MASTER_KEY` | generated | Base64 32-byte key for credential encryption |\n| `SWITCHBOARD_ALLOW_REMOTE` | `0` | Allow non-loopback dashboard access |\n| `SWITCHBOARD_CORS_ORIGINS` | *(none)* | Origins allowed to call `/v1` from a browser |\n| `SWITCHBOARD_LOG_LEVEL` | `info` / `debug` | `debug` · `info` · `warn` · `error` · `silent` |\n| `SWITCHBOARD_LOG_FORMAT` | auto | `json`, or `pretty` in a TTY |\n| `SWITCHBOARD_PACKAGED` | `0` | Set by the desktop build |\n\nProvider credentials are sealed with **AES-256-GCM** before they touch disk. The master key\nlives in `<data-dir>/master.key` with `0600` permissions. No endpoint returns a stored\ncredential — only a four-character hint.\n\n### CORS\n\n`/v1` sends **no** `Access-Control-Allow-Origin` by default. The endpoint is built for\nserver-side SDK clients, which send no `Origin` header and are unaffected by CORS — while a\nwildcard would let any page you happen to visit spend your provider credentials the moment\nit learned a key.\n\nIf a browser app calls the gateway directly, list its origins:\n\n```bash\nSWITCHBOARD_CORS_ORIGINS=http://localhost:3000,https://myapp.example\n```\n\nAllowed origins are reflected rather than wildcarded, with `Vary: Origin` so a shared cache\ncannot hand one origin's response to another. `*` still works if you want the old\nbehaviour, but it is now a decision rather than a default.\n\n### Logging\n\nOne JSON object per line on stdout, or a human-readable line in a TTY. Values that look\nlike credentials are redacted before anything is written — by key name (`authorization`,\n`api_key`, `token`, …) and by shape, so a key pasted into free text is caught too.\n\n```bash\nswitchboard 2>&1 | jq 'select(.level == \"warn\")'\n```\n\nThe same redaction runs on the way **into** the database. Every request log row stores the\nrequest body, the response and the routing decision, so a client that sends its own provider\nkey in a header — or pastes one into a prompt — would otherwise have it written to disk in\nplain text and kept for as long as the row lives. Redacting at the storage boundary rather\nthan on the dashboard's read path means nothing else that can reach the table sees the\noriginal either.\n\nRows are pruned to the **Log retention** setting on each health tick. `0` means keep\nforever, which is a legitimate choice rather than an unconfigured one.\n\n---\n\n## CLI\n\n```bash\nsb start [--port] [--open] # start the gateway\nsb status # health at a glance\nsb doctor # diagnose the install\nsb providers list # connections with status and spend\nsb providers add groq # prompts for the key, echo off\nsb providers test <id> # force a probe\nsb models [--free] # available models with pricing\nsb combos # routing policies\nsb keys new <name> # create an API key\nsb chat [--model] # streaming REPL\nsb logs [--errors] # recent requests\nsb usage [--days 7] # spend summary\nsb open # open the dashboard\n```\n\n---\n\n## Desktop app\n\n```bash\nnpm run electron:build:win # or :mac / :linux\n```\n\nProduces an installer in `release/`. The desktop build runs the gateway as a child process,\nwrites to a real app-data directory, and lives in the system tray with start-at-login,\ncopy-endpoint and restart controls.\n\nIcons are generated from the product mark rather than committed by hand:\n\n```bash\nnpm run icons # icon.png/.ico/.icns + tray at 1x and 2x\nnpm run verify:electron # structural check, no toolchain download\n```\n\n`verify:electron` runs on every CI leg and is the cheap half of the packaging check: the\nbuilder config loads, every referenced icon exists with the right magic bytes *and*\ndimensions, `main.cjs` and `preload.cjs` parse, the preload goes through `contextBridge`\nwithout handing over `ipcRenderer`, and `contextIsolation` / `sandbox` are still on. The\nexpensive half is a real `electron-builder` run on Windows and Linux in\n[desktop.yml](.github/workflows/desktop.yml), which also asserts the installer is larger\nthan 20 MB — electron-builder exits 0 having shipped only the shell if the `files` globs are\nwrong.\n\n---\n\n## Development\n\n```bash\nnpm run dev # dev server on :7272\nnpm run typecheck # tsc --noEmit, strict\nnpm run build # production build\nnpm run electron:dev # desktop shell against the dev server\nnpm run verify:name # the npm name still resolves to this repository\nnpm run verify:install # pack, install globally, run the command\n```\n\n`verify:install` is the check that matters before a release: it packs the tarball, installs\nit into a throwaway global prefix, and runs `switchboard --version` and `sb --version`. Every\nother check in CI runs against the repository, so none of them could tell that the published\ninstall instruction pointed at a different package entirely.\n\nRequires **Node 22.13+** — `node:sqlite` appeared in 22.5 but was flag-gated until 22.13.\nBuilt with Next.js 16, React 19,\nTypeScript 5.9 in strict mode with `noUncheckedIndexedAccess`, and Tailwind CSS v4.\n\nAdding a provider is a single entry in `src/lib/providers/catalog.ts` when it speaks the\nOpenAI wire format — see [docs/development.md](docs/development.md).\n\nConventions for humans and agents are in [CLAUDE.md](CLAUDE.md) and [AGENTS.md](AGENTS.md);\ncontribution workflow in [CONTRIBUTING.md](CONTRIBUTING.md).\n\n### Tests\n\n```bash\nnpm run test:run # ~1,035 tests\nnpm run test:ci # with coverage\n```\n\nVitest, running on every CI leg. Roughly 75% overall, concentrated where a silent bug costs\nmoney or loses a request:\n\n| Area | Coverage |\n| --- | --- |\n| Scoring, cost and free-tier settlement, rate limiting | 100% |\n| Adapter helpers — the error taxonomy every fallback depends on | 99% |\n| Candidate expansion and exclusion reasons | 95% |\n| Circuit breaker state machine | 100% statements |\n| Wire translation for Anthropic, Google and Cohere | ~80% |\n| Credential vault and the redaction layer | ~94% |\n\nThe adapter tests run through the real `execute()` path with `fetch` stubbed, so each one\ncovers the URL, the auth headers, both translation directions and the error classification\ntogether. There are integration tests for the gateway and the management API, and component\ntests for the UI primitives, the live-event transport's reconnection, and the policy\neditor.\n\nTests assert the behaviour the code's comments claim, so the deliberate decisions\n(`bad_request` never retries, a started stream is never retried, free tiers settle at $0)\nfail loudly if anyone \"simplifies\" them away.\n\n### Releases\n\n`main` is the development line. Every version is also cut as a `release/vX.Y.Z` branch with\na matching tag and GitHub release, so you can pin to one and keep receiving fixes for that\nline without inheriting whatever landed on `main` today.\n\nBumping `version` in `package.json` on `main` triggers it: CI typechecks, tests, builds,\nboots the gateway, verifies the publish tarball carries no database or key, then branches,\ntags and publishes the release.\n\nA running record of what changed and why is in [SESSION_SUMMARY.md](SESSION_SUMMARY.md),\nincluding the findings that are known and deliberately unfixed.\n\n### Status\n\nPre-1.0, and specific about what that means.\n\nAn external audit put it at **7.2/10** — strong on architecture, resilience and type\nsafety, with test coverage scored **0/10** as the single largest gap, alongside five\nsmaller findings. A second pass raised four more once those were closed. All ten are now\nclosed:\n\n| Finding | Resolution |\n| --- | --- |\n| No test suite | 1,035 tests; the routing core at 95–100% |\n| Rate limiting reset on restart | Persisted in SQLite, counted per second so it stays cheap on the hot path |\n| `/v1` sent `Access-Control-Allow-Origin: *` | No CORS by default; explicit origin allowlist |\n| No structured logging | JSON logs with a tested redaction layer |\n| Desktop build shipped a stock icon | Icons generated from the product mark |\n| Failed requests skewed latency | Filtered to successes only |\n| Adapter translation barely covered (27%) | 121 tests across the Anthropic, Google and Cohere wire formats; adapters at 80% |\n| Management API routes untested | 47 integration tests driving the real handlers |\n| Log retention setting was never applied | Enforced on the health tick; `0` still means keep forever |\n| Request payloads stored unredacted | Redacted at the storage boundary, not on the read path |\n| Desktop packaging unverified beyond local dev | `npm run verify:electron` on every CI leg, plus a real `electron-builder` run on Windows and Linux |\n| `npm install -g switchboard` installed someone else's package | Renamed to `switchboard-gateway`; CI now checks the registry name and installs the packed tarball to prove the command works |\n| `/api/system/status` reported a hardcoded `0.1.0` | Read from the manifest, with a test pinning it |\n\nWriting the tests found three real bugs that had survived every manual check: a non-ASCII\ncharacter in a provider's error message crashed the response instead of returning the\nclient's error; a rate limit of `0.5` bricked a key permanently; and a provider that was\nreliably *down* posted the fastest p50 in the fleet, so the `fastest` strategy actively\npreferred it.\n\n**Still open**, and worth knowing before you rely on it:\n\n- No tests for the DB repositories or most dashboard pages, and no end-to-end tests.\n- No audit log of administrative actions.\n- No graceful shutdown — `SIGTERM` kills in-flight streams rather than draining them.\n- `context_length` counts toward the circuit breaker, unlike `bad_request`, so a client\n looping oversized prompts can open the breaker on a healthy provider.\n- A half-open breaker trial that gets a 429 never reports back, so the trial slot stays\n held until its window expires.\n\n**Do not run it exposed to the internet** without a reverse proxy and authentication in\nfront. It is built for a machine you control. See [SECURITY.md](SECURITY.md) for the threat\nmodel.\n\n---\n\n## License\n\nMIT © 2026 Abeer Meer\n"}, "ba20e7f0c02fb4913f2e0599686a11e4bdedbaf1": {"t": 1785771468.3902054, "n": 20020, "h": "f7a0510c281c48fe", "c": "# Switchboard\n\n[![CI](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml/badge.svg)](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml)\n[![Tests](https://img.shields.io/badge/tests-1033-3ecf8e.svg)](#tests)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n[![Node](https://img.shields.io/badge/node-%E2%89%A522.13-f0912f.svg)](https://nodejs.org)\n\n**One endpoint. Every provider. Free tiers first, automatic fallback, everything local.**\n\nSwitchboard is an AI gateway that runs on your own machine. Point any OpenAI-compatible\ntool at it and it decides — per request — which provider and model should serve the call,\npreferring free tiers, routing around outages, and recording exactly why it chose what it\nchose.\n\n```\nyour tools ──▶ http://127.0.0.1:7272/v1 ──▶ Groq · Cerebras · Google · OpenRouter\n DeepSeek · Mistral · OpenAI · Anthropic …\n```\n\nNothing leaves your machine except the upstream provider calls themselves. No telemetry,\nno account, no cloud control plane.\n\n---\n\n## Demo\n\n[![Switchboard — every candidate, every factor](brag-output/brag.jpg)](https://github.com/abeermeer/switchboard/releases/download/v0.4.0/brag.mp4)\n\n**[▶ Watch the 20-second walkthrough](https://github.com/abeermeer/switchboard/releases/download/v0.4.0/brag.mp4)**\n\nA provider 429s mid-request, the fallback lands on a different one, and the decision trace\nexplains exactly why the replacement won. Every number on screen is real output from a\nrunning instance — the response headers, the four candidate scores, the factor notes, and\nthe exclusion reason.\n\nThat still above is the routing decision for one request: four candidates ranked, the\nwinner in copper, the price spread across providers serving the same class of model, and\n`17 eligible · 5 excluded`. Every other gateway gives you a log line; this is the part\nworth showing.\n\n---\n\n## Why\n\nRunning six provider SDKs across four tools means six sets of keys, six failure modes, and\nno idea what any of it costs. Switchboard collapses that into one endpoint with one key,\nand answers three questions the raw SDKs cannot:\n\n- **Which provider should serve this?** Scored live on health, latency, price and tier.\n- **What happens when it is down?** The next candidate takes it, transparently.\n- **What is this actually costing me?** Per request, per model, per key, against a\n frontier-pricing baseline.\n\n### What makes it different\n\nPlenty of tools proxy several providers. The distinction here is that Switchboard **shows\nits work**.\n\n| | Typical gateway | Switchboard |\n| --- | --- | --- |\n| Why a provider was chosen | A log line, if anything | Every candidate stored with its eight weighted score factors |\n| Why the others lost | Not recorded | An explicit exclusion reason per candidate, rendered in the UI |\n| Tuning a routing policy | Change it and send real traffic | Simulate it — score the ranking without sending anything upstream |\n| Free-tier requests | Billed at list price | Settled at **$0**, so the ladder ranks correctly and spend is honest |\n| Install | Native module, needs a toolchain | `node:sqlite` — no node-gyp, identical on Windows |\n| Where a failure shows up | In your client, as a 500 | Absorbed by the fallback walk; the client sees a 200 |\n\n---\n\n## Quickstart\n\n**1) Install and run**\n\n```bash\nnpm install -g switchboard\nswitchboard\n```\n\nThat's it. The dashboard is on **http://localhost:7272**, the gateway on\n**http://localhost:7272/v1**.\n\nYour database and encryption key live in your OS application-data directory\n(`%APPDATA%\\Switchboard`, `~/Library/Application Support/Switchboard`, or\n`~/.local/share/Switchboard`), so upgrading with `npm update -g switchboard` never touches\nthem.\n\n**2) Connect a free provider**\n\nOpen the dashboard and pick one. Groq, Cerebras and Google all give real free tiers with\nno payment method. Or from the terminal:\n\n```bash\nsb providers add groq # prompts for the key, echo off\n```\n\n**3) Create a key and point a tool at it**\n\n```bash\nsb keys new \"my laptop\"\n```\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n<details>\n<summary><b>Other ways to run it</b></summary>\n\n**From source**\n\n```bash\ngit clone https://github.com/abeermeer/switchboard.git\ncd switchboard\nnpm install\nnpm run build\nnpm start\n```\n\n**Docker**\n\n```bash\ndocker build -t switchboard .\ndocker run -d -p 7272:7272 -v switchboard-data:/data switchboard\n```\n\n**Desktop app** — `npm run electron:build:win` (or `:mac` / `:linux`) puts an installer in\n`release/`. Runs in the tray with start-at-login.\n\n**Pin to a release line** — every version is branched, so you can track one:\n\n```bash\nnpm install -g switchboard@0.4.0\n```\n\n</details>\n\n`\"model\": \"auto\"` hands the decision to the router. You can also send a bare model id\n(`llama-3.3-70b-versatile`), a pinned provider (`groq/llama-3.3-70b-versatile`), or a\npolicy slug you have defined.\n\n---\n\n## Point your tools at it\n\n**Claude Code**\n\n```bash\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:7272/v1\"\nexport ANTHROPIC_API_KEY=\"sb-live-your-key\"\n```\n\n**Cursor** — Settings → Models → Override OpenAI Base URL\n\n```\nBase URL: http://127.0.0.1:7272/v1\nAPI Key: sb-live-your-key\nModel: auto\n```\n\n**OpenAI SDK (Python)**\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(base_url=\"http://127.0.0.1:7272/v1\", api_key=\"sb-live-your-key\")\nresponse = client.chat.completions.create(\n model=\"auto\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\n```\n\n**OpenAI SDK (Node)**\n\n```js\nimport OpenAI from 'openai';\n\nconst client = new OpenAI({\n baseURL: 'http://127.0.0.1:7272/v1',\n apiKey: 'sb-live-your-key',\n});\n```\n\n**Cline / Continue / Aider** — any \"OpenAI-compatible endpoint\" field takes the same base\nURL and key.\n\n---\n\n## Endpoints\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `POST` | `/v1/chat/completions` | Streaming and non-streaming |\n| `POST` | `/v1/completions` | Legacy, shimmed onto chat |\n| `POST` | `/v1/embeddings` | |\n| `POST` | `/v1/images/generations` | |\n| `POST` | `/v1/audio/speech` | Returns audio bytes |\n| `POST` | `/v1/audio/transcriptions` | Multipart upload |\n| `POST` | `/v1/rerank` | |\n| `POST` | `/v1/moderations` | |\n| `GET` | `/v1/models` | Every model across every connected provider |\n| `GET` | `/v1/models/{model}` | |\n\nEvery response carries routing telemetry, so you can debug from curl alone:\n\n```\nx-switchboard-request-id: req_6751a555d0264a6aa7e0\nx-switchboard-provider: groq\nx-switchboard-model: llama-3.3-70b-versatile\nx-switchboard-tier: free\nx-switchboard-attempts: 2\nx-switchboard-cost-usd: 0.000083\nx-switchboard-ttft-ms: 340\nx-switchboard-fallback-reason: Rate limited (429), retry after 12s\n```\n\n`x-switchboard-fallback-reason` only appears when a fallback actually happened, so its\npresence is itself the signal.\n\n---\n\n## Routing\n\nA **policy** (called a combo in the API) is a model name your clients can send. Four ship\nby default:\n\n| Slug | Strategy | Optimises for |\n| --- | --- | --- |\n| `auto` | free-first | Exhaust free tiers before spending anything |\n| `free-only` | free-first, $0 ceiling | Never spends money, fails instead |\n| `fast` | fastest | Lowest observed time to first token |\n| `quality` | quality-first | Strongest model available, price ignored |\n\nSeven strategies are available:\n\n| Strategy | Behaviour |\n| --- | --- |\n| `free-first` | Free tier, then cheap, then paid. The default. |\n| `cost-optimized` | Lowest projected USD for this specific request. |\n| `fastest` | Lowest measured p50 time-to-first-token. |\n| `quality-first` | Inverts the cost factor — price becomes a quality signal. |\n| `priority` | Strict chain order. Predictable, ignores live health. |\n| `round-robin` | Spreads load across healthy members to stretch rate limits. |\n| `failover` | First member always; others only on outright failure. |\n\nEvery candidate is scored on eight factors (health, latency, cost, tier, priority, quota,\nthroughput, stability), weighted per strategy. The weights are visible in the dashboard,\nand the full arithmetic is stored with every request.\n\n### The simulator\n\nThe routing page runs the exact expand-and-score path a live request takes, but stops\nbefore sending anything upstream. Drag a member, move the cost ceiling, and watch the\nranking reshuffle — including which candidates were excluded and for what reason.\n\n---\n\n## Architecture\n\n```mermaid\nflowchart LR\n A[Client] --> B[/v1 endpoint/]\n B --> C[Authenticate<br/>key · rate limit · budget]\n C --> D[Expand candidates<br/>connections × models]\n D --> E[Filter<br/>health · context · features · budget]\n E --> F[Score<br/>8 weighted factors]\n F --> G[Execute]\n G -->|success| H[Log + usage rollup]\n G -->|failure| I{Retryable?}\n I -->|yes| G\n I -->|no| H\n H --> A\n```\n\n| Layer | Location |\n| --- | --- |\n| Domain contract | `src/types/core.ts` |\n| Persistence | `src/lib/db/` — `node:sqlite`, WAL, append-only migrations |\n| Providers | `src/lib/providers/` — catalog + 4 wire adapters |\n| Resilience | `src/lib/resilience/`, `src/lib/health/` |\n| Router | `src/lib/router/` — candidates → score → execute |\n| Gateway API | `src/app/v1/` |\n| Management API | `src/app/api/` |\n| Dashboard | `src/app/(dashboard)/`, `src/components/` |\n\nStorage is Node's built-in `node:sqlite` — no native module, no `node-gyp`, no prebuilt\nbinary roulette. `npm install` works the same on Windows as it does on Linux.\n\n---\n\n## Resilience\n\nThree independent layers, so one failing provider never becomes a failing gateway:\n\n1. **Circuit breaker**, per connection. Opens after N consecutive failures — immediately\n on a `401`, since a bad credential will not fix itself. Cooldown doubles on repeat\n opens, capped at 15 minutes, with jitter so recovering providers are not stampeded.\n2. **Model lockout**, per model. A `429` locks out that one model rather than the whole\n connection, honouring `Retry-After` when the provider sends it.\n3. **Fallback walk**, per request. Up to `maxAttempts` candidates, stopping early when\n retrying elsewhere cannot help — a malformed request produces the same `400` on every\n provider.\n\nA streaming response that has already sent its first byte is **never** retried. Once bytes\nare on the wire the client has committed, and a silent provider switch mid-stream would\ncorrupt the response.\n\n---\n\n## Configuration\n\nEverything is optional; sane defaults are generated on first run.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `PORT` | `7272` | Gateway and dashboard |\n| `SWITCHBOARD_DATA_DIR` | `./data` | Database, key vault, logs |\n| `SWITCHBOARD_MASTER_KEY` | generated | Base64 32-byte key for credential encryption |\n| `SWITCHBOARD_ALLOW_REMOTE` | `0` | Allow non-loopback dashboard access |\n| `SWITCHBOARD_CORS_ORIGINS` | *(none)* | Origins allowed to call `/v1` from a browser |\n| `SWITCHBOARD_LOG_LEVEL` | `info` / `debug` | `debug` · `info` · `warn` · `error` · `silent` |\n| `SWITCHBOARD_LOG_FORMAT` | auto | `json`, or `pretty` in a TTY |\n| `SWITCHBOARD_PACKAGED` | `0` | Set by the desktop build |\n\nProvider credentials are sealed with **AES-256-GCM** before they touch disk. The master key\nlives in `<data-dir>/master.key` with `0600` permissions. No endpoint returns a stored\ncredential — only a four-character hint.\n\n### CORS\n\n`/v1` sends **no** `Access-Control-Allow-Origin` by default. The endpoint is built for\nserver-side SDK clients, which send no `Origin` header and are unaffected by CORS — while a\nwildcard would let any page you happen to visit spend your provider credentials the moment\nit learned a key.\n\nIf a browser app calls the gateway directly, list its origins:\n\n```bash\nSWITCHBOARD_CORS_ORIGINS=http://localhost:3000,https://myapp.example\n```\n\nAllowed origins are reflected rather than wildcarded, with `Vary: Origin` so a shared cache\ncannot hand one origin's response to another. `*` still works if you want the old\nbehaviour, but it is now a decision rather than a default.\n\n### Logging\n\nOne JSON object per line on stdout, or a human-readable line in a TTY. Values that look\nlike credentials are redacted before anything is written — by key name (`authorization`,\n`api_key`, `token`, …) and by shape, so a key pasted into free text is caught too.\n\n```bash\nswitchboard 2>&1 | jq 'select(.level == \"warn\")'\n```\n\nThe same redaction runs on the way **into** the database. Every request log row stores the\nrequest body, the response and the routing decision, so a client that sends its own provider\nkey in a header — or pastes one into a prompt — would otherwise have it written to disk in\nplain text and kept for as long as the row lives. Redacting at the storage boundary rather\nthan on the dashboard's read path means nothing else that can reach the table sees the\noriginal either.\n\nRows are pruned to the **Log retention** setting on each health tick. `0` means keep\nforever, which is a legitimate choice rather than an unconfigured one.\n\n---\n\n## CLI\n\n```bash\nsb start [--port] [--open] # start the gateway\nsb status # health at a glance\nsb doctor # diagnose the install\nsb providers list # connections with status and spend\nsb providers add groq # prompts for the key, echo off\nsb providers test <id> # force a probe\nsb models [--free] # available models with pricing\nsb combos # routing policies\nsb keys new <name> # create an API key\nsb chat [--model] # streaming REPL\nsb logs [--errors] # recent requests\nsb usage [--days 7] # spend summary\nsb open # open the dashboard\n```\n\n---\n\n## Desktop app\n\n```bash\nnpm run electron:build:win # or :mac / :linux\n```\n\nProduces an installer in `release/`. The desktop build runs the gateway as a child process,\nwrites to a real app-data directory, and lives in the system tray with start-at-login,\ncopy-endpoint and restart controls.\n\nIcons are generated from the product mark rather than committed by hand:\n\n```bash\nnpm run icons # icon.png/.ico/.icns + tray at 1x and 2x\nnpm run verify:electron # structural check, no toolchain download\n```\n\n`verify:electron` runs on every CI leg and is the cheap half of the packaging check: the\nbuilder config loads, every referenced icon exists with the right magic bytes *and*\ndimensions, `main.cjs` and `preload.cjs` parse, the preload goes through `contextBridge`\nwithout handing over `ipcRenderer`, and `contextIsolation` / `sandbox` are still on. The\nexpensive half is a real `electron-builder` run on Windows and Linux in\n[desktop.yml](.github/workflows/desktop.yml), which also asserts the installer is larger\nthan 20 MB — electron-builder exits 0 having shipped only the shell if the `files` globs are\nwrong.\n\n---\n\n## Development\n\n```bash\nnpm run dev # dev server on :7272\nnpm run typecheck # tsc --noEmit, strict\nnpm run build # production build\nnpm run electron:dev # desktop shell against the dev server\n```\n\nRequires **Node 22.13+** — `node:sqlite` appeared in 22.5 but was flag-gated until 22.13.\nBuilt with Next.js 16, React 19,\nTypeScript 5.9 in strict mode with `noUncheckedIndexedAccess`, and Tailwind CSS v4.\n\nAdding a provider is a single entry in `src/lib/providers/catalog.ts` when it speaks the\nOpenAI wire format — see [docs/development.md](docs/development.md).\n\nConventions for humans and agents are in [CLAUDE.md](CLAUDE.md) and [AGENTS.md](AGENTS.md);\ncontribution workflow in [CONTRIBUTING.md](CONTRIBUTING.md).\n\n### Tests\n\n```bash\nnpm run test:run # ~1,030 tests\nnpm run test:ci # with coverage\n```\n\nVitest, running on every CI leg. Roughly 75% overall, concentrated where a silent bug costs\nmoney or loses a request:\n\n| Area | Coverage |\n| --- | --- |\n| Scoring, cost and free-tier settlement, rate limiting | 100% |\n| Adapter helpers — the error taxonomy every fallback depends on | 99% |\n| Candidate expansion and exclusion reasons | 95% |\n| Circuit breaker state machine | 100% statements |\n| Wire translation for Anthropic, Google and Cohere | ~80% |\n| Credential vault and the redaction layer | ~94% |\n\nThe adapter tests run through the real `execute()` path with `fetch` stubbed, so each one\ncovers the URL, the auth headers, both translation directions and the error classification\ntogether. There are integration tests for the gateway and the management API, and component\ntests for the UI primitives, the live-event transport's reconnection, and the policy\neditor.\n\nTests assert the behaviour the code's comments claim, so the deliberate decisions\n(`bad_request` never retries, a started stream is never retried, free tiers settle at $0)\nfail loudly if anyone \"simplifies\" them away.\n\n### Releases\n\n`main` is the development line. Every version is also cut as a `release/vX.Y.Z` branch with\na matching tag and GitHub release, so you can pin to one and keep receiving fixes for that\nline without inheriting whatever landed on `main` today.\n\nBumping `version` in `package.json` on `main` triggers it: CI typechecks, tests, builds,\nboots the gateway, verifies the publish tarball carries no database or key, then branches,\ntags and publishes the release.\n\nA running record of what changed and why is in [SESSION_SUMMARY.md](SESSION_SUMMARY.md),\nincluding the findings that are known and deliberately unfixed.\n\n### Status\n\nPre-1.0, and specific about what that means.\n\nAn external audit put it at **7.2/10** — strong on architecture, resilience and type\nsafety, with test coverage scored **0/10** as the single largest gap, alongside five\nsmaller findings. A second pass raised four more once those were closed. All ten are now\nclosed:\n\n| Finding | Resolution |\n| --- | --- |\n| No test suite | 1,033 tests; the routing core at 95–100% |\n| Rate limiting reset on restart | Persisted in SQLite, counted per second so it stays cheap on the hot path |\n| `/v1` sent `Access-Control-Allow-Origin: *` | No CORS by default; explicit origin allowlist |\n| No structured logging | JSON logs with a tested redaction layer |\n| Desktop build shipped a stock icon | Icons generated from the product mark |\n| Failed requests skewed latency | Filtered to successes only |\n| Adapter translation barely covered (27%) | 121 tests across the Anthropic, Google and Cohere wire formats; adapters at 80% |\n| Management API routes untested | 47 integration tests driving the real handlers |\n| Log retention setting was never applied | Enforced on the health tick; `0` still means keep forever |\n| Request payloads stored unredacted | Redacted at the storage boundary, not on the read path |\n| Desktop packaging unverified beyond local dev | `npm run verify:electron` on every CI leg, plus a real `electron-builder` run on Windows and Linux |\n\nWriting the tests found three real bugs that had survived every manual check: a non-ASCII\ncharacter in a provider's error message crashed the response instead of returning the\nclient's error; a rate limit of `0.5` bricked a key permanently; and a provider that was\nreliably *down* posted the fastest p50 in the fleet, so the `fastest` strategy actively\npreferred it.\n\n**Still open**, and worth knowing before you rely on it:\n\n- No tests for the DB repositories or most dashboard pages, and no end-to-end tests.\n- No audit log of administrative actions.\n- No graceful shutdown — `SIGTERM` kills in-flight streams rather than draining them.\n- `context_length` counts toward the circuit breaker, unlike `bad_request`, so a client\n looping oversized prompts can open the breaker on a healthy provider.\n- A half-open breaker trial that gets a 429 never reports back, so the trial slot stays\n held until its window expires.\n\n**Do not run it exposed to the internet** without a reverse proxy and authentication in\nfront. It is built for a machine you control. See [SECURITY.md](SECURITY.md) for the threat\nmodel.\n\n---\n\n## License\n\nMIT © 2026 Abeer Meer\n"}, "9b9123fa9450d2363c63f29670e7b157d81c2e38": {"t": 1785771470.0630307, "n": 17654, "h": "6742c1232cf3f8f5", "c": "# Switchboard\n\n[![CI](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml/badge.svg)](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml)\n[![Tests](https://img.shields.io/badge/tests-852-3ecf8e.svg)](#tests)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n[![Node](https://img.shields.io/badge/node-%E2%89%A522.13-f0912f.svg)](https://nodejs.org)\n\n**One endpoint. Every provider. Free tiers first, automatic fallback, everything local.**\n\nSwitchboard is an AI gateway that runs on your own machine. Point any OpenAI-compatible\ntool at it and it decides — per request — which provider and model should serve the call,\npreferring free tiers, routing around outages, and recording exactly why it chose what it\nchose.\n\n```\nyour tools ──▶ http://127.0.0.1:7272/v1 ──▶ Groq · Cerebras · Google · OpenRouter\n DeepSeek · Mistral · OpenAI · Anthropic …\n```\n\nNothing leaves your machine except the upstream provider calls themselves. No telemetry,\nno account, no cloud control plane.\n\n---\n\n## Demo\n\n[![Switchboard — every candidate, every factor](brag-output/brag.jpg)](https://github.com/abeermeer/switchboard/releases/download/v0.3.0/brag.mp4)\n\n**[▶ Watch the 20-second walkthrough](https://github.com/abeermeer/switchboard/releases/download/v0.3.0/brag.mp4)**\n\nA provider 429s mid-request, the fallback lands on a different one, and the decision trace\nexplains exactly why the replacement won. Every number on screen is real output from a\nrunning instance — the response headers, the four candidate scores, the factor notes, and\nthe exclusion reason.\n\nThat still above is the routing decision for one request: four candidates ranked, the\nwinner in copper, the price spread across providers serving the same class of model, and\n`17 eligible · 5 excluded`. Every other gateway gives you a log line; this is the part\nworth showing.\n\n---\n\n## Why\n\nRunning six provider SDKs across four tools means six sets of keys, six failure modes, and\nno idea what any of it costs. Switchboard collapses that into one endpoint with one key,\nand answers three questions the raw SDKs cannot:\n\n- **Which provider should serve this?** Scored live on health, latency, price and tier.\n- **What happens when it is down?** The next candidate takes it, transparently.\n- **What is this actually costing me?** Per request, per model, per key, against a\n frontier-pricing baseline.\n\n### What makes it different\n\nPlenty of tools proxy several providers. The distinction here is that Switchboard **shows\nits work**.\n\n| | Typical gateway | Switchboard |\n| --- | --- | --- |\n| Why a provider was chosen | A log line, if anything | Every candidate stored with its eight weighted score factors |\n| Why the others lost | Not recorded | An explicit exclusion reason per candidate, rendered in the UI |\n| Tuning a routing policy | Change it and send real traffic | Simulate it — score the ranking without sending anything upstream |\n| Free-tier requests | Billed at list price | Settled at **$0**, so the ladder ranks correctly and spend is honest |\n| Install | Native module, needs a toolchain | `node:sqlite` — no node-gyp, identical on Windows |\n| Where a failure shows up | In your client, as a 500 | Absorbed by the fallback walk; the client sees a 200 |\n\n---\n\n## Quickstart\n\n**1) Install and run**\n\n```bash\nnpm install -g switchboard\nswitchboard\n```\n\nThat's it. The dashboard is on **http://localhost:7272**, the gateway on\n**http://localhost:7272/v1**.\n\nYour database and encryption key live in your OS application-data directory\n(`%APPDATA%\\Switchboard`, `~/Library/Application Support/Switchboard`, or\n`~/.local/share/Switchboard`), so upgrading with `npm update -g switchboard` never touches\nthem.\n\n**2) Connect a free provider**\n\nOpen the dashboard and pick one. Groq, Cerebras and Google all give real free tiers with\nno payment method. Or from the terminal:\n\n```bash\nsb providers add groq # prompts for the key, echo off\n```\n\n**3) Create a key and point a tool at it**\n\n```bash\nsb keys new \"my laptop\"\n```\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n<details>\n<summary><b>Other ways to run it</b></summary>\n\n**From source**\n\n```bash\ngit clone https://github.com/abeermeer/switchboard.git\ncd switchboard\nnpm install\nnpm run build\nnpm start\n```\n\n**Docker**\n\n```bash\ndocker build -t switchboard .\ndocker run -d -p 7272:7272 -v switchboard-data:/data switchboard\n```\n\n**Desktop app** — `npm run electron:build:win` (or `:mac` / `:linux`) puts an installer in\n`release/`. Runs in the tray with start-at-login.\n\n**Pin to a release line** — every version is branched, so you can track one:\n\n```bash\nnpm install -g switchboard@0.3.0\n```\n\n</details>\n\n`\"model\": \"auto\"` hands the decision to the router. You can also send a bare model id\n(`llama-3.3-70b-versatile`), a pinned provider (`groq/llama-3.3-70b-versatile`), or a\npolicy slug you have defined.\n\n---\n\n## Point your tools at it\n\n**Claude Code**\n\n```bash\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:7272/v1\"\nexport ANTHROPIC_API_KEY=\"sb-live-your-key\"\n```\n\n**Cursor** — Settings → Models → Override OpenAI Base URL\n\n```\nBase URL: http://127.0.0.1:7272/v1\nAPI Key: sb-live-your-key\nModel: auto\n```\n\n**OpenAI SDK (Python)**\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(base_url=\"http://127.0.0.1:7272/v1\", api_key=\"sb-live-your-key\")\nresponse = client.chat.completions.create(\n model=\"auto\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\n```\n\n**OpenAI SDK (Node)**\n\n```js\nimport OpenAI from 'openai';\n\nconst client = new OpenAI({\n baseURL: 'http://127.0.0.1:7272/v1',\n apiKey: 'sb-live-your-key',\n});\n```\n\n**Cline / Continue / Aider** — any \"OpenAI-compatible endpoint\" field takes the same base\nURL and key.\n\n---\n\n## Endpoints\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `POST` | `/v1/chat/completions` | Streaming and non-streaming |\n| `POST` | `/v1/completions` | Legacy, shimmed onto chat |\n| `POST` | `/v1/embeddings` | |\n| `POST` | `/v1/images/generations` | |\n| `POST` | `/v1/audio/speech` | Returns audio bytes |\n| `POST` | `/v1/audio/transcriptions` | Multipart upload |\n| `POST` | `/v1/rerank` | |\n| `POST` | `/v1/moderations` | |\n| `GET` | `/v1/models` | Every model across every connected provider |\n| `GET` | `/v1/models/{model}` | |\n\nEvery response carries routing telemetry, so you can debug from curl alone:\n\n```\nx-switchboard-request-id: req_6751a555d0264a6aa7e0\nx-switchboard-provider: groq\nx-switchboard-model: llama-3.3-70b-versatile\nx-switchboard-tier: free\nx-switchboard-attempts: 2\nx-switchboard-cost-usd: 0.000083\nx-switchboard-ttft-ms: 340\nx-switchboard-fallback-reason: Rate limited (429), retry after 12s\n```\n\n`x-switchboard-fallback-reason` only appears when a fallback actually happened, so its\npresence is itself the signal.\n\n---\n\n## Routing\n\nA **policy** (called a combo in the API) is a model name your clients can send. Four ship\nby default:\n\n| Slug | Strategy | Optimises for |\n| --- | --- | --- |\n| `auto` | free-first | Exhaust free tiers before spending anything |\n| `free-only` | free-first, $0 ceiling | Never spends money, fails instead |\n| `fast` | fastest | Lowest observed time to first token |\n| `quality` | quality-first | Strongest model available, price ignored |\n\nSeven strategies are available:\n\n| Strategy | Behaviour |\n| --- | --- |\n| `free-first` | Free tier, then cheap, then paid. The default. |\n| `cost-optimized` | Lowest projected USD for this specific request. |\n| `fastest` | Lowest measured p50 time-to-first-token. |\n| `quality-first` | Inverts the cost factor — price becomes a quality signal. |\n| `priority` | Strict chain order. Predictable, ignores live health. |\n| `round-robin` | Spreads load across healthy members to stretch rate limits. |\n| `failover` | First member always; others only on outright failure. |\n\nEvery candidate is scored on eight factors (health, latency, cost, tier, priority, quota,\nthroughput, stability), weighted per strategy. The weights are visible in the dashboard,\nand the full arithmetic is stored with every request.\n\n### The simulator\n\nThe routing page runs the exact expand-and-score path a live request takes, but stops\nbefore sending anything upstream. Drag a member, move the cost ceiling, and watch the\nranking reshuffle — including which candidates were excluded and for what reason.\n\n---\n\n## Architecture\n\n```mermaid\nflowchart LR\n A[Client] --> B[/v1 endpoint/]\n B --> C[Authenticate<br/>key · rate limit · budget]\n C --> D[Expand candidates<br/>connections × models]\n D --> E[Filter<br/>health · context · features · budget]\n E --> F[Score<br/>8 weighted factors]\n F --> G[Execute]\n G -->|success| H[Log + usage rollup]\n G -->|failure| I{Retryable?}\n I -->|yes| G\n I -->|no| H\n H --> A\n```\n\n| Layer | Location |\n| --- | --- |\n| Domain contract | `src/types/core.ts` |\n| Persistence | `src/lib/db/` — `node:sqlite`, WAL, append-only migrations |\n| Providers | `src/lib/providers/` — catalog + 4 wire adapters |\n| Resilience | `src/lib/resilience/`, `src/lib/health/` |\n| Router | `src/lib/router/` — candidates → score → execute |\n| Gateway API | `src/app/v1/` |\n| Management API | `src/app/api/` |\n| Dashboard | `src/app/(dashboard)/`, `src/components/` |\n\nStorage is Node's built-in `node:sqlite` — no native module, no `node-gyp`, no prebuilt\nbinary roulette. `npm install` works the same on Windows as it does on Linux.\n\n---\n\n## Resilience\n\nThree independent layers, so one failing provider never becomes a failing gateway:\n\n1. **Circuit breaker**, per connection. Opens after N consecutive failures — immediately\n on a `401`, since a bad credential will not fix itself. Cooldown doubles on repeat\n opens, capped at 15 minutes, with jitter so recovering providers are not stampeded.\n2. **Model lockout**, per model. A `429` locks out that one model rather than the whole\n connection, honouring `Retry-After` when the provider sends it.\n3. **Fallback walk**, per request. Up to `maxAttempts` candidates, stopping early when\n retrying elsewhere cannot help — a malformed request produces the same `400` on every\n provider.\n\nA streaming response that has already sent its first byte is **never** retried. Once bytes\nare on the wire the client has committed, and a silent provider switch mid-stream would\ncorrupt the response.\n\n---\n\n## Configuration\n\nEverything is optional; sane defaults are generated on first run.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `PORT` | `7272` | Gateway and dashboard |\n| `SWITCHBOARD_DATA_DIR` | `./data` | Database, key vault, logs |\n| `SWITCHBOARD_MASTER_KEY` | generated | Base64 32-byte key for credential encryption |\n| `SWITCHBOARD_ALLOW_REMOTE` | `0` | Allow non-loopback dashboard access |\n| `SWITCHBOARD_CORS_ORIGINS` | *(none)* | Origins allowed to call `/v1` from a browser |\n| `SWITCHBOARD_LOG_LEVEL` | `info` / `debug` | `debug` · `info` · `warn` · `error` · `silent` |\n| `SWITCHBOARD_LOG_FORMAT` | auto | `json`, or `pretty` in a TTY |\n| `SWITCHBOARD_PACKAGED` | `0` | Set by the desktop build |\n\nProvider credentials are sealed with **AES-256-GCM** before they touch disk. The master key\nlives in `<data-dir>/master.key` with `0600` permissions. No endpoint returns a stored\ncredential — only a four-character hint.\n\n### CORS\n\n`/v1` sends **no** `Access-Control-Allow-Origin` by default. The endpoint is built for\nserver-side SDK clients, which send no `Origin` header and are unaffected by CORS — while a\nwildcard would let any page you happen to visit spend your provider credentials the moment\nit learned a key.\n\nIf a browser app calls the gateway directly, list its origins:\n\n```bash\nSWITCHBOARD_CORS_ORIGINS=http://localhost:3000,https://myapp.example\n```\n\nAllowed origins are reflected rather than wildcarded, with `Vary: Origin` so a shared cache\ncannot hand one origin's response to another. `*` still works if you want the old\nbehaviour, but it is now a decision rather than a default.\n\n### Logging\n\nOne JSON object per line on stdout, or a human-readable line in a TTY. Values that look\nlike credentials are redacted before anything is written — by key name (`authorization`,\n`api_key`, `token`, …) and by shape, so a key pasted into free text is caught too.\n\n```bash\nswitchboard 2>&1 | jq 'select(.level == \"warn\")'\n```\n\n---\n\n## CLI\n\n```bash\nsb start [--port] [--open] # start the gateway\nsb status # health at a glance\nsb doctor # diagnose the install\nsb providers list # connections with status and spend\nsb providers add groq # prompts for the key, echo off\nsb providers test <id> # force a probe\nsb models [--free] # available models with pricing\nsb combos # routing policies\nsb keys new <name> # create an API key\nsb chat [--model] # streaming REPL\nsb logs [--errors] # recent requests\nsb usage [--days 7] # spend summary\nsb open # open the dashboard\n```\n\n---\n\n## Desktop app\n\n```bash\nnpm run electron:build:win # or :mac / :linux\n```\n\nProduces an installer in `release/`. The desktop build runs the gateway as a child process,\nwrites to a real app-data directory, and lives in the system tray with start-at-login,\ncopy-endpoint and restart controls.\n\nIcons go in `electron/assets/icon.{ico,icns,png}` — the build works without them.\n\n---\n\n## Development\n\n```bash\nnpm run dev # dev server on :7272\nnpm run typecheck # tsc --noEmit, strict\nnpm run build # production build\nnpm run electron:dev # desktop shell against the dev server\n```\n\nRequires **Node 22.13+** — `node:sqlite` appeared in 22.5 but was flag-gated until 22.13.\nBuilt with Next.js 16, React 19,\nTypeScript 5.9 in strict mode with `noUncheckedIndexedAccess`, and Tailwind CSS v4.\n\nAdding a provider is a single entry in `src/lib/providers/catalog.ts` when it speaks the\nOpenAI wire format — see [docs/development.md](docs/development.md).\n\nConventions for humans and agents are in [CLAUDE.md](CLAUDE.md) and [AGENTS.md](AGENTS.md);\ncontribution workflow in [CONTRIBUTING.md](CONTRIBUTING.md).\n\n### Tests\n\n```bash\nnpm run test:run # ~850 tests\nnpm run test:ci # with coverage\n```\n\nVitest, running on every CI leg. The suite covers the parts where a silent bug costs money\nor loses a request: the scoring model (100%), cost and free-tier settlement (100%), rate\nlimiting (100%), the adapter helpers that classify every upstream error (99%), candidate\nexpansion and exclusion (95%), the circuit breaker's full state machine, the credential\nvault and its redaction layer, and an integration layer that drives the real route handlers\nagainst a stubbed provider — including the fallback walk itself.\n\nThere are component tests too, for the UI primitives, the live-event transport's\nreconnection and backoff, and the policy editor's chain reordering.\n\nTests assert the behaviour the code's comments claim, so the deliberate decisions\n(`bad_request` never retries, a started stream is never retried, free tiers settle at $0)\nfail loudly if anyone \"simplifies\" them away.\n\n### Releases\n\n`main` is the development line. Every version is also cut as a `release/vX.Y.Z` branch with\na matching tag and GitHub release, so you can pin to one and keep receiving fixes for that\nline without inheriting whatever landed on `main` today.\n\nBumping `version` in `package.json` on `main` triggers it: CI typechecks, tests, builds,\nboots the gateway, verifies the publish tarball carries no database or key, then branches,\ntags and publishes the release.\n\nA running record of what changed and why is in [SESSION_SUMMARY.md](SESSION_SUMMARY.md),\nincluding the findings that are known and deliberately unfixed.\n\n### Status\n\nPre-1.0, and specific about what that means.\n\nAn external audit put it at **7.2/10** — strong on architecture, resilience and type\nsafety, with test coverage scored **0/10** as the single largest gap, alongside five\nsmaller findings. All six are now closed:\n\n| Finding | Resolution |\n| --- | --- |\n| No test suite | 852 tests; the routing core at 95–100% |\n| Rate limiting reset on restart | Persisted in SQLite |\n| `/v1` sent `Access-Control-Allow-Origin: *` | No CORS by default; explicit origin allowlist |\n| No structured logging | JSON logs with a tested redaction layer |\n| Desktop build shipped a stock icon | Icons generated from the product mark |\n| Failed requests skewed latency | Filtered to successes only |\n\nWriting the tests found three real bugs that had survived every manual check: a non-ASCII\ncharacter in a provider's error message crashed the response instead of returning the\nclient's error; a rate limit of `0.5` bricked a key permanently; and a provider that was\nreliably *down* posted the fastest p50 in the fleet, so the `fastest` strategy actively\npreferred it.\n\n**Still open**, and worth knowing before you rely on it:\n\n- No tests for the DB repositories, the management API, or most dashboard pages, and no\n end-to-end tests.\n- No audit log of administrative actions.\n- No graceful shutdown — `SIGTERM` kills in-flight streams rather than draining them.\n- `context_length` counts toward the circuit breaker, unlike `bad_request`, so a client\n looping oversized prompts can open the breaker on a healthy provider.\n\n**Do not run it exposed to the internet** without a reverse proxy and authentication in\nfront. It is built for a machine you control. See [SECURITY.md](SECURITY.md) for the threat\nmodel.\n\n---\n\n## License\n\nMIT © 2026 Abeer Meer\n"}, "89ada5422e9ddeaca1218b663b1a3fee54ef92ec": {"t": 1785771471.7061357, "n": 17409, "h": "54fc89493554c979", "c": "# Switchboard\n\n[![CI](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml/badge.svg)](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml)\n[![Tests](https://img.shields.io/badge/tests-852-3ecf8e.svg)](#tests)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n[![Node](https://img.shields.io/badge/node-%E2%89%A522.13-f0912f.svg)](https://nodejs.org)\n\n**One endpoint. Every provider. Free tiers first, automatic fallback, everything local.**\n\nSwitchboard is an AI gateway that runs on your own machine. Point any OpenAI-compatible\ntool at it and it decides — per request — which provider and model should serve the call,\npreferring free tiers, routing around outages, and recording exactly why it chose what it\nchose.\n\n```\nyour tools ──▶ http://127.0.0.1:7272/v1 ──▶ Groq · Cerebras · Google · OpenRouter\n DeepSeek · Mistral · OpenAI · Anthropic …\n```\n\nNothing leaves your machine except the upstream provider calls themselves. No telemetry,\nno account, no cloud control plane.\n\n---\n\n## Demo\n\n[![Switchboard — every candidate, every factor](brag-output/brag.jpg)](https://github.com/abeermeer/switchboard/releases/download/v0.3.0/brag.mp4)\n\n**[▶ Watch the 20-second walkthrough](https://github.com/abeermeer/switchboard/releases/download/v0.3.0/brag.mp4)**\n\nA provider 429s mid-request, the fallback lands on a different one, and the decision trace\nexplains exactly why the replacement won. Every number on screen is real output from a\nrunning instance — the response headers, the four candidate scores, the factor notes, and\nthe exclusion reason.\n\nThat still above is the routing decision for one request: four candidates ranked, the\nwinner in copper, the price spread across providers serving the same class of model, and\n`17 eligible · 5 excluded`. Every other gateway gives you a log line; this is the part\nworth showing.\n\n---\n\n## Why\n\nRunning six provider SDKs across four tools means six sets of keys, six failure modes, and\nno idea what any of it costs. Switchboard collapses that into one endpoint with one key,\nand answers three questions the raw SDKs cannot:\n\n- **Which provider should serve this?** Scored live on health, latency, price and tier.\n- **What happens when it is down?** The next candidate takes it, transparently.\n- **What is this actually costing me?** Per request, per model, per key, against a\n frontier-pricing baseline.\n\n### What makes it different\n\nPlenty of tools proxy several providers. The distinction here is that Switchboard **shows\nits work**.\n\n| | Typical gateway | Switchboard |\n| --- | --- | --- |\n| Why a provider was chosen | A log line, if anything | Every candidate stored with its eight weighted score factors |\n| Why the others lost | Not recorded | An explicit exclusion reason per candidate, rendered in the UI |\n| Tuning a routing policy | Change it and send real traffic | Simulate it — score the ranking without sending anything upstream |\n| Free-tier requests | Billed at list price | Settled at **$0**, so the ladder ranks correctly and spend is honest |\n| Install | Native module, needs a toolchain | `node:sqlite` — no node-gyp, identical on Windows |\n| Where a failure shows up | In your client, as a 500 | Absorbed by the fallback walk; the client sees a 200 |\n\n---\n\n## Quickstart\n\n**1) Install and run**\n\n```bash\nnpm install -g switchboard\nswitchboard\n```\n\nThat's it. The dashboard is on **http://localhost:7272**, the gateway on\n**http://localhost:7272/v1**.\n\nYour database and encryption key live in your OS application-data directory\n(`%APPDATA%\\Switchboard`, `~/Library/Application Support/Switchboard`, or\n`~/.local/share/Switchboard`), so upgrading with `npm update -g switchboard` never touches\nthem.\n\n**2) Connect a free provider**\n\nOpen the dashboard and pick one. Groq, Cerebras and Google all give real free tiers with\nno payment method. Or from the terminal:\n\n```bash\nsb providers add groq # prompts for the key, echo off\n```\n\n**3) Create a key and point a tool at it**\n\n```bash\nsb keys new \"my laptop\"\n```\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n<details>\n<summary><b>Other ways to run it</b></summary>\n\n**From source**\n\n```bash\ngit clone https://github.com/abeermeer/switchboard.git\ncd switchboard\nnpm install\nnpm run build\nnpm start\n```\n\n**Docker**\n\n```bash\ndocker build -t switchboard .\ndocker run -d -p 7272:7272 -v switchboard-data:/data switchboard\n```\n\n**Desktop app** — `npm run electron:build:win` (or `:mac` / `:linux`) puts an installer in\n`release/`. Runs in the tray with start-at-login.\n\n**Pin to a release line** — every version is branched, so you can track one:\n\n```bash\nnpm install -g switchboard@0.3.0\n```\n\n</details>\n\n`\"model\": \"auto\"` hands the decision to the router. You can also send a bare model id\n(`llama-3.3-70b-versatile`), a pinned provider (`groq/llama-3.3-70b-versatile`), or a\npolicy slug you have defined.\n\n---\n\n## Point your tools at it\n\n**Claude Code**\n\n```bash\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:7272/v1\"\nexport ANTHROPIC_API_KEY=\"sb-live-your-key\"\n```\n\n**Cursor** — Settings → Models → Override OpenAI Base URL\n\n```\nBase URL: http://127.0.0.1:7272/v1\nAPI Key: sb-live-your-key\nModel: auto\n```\n\n**OpenAI SDK (Python)**\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(base_url=\"http://127.0.0.1:7272/v1\", api_key=\"sb-live-your-key\")\nresponse = client.chat.completions.create(\n model=\"auto\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\n```\n\n**OpenAI SDK (Node)**\n\n```js\nimport OpenAI from 'openai';\n\nconst client = new OpenAI({\n baseURL: 'http://127.0.0.1:7272/v1',\n apiKey: 'sb-live-your-key',\n});\n```\n\n**Cline / Continue / Aider** — any \"OpenAI-compatible endpoint\" field takes the same base\nURL and key.\n\n---\n\n## Endpoints\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `POST` | `/v1/chat/completions` | Streaming and non-streaming |\n| `POST` | `/v1/completions` | Legacy, shimmed onto chat |\n| `POST` | `/v1/embeddings` | |\n| `POST` | `/v1/images/generations` | |\n| `POST` | `/v1/audio/speech` | Returns audio bytes |\n| `POST` | `/v1/audio/transcriptions` | Multipart upload |\n| `POST` | `/v1/rerank` | |\n| `POST` | `/v1/moderations` | |\n| `GET` | `/v1/models` | Every model across every connected provider |\n| `GET` | `/v1/models/{model}` | |\n\nEvery response carries routing telemetry, so you can debug from curl alone:\n\n```\nx-switchboard-request-id: req_6751a555d0264a6aa7e0\nx-switchboard-provider: groq\nx-switchboard-model: llama-3.3-70b-versatile\nx-switchboard-tier: free\nx-switchboard-attempts: 2\nx-switchboard-cost-usd: 0.000083\nx-switchboard-ttft-ms: 340\nx-switchboard-fallback-reason: Rate limited (429), retry after 12s\n```\n\n`x-switchboard-fallback-reason` only appears when a fallback actually happened, so its\npresence is itself the signal.\n\n---\n\n## Routing\n\nA **policy** (called a combo in the API) is a model name your clients can send. Four ship\nby default:\n\n| Slug | Strategy | Optimises for |\n| --- | --- | --- |\n| `auto` | free-first | Exhaust free tiers before spending anything |\n| `free-only` | free-first, $0 ceiling | Never spends money, fails instead |\n| `fast` | fastest | Lowest observed time to first token |\n| `quality` | quality-first | Strongest model available, price ignored |\n\nSeven strategies are available:\n\n| Strategy | Behaviour |\n| --- | --- |\n| `free-first` | Free tier, then cheap, then paid. The default. |\n| `cost-optimized` | Lowest projected USD for this specific request. |\n| `fastest` | Lowest measured p50 time-to-first-token. |\n| `quality-first` | Inverts the cost factor — price becomes a quality signal. |\n| `priority` | Strict chain order. Predictable, ignores live health. |\n| `round-robin` | Spreads load across healthy members to stretch rate limits. |\n| `failover` | First member always; others only on outright failure. |\n\nEvery candidate is scored on eight factors (health, latency, cost, tier, priority, quota,\nthroughput, stability), weighted per strategy. The weights are visible in the dashboard,\nand the full arithmetic is stored with every request.\n\n### The simulator\n\nThe routing page runs the exact expand-and-score path a live request takes, but stops\nbefore sending anything upstream. Drag a member, move the cost ceiling, and watch the\nranking reshuffle — including which candidates were excluded and for what reason.\n\n---\n\n## Architecture\n\n```mermaid\nflowchart LR\n A[Client] --> B[/v1 endpoint/]\n B --> C[Authenticate<br/>key · rate limit · budget]\n C --> D[Expand candidates<br/>connections × models]\n D --> E[Filter<br/>health · context · features · budget]\n E --> F[Score<br/>8 weighted factors]\n F --> G[Execute]\n G -->|success| H[Log + usage rollup]\n G -->|failure| I{Retryable?}\n I -->|yes| G\n I -->|no| H\n H --> A\n```\n\n| Layer | Location |\n| --- | --- |\n| Domain contract | `src/types/core.ts` |\n| Persistence | `src/lib/db/` — `node:sqlite`, WAL, append-only migrations |\n| Providers | `src/lib/providers/` — catalog + 4 wire adapters |\n| Resilience | `src/lib/resilience/`, `src/lib/health/` |\n| Router | `src/lib/router/` — candidates → score → execute |\n| Gateway API | `src/app/v1/` |\n| Management API | `src/app/api/` |\n| Dashboard | `src/app/(dashboard)/`, `src/components/` |\n\nStorage is Node's built-in `node:sqlite` — no native module, no `node-gyp`, no prebuilt\nbinary roulette. `npm install` works the same on Windows as it does on Linux.\n\n---\n\n## Resilience\n\nThree independent layers, so one failing provider never becomes a failing gateway:\n\n1. **Circuit breaker**, per connection. Opens after N consecutive failures — immediately\n on a `401`, since a bad credential will not fix itself. Cooldown doubles on repeat\n opens, capped at 15 minutes, with jitter so recovering providers are not stampeded.\n2. **Model lockout**, per model. A `429` locks out that one model rather than the whole\n connection, honouring `Retry-After` when the provider sends it.\n3. **Fallback walk**, per request. Up to `maxAttempts` candidates, stopping early when\n retrying elsewhere cannot help — a malformed request produces the same `400` on every\n provider.\n\nA streaming response that has already sent its first byte is **never** retried. Once bytes\nare on the wire the client has committed, and a silent provider switch mid-stream would\ncorrupt the response.\n\n---\n\n## Configuration\n\nEverything is optional; sane defaults are generated on first run.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `PORT` | `7272` | Gateway and dashboard |\n| `SWITCHBOARD_DATA_DIR` | `./data` | Database, key vault, logs |\n| `SWITCHBOARD_MASTER_KEY` | generated | Base64 32-byte key for credential encryption |\n| `SWITCHBOARD_ALLOW_REMOTE` | `0` | Allow non-loopback dashboard access |\n| `SWITCHBOARD_CORS_ORIGINS` | *(none)* | Origins allowed to call `/v1` from a browser |\n| `SWITCHBOARD_LOG_LEVEL` | `info` / `debug` | `debug` · `info` · `warn` · `error` · `silent` |\n| `SWITCHBOARD_LOG_FORMAT` | auto | `json`, or `pretty` in a TTY |\n| `SWITCHBOARD_PACKAGED` | `0` | Set by the desktop build |\n\nProvider credentials are sealed with **AES-256-GCM** before they touch disk. The master key\nlives in `<data-dir>/master.key` with `0600` permissions. No endpoint returns a stored\ncredential — only a four-character hint.\n\n### CORS\n\n`/v1` sends **no** `Access-Control-Allow-Origin` by default. The endpoint is built for\nserver-side SDK clients, which send no `Origin` header and are unaffected by CORS — while a\nwildcard would let any page you happen to visit spend your provider credentials the moment\nit learned a key.\n\nIf a browser app calls the gateway directly, list its origins:\n\n```bash\nSWITCHBOARD_CORS_ORIGINS=http://localhost:3000,https://myapp.example\n```\n\nAllowed origins are reflected rather than wildcarded, with `Vary: Origin` so a shared cache\ncannot hand one origin's response to another. `*` still works if you want the old\nbehaviour, but it is now a decision rather than a default.\n\n### Logging\n\nOne JSON object per line on stdout, or a human-readable line in a TTY. Values that look\nlike credentials are redacted before anything is written — by key name (`authorization`,\n`api_key`, `token`, …) and by shape, so a key pasted into free text is caught too.\n\n```bash\nswitchboard 2>&1 | jq 'select(.level == \"warn\")'\n```\n\n---\n\n## CLI\n\n```bash\nsb start [--port] [--open] # start the gateway\nsb status # health at a glance\nsb doctor # diagnose the install\nsb providers list # connections with status and spend\nsb providers add groq # prompts for the key, echo off\nsb providers test <id> # force a probe\nsb models [--free] # available models with pricing\nsb combos # routing policies\nsb keys new <name> # create an API key\nsb chat [--model] # streaming REPL\nsb logs [--errors] # recent requests\nsb usage [--days 7] # spend summary\nsb open # open the dashboard\n```\n\n---\n\n## Desktop app\n\n```bash\nnpm run electron:build:win # or :mac / :linux\n```\n\nProduces an installer in `release/`. The desktop build runs the gateway as a child process,\nwrites to a real app-data directory, and lives in the system tray with start-at-login,\ncopy-endpoint and restart controls.\n\nIcons go in `electron/assets/icon.{ico,icns,png}` — the build works without them.\n\n---\n\n## Development\n\n```bash\nnpm run dev # dev server on :7272\nnpm run typecheck # tsc --noEmit, strict\nnpm run build # production build\nnpm run electron:dev # desktop shell against the dev server\n```\n\nRequires **Node 22.13+** — `node:sqlite` appeared in 22.5 but was flag-gated until 22.13.\nBuilt with Next.js 16, React 19,\nTypeScript 5.9 in strict mode with `noUncheckedIndexedAccess`, and Tailwind CSS v4.\n\nAdding a provider is a single entry in `src/lib/providers/catalog.ts` when it speaks the\nOpenAI wire format — see [docs/development.md](docs/development.md).\n\nConventions for humans and agents are in [CLAUDE.md](CLAUDE.md) and [AGENTS.md](AGENTS.md);\ncontribution workflow in [CONTRIBUTING.md](CONTRIBUTING.md).\n\n### Tests\n\n```bash\nnpm run test:run # ~850 tests\nnpm run test:ci # with coverage\n```\n\nVitest, running on every CI leg. The suite covers the parts where a silent bug costs money\nor loses a request: the scoring model (100%), cost and free-tier settlement (100%), rate\nlimiting (100%), the adapter helpers that classify every upstream error (99%), candidate\nexpansion and exclusion (95%), the circuit breaker's full state machine, the credential\nvault and its redaction layer, and an integration layer that drives the real route handlers\nagainst a stubbed provider — including the fallback walk itself.\n\nThere are component tests too, for the UI primitives, the live-event transport's\nreconnection and backoff, and the policy editor's chain reordering.\n\nTests assert the behaviour the code's comments claim, so the deliberate decisions\n(`bad_request` never retries, a started stream is never retried, free tiers settle at $0)\nfail loudly if anyone \"simplifies\" them away.\n\n### Releases\n\n`main` is the development line. Every version is also cut as a `release/vX.Y.Z` branch with\na matching tag and GitHub release, so you can pin to one and keep receiving fixes for that\nline without inheriting whatever landed on `main` today.\n\nBumping `version` in `package.json` on `main` triggers it: CI typechecks, tests, builds,\nboots the gateway, verifies the publish tarball carries no database or key, then branches,\ntags and publishes the release.\n\nA running record of what changed and why is in [SESSION_SUMMARY.md](SESSION_SUMMARY.md),\nincluding the findings that are known and deliberately unfixed.\n\n### Status\n\nPre-1.0 and honest about it. An external audit put it at **7.2/10** — strong on\narchitecture, resilience and type safety, with test coverage scored 0/10 as the single\nlargest gap.\n\nThat gap is now closed for the routing core: 709 tests covering scoring, cost settlement,\ncandidate expansion, the breaker, the vault, the adapter error taxonomy, and the fallback\nwalk end to end. Writing them surfaced a real bug — a non-ASCII character in a provider's\nerror message crashed the response instead of returning the client's error, which any\nnon-English upstream message would have triggered.\n\nSince then the rest of the audit's findings have been closed too: CORS no longer wildcards\n`/v1`, rate limits persist across restarts, logging is structured with a tested redaction\nlayer, the desktop build ships real icons, and a latency bug that made a broken provider\nlook like the *fastest* candidate is fixed.\n\nStill open, and worth knowing before you rely on it:\n\n- No tests for the DB repositories, the management API, or most dashboard pages, and no\n end-to-end tests.\n- No audit log of administrative actions.\n- No graceful shutdown — SIGTERM kills in-flight streams rather than draining them.\n- `context_length` counts toward the circuit breaker, unlike `bad_request`, so a client\n looping oversized prompts can open the breaker on a healthy provider.\n\n**Do not run it exposed to the internet** without a reverse proxy and authentication in\nfront. It is built for a machine you control.\n\n---\n\n## License\n\nMIT © 2026 Abeer Meer\n"}, "ee9eae36f67043173783253bcf0f4faf62db8c8d": {"t": 1785771473.452886, "n": 17409, "h": "ffa0d48160467fd8", "c": "# Switchboard\n\n[![CI](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml/badge.svg)](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml)\n[![Tests](https://img.shields.io/badge/tests-852-3ecf8e.svg)](#tests)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n[![Node](https://img.shields.io/badge/node-%E2%89%A522.13-f0912f.svg)](https://nodejs.org)\n\n**One endpoint. Every provider. Free tiers first, automatic fallback, everything local.**\n\nSwitchboard is an AI gateway that runs on your own machine. Point any OpenAI-compatible\ntool at it and it decides — per request — which provider and model should serve the call,\npreferring free tiers, routing around outages, and recording exactly why it chose what it\nchose.\n\n```\nyour tools ──▶ http://127.0.0.1:7272/v1 ──▶ Groq · Cerebras · Google · OpenRouter\n DeepSeek · Mistral · OpenAI · Anthropic …\n```\n\nNothing leaves your machine except the upstream provider calls themselves. No telemetry,\nno account, no cloud control plane.\n\n---\n\n## Demo\n\n[![Switchboard — every candidate, every factor](brag-output/brag.jpg)](https://github.com/abeermeer/switchboard/releases/download/v0.2.0/brag.mp4)\n\n**[▶ Watch the 20-second walkthrough](https://github.com/abeermeer/switchboard/releases/download/v0.2.0/brag.mp4)**\n\nA provider 429s mid-request, the fallback lands on a different one, and the decision trace\nexplains exactly why the replacement won. Every number on screen is real output from a\nrunning instance — the response headers, the four candidate scores, the factor notes, and\nthe exclusion reason.\n\nThat still above is the routing decision for one request: four candidates ranked, the\nwinner in copper, the price spread across providers serving the same class of model, and\n`17 eligible · 5 excluded`. Every other gateway gives you a log line; this is the part\nworth showing.\n\n---\n\n## Why\n\nRunning six provider SDKs across four tools means six sets of keys, six failure modes, and\nno idea what any of it costs. Switchboard collapses that into one endpoint with one key,\nand answers three questions the raw SDKs cannot:\n\n- **Which provider should serve this?** Scored live on health, latency, price and tier.\n- **What happens when it is down?** The next candidate takes it, transparently.\n- **What is this actually costing me?** Per request, per model, per key, against a\n frontier-pricing baseline.\n\n### What makes it different\n\nPlenty of tools proxy several providers. The distinction here is that Switchboard **shows\nits work**.\n\n| | Typical gateway | Switchboard |\n| --- | --- | --- |\n| Why a provider was chosen | A log line, if anything | Every candidate stored with its eight weighted score factors |\n| Why the others lost | Not recorded | An explicit exclusion reason per candidate, rendered in the UI |\n| Tuning a routing policy | Change it and send real traffic | Simulate it — score the ranking without sending anything upstream |\n| Free-tier requests | Billed at list price | Settled at **$0**, so the ladder ranks correctly and spend is honest |\n| Install | Native module, needs a toolchain | `node:sqlite` — no node-gyp, identical on Windows |\n| Where a failure shows up | In your client, as a 500 | Absorbed by the fallback walk; the client sees a 200 |\n\n---\n\n## Quickstart\n\n**1) Install and run**\n\n```bash\nnpm install -g switchboard\nswitchboard\n```\n\nThat's it. The dashboard is on **http://localhost:7272**, the gateway on\n**http://localhost:7272/v1**.\n\nYour database and encryption key live in your OS application-data directory\n(`%APPDATA%\\Switchboard`, `~/Library/Application Support/Switchboard`, or\n`~/.local/share/Switchboard`), so upgrading with `npm update -g switchboard` never touches\nthem.\n\n**2) Connect a free provider**\n\nOpen the dashboard and pick one. Groq, Cerebras and Google all give real free tiers with\nno payment method. Or from the terminal:\n\n```bash\nsb providers add groq # prompts for the key, echo off\n```\n\n**3) Create a key and point a tool at it**\n\n```bash\nsb keys new \"my laptop\"\n```\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n<details>\n<summary><b>Other ways to run it</b></summary>\n\n**From source**\n\n```bash\ngit clone https://github.com/abeermeer/switchboard.git\ncd switchboard\nnpm install\nnpm run build\nnpm start\n```\n\n**Docker**\n\n```bash\ndocker build -t switchboard .\ndocker run -d -p 7272:7272 -v switchboard-data:/data switchboard\n```\n\n**Desktop app** — `npm run electron:build:win` (or `:mac` / `:linux`) puts an installer in\n`release/`. Runs in the tray with start-at-login.\n\n**Pin to a release line** — every version is branched, so you can track one:\n\n```bash\nnpm install -g switchboard@0.2.0\n```\n\n</details>\n\n`\"model\": \"auto\"` hands the decision to the router. You can also send a bare model id\n(`llama-3.3-70b-versatile`), a pinned provider (`groq/llama-3.3-70b-versatile`), or a\npolicy slug you have defined.\n\n---\n\n## Point your tools at it\n\n**Claude Code**\n\n```bash\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:7272/v1\"\nexport ANTHROPIC_API_KEY=\"sb-live-your-key\"\n```\n\n**Cursor** — Settings → Models → Override OpenAI Base URL\n\n```\nBase URL: http://127.0.0.1:7272/v1\nAPI Key: sb-live-your-key\nModel: auto\n```\n\n**OpenAI SDK (Python)**\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(base_url=\"http://127.0.0.1:7272/v1\", api_key=\"sb-live-your-key\")\nresponse = client.chat.completions.create(\n model=\"auto\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\n```\n\n**OpenAI SDK (Node)**\n\n```js\nimport OpenAI from 'openai';\n\nconst client = new OpenAI({\n baseURL: 'http://127.0.0.1:7272/v1',\n apiKey: 'sb-live-your-key',\n});\n```\n\n**Cline / Continue / Aider** — any \"OpenAI-compatible endpoint\" field takes the same base\nURL and key.\n\n---\n\n## Endpoints\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `POST` | `/v1/chat/completions` | Streaming and non-streaming |\n| `POST` | `/v1/completions` | Legacy, shimmed onto chat |\n| `POST` | `/v1/embeddings` | |\n| `POST` | `/v1/images/generations` | |\n| `POST` | `/v1/audio/speech` | Returns audio bytes |\n| `POST` | `/v1/audio/transcriptions` | Multipart upload |\n| `POST` | `/v1/rerank` | |\n| `POST` | `/v1/moderations` | |\n| `GET` | `/v1/models` | Every model across every connected provider |\n| `GET` | `/v1/models/{model}` | |\n\nEvery response carries routing telemetry, so you can debug from curl alone:\n\n```\nx-switchboard-request-id: req_6751a555d0264a6aa7e0\nx-switchboard-provider: groq\nx-switchboard-model: llama-3.3-70b-versatile\nx-switchboard-tier: free\nx-switchboard-attempts: 2\nx-switchboard-cost-usd: 0.000083\nx-switchboard-ttft-ms: 340\nx-switchboard-fallback-reason: Rate limited (429), retry after 12s\n```\n\n`x-switchboard-fallback-reason` only appears when a fallback actually happened, so its\npresence is itself the signal.\n\n---\n\n## Routing\n\nA **policy** (called a combo in the API) is a model name your clients can send. Four ship\nby default:\n\n| Slug | Strategy | Optimises for |\n| --- | --- | --- |\n| `auto` | free-first | Exhaust free tiers before spending anything |\n| `free-only` | free-first, $0 ceiling | Never spends money, fails instead |\n| `fast` | fastest | Lowest observed time to first token |\n| `quality` | quality-first | Strongest model available, price ignored |\n\nSeven strategies are available:\n\n| Strategy | Behaviour |\n| --- | --- |\n| `free-first` | Free tier, then cheap, then paid. The default. |\n| `cost-optimized` | Lowest projected USD for this specific request. |\n| `fastest` | Lowest measured p50 time-to-first-token. |\n| `quality-first` | Inverts the cost factor — price becomes a quality signal. |\n| `priority` | Strict chain order. Predictable, ignores live health. |\n| `round-robin` | Spreads load across healthy members to stretch rate limits. |\n| `failover` | First member always; others only on outright failure. |\n\nEvery candidate is scored on eight factors (health, latency, cost, tier, priority, quota,\nthroughput, stability), weighted per strategy. The weights are visible in the dashboard,\nand the full arithmetic is stored with every request.\n\n### The simulator\n\nThe routing page runs the exact expand-and-score path a live request takes, but stops\nbefore sending anything upstream. Drag a member, move the cost ceiling, and watch the\nranking reshuffle — including which candidates were excluded and for what reason.\n\n---\n\n## Architecture\n\n```mermaid\nflowchart LR\n A[Client] --> B[/v1 endpoint/]\n B --> C[Authenticate<br/>key · rate limit · budget]\n C --> D[Expand candidates<br/>connections × models]\n D --> E[Filter<br/>health · context · features · budget]\n E --> F[Score<br/>8 weighted factors]\n F --> G[Execute]\n G -->|success| H[Log + usage rollup]\n G -->|failure| I{Retryable?}\n I -->|yes| G\n I -->|no| H\n H --> A\n```\n\n| Layer | Location |\n| --- | --- |\n| Domain contract | `src/types/core.ts` |\n| Persistence | `src/lib/db/` — `node:sqlite`, WAL, append-only migrations |\n| Providers | `src/lib/providers/` — catalog + 4 wire adapters |\n| Resilience | `src/lib/resilience/`, `src/lib/health/` |\n| Router | `src/lib/router/` — candidates → score → execute |\n| Gateway API | `src/app/v1/` |\n| Management API | `src/app/api/` |\n| Dashboard | `src/app/(dashboard)/`, `src/components/` |\n\nStorage is Node's built-in `node:sqlite` — no native module, no `node-gyp`, no prebuilt\nbinary roulette. `npm install` works the same on Windows as it does on Linux.\n\n---\n\n## Resilience\n\nThree independent layers, so one failing provider never becomes a failing gateway:\n\n1. **Circuit breaker**, per connection. Opens after N consecutive failures — immediately\n on a `401`, since a bad credential will not fix itself. Cooldown doubles on repeat\n opens, capped at 15 minutes, with jitter so recovering providers are not stampeded.\n2. **Model lockout**, per model. A `429` locks out that one model rather than the whole\n connection, honouring `Retry-After` when the provider sends it.\n3. **Fallback walk**, per request. Up to `maxAttempts` candidates, stopping early when\n retrying elsewhere cannot help — a malformed request produces the same `400` on every\n provider.\n\nA streaming response that has already sent its first byte is **never** retried. Once bytes\nare on the wire the client has committed, and a silent provider switch mid-stream would\ncorrupt the response.\n\n---\n\n## Configuration\n\nEverything is optional; sane defaults are generated on first run.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `PORT` | `7272` | Gateway and dashboard |\n| `SWITCHBOARD_DATA_DIR` | `./data` | Database, key vault, logs |\n| `SWITCHBOARD_MASTER_KEY` | generated | Base64 32-byte key for credential encryption |\n| `SWITCHBOARD_ALLOW_REMOTE` | `0` | Allow non-loopback dashboard access |\n| `SWITCHBOARD_CORS_ORIGINS` | *(none)* | Origins allowed to call `/v1` from a browser |\n| `SWITCHBOARD_LOG_LEVEL` | `info` / `debug` | `debug` · `info` · `warn` · `error` · `silent` |\n| `SWITCHBOARD_LOG_FORMAT` | auto | `json`, or `pretty` in a TTY |\n| `SWITCHBOARD_PACKAGED` | `0` | Set by the desktop build |\n\nProvider credentials are sealed with **AES-256-GCM** before they touch disk. The master key\nlives in `<data-dir>/master.key` with `0600` permissions. No endpoint returns a stored\ncredential — only a four-character hint.\n\n### CORS\n\n`/v1` sends **no** `Access-Control-Allow-Origin` by default. The endpoint is built for\nserver-side SDK clients, which send no `Origin` header and are unaffected by CORS — while a\nwildcard would let any page you happen to visit spend your provider credentials the moment\nit learned a key.\n\nIf a browser app calls the gateway directly, list its origins:\n\n```bash\nSWITCHBOARD_CORS_ORIGINS=http://localhost:3000,https://myapp.example\n```\n\nAllowed origins are reflected rather than wildcarded, with `Vary: Origin` so a shared cache\ncannot hand one origin's response to another. `*` still works if you want the old\nbehaviour, but it is now a decision rather than a default.\n\n### Logging\n\nOne JSON object per line on stdout, or a human-readable line in a TTY. Values that look\nlike credentials are redacted before anything is written — by key name (`authorization`,\n`api_key`, `token`, …) and by shape, so a key pasted into free text is caught too.\n\n```bash\nswitchboard 2>&1 | jq 'select(.level == \"warn\")'\n```\n\n---\n\n## CLI\n\n```bash\nsb start [--port] [--open] # start the gateway\nsb status # health at a glance\nsb doctor # diagnose the install\nsb providers list # connections with status and spend\nsb providers add groq # prompts for the key, echo off\nsb providers test <id> # force a probe\nsb models [--free] # available models with pricing\nsb combos # routing policies\nsb keys new <name> # create an API key\nsb chat [--model] # streaming REPL\nsb logs [--errors] # recent requests\nsb usage [--days 7] # spend summary\nsb open # open the dashboard\n```\n\n---\n\n## Desktop app\n\n```bash\nnpm run electron:build:win # or :mac / :linux\n```\n\nProduces an installer in `release/`. The desktop build runs the gateway as a child process,\nwrites to a real app-data directory, and lives in the system tray with start-at-login,\ncopy-endpoint and restart controls.\n\nIcons go in `electron/assets/icon.{ico,icns,png}` — the build works without them.\n\n---\n\n## Development\n\n```bash\nnpm run dev # dev server on :7272\nnpm run typecheck # tsc --noEmit, strict\nnpm run build # production build\nnpm run electron:dev # desktop shell against the dev server\n```\n\nRequires **Node 22.13+** — `node:sqlite` appeared in 22.5 but was flag-gated until 22.13.\nBuilt with Next.js 16, React 19,\nTypeScript 5.9 in strict mode with `noUncheckedIndexedAccess`, and Tailwind CSS v4.\n\nAdding a provider is a single entry in `src/lib/providers/catalog.ts` when it speaks the\nOpenAI wire format — see [docs/development.md](docs/development.md).\n\nConventions for humans and agents are in [CLAUDE.md](CLAUDE.md) and [AGENTS.md](AGENTS.md);\ncontribution workflow in [CONTRIBUTING.md](CONTRIBUTING.md).\n\n### Tests\n\n```bash\nnpm run test:run # ~850 tests\nnpm run test:ci # with coverage\n```\n\nVitest, running on every CI leg. The suite covers the parts where a silent bug costs money\nor loses a request: the scoring model (100%), cost and free-tier settlement (100%), rate\nlimiting (100%), the adapter helpers that classify every upstream error (99%), candidate\nexpansion and exclusion (95%), the circuit breaker's full state machine, the credential\nvault and its redaction layer, and an integration layer that drives the real route handlers\nagainst a stubbed provider — including the fallback walk itself.\n\nThere are component tests too, for the UI primitives, the live-event transport's\nreconnection and backoff, and the policy editor's chain reordering.\n\nTests assert the behaviour the code's comments claim, so the deliberate decisions\n(`bad_request` never retries, a started stream is never retried, free tiers settle at $0)\nfail loudly if anyone \"simplifies\" them away.\n\n### Releases\n\n`main` is the development line. Every version is also cut as a `release/vX.Y.Z` branch with\na matching tag and GitHub release, so you can pin to one and keep receiving fixes for that\nline without inheriting whatever landed on `main` today.\n\nBumping `version` in `package.json` on `main` triggers it: CI typechecks, tests, builds,\nboots the gateway, verifies the publish tarball carries no database or key, then branches,\ntags and publishes the release.\n\nA running record of what changed and why is in [SESSION_SUMMARY.md](SESSION_SUMMARY.md),\nincluding the findings that are known and deliberately unfixed.\n\n### Status\n\nPre-1.0 and honest about it. An external audit put it at **7.2/10** — strong on\narchitecture, resilience and type safety, with test coverage scored 0/10 as the single\nlargest gap.\n\nThat gap is now closed for the routing core: 709 tests covering scoring, cost settlement,\ncandidate expansion, the breaker, the vault, the adapter error taxonomy, and the fallback\nwalk end to end. Writing them surfaced a real bug — a non-ASCII character in a provider's\nerror message crashed the response instead of returning the client's error, which any\nnon-English upstream message would have triggered.\n\nSince then the rest of the audit's findings have been closed too: CORS no longer wildcards\n`/v1`, rate limits persist across restarts, logging is structured with a tested redaction\nlayer, the desktop build ships real icons, and a latency bug that made a broken provider\nlook like the *fastest* candidate is fixed.\n\nStill open, and worth knowing before you rely on it:\n\n- No tests for the DB repositories, the management API, or most dashboard pages, and no\n end-to-end tests.\n- No audit log of administrative actions.\n- No graceful shutdown — SIGTERM kills in-flight streams rather than draining them.\n- `context_length` counts toward the circuit breaker, unlike `bad_request`, so a client\n looping oversized prompts can open the breaker on a healthy provider.\n\n**Do not run it exposed to the internet** without a reverse proxy and authentication in\nfront. It is built for a machine you control.\n\n---\n\n## License\n\nMIT © 2026 Abeer Meer\n"}, "6d348ffd7c7a558fed06bd30d7e90a016e7d522d": {"t": 1785771474.8714294, "n": 15557, "h": "2f2f9c53bacaac1f", "c": "# Switchboard\n\n[![CI](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml/badge.svg)](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml)\n[![Tests](https://img.shields.io/badge/tests-709-3ecf8e.svg)](#tests)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n[![Node](https://img.shields.io/badge/node-%E2%89%A522.13-f0912f.svg)](https://nodejs.org)\n\n**One endpoint. Every provider. Free tiers first, automatic fallback, everything local.**\n\nSwitchboard is an AI gateway that runs on your own machine. Point any OpenAI-compatible\ntool at it and it decides — per request — which provider and model should serve the call,\npreferring free tiers, routing around outages, and recording exactly why it chose what it\nchose.\n\n```\nyour tools ──▶ http://127.0.0.1:7272/v1 ──▶ Groq · Cerebras · Google · OpenRouter\n DeepSeek · Mistral · OpenAI · Anthropic …\n```\n\nNothing leaves your machine except the upstream provider calls themselves. No telemetry,\nno account, no cloud control plane.\n\n---\n\n## Demo\n\n[![Switchboard — every candidate, every factor](brag-output/brag.jpg)](https://github.com/abeermeer/switchboard/releases/download/v0.2.0/brag.mp4)\n\n**[▶ Watch the 20-second walkthrough](https://github.com/abeermeer/switchboard/releases/download/v0.2.0/brag.mp4)**\n\nA provider 429s mid-request, the fallback lands on a different one, and the decision trace\nexplains exactly why the replacement won. Every number on screen is real output from a\nrunning instance — the response headers, the four candidate scores, the factor notes, and\nthe exclusion reason.\n\nThat still above is the routing decision for one request: four candidates ranked, the\nwinner in copper, the price spread across providers serving the same class of model, and\n`17 eligible · 5 excluded`. Every other gateway gives you a log line; this is the part\nworth showing.\n\n---\n\n## Why\n\nRunning six provider SDKs across four tools means six sets of keys, six failure modes, and\nno idea what any of it costs. Switchboard collapses that into one endpoint with one key,\nand answers three questions the raw SDKs cannot:\n\n- **Which provider should serve this?** Scored live on health, latency, price and tier.\n- **What happens when it is down?** The next candidate takes it, transparently.\n- **What is this actually costing me?** Per request, per model, per key, against a\n frontier-pricing baseline.\n\n### What makes it different\n\nPlenty of tools proxy several providers. The distinction here is that Switchboard **shows\nits work**.\n\n| | Typical gateway | Switchboard |\n| --- | --- | --- |\n| Why a provider was chosen | A log line, if anything | Every candidate stored with its eight weighted score factors |\n| Why the others lost | Not recorded | An explicit exclusion reason per candidate, rendered in the UI |\n| Tuning a routing policy | Change it and send real traffic | Simulate it — score the ranking without sending anything upstream |\n| Free-tier requests | Billed at list price | Settled at **$0**, so the ladder ranks correctly and spend is honest |\n| Install | Native module, needs a toolchain | `node:sqlite` — no node-gyp, identical on Windows |\n| Where a failure shows up | In your client, as a 500 | Absorbed by the fallback walk; the client sees a 200 |\n\n---\n\n## Quickstart\n\n**1) Install and run**\n\n```bash\nnpm install -g switchboard\nswitchboard\n```\n\nThat's it. The dashboard is on **http://localhost:7272**, the gateway on\n**http://localhost:7272/v1**.\n\nYour database and encryption key live in your OS application-data directory\n(`%APPDATA%\\Switchboard`, `~/Library/Application Support/Switchboard`, or\n`~/.local/share/Switchboard`), so upgrading with `npm update -g switchboard` never touches\nthem.\n\n**2) Connect a free provider**\n\nOpen the dashboard and pick one. Groq, Cerebras and Google all give real free tiers with\nno payment method. Or from the terminal:\n\n```bash\nsb providers add groq # prompts for the key, echo off\n```\n\n**3) Create a key and point a tool at it**\n\n```bash\nsb keys new \"my laptop\"\n```\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n<details>\n<summary><b>Other ways to run it</b></summary>\n\n**From source**\n\n```bash\ngit clone https://github.com/abeermeer/switchboard.git\ncd switchboard\nnpm install\nnpm run build\nnpm start\n```\n\n**Docker**\n\n```bash\ndocker build -t switchboard .\ndocker run -d -p 7272:7272 -v switchboard-data:/data switchboard\n```\n\n**Desktop app** — `npm run electron:build:win` (or `:mac` / `:linux`) puts an installer in\n`release/`. Runs in the tray with start-at-login.\n\n**Pin to a release line** — every version is branched, so you can track one:\n\n```bash\nnpm install -g switchboard@0.2.0\n```\n\n</details>\n\n`\"model\": \"auto\"` hands the decision to the router. You can also send a bare model id\n(`llama-3.3-70b-versatile`), a pinned provider (`groq/llama-3.3-70b-versatile`), or a\npolicy slug you have defined.\n\n---\n\n## Point your tools at it\n\n**Claude Code**\n\n```bash\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:7272/v1\"\nexport ANTHROPIC_API_KEY=\"sb-live-your-key\"\n```\n\n**Cursor** — Settings → Models → Override OpenAI Base URL\n\n```\nBase URL: http://127.0.0.1:7272/v1\nAPI Key: sb-live-your-key\nModel: auto\n```\n\n**OpenAI SDK (Python)**\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(base_url=\"http://127.0.0.1:7272/v1\", api_key=\"sb-live-your-key\")\nresponse = client.chat.completions.create(\n model=\"auto\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\n```\n\n**OpenAI SDK (Node)**\n\n```js\nimport OpenAI from 'openai';\n\nconst client = new OpenAI({\n baseURL: 'http://127.0.0.1:7272/v1',\n apiKey: 'sb-live-your-key',\n});\n```\n\n**Cline / Continue / Aider** — any \"OpenAI-compatible endpoint\" field takes the same base\nURL and key.\n\n---\n\n## Endpoints\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `POST` | `/v1/chat/completions` | Streaming and non-streaming |\n| `POST` | `/v1/completions` | Legacy, shimmed onto chat |\n| `POST` | `/v1/embeddings` | |\n| `POST` | `/v1/images/generations` | |\n| `POST` | `/v1/audio/speech` | Returns audio bytes |\n| `POST` | `/v1/audio/transcriptions` | Multipart upload |\n| `POST` | `/v1/rerank` | |\n| `POST` | `/v1/moderations` | |\n| `GET` | `/v1/models` | Every model across every connected provider |\n| `GET` | `/v1/models/{model}` | |\n\nEvery response carries routing telemetry, so you can debug from curl alone:\n\n```\nx-switchboard-request-id: req_6751a555d0264a6aa7e0\nx-switchboard-provider: groq\nx-switchboard-model: llama-3.3-70b-versatile\nx-switchboard-tier: free\nx-switchboard-attempts: 2\nx-switchboard-cost-usd: 0.000083\nx-switchboard-ttft-ms: 340\nx-switchboard-fallback-reason: Rate limited (429), retry after 12s\n```\n\n`x-switchboard-fallback-reason` only appears when a fallback actually happened, so its\npresence is itself the signal.\n\n---\n\n## Routing\n\nA **policy** (called a combo in the API) is a model name your clients can send. Four ship\nby default:\n\n| Slug | Strategy | Optimises for |\n| --- | --- | --- |\n| `auto` | free-first | Exhaust free tiers before spending anything |\n| `free-only` | free-first, $0 ceiling | Never spends money, fails instead |\n| `fast` | fastest | Lowest observed time to first token |\n| `quality` | quality-first | Strongest model available, price ignored |\n\nSeven strategies are available:\n\n| Strategy | Behaviour |\n| --- | --- |\n| `free-first` | Free tier, then cheap, then paid. The default. |\n| `cost-optimized` | Lowest projected USD for this specific request. |\n| `fastest` | Lowest measured p50 time-to-first-token. |\n| `quality-first` | Inverts the cost factor — price becomes a quality signal. |\n| `priority` | Strict chain order. Predictable, ignores live health. |\n| `round-robin` | Spreads load across healthy members to stretch rate limits. |\n| `failover` | First member always; others only on outright failure. |\n\nEvery candidate is scored on eight factors (health, latency, cost, tier, priority, quota,\nthroughput, stability), weighted per strategy. The weights are visible in the dashboard,\nand the full arithmetic is stored with every request.\n\n### The simulator\n\nThe routing page runs the exact expand-and-score path a live request takes, but stops\nbefore sending anything upstream. Drag a member, move the cost ceiling, and watch the\nranking reshuffle — including which candidates were excluded and for what reason.\n\n---\n\n## Architecture\n\n```mermaid\nflowchart LR\n A[Client] --> B[/v1 endpoint/]\n B --> C[Authenticate<br/>key · rate limit · budget]\n C --> D[Expand candidates<br/>connections × models]\n D --> E[Filter<br/>health · context · features · budget]\n E --> F[Score<br/>8 weighted factors]\n F --> G[Execute]\n G -->|success| H[Log + usage rollup]\n G -->|failure| I{Retryable?}\n I -->|yes| G\n I -->|no| H\n H --> A\n```\n\n| Layer | Location |\n| --- | --- |\n| Domain contract | `src/types/core.ts` |\n| Persistence | `src/lib/db/` — `node:sqlite`, WAL, append-only migrations |\n| Providers | `src/lib/providers/` — catalog + 4 wire adapters |\n| Resilience | `src/lib/resilience/`, `src/lib/health/` |\n| Router | `src/lib/router/` — candidates → score → execute |\n| Gateway API | `src/app/v1/` |\n| Management API | `src/app/api/` |\n| Dashboard | `src/app/(dashboard)/`, `src/components/` |\n\nStorage is Node's built-in `node:sqlite` — no native module, no `node-gyp`, no prebuilt\nbinary roulette. `npm install` works the same on Windows as it does on Linux.\n\n---\n\n## Resilience\n\nThree independent layers, so one failing provider never becomes a failing gateway:\n\n1. **Circuit breaker**, per connection. Opens after N consecutive failures — immediately\n on a `401`, since a bad credential will not fix itself. Cooldown doubles on repeat\n opens, capped at 15 minutes, with jitter so recovering providers are not stampeded.\n2. **Model lockout**, per model. A `429` locks out that one model rather than the whole\n connection, honouring `Retry-After` when the provider sends it.\n3. **Fallback walk**, per request. Up to `maxAttempts` candidates, stopping early when\n retrying elsewhere cannot help — a malformed request produces the same `400` on every\n provider.\n\nA streaming response that has already sent its first byte is **never** retried. Once bytes\nare on the wire the client has committed, and a silent provider switch mid-stream would\ncorrupt the response.\n\n---\n\n## Configuration\n\nEverything is optional; sane defaults are generated on first run.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `PORT` | `7272` | Gateway and dashboard |\n| `SWITCHBOARD_DATA_DIR` | `./data` | Database, key vault, logs |\n| `SWITCHBOARD_MASTER_KEY` | generated | Base64 32-byte key for credential encryption |\n| `SWITCHBOARD_ALLOW_REMOTE` | `0` | Allow non-loopback dashboard access |\n| `SWITCHBOARD_PACKAGED` | `0` | Set by the desktop build |\n\nProvider credentials are sealed with **AES-256-GCM** before they touch disk. The master key\nlives in `<data-dir>/master.key` with `0600` permissions. No endpoint returns a stored\ncredential — only a four-character hint.\n\n---\n\n## CLI\n\n```bash\nsb start [--port] [--open] # start the gateway\nsb status # health at a glance\nsb doctor # diagnose the install\nsb providers list # connections with status and spend\nsb providers add groq # prompts for the key, echo off\nsb providers test <id> # force a probe\nsb models [--free] # available models with pricing\nsb combos # routing policies\nsb keys new <name> # create an API key\nsb chat [--model] # streaming REPL\nsb logs [--errors] # recent requests\nsb usage [--days 7] # spend summary\nsb open # open the dashboard\n```\n\n---\n\n## Desktop app\n\n```bash\nnpm run electron:build:win # or :mac / :linux\n```\n\nProduces an installer in `release/`. The desktop build runs the gateway as a child process,\nwrites to a real app-data directory, and lives in the system tray with start-at-login,\ncopy-endpoint and restart controls.\n\nIcons go in `electron/assets/icon.{ico,icns,png}` — the build works without them.\n\n---\n\n## Development\n\n```bash\nnpm run dev # dev server on :7272\nnpm run typecheck # tsc --noEmit, strict\nnpm run build # production build\nnpm run electron:dev # desktop shell against the dev server\n```\n\nRequires **Node 22.13+** — `node:sqlite` appeared in 22.5 but was flag-gated until 22.13.\nBuilt with Next.js 16, React 19,\nTypeScript 5.9 in strict mode with `noUncheckedIndexedAccess`, and Tailwind CSS v4.\n\nAdding a provider is a single entry in `src/lib/providers/catalog.ts` when it speaks the\nOpenAI wire format — see [docs/development.md](docs/development.md).\n\nConventions for humans and agents are in [CLAUDE.md](CLAUDE.md) and [AGENTS.md](AGENTS.md);\ncontribution workflow in [CONTRIBUTING.md](CONTRIBUTING.md).\n\n### Tests\n\n```bash\nnpm run test:run # ~700 tests\nnpm run test:ci # with coverage\n```\n\nVitest, running on every CI leg. The suite covers the parts where a silent bug costs money\nor loses a request: the scoring model (100%), cost and free-tier settlement (100%), the\nadapter helpers that classify every upstream error (99%), candidate expansion and exclusion\n(95%), the circuit breaker's full state machine, the credential vault, and an integration\nlayer that drives the real route handlers against a stubbed provider — including the\nfallback walk itself.\n\nTests assert the behaviour the code's comments claim, so the deliberate decisions\n(`bad_request` never retries, a started stream is never retried, free tiers settle at $0)\nfail loudly if anyone \"simplifies\" them away.\n\n### Releases\n\n`main` is the development line. Every version is also cut as a `release/vX.Y.Z` branch with\na matching tag and GitHub release, so you can pin to one and keep receiving fixes for that\nline without inheriting whatever landed on `main` today.\n\nBumping `version` in `package.json` on `main` triggers it: CI typechecks, tests, builds,\nboots the gateway, verifies the publish tarball carries no database or key, then branches,\ntags and publishes the release.\n\nA running record of what changed and why is in [SESSION_SUMMARY.md](SESSION_SUMMARY.md),\nincluding the findings that are known and deliberately unfixed.\n\n### Status\n\nPre-1.0 and honest about it. An external audit put it at **7.2/10** — strong on\narchitecture, resilience and type safety, with test coverage scored 0/10 as the single\nlargest gap.\n\nThat gap is now closed for the routing core: 709 tests covering scoring, cost settlement,\ncandidate expansion, the breaker, the vault, the adapter error taxonomy, and the fallback\nwalk end to end. Writing them surfaced a real bug — a non-ASCII character in a provider's\nerror message crashed the response instead of returning the client's error, which any\nnon-English upstream message would have triggered.\n\nStill open, and worth knowing before you rely on it:\n\n- No tests for the DB repositories, the management API, or the dashboard.\n- Rate limiting is in-memory and resets on restart.\n- No structured logging — `console.error` only, with no redaction layer.\n- `/v1/*` sends `Access-Control-Allow-Origin: *`, which is fine on loopback and\n questionable the moment it is exposed.\n\n**Do not run it exposed to the internet** without a reverse proxy and authentication in\nfront. It is built for a machine you control.\n\n---\n\n## License\n\nMIT © 2026 Abeer Meer\n"}, "1ac4ac1244e62b2e677d92c7181d3ab482365d37": {"t": 1785771476.40531, "n": 14445, "h": "101ef3b2c5371fe7", "c": "# Switchboard\n\n[![CI](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml/badge.svg)](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n[![Node](https://img.shields.io/badge/node-%E2%89%A522.13-3ecf8e.svg)](https://nodejs.org)\n\n**One endpoint. Every provider. Free tiers first, automatic fallback, everything local.**\n\nSwitchboard is an AI gateway that runs on your own machine. Point any OpenAI-compatible\ntool at it and it decides — per request — which provider and model should serve the call,\npreferring free tiers, routing around outages, and recording exactly why it chose what it\nchose.\n\n```\nyour tools ──▶ http://127.0.0.1:7272/v1 ──▶ Groq · Cerebras · Google · OpenRouter\n DeepSeek · Mistral · OpenAI · Anthropic …\n```\n\nNothing leaves your machine except the upstream provider calls themselves. No telemetry,\nno account, no cloud control plane.\n\n---\n\n## Demo\n\n[![Switchboard — every candidate, every factor](brag-output/brag.jpg)](https://github.com/abeermeer/switchboard/releases/download/v0.2.0/brag.mp4)\n\n**[▶ Watch the 20-second walkthrough](https://github.com/abeermeer/switchboard/releases/download/v0.2.0/brag.mp4)**\n\nA provider 429s mid-request, the fallback lands on a different one, and the decision trace\nexplains exactly why the replacement won. Every number on screen is real output from a\nrunning instance — the response headers, the four candidate scores, the factor notes, and\nthe exclusion reason.\n\nThat still above is the routing decision for one request: four candidates ranked, the\nwinner in copper, the price spread across providers serving the same class of model, and\n`17 eligible · 5 excluded`. Every other gateway gives you a log line; this is the part\nworth showing.\n\n---\n\n## Why\n\nRunning six provider SDKs across four tools means six sets of keys, six failure modes, and\nno idea what any of it costs. Switchboard collapses that into one endpoint with one key,\nand answers three questions the raw SDKs cannot:\n\n- **Which provider should serve this?** Scored live on health, latency, price and tier.\n- **What happens when it is down?** The next candidate takes it, transparently.\n- **What is this actually costing me?** Per request, per model, per key, against a\n frontier-pricing baseline.\n\n---\n\n## Quickstart\n\n**1) Install and run**\n\n```bash\nnpm install -g switchboard\nswitchboard\n```\n\nThat's it. The dashboard is on **http://localhost:7272**, the gateway on\n**http://localhost:7272/v1**.\n\nYour database and encryption key live in your OS application-data directory\n(`%APPDATA%\\Switchboard`, `~/Library/Application Support/Switchboard`, or\n`~/.local/share/Switchboard`), so upgrading with `npm update -g switchboard` never touches\nthem.\n\n**2) Connect a free provider**\n\nOpen the dashboard and pick one. Groq, Cerebras and Google all give real free tiers with\nno payment method. Or from the terminal:\n\n```bash\nsb providers add groq # prompts for the key, echo off\n```\n\n**3) Create a key and point a tool at it**\n\n```bash\nsb keys new \"my laptop\"\n```\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n<details>\n<summary><b>Other ways to run it</b></summary>\n\n**From source**\n\n```bash\ngit clone https://github.com/abeermeer/switchboard.git\ncd switchboard\nnpm install\nnpm run build\nnpm start\n```\n\n**Docker**\n\n```bash\ndocker build -t switchboard .\ndocker run -d -p 7272:7272 -v switchboard-data:/data switchboard\n```\n\n**Desktop app** — `npm run electron:build:win` (or `:mac` / `:linux`) puts an installer in\n`release/`. Runs in the tray with start-at-login.\n\n**Pin to a release line** — every version is branched, so you can track one:\n\n```bash\nnpm install -g switchboard@0.2.0\n```\n\n</details>\n\nThen point something at it:\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n`\"model\": \"auto\"` hands the decision to the router. You can also send a bare model id\n(`llama-3.3-70b-versatile`), a pinned provider (`groq/llama-3.3-70b-versatile`), or a\npolicy slug you have defined.\n\n---\n\n## Point your tools at it\n\n**Claude Code**\n\n```bash\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:7272/v1\"\nexport ANTHROPIC_API_KEY=\"sb-live-your-key\"\n```\n\n**Cursor** — Settings → Models → Override OpenAI Base URL\n\n```\nBase URL: http://127.0.0.1:7272/v1\nAPI Key: sb-live-your-key\nModel: auto\n```\n\n**OpenAI SDK (Python)**\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(base_url=\"http://127.0.0.1:7272/v1\", api_key=\"sb-live-your-key\")\nresponse = client.chat.completions.create(\n model=\"auto\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\n```\n\n**OpenAI SDK (Node)**\n\n```js\nimport OpenAI from 'openai';\n\nconst client = new OpenAI({\n baseURL: 'http://127.0.0.1:7272/v1',\n apiKey: 'sb-live-your-key',\n});\n```\n\n**Cline / Continue / Aider** — any \"OpenAI-compatible endpoint\" field takes the same base\nURL and key.\n\n---\n\n## Endpoints\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `POST` | `/v1/chat/completions` | Streaming and non-streaming |\n| `POST` | `/v1/completions` | Legacy, shimmed onto chat |\n| `POST` | `/v1/embeddings` | |\n| `POST` | `/v1/images/generations` | |\n| `POST` | `/v1/audio/speech` | Returns audio bytes |\n| `POST` | `/v1/audio/transcriptions` | Multipart upload |\n| `POST` | `/v1/rerank` | |\n| `POST` | `/v1/moderations` | |\n| `GET` | `/v1/models` | Every model across every connected provider |\n| `GET` | `/v1/models/{model}` | |\n\nEvery response carries routing telemetry, so you can debug from curl alone:\n\n```\nx-switchboard-request-id: req_6751a555d0264a6aa7e0\nx-switchboard-provider: groq\nx-switchboard-model: llama-3.3-70b-versatile\nx-switchboard-tier: free\nx-switchboard-attempts: 2\nx-switchboard-cost-usd: 0.000083\nx-switchboard-ttft-ms: 340\nx-switchboard-fallback-reason: Rate limited (429), retry after 12s\n```\n\n`x-switchboard-fallback-reason` only appears when a fallback actually happened, so its\npresence is itself the signal.\n\n---\n\n## Routing\n\nA **policy** (called a combo in the API) is a model name your clients can send. Four ship\nby default:\n\n| Slug | Strategy | Optimises for |\n| --- | --- | --- |\n| `auto` | free-first | Exhaust free tiers before spending anything |\n| `free-only` | free-first, $0 ceiling | Never spends money, fails instead |\n| `fast` | fastest | Lowest observed time to first token |\n| `quality` | quality-first | Strongest model available, price ignored |\n\nSeven strategies are available:\n\n| Strategy | Behaviour |\n| --- | --- |\n| `free-first` | Free tier, then cheap, then paid. The default. |\n| `cost-optimized` | Lowest projected USD for this specific request. |\n| `fastest` | Lowest measured p50 time-to-first-token. |\n| `quality-first` | Inverts the cost factor — price becomes a quality signal. |\n| `priority` | Strict chain order. Predictable, ignores live health. |\n| `round-robin` | Spreads load across healthy members to stretch rate limits. |\n| `failover` | First member always; others only on outright failure. |\n\nEvery candidate is scored on eight factors (health, latency, cost, tier, priority, quota,\nthroughput, stability), weighted per strategy. The weights are visible in the dashboard,\nand the full arithmetic is stored with every request.\n\n### The simulator\n\nThe routing page runs the exact expand-and-score path a live request takes, but stops\nbefore sending anything upstream. Drag a member, move the cost ceiling, and watch the\nranking reshuffle — including which candidates were excluded and for what reason.\n\n---\n\n## Architecture\n\n```mermaid\nflowchart LR\n A[Client] --> B[/v1 endpoint/]\n B --> C[Authenticate<br/>key · rate limit · budget]\n C --> D[Expand candidates<br/>connections × models]\n D --> E[Filter<br/>health · context · features · budget]\n E --> F[Score<br/>8 weighted factors]\n F --> G[Execute]\n G -->|success| H[Log + usage rollup]\n G -->|failure| I{Retryable?}\n I -->|yes| G\n I -->|no| H\n H --> A\n```\n\n| Layer | Location |\n| --- | --- |\n| Domain contract | `src/types/core.ts` |\n| Persistence | `src/lib/db/` — `node:sqlite`, WAL, append-only migrations |\n| Providers | `src/lib/providers/` — catalog + 4 wire adapters |\n| Resilience | `src/lib/resilience/`, `src/lib/health/` |\n| Router | `src/lib/router/` — candidates → score → execute |\n| Gateway API | `src/app/v1/` |\n| Management API | `src/app/api/` |\n| Dashboard | `src/app/(dashboard)/`, `src/components/` |\n\nStorage is Node's built-in `node:sqlite` — no native module, no `node-gyp`, no prebuilt\nbinary roulette. `npm install` works the same on Windows as it does on Linux.\n\n---\n\n## Resilience\n\nThree independent layers, so one failing provider never becomes a failing gateway:\n\n1. **Circuit breaker**, per connection. Opens after N consecutive failures — immediately\n on a `401`, since a bad credential will not fix itself. Cooldown doubles on repeat\n opens, capped at 15 minutes, with jitter so recovering providers are not stampeded.\n2. **Model lockout**, per model. A `429` locks out that one model rather than the whole\n connection, honouring `Retry-After` when the provider sends it.\n3. **Fallback walk**, per request. Up to `maxAttempts` candidates, stopping early when\n retrying elsewhere cannot help — a malformed request produces the same `400` on every\n provider.\n\nA streaming response that has already sent its first byte is **never** retried. Once bytes\nare on the wire the client has committed, and a silent provider switch mid-stream would\ncorrupt the response.\n\n---\n\n## Configuration\n\nEverything is optional; sane defaults are generated on first run.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `PORT` | `7272` | Gateway and dashboard |\n| `SWITCHBOARD_DATA_DIR` | `./data` | Database, key vault, logs |\n| `SWITCHBOARD_MASTER_KEY` | generated | Base64 32-byte key for credential encryption |\n| `SWITCHBOARD_ALLOW_REMOTE` | `0` | Allow non-loopback dashboard access |\n| `SWITCHBOARD_PACKAGED` | `0` | Set by the desktop build |\n\nProvider credentials are sealed with **AES-256-GCM** before they touch disk. The master key\nlives in `<data-dir>/master.key` with `0600` permissions. No endpoint returns a stored\ncredential — only a four-character hint.\n\n---\n\n## CLI\n\n```bash\nsb start [--port] [--open] # start the gateway\nsb status # health at a glance\nsb doctor # diagnose the install\nsb providers list # connections with status and spend\nsb providers add groq # prompts for the key, echo off\nsb providers test <id> # force a probe\nsb models [--free] # available models with pricing\nsb combos # routing policies\nsb keys new <name> # create an API key\nsb chat [--model] # streaming REPL\nsb logs [--errors] # recent requests\nsb usage [--days 7] # spend summary\nsb open # open the dashboard\n```\n\n---\n\n## Desktop app\n\n```bash\nnpm run electron:build:win # or :mac / :linux\n```\n\nProduces an installer in `release/`. The desktop build runs the gateway as a child process,\nwrites to a real app-data directory, and lives in the system tray with start-at-login,\ncopy-endpoint and restart controls.\n\nIcons go in `electron/assets/icon.{ico,icns,png}` — the build works without them.\n\n---\n\n## Development\n\n```bash\nnpm run dev # dev server on :7272\nnpm run typecheck # tsc --noEmit, strict\nnpm run build # production build\nnpm run electron:dev # desktop shell against the dev server\n```\n\nRequires **Node 22.13+** — `node:sqlite` appeared in 22.5 but was flag-gated until 22.13.\nBuilt with Next.js 16, React 19,\nTypeScript 5.9 in strict mode with `noUncheckedIndexedAccess`, and Tailwind CSS v4.\n\nAdding a provider is a single entry in `src/lib/providers/catalog.ts` when it speaks the\nOpenAI wire format — see [docs/development.md](docs/development.md).\n\nConventions for humans and agents are in [CLAUDE.md](CLAUDE.md) and [AGENTS.md](AGENTS.md);\ncontribution workflow in [CONTRIBUTING.md](CONTRIBUTING.md).\n\n### Tests\n\n```bash\nnpm run test:run # ~700 tests\nnpm run test:ci # with coverage\n```\n\nVitest, running on every CI leg. The suite covers the parts where a silent bug costs money\nor loses a request: the scoring model (100%), cost and free-tier settlement (100%), the\nadapter helpers that classify every upstream error (99%), candidate expansion and exclusion\n(95%), the circuit breaker's full state machine, the credential vault, and an integration\nlayer that drives the real route handlers against a stubbed provider — including the\nfallback walk itself.\n\nTests assert the behaviour the code's comments claim, so the deliberate decisions\n(`bad_request` never retries, a started stream is never retried, free tiers settle at $0)\nfail loudly if anyone \"simplifies\" them away.\n\n### Releases\n\n`main` is the development line. Every version is also cut as a `release/vX.Y.Z` branch with\na matching tag and GitHub release, so you can pin to one and keep receiving fixes for that\nline without inheriting whatever landed on `main` today.\n\nBumping `version` in `package.json` on `main` triggers it: CI typechecks, builds, boots the\ngateway, verifies the publish tarball carries no database or key, then branches, tags and\npublishes the release.\n\n### Status\n\nPre-1.0 and honest about it. An external audit put it at **7.2/10** — strong on\narchitecture, resilience and type safety, with test coverage scored 0/10 as the single\nlargest gap.\n\nThat gap is now closed for the routing core: ~700 tests covering scoring, cost settlement,\ncandidate expansion, the breaker, the vault, the adapter error taxonomy, and the fallback\nwalk end to end. Writing them surfaced a real bug — a non-ASCII character in a provider's\nerror message crashed the response instead of returning the client's error, which any\nnon-English upstream message would have triggered.\n\nStill open: no tests for the DB repositories, the management API or the dashboard; rate\nlimiting is in-memory and resets on restart; there is no structured logging. Do not run it\nexposed to the internet without a reverse proxy and auth in front.\n\n---\n\n## License\n\nMIT © 2026 Abeer Meer\n"}, "2d0e475d7486291d0a145776f887dc1707b0d323": {"t": 1785771478.1061547, "n": 14445, "h": "2bc42b743d611105", "c": "# Switchboard\n\n[![CI](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml/badge.svg)](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n[![Node](https://img.shields.io/badge/node-%E2%89%A522.13-3ecf8e.svg)](https://nodejs.org)\n\n**One endpoint. Every provider. Free tiers first, automatic fallback, everything local.**\n\nSwitchboard is an AI gateway that runs on your own machine. Point any OpenAI-compatible\ntool at it and it decides — per request — which provider and model should serve the call,\npreferring free tiers, routing around outages, and recording exactly why it chose what it\nchose.\n\n```\nyour tools ──▶ http://127.0.0.1:7272/v1 ──▶ Groq · Cerebras · Google · OpenRouter\n DeepSeek · Mistral · OpenAI · Anthropic …\n```\n\nNothing leaves your machine except the upstream provider calls themselves. No telemetry,\nno account, no cloud control plane.\n\n---\n\n## Demo\n\n[![Switchboard — every candidate, every factor](brag-output/brag.jpg)](https://github.com/abeermeer/switchboard/releases/download/v0.2.0/brag.mp4)\n\n**[▶ Watch the 20-second walkthrough](https://github.com/abeermeer/switchboard/releases/download/v0.2.0/brag.mp4)**\n\nA provider 429s mid-request, the fallback lands on a different one, and the decision trace\nexplains exactly why the replacement won. Every number on screen is real output from a\nrunning instance — the response headers, the four candidate scores, the factor notes, and\nthe exclusion reason.\n\nThat still above is the routing decision for one request: four candidates ranked, the\nwinner in copper, the price spread across providers serving the same class of model, and\n`17 eligible · 5 excluded`. Every other gateway gives you a log line; this is the part\nworth showing.\n\n---\n\n## Why\n\nRunning six provider SDKs across four tools means six sets of keys, six failure modes, and\nno idea what any of it costs. Switchboard collapses that into one endpoint with one key,\nand answers three questions the raw SDKs cannot:\n\n- **Which provider should serve this?** Scored live on health, latency, price and tier.\n- **What happens when it is down?** The next candidate takes it, transparently.\n- **What is this actually costing me?** Per request, per model, per key, against a\n frontier-pricing baseline.\n\n---\n\n## Quickstart\n\n**1) Install and run**\n\n```bash\nnpm install -g switchboard\nswitchboard\n```\n\nThat's it. The dashboard is on **http://localhost:7272**, the gateway on\n**http://localhost:7272/v1**.\n\nYour database and encryption key live in your OS application-data directory\n(`%APPDATA%\\Switchboard`, `~/Library/Application Support/Switchboard`, or\n`~/.local/share/Switchboard`), so upgrading with `npm update -g switchboard` never touches\nthem.\n\n**2) Connect a free provider**\n\nOpen the dashboard and pick one. Groq, Cerebras and Google all give real free tiers with\nno payment method. Or from the terminal:\n\n```bash\nsb providers add groq # prompts for the key, echo off\n```\n\n**3) Create a key and point a tool at it**\n\n```bash\nsb keys new \"my laptop\"\n```\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n<details>\n<summary><b>Other ways to run it</b></summary>\n\n**From source**\n\n```bash\ngit clone https://github.com/abeermeer/switchboard.git\ncd switchboard\nnpm install\nnpm run build\nnpm start\n```\n\n**Docker**\n\n```bash\ndocker build -t switchboard .\ndocker run -d -p 7272:7272 -v switchboard-data:/data switchboard\n```\n\n**Desktop app** — `npm run electron:build:win` (or `:mac` / `:linux`) puts an installer in\n`release/`. Runs in the tray with start-at-login.\n\n**Pin to a release line** — every version is branched, so you can track one:\n\n```bash\nnpm install -g switchboard@0.1.0\n```\n\n</details>\n\nThen point something at it:\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n`\"model\": \"auto\"` hands the decision to the router. You can also send a bare model id\n(`llama-3.3-70b-versatile`), a pinned provider (`groq/llama-3.3-70b-versatile`), or a\npolicy slug you have defined.\n\n---\n\n## Point your tools at it\n\n**Claude Code**\n\n```bash\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:7272/v1\"\nexport ANTHROPIC_API_KEY=\"sb-live-your-key\"\n```\n\n**Cursor** — Settings → Models → Override OpenAI Base URL\n\n```\nBase URL: http://127.0.0.1:7272/v1\nAPI Key: sb-live-your-key\nModel: auto\n```\n\n**OpenAI SDK (Python)**\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(base_url=\"http://127.0.0.1:7272/v1\", api_key=\"sb-live-your-key\")\nresponse = client.chat.completions.create(\n model=\"auto\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\n```\n\n**OpenAI SDK (Node)**\n\n```js\nimport OpenAI from 'openai';\n\nconst client = new OpenAI({\n baseURL: 'http://127.0.0.1:7272/v1',\n apiKey: 'sb-live-your-key',\n});\n```\n\n**Cline / Continue / Aider** — any \"OpenAI-compatible endpoint\" field takes the same base\nURL and key.\n\n---\n\n## Endpoints\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `POST` | `/v1/chat/completions` | Streaming and non-streaming |\n| `POST` | `/v1/completions` | Legacy, shimmed onto chat |\n| `POST` | `/v1/embeddings` | |\n| `POST` | `/v1/images/generations` | |\n| `POST` | `/v1/audio/speech` | Returns audio bytes |\n| `POST` | `/v1/audio/transcriptions` | Multipart upload |\n| `POST` | `/v1/rerank` | |\n| `POST` | `/v1/moderations` | |\n| `GET` | `/v1/models` | Every model across every connected provider |\n| `GET` | `/v1/models/{model}` | |\n\nEvery response carries routing telemetry, so you can debug from curl alone:\n\n```\nx-switchboard-request-id: req_6751a555d0264a6aa7e0\nx-switchboard-provider: groq\nx-switchboard-model: llama-3.3-70b-versatile\nx-switchboard-tier: free\nx-switchboard-attempts: 2\nx-switchboard-cost-usd: 0.000083\nx-switchboard-ttft-ms: 340\nx-switchboard-fallback-reason: Rate limited (429), retry after 12s\n```\n\n`x-switchboard-fallback-reason` only appears when a fallback actually happened, so its\npresence is itself the signal.\n\n---\n\n## Routing\n\nA **policy** (called a combo in the API) is a model name your clients can send. Four ship\nby default:\n\n| Slug | Strategy | Optimises for |\n| --- | --- | --- |\n| `auto` | free-first | Exhaust free tiers before spending anything |\n| `free-only` | free-first, $0 ceiling | Never spends money, fails instead |\n| `fast` | fastest | Lowest observed time to first token |\n| `quality` | quality-first | Strongest model available, price ignored |\n\nSeven strategies are available:\n\n| Strategy | Behaviour |\n| --- | --- |\n| `free-first` | Free tier, then cheap, then paid. The default. |\n| `cost-optimized` | Lowest projected USD for this specific request. |\n| `fastest` | Lowest measured p50 time-to-first-token. |\n| `quality-first` | Inverts the cost factor — price becomes a quality signal. |\n| `priority` | Strict chain order. Predictable, ignores live health. |\n| `round-robin` | Spreads load across healthy members to stretch rate limits. |\n| `failover` | First member always; others only on outright failure. |\n\nEvery candidate is scored on eight factors (health, latency, cost, tier, priority, quota,\nthroughput, stability), weighted per strategy. The weights are visible in the dashboard,\nand the full arithmetic is stored with every request.\n\n### The simulator\n\nThe routing page runs the exact expand-and-score path a live request takes, but stops\nbefore sending anything upstream. Drag a member, move the cost ceiling, and watch the\nranking reshuffle — including which candidates were excluded and for what reason.\n\n---\n\n## Architecture\n\n```mermaid\nflowchart LR\n A[Client] --> B[/v1 endpoint/]\n B --> C[Authenticate<br/>key · rate limit · budget]\n C --> D[Expand candidates<br/>connections × models]\n D --> E[Filter<br/>health · context · features · budget]\n E --> F[Score<br/>8 weighted factors]\n F --> G[Execute]\n G -->|success| H[Log + usage rollup]\n G -->|failure| I{Retryable?}\n I -->|yes| G\n I -->|no| H\n H --> A\n```\n\n| Layer | Location |\n| --- | --- |\n| Domain contract | `src/types/core.ts` |\n| Persistence | `src/lib/db/` — `node:sqlite`, WAL, append-only migrations |\n| Providers | `src/lib/providers/` — catalog + 4 wire adapters |\n| Resilience | `src/lib/resilience/`, `src/lib/health/` |\n| Router | `src/lib/router/` — candidates → score → execute |\n| Gateway API | `src/app/v1/` |\n| Management API | `src/app/api/` |\n| Dashboard | `src/app/(dashboard)/`, `src/components/` |\n\nStorage is Node's built-in `node:sqlite` — no native module, no `node-gyp`, no prebuilt\nbinary roulette. `npm install` works the same on Windows as it does on Linux.\n\n---\n\n## Resilience\n\nThree independent layers, so one failing provider never becomes a failing gateway:\n\n1. **Circuit breaker**, per connection. Opens after N consecutive failures — immediately\n on a `401`, since a bad credential will not fix itself. Cooldown doubles on repeat\n opens, capped at 15 minutes, with jitter so recovering providers are not stampeded.\n2. **Model lockout**, per model. A `429` locks out that one model rather than the whole\n connection, honouring `Retry-After` when the provider sends it.\n3. **Fallback walk**, per request. Up to `maxAttempts` candidates, stopping early when\n retrying elsewhere cannot help — a malformed request produces the same `400` on every\n provider.\n\nA streaming response that has already sent its first byte is **never** retried. Once bytes\nare on the wire the client has committed, and a silent provider switch mid-stream would\ncorrupt the response.\n\n---\n\n## Configuration\n\nEverything is optional; sane defaults are generated on first run.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `PORT` | `7272` | Gateway and dashboard |\n| `SWITCHBOARD_DATA_DIR` | `./data` | Database, key vault, logs |\n| `SWITCHBOARD_MASTER_KEY` | generated | Base64 32-byte key for credential encryption |\n| `SWITCHBOARD_ALLOW_REMOTE` | `0` | Allow non-loopback dashboard access |\n| `SWITCHBOARD_PACKAGED` | `0` | Set by the desktop build |\n\nProvider credentials are sealed with **AES-256-GCM** before they touch disk. The master key\nlives in `<data-dir>/master.key` with `0600` permissions. No endpoint returns a stored\ncredential — only a four-character hint.\n\n---\n\n## CLI\n\n```bash\nsb start [--port] [--open] # start the gateway\nsb status # health at a glance\nsb doctor # diagnose the install\nsb providers list # connections with status and spend\nsb providers add groq # prompts for the key, echo off\nsb providers test <id> # force a probe\nsb models [--free] # available models with pricing\nsb combos # routing policies\nsb keys new <name> # create an API key\nsb chat [--model] # streaming REPL\nsb logs [--errors] # recent requests\nsb usage [--days 7] # spend summary\nsb open # open the dashboard\n```\n\n---\n\n## Desktop app\n\n```bash\nnpm run electron:build:win # or :mac / :linux\n```\n\nProduces an installer in `release/`. The desktop build runs the gateway as a child process,\nwrites to a real app-data directory, and lives in the system tray with start-at-login,\ncopy-endpoint and restart controls.\n\nIcons go in `electron/assets/icon.{ico,icns,png}` — the build works without them.\n\n---\n\n## Development\n\n```bash\nnpm run dev # dev server on :7272\nnpm run typecheck # tsc --noEmit, strict\nnpm run build # production build\nnpm run electron:dev # desktop shell against the dev server\n```\n\nRequires **Node 22.13+** — `node:sqlite` appeared in 22.5 but was flag-gated until 22.13.\nBuilt with Next.js 16, React 19,\nTypeScript 5.9 in strict mode with `noUncheckedIndexedAccess`, and Tailwind CSS v4.\n\nAdding a provider is a single entry in `src/lib/providers/catalog.ts` when it speaks the\nOpenAI wire format — see [docs/development.md](docs/development.md).\n\nConventions for humans and agents are in [CLAUDE.md](CLAUDE.md) and [AGENTS.md](AGENTS.md);\ncontribution workflow in [CONTRIBUTING.md](CONTRIBUTING.md).\n\n### Tests\n\n```bash\nnpm run test:run # ~700 tests\nnpm run test:ci # with coverage\n```\n\nVitest, running on every CI leg. The suite covers the parts where a silent bug costs money\nor loses a request: the scoring model (100%), cost and free-tier settlement (100%), the\nadapter helpers that classify every upstream error (99%), candidate expansion and exclusion\n(95%), the circuit breaker's full state machine, the credential vault, and an integration\nlayer that drives the real route handlers against a stubbed provider — including the\nfallback walk itself.\n\nTests assert the behaviour the code's comments claim, so the deliberate decisions\n(`bad_request` never retries, a started stream is never retried, free tiers settle at $0)\nfail loudly if anyone \"simplifies\" them away.\n\n### Releases\n\n`main` is the development line. Every version is also cut as a `release/vX.Y.Z` branch with\na matching tag and GitHub release, so you can pin to one and keep receiving fixes for that\nline without inheriting whatever landed on `main` today.\n\nBumping `version` in `package.json` on `main` triggers it: CI typechecks, builds, boots the\ngateway, verifies the publish tarball carries no database or key, then branches, tags and\npublishes the release.\n\n### Status\n\nPre-1.0 and honest about it. An external audit put it at **7.2/10** — strong on\narchitecture, resilience and type safety, with test coverage scored 0/10 as the single\nlargest gap.\n\nThat gap is now closed for the routing core: ~700 tests covering scoring, cost settlement,\ncandidate expansion, the breaker, the vault, the adapter error taxonomy, and the fallback\nwalk end to end. Writing them surfaced a real bug — a non-ASCII character in a provider's\nerror message crashed the response instead of returning the client's error, which any\nnon-English upstream message would have triggered.\n\nStill open: no tests for the DB repositories, the management API or the dashboard; rate\nlimiting is in-memory and resets on restart; there is no structured logging. Do not run it\nexposed to the internet without a reverse proxy and auth in front.\n\n---\n\n## License\n\nMIT © 2026 Abeer Meer\n"}, "d0191c51c86cfad38c050712bf8346fa6c63ddb7": {"t": 1785771479.501523, "n": 14445, "h": "31cea301b3b006ba", "c": "# Switchboard\n\n[![CI](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml/badge.svg)](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n[![Node](https://img.shields.io/badge/node-%E2%89%A522.13-3ecf8e.svg)](https://nodejs.org)\n\n**One endpoint. Every provider. Free tiers first, automatic fallback, everything local.**\n\nSwitchboard is an AI gateway that runs on your own machine. Point any OpenAI-compatible\ntool at it and it decides — per request — which provider and model should serve the call,\npreferring free tiers, routing around outages, and recording exactly why it chose what it\nchose.\n\n```\nyour tools ──▶ http://127.0.0.1:7272/v1 ──▶ Groq · Cerebras · Google · OpenRouter\n DeepSeek · Mistral · OpenAI · Anthropic …\n```\n\nNothing leaves your machine except the upstream provider calls themselves. No telemetry,\nno account, no cloud control plane.\n\n---\n\n## Demo\n\n[![Switchboard — every candidate, every factor](brag-output/brag.jpg)](https://github.com/abeermeer/switchboard/releases/download/v0.1.0/brag.mp4)\n\n**[▶ Watch the 20-second walkthrough](https://github.com/abeermeer/switchboard/releases/download/v0.1.0/brag.mp4)**\n\nA provider 429s mid-request, the fallback lands on a different one, and the decision trace\nexplains exactly why the replacement won. Every number on screen is real output from a\nrunning instance — the response headers, the four candidate scores, the factor notes, and\nthe exclusion reason.\n\nThat still above is the routing decision for one request: four candidates ranked, the\nwinner in copper, the price spread across providers serving the same class of model, and\n`17 eligible · 5 excluded`. Every other gateway gives you a log line; this is the part\nworth showing.\n\n---\n\n## Why\n\nRunning six provider SDKs across four tools means six sets of keys, six failure modes, and\nno idea what any of it costs. Switchboard collapses that into one endpoint with one key,\nand answers three questions the raw SDKs cannot:\n\n- **Which provider should serve this?** Scored live on health, latency, price and tier.\n- **What happens when it is down?** The next candidate takes it, transparently.\n- **What is this actually costing me?** Per request, per model, per key, against a\n frontier-pricing baseline.\n\n---\n\n## Quickstart\n\n**1) Install and run**\n\n```bash\nnpm install -g switchboard\nswitchboard\n```\n\nThat's it. The dashboard is on **http://localhost:7272**, the gateway on\n**http://localhost:7272/v1**.\n\nYour database and encryption key live in your OS application-data directory\n(`%APPDATA%\\Switchboard`, `~/Library/Application Support/Switchboard`, or\n`~/.local/share/Switchboard`), so upgrading with `npm update -g switchboard` never touches\nthem.\n\n**2) Connect a free provider**\n\nOpen the dashboard and pick one. Groq, Cerebras and Google all give real free tiers with\nno payment method. Or from the terminal:\n\n```bash\nsb providers add groq # prompts for the key, echo off\n```\n\n**3) Create a key and point a tool at it**\n\n```bash\nsb keys new \"my laptop\"\n```\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n<details>\n<summary><b>Other ways to run it</b></summary>\n\n**From source**\n\n```bash\ngit clone https://github.com/abeermeer/switchboard.git\ncd switchboard\nnpm install\nnpm run build\nnpm start\n```\n\n**Docker**\n\n```bash\ndocker build -t switchboard .\ndocker run -d -p 7272:7272 -v switchboard-data:/data switchboard\n```\n\n**Desktop app** — `npm run electron:build:win` (or `:mac` / `:linux`) puts an installer in\n`release/`. Runs in the tray with start-at-login.\n\n**Pin to a release line** — every version is branched, so you can track one:\n\n```bash\nnpm install -g switchboard@0.1.0\n```\n\n</details>\n\nThen point something at it:\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n`\"model\": \"auto\"` hands the decision to the router. You can also send a bare model id\n(`llama-3.3-70b-versatile`), a pinned provider (`groq/llama-3.3-70b-versatile`), or a\npolicy slug you have defined.\n\n---\n\n## Point your tools at it\n\n**Claude Code**\n\n```bash\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:7272/v1\"\nexport ANTHROPIC_API_KEY=\"sb-live-your-key\"\n```\n\n**Cursor** — Settings → Models → Override OpenAI Base URL\n\n```\nBase URL: http://127.0.0.1:7272/v1\nAPI Key: sb-live-your-key\nModel: auto\n```\n\n**OpenAI SDK (Python)**\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(base_url=\"http://127.0.0.1:7272/v1\", api_key=\"sb-live-your-key\")\nresponse = client.chat.completions.create(\n model=\"auto\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\n```\n\n**OpenAI SDK (Node)**\n\n```js\nimport OpenAI from 'openai';\n\nconst client = new OpenAI({\n baseURL: 'http://127.0.0.1:7272/v1',\n apiKey: 'sb-live-your-key',\n});\n```\n\n**Cline / Continue / Aider** — any \"OpenAI-compatible endpoint\" field takes the same base\nURL and key.\n\n---\n\n## Endpoints\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `POST` | `/v1/chat/completions` | Streaming and non-streaming |\n| `POST` | `/v1/completions` | Legacy, shimmed onto chat |\n| `POST` | `/v1/embeddings` | |\n| `POST` | `/v1/images/generations` | |\n| `POST` | `/v1/audio/speech` | Returns audio bytes |\n| `POST` | `/v1/audio/transcriptions` | Multipart upload |\n| `POST` | `/v1/rerank` | |\n| `POST` | `/v1/moderations` | |\n| `GET` | `/v1/models` | Every model across every connected provider |\n| `GET` | `/v1/models/{model}` | |\n\nEvery response carries routing telemetry, so you can debug from curl alone:\n\n```\nx-switchboard-request-id: req_6751a555d0264a6aa7e0\nx-switchboard-provider: groq\nx-switchboard-model: llama-3.3-70b-versatile\nx-switchboard-tier: free\nx-switchboard-attempts: 2\nx-switchboard-cost-usd: 0.000083\nx-switchboard-ttft-ms: 340\nx-switchboard-fallback-reason: Rate limited (429), retry after 12s\n```\n\n`x-switchboard-fallback-reason` only appears when a fallback actually happened, so its\npresence is itself the signal.\n\n---\n\n## Routing\n\nA **policy** (called a combo in the API) is a model name your clients can send. Four ship\nby default:\n\n| Slug | Strategy | Optimises for |\n| --- | --- | --- |\n| `auto` | free-first | Exhaust free tiers before spending anything |\n| `free-only` | free-first, $0 ceiling | Never spends money, fails instead |\n| `fast` | fastest | Lowest observed time to first token |\n| `quality` | quality-first | Strongest model available, price ignored |\n\nSeven strategies are available:\n\n| Strategy | Behaviour |\n| --- | --- |\n| `free-first` | Free tier, then cheap, then paid. The default. |\n| `cost-optimized` | Lowest projected USD for this specific request. |\n| `fastest` | Lowest measured p50 time-to-first-token. |\n| `quality-first` | Inverts the cost factor — price becomes a quality signal. |\n| `priority` | Strict chain order. Predictable, ignores live health. |\n| `round-robin` | Spreads load across healthy members to stretch rate limits. |\n| `failover` | First member always; others only on outright failure. |\n\nEvery candidate is scored on eight factors (health, latency, cost, tier, priority, quota,\nthroughput, stability), weighted per strategy. The weights are visible in the dashboard,\nand the full arithmetic is stored with every request.\n\n### The simulator\n\nThe routing page runs the exact expand-and-score path a live request takes, but stops\nbefore sending anything upstream. Drag a member, move the cost ceiling, and watch the\nranking reshuffle — including which candidates were excluded and for what reason.\n\n---\n\n## Architecture\n\n```mermaid\nflowchart LR\n A[Client] --> B[/v1 endpoint/]\n B --> C[Authenticate<br/>key · rate limit · budget]\n C --> D[Expand candidates<br/>connections × models]\n D --> E[Filter<br/>health · context · features · budget]\n E --> F[Score<br/>8 weighted factors]\n F --> G[Execute]\n G -->|success| H[Log + usage rollup]\n G -->|failure| I{Retryable?}\n I -->|yes| G\n I -->|no| H\n H --> A\n```\n\n| Layer | Location |\n| --- | --- |\n| Domain contract | `src/types/core.ts` |\n| Persistence | `src/lib/db/` — `node:sqlite`, WAL, append-only migrations |\n| Providers | `src/lib/providers/` — catalog + 4 wire adapters |\n| Resilience | `src/lib/resilience/`, `src/lib/health/` |\n| Router | `src/lib/router/` — candidates → score → execute |\n| Gateway API | `src/app/v1/` |\n| Management API | `src/app/api/` |\n| Dashboard | `src/app/(dashboard)/`, `src/components/` |\n\nStorage is Node's built-in `node:sqlite` — no native module, no `node-gyp`, no prebuilt\nbinary roulette. `npm install` works the same on Windows as it does on Linux.\n\n---\n\n## Resilience\n\nThree independent layers, so one failing provider never becomes a failing gateway:\n\n1. **Circuit breaker**, per connection. Opens after N consecutive failures — immediately\n on a `401`, since a bad credential will not fix itself. Cooldown doubles on repeat\n opens, capped at 15 minutes, with jitter so recovering providers are not stampeded.\n2. **Model lockout**, per model. A `429` locks out that one model rather than the whole\n connection, honouring `Retry-After` when the provider sends it.\n3. **Fallback walk**, per request. Up to `maxAttempts` candidates, stopping early when\n retrying elsewhere cannot help — a malformed request produces the same `400` on every\n provider.\n\nA streaming response that has already sent its first byte is **never** retried. Once bytes\nare on the wire the client has committed, and a silent provider switch mid-stream would\ncorrupt the response.\n\n---\n\n## Configuration\n\nEverything is optional; sane defaults are generated on first run.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `PORT` | `7272` | Gateway and dashboard |\n| `SWITCHBOARD_DATA_DIR` | `./data` | Database, key vault, logs |\n| `SWITCHBOARD_MASTER_KEY` | generated | Base64 32-byte key for credential encryption |\n| `SWITCHBOARD_ALLOW_REMOTE` | `0` | Allow non-loopback dashboard access |\n| `SWITCHBOARD_PACKAGED` | `0` | Set by the desktop build |\n\nProvider credentials are sealed with **AES-256-GCM** before they touch disk. The master key\nlives in `<data-dir>/master.key` with `0600` permissions. No endpoint returns a stored\ncredential — only a four-character hint.\n\n---\n\n## CLI\n\n```bash\nsb start [--port] [--open] # start the gateway\nsb status # health at a glance\nsb doctor # diagnose the install\nsb providers list # connections with status and spend\nsb providers add groq # prompts for the key, echo off\nsb providers test <id> # force a probe\nsb models [--free] # available models with pricing\nsb combos # routing policies\nsb keys new <name> # create an API key\nsb chat [--model] # streaming REPL\nsb logs [--errors] # recent requests\nsb usage [--days 7] # spend summary\nsb open # open the dashboard\n```\n\n---\n\n## Desktop app\n\n```bash\nnpm run electron:build:win # or :mac / :linux\n```\n\nProduces an installer in `release/`. The desktop build runs the gateway as a child process,\nwrites to a real app-data directory, and lives in the system tray with start-at-login,\ncopy-endpoint and restart controls.\n\nIcons go in `electron/assets/icon.{ico,icns,png}` — the build works without them.\n\n---\n\n## Development\n\n```bash\nnpm run dev # dev server on :7272\nnpm run typecheck # tsc --noEmit, strict\nnpm run build # production build\nnpm run electron:dev # desktop shell against the dev server\n```\n\nRequires **Node 22.13+** — `node:sqlite` appeared in 22.5 but was flag-gated until 22.13.\nBuilt with Next.js 16, React 19,\nTypeScript 5.9 in strict mode with `noUncheckedIndexedAccess`, and Tailwind CSS v4.\n\nAdding a provider is a single entry in `src/lib/providers/catalog.ts` when it speaks the\nOpenAI wire format — see [docs/development.md](docs/development.md).\n\nConventions for humans and agents are in [CLAUDE.md](CLAUDE.md) and [AGENTS.md](AGENTS.md);\ncontribution workflow in [CONTRIBUTING.md](CONTRIBUTING.md).\n\n### Tests\n\n```bash\nnpm run test:run # ~700 tests\nnpm run test:ci # with coverage\n```\n\nVitest, running on every CI leg. The suite covers the parts where a silent bug costs money\nor loses a request: the scoring model (100%), cost and free-tier settlement (100%), the\nadapter helpers that classify every upstream error (99%), candidate expansion and exclusion\n(95%), the circuit breaker's full state machine, the credential vault, and an integration\nlayer that drives the real route handlers against a stubbed provider — including the\nfallback walk itself.\n\nTests assert the behaviour the code's comments claim, so the deliberate decisions\n(`bad_request` never retries, a started stream is never retried, free tiers settle at $0)\nfail loudly if anyone \"simplifies\" them away.\n\n### Releases\n\n`main` is the development line. Every version is also cut as a `release/vX.Y.Z` branch with\na matching tag and GitHub release, so you can pin to one and keep receiving fixes for that\nline without inheriting whatever landed on `main` today.\n\nBumping `version` in `package.json` on `main` triggers it: CI typechecks, builds, boots the\ngateway, verifies the publish tarball carries no database or key, then branches, tags and\npublishes the release.\n\n### Status\n\nPre-1.0 and honest about it. An external audit put it at **7.2/10** — strong on\narchitecture, resilience and type safety, with test coverage scored 0/10 as the single\nlargest gap.\n\nThat gap is now closed for the routing core: ~700 tests covering scoring, cost settlement,\ncandidate expansion, the breaker, the vault, the adapter error taxonomy, and the fallback\nwalk end to end. Writing them surfaced a real bug — a non-ASCII character in a provider's\nerror message crashed the response instead of returning the client's error, which any\nnon-English upstream message would have triggered.\n\nStill open: no tests for the DB repositories, the management API or the dashboard; rate\nlimiting is in-memory and resets on restart; there is no structured logging. Do not run it\nexposed to the internet without a reverse proxy and auth in front.\n\n---\n\n## License\n\nMIT © 2026 Abeer Meer\n"}, "df76ee5168663321934a3f73244e24e5c655363e": {"t": 1785771480.99789, "n": 13166, "h": "1324005be98bcf7b", "c": "# Switchboard\n\n[![CI](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml/badge.svg)](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n[![Node](https://img.shields.io/badge/node-%E2%89%A522.13-3ecf8e.svg)](https://nodejs.org)\n\n**One endpoint. Every provider. Free tiers first, automatic fallback, everything local.**\n\nSwitchboard is an AI gateway that runs on your own machine. Point any OpenAI-compatible\ntool at it and it decides — per request — which provider and model should serve the call,\npreferring free tiers, routing around outages, and recording exactly why it chose what it\nchose.\n\n```\nyour tools ──▶ http://127.0.0.1:7272/v1 ──▶ Groq · Cerebras · Google · OpenRouter\n DeepSeek · Mistral · OpenAI · Anthropic …\n```\n\nNothing leaves your machine except the upstream provider calls themselves. No telemetry,\nno account, no cloud control plane.\n\n---\n\n## Demo\n\n[![Switchboard — every candidate, every factor](brag-output/brag.jpg)](https://github.com/abeermeer/switchboard/releases/download/v0.1.0/brag.mp4)\n\n**[▶ Watch the 20-second walkthrough](https://github.com/abeermeer/switchboard/releases/download/v0.1.0/brag.mp4)**\n\nA provider 429s mid-request, the fallback lands on a different one, and the decision trace\nexplains exactly why the replacement won. Every number on screen is real output from a\nrunning instance — the response headers, the four candidate scores, the factor notes, and\nthe exclusion reason.\n\nThat still above is the routing decision for one request: four candidates ranked, the\nwinner in copper, the price spread across providers serving the same class of model, and\n`17 eligible · 5 excluded`. Every other gateway gives you a log line; this is the part\nworth showing.\n\n---\n\n## Why\n\nRunning six provider SDKs across four tools means six sets of keys, six failure modes, and\nno idea what any of it costs. Switchboard collapses that into one endpoint with one key,\nand answers three questions the raw SDKs cannot:\n\n- **Which provider should serve this?** Scored live on health, latency, price and tier.\n- **What happens when it is down?** The next candidate takes it, transparently.\n- **What is this actually costing me?** Per request, per model, per key, against a\n frontier-pricing baseline.\n\n---\n\n## Quickstart\n\n**1) Install and run**\n\n```bash\nnpm install -g switchboard\nswitchboard\n```\n\nThat's it. The dashboard is on **http://localhost:7272**, the gateway on\n**http://localhost:7272/v1**.\n\nYour database and encryption key live in your OS application-data directory\n(`%APPDATA%\\Switchboard`, `~/Library/Application Support/Switchboard`, or\n`~/.local/share/Switchboard`), so upgrading with `npm update -g switchboard` never touches\nthem.\n\n**2) Connect a free provider**\n\nOpen the dashboard and pick one. Groq, Cerebras and Google all give real free tiers with\nno payment method. Or from the terminal:\n\n```bash\nsb providers add groq # prompts for the key, echo off\n```\n\n**3) Create a key and point a tool at it**\n\n```bash\nsb keys new \"my laptop\"\n```\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n<details>\n<summary><b>Other ways to run it</b></summary>\n\n**From source**\n\n```bash\ngit clone https://github.com/abeermeer/switchboard.git\ncd switchboard\nnpm install\nnpm run build\nnpm start\n```\n\n**Docker**\n\n```bash\ndocker build -t switchboard .\ndocker run -d -p 7272:7272 -v switchboard-data:/data switchboard\n```\n\n**Desktop app** — `npm run electron:build:win` (or `:mac` / `:linux`) puts an installer in\n`release/`. Runs in the tray with start-at-login.\n\n**Pin to a release line** — every version is branched, so you can track one:\n\n```bash\nnpm install -g switchboard@0.1.0\n```\n\n</details>\n\nThen point something at it:\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n`\"model\": \"auto\"` hands the decision to the router. You can also send a bare model id\n(`llama-3.3-70b-versatile`), a pinned provider (`groq/llama-3.3-70b-versatile`), or a\npolicy slug you have defined.\n\n---\n\n## Point your tools at it\n\n**Claude Code**\n\n```bash\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:7272/v1\"\nexport ANTHROPIC_API_KEY=\"sb-live-your-key\"\n```\n\n**Cursor** — Settings → Models → Override OpenAI Base URL\n\n```\nBase URL: http://127.0.0.1:7272/v1\nAPI Key: sb-live-your-key\nModel: auto\n```\n\n**OpenAI SDK (Python)**\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(base_url=\"http://127.0.0.1:7272/v1\", api_key=\"sb-live-your-key\")\nresponse = client.chat.completions.create(\n model=\"auto\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\n```\n\n**OpenAI SDK (Node)**\n\n```js\nimport OpenAI from 'openai';\n\nconst client = new OpenAI({\n baseURL: 'http://127.0.0.1:7272/v1',\n apiKey: 'sb-live-your-key',\n});\n```\n\n**Cline / Continue / Aider** — any \"OpenAI-compatible endpoint\" field takes the same base\nURL and key.\n\n---\n\n## Endpoints\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `POST` | `/v1/chat/completions` | Streaming and non-streaming |\n| `POST` | `/v1/completions` | Legacy, shimmed onto chat |\n| `POST` | `/v1/embeddings` | |\n| `POST` | `/v1/images/generations` | |\n| `POST` | `/v1/audio/speech` | Returns audio bytes |\n| `POST` | `/v1/audio/transcriptions` | Multipart upload |\n| `POST` | `/v1/rerank` | |\n| `POST` | `/v1/moderations` | |\n| `GET` | `/v1/models` | Every model across every connected provider |\n| `GET` | `/v1/models/{model}` | |\n\nEvery response carries routing telemetry, so you can debug from curl alone:\n\n```\nx-switchboard-request-id: req_6751a555d0264a6aa7e0\nx-switchboard-provider: groq\nx-switchboard-model: llama-3.3-70b-versatile\nx-switchboard-tier: free\nx-switchboard-attempts: 2\nx-switchboard-cost-usd: 0.000083\nx-switchboard-ttft-ms: 340\nx-switchboard-fallback-reason: Rate limited (429), retry after 12s\n```\n\n`x-switchboard-fallback-reason` only appears when a fallback actually happened, so its\npresence is itself the signal.\n\n---\n\n## Routing\n\nA **policy** (called a combo in the API) is a model name your clients can send. Four ship\nby default:\n\n| Slug | Strategy | Optimises for |\n| --- | --- | --- |\n| `auto` | free-first | Exhaust free tiers before spending anything |\n| `free-only` | free-first, $0 ceiling | Never spends money, fails instead |\n| `fast` | fastest | Lowest observed time to first token |\n| `quality` | quality-first | Strongest model available, price ignored |\n\nSeven strategies are available:\n\n| Strategy | Behaviour |\n| --- | --- |\n| `free-first` | Free tier, then cheap, then paid. The default. |\n| `cost-optimized` | Lowest projected USD for this specific request. |\n| `fastest` | Lowest measured p50 time-to-first-token. |\n| `quality-first` | Inverts the cost factor — price becomes a quality signal. |\n| `priority` | Strict chain order. Predictable, ignores live health. |\n| `round-robin` | Spreads load across healthy members to stretch rate limits. |\n| `failover` | First member always; others only on outright failure. |\n\nEvery candidate is scored on eight factors (health, latency, cost, tier, priority, quota,\nthroughput, stability), weighted per strategy. The weights are visible in the dashboard,\nand the full arithmetic is stored with every request.\n\n### The simulator\n\nThe routing page runs the exact expand-and-score path a live request takes, but stops\nbefore sending anything upstream. Drag a member, move the cost ceiling, and watch the\nranking reshuffle — including which candidates were excluded and for what reason.\n\n---\n\n## Architecture\n\n```mermaid\nflowchart LR\n A[Client] --> B[/v1 endpoint/]\n B --> C[Authenticate<br/>key · rate limit · budget]\n C --> D[Expand candidates<br/>connections × models]\n D --> E[Filter<br/>health · context · features · budget]\n E --> F[Score<br/>8 weighted factors]\n F --> G[Execute]\n G -->|success| H[Log + usage rollup]\n G -->|failure| I{Retryable?}\n I -->|yes| G\n I -->|no| H\n H --> A\n```\n\n| Layer | Location |\n| --- | --- |\n| Domain contract | `src/types/core.ts` |\n| Persistence | `src/lib/db/` — `node:sqlite`, WAL, append-only migrations |\n| Providers | `src/lib/providers/` — catalog + 4 wire adapters |\n| Resilience | `src/lib/resilience/`, `src/lib/health/` |\n| Router | `src/lib/router/` — candidates → score → execute |\n| Gateway API | `src/app/v1/` |\n| Management API | `src/app/api/` |\n| Dashboard | `src/app/(dashboard)/`, `src/components/` |\n\nStorage is Node's built-in `node:sqlite` — no native module, no `node-gyp`, no prebuilt\nbinary roulette. `npm install` works the same on Windows as it does on Linux.\n\n---\n\n## Resilience\n\nThree independent layers, so one failing provider never becomes a failing gateway:\n\n1. **Circuit breaker**, per connection. Opens after N consecutive failures — immediately\n on a `401`, since a bad credential will not fix itself. Cooldown doubles on repeat\n opens, capped at 15 minutes, with jitter so recovering providers are not stampeded.\n2. **Model lockout**, per model. A `429` locks out that one model rather than the whole\n connection, honouring `Retry-After` when the provider sends it.\n3. **Fallback walk**, per request. Up to `maxAttempts` candidates, stopping early when\n retrying elsewhere cannot help — a malformed request produces the same `400` on every\n provider.\n\nA streaming response that has already sent its first byte is **never** retried. Once bytes\nare on the wire the client has committed, and a silent provider switch mid-stream would\ncorrupt the response.\n\n---\n\n## Configuration\n\nEverything is optional; sane defaults are generated on first run.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `PORT` | `7272` | Gateway and dashboard |\n| `SWITCHBOARD_DATA_DIR` | `./data` | Database, key vault, logs |\n| `SWITCHBOARD_MASTER_KEY` | generated | Base64 32-byte key for credential encryption |\n| `SWITCHBOARD_ALLOW_REMOTE` | `0` | Allow non-loopback dashboard access |\n| `SWITCHBOARD_PACKAGED` | `0` | Set by the desktop build |\n\nProvider credentials are sealed with **AES-256-GCM** before they touch disk. The master key\nlives in `<data-dir>/master.key` with `0600` permissions. No endpoint returns a stored\ncredential — only a four-character hint.\n\n---\n\n## CLI\n\n```bash\nsb start [--port] [--open] # start the gateway\nsb status # health at a glance\nsb doctor # diagnose the install\nsb providers list # connections with status and spend\nsb providers add groq # prompts for the key, echo off\nsb providers test <id> # force a probe\nsb models [--free] # available models with pricing\nsb combos # routing policies\nsb keys new <name> # create an API key\nsb chat [--model] # streaming REPL\nsb logs [--errors] # recent requests\nsb usage [--days 7] # spend summary\nsb open # open the dashboard\n```\n\n---\n\n## Desktop app\n\n```bash\nnpm run electron:build:win # or :mac / :linux\n```\n\nProduces an installer in `release/`. The desktop build runs the gateway as a child process,\nwrites to a real app-data directory, and lives in the system tray with start-at-login,\ncopy-endpoint and restart controls.\n\nIcons go in `electron/assets/icon.{ico,icns,png}` — the build works without them.\n\n---\n\n## Development\n\n```bash\nnpm run dev # dev server on :7272\nnpm run typecheck # tsc --noEmit, strict\nnpm run build # production build\nnpm run electron:dev # desktop shell against the dev server\n```\n\nRequires **Node 22.13+** — `node:sqlite` appeared in 22.5 but was flag-gated until 22.13.\nBuilt with Next.js 16, React 19,\nTypeScript 5.9 in strict mode with `noUncheckedIndexedAccess`, and Tailwind CSS v4.\n\nAdding a provider is a single entry in `src/lib/providers/catalog.ts` when it speaks the\nOpenAI wire format — see [docs/development.md](docs/development.md).\n\nConventions for humans and agents are in [CLAUDE.md](CLAUDE.md) and [AGENTS.md](AGENTS.md);\ncontribution workflow in [CONTRIBUTING.md](CONTRIBUTING.md).\n\n### Releases\n\n`main` is the development line. Every version is also cut as a `release/vX.Y.Z` branch with\na matching tag and GitHub release, so you can pin to one and keep receiving fixes for that\nline without inheriting whatever landed on `main` today.\n\nBumping `version` in `package.json` on `main` triggers it: CI typechecks, builds, boots the\ngateway, verifies the publish tarball carries no database or key, then branches, tags and\npublishes the release.\n\n### Status\n\nPre-1.0 and honest about it. An external audit put it at **7.2/10** — strong on\narchitecture, resilience and type safety; the standing gaps are **no test suite**,\nin-memory rate limiting, and no structured logging. Those are tracked and being worked\nthrough. Do not run it exposed to the internet without a reverse proxy and auth in front.\n\n---\n\n## License\n\nMIT © 2026 Abeer Meer\n"}, "efa5681c9547834368204b362454727bcf4171b4": {"t": 1785771482.7113414, "n": 12683, "h": "005d40c5e9a47721", "c": "# Switchboard\n\n[![CI](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml/badge.svg)](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n[![Node](https://img.shields.io/badge/node-%E2%89%A522.13-3ecf8e.svg)](https://nodejs.org)\n\n**One endpoint. Every provider. Free tiers first, automatic fallback, everything local.**\n\nSwitchboard is an AI gateway that runs on your own machine. Point any OpenAI-compatible\ntool at it and it decides — per request — which provider and model should serve the call,\npreferring free tiers, routing around outages, and recording exactly why it chose what it\nchose.\n\n```\nyour tools ──▶ http://127.0.0.1:7272/v1 ──▶ Groq · Cerebras · Google · OpenRouter\n DeepSeek · Mistral · OpenAI · Anthropic …\n```\n\nNothing leaves your machine except the upstream provider calls themselves. No telemetry,\nno account, no cloud control plane.\n\n---\n\n## Demo\n\n[![Switchboard — every candidate, every factor](brag-output/brag.jpg)](brag-output/brag.mp4)\n\n**[▶ Watch the 20-second walkthrough](brag-output/brag.mp4)** — a provider 429s mid-request,\nthe fallback lands on a different one, and the decision trace explains exactly why the\nreplacement won. Every number in it is real output from a running instance.\n\n---\n\n## Why\n\nRunning six provider SDKs across four tools means six sets of keys, six failure modes, and\nno idea what any of it costs. Switchboard collapses that into one endpoint with one key,\nand answers three questions the raw SDKs cannot:\n\n- **Which provider should serve this?** Scored live on health, latency, price and tier.\n- **What happens when it is down?** The next candidate takes it, transparently.\n- **What is this actually costing me?** Per request, per model, per key, against a\n frontier-pricing baseline.\n\n---\n\n## Quickstart\n\n**1) Install and run**\n\n```bash\nnpm install -g switchboard\nswitchboard\n```\n\nThat's it. The dashboard is on **http://localhost:7272**, the gateway on\n**http://localhost:7272/v1**.\n\nYour database and encryption key live in your OS application-data directory\n(`%APPDATA%\\Switchboard`, `~/Library/Application Support/Switchboard`, or\n`~/.local/share/Switchboard`), so upgrading with `npm update -g switchboard` never touches\nthem.\n\n**2) Connect a free provider**\n\nOpen the dashboard and pick one. Groq, Cerebras and Google all give real free tiers with\nno payment method. Or from the terminal:\n\n```bash\nsb providers add groq # prompts for the key, echo off\n```\n\n**3) Create a key and point a tool at it**\n\n```bash\nsb keys new \"my laptop\"\n```\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n<details>\n<summary><b>Other ways to run it</b></summary>\n\n**From source**\n\n```bash\ngit clone https://github.com/abeermeer/switchboard.git\ncd switchboard\nnpm install\nnpm run build\nnpm start\n```\n\n**Docker**\n\n```bash\ndocker build -t switchboard .\ndocker run -d -p 7272:7272 -v switchboard-data:/data switchboard\n```\n\n**Desktop app** — `npm run electron:build:win` (or `:mac` / `:linux`) puts an installer in\n`release/`. Runs in the tray with start-at-login.\n\n**Pin to a release line** — every version is branched, so you can track one:\n\n```bash\nnpm install -g switchboard@0.1.0\n```\n\n</details>\n\nThen point something at it:\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n`\"model\": \"auto\"` hands the decision to the router. You can also send a bare model id\n(`llama-3.3-70b-versatile`), a pinned provider (`groq/llama-3.3-70b-versatile`), or a\npolicy slug you have defined.\n\n---\n\n## Point your tools at it\n\n**Claude Code**\n\n```bash\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:7272/v1\"\nexport ANTHROPIC_API_KEY=\"sb-live-your-key\"\n```\n\n**Cursor** — Settings → Models → Override OpenAI Base URL\n\n```\nBase URL: http://127.0.0.1:7272/v1\nAPI Key: sb-live-your-key\nModel: auto\n```\n\n**OpenAI SDK (Python)**\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(base_url=\"http://127.0.0.1:7272/v1\", api_key=\"sb-live-your-key\")\nresponse = client.chat.completions.create(\n model=\"auto\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\n```\n\n**OpenAI SDK (Node)**\n\n```js\nimport OpenAI from 'openai';\n\nconst client = new OpenAI({\n baseURL: 'http://127.0.0.1:7272/v1',\n apiKey: 'sb-live-your-key',\n});\n```\n\n**Cline / Continue / Aider** — any \"OpenAI-compatible endpoint\" field takes the same base\nURL and key.\n\n---\n\n## Endpoints\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `POST` | `/v1/chat/completions` | Streaming and non-streaming |\n| `POST` | `/v1/completions` | Legacy, shimmed onto chat |\n| `POST` | `/v1/embeddings` | |\n| `POST` | `/v1/images/generations` | |\n| `POST` | `/v1/audio/speech` | Returns audio bytes |\n| `POST` | `/v1/audio/transcriptions` | Multipart upload |\n| `POST` | `/v1/rerank` | |\n| `POST` | `/v1/moderations` | |\n| `GET` | `/v1/models` | Every model across every connected provider |\n| `GET` | `/v1/models/{model}` | |\n\nEvery response carries routing telemetry, so you can debug from curl alone:\n\n```\nx-switchboard-request-id: req_6751a555d0264a6aa7e0\nx-switchboard-provider: groq\nx-switchboard-model: llama-3.3-70b-versatile\nx-switchboard-tier: free\nx-switchboard-attempts: 2\nx-switchboard-cost-usd: 0.000083\nx-switchboard-ttft-ms: 340\nx-switchboard-fallback-reason: Rate limited (429), retry after 12s\n```\n\n`x-switchboard-fallback-reason` only appears when a fallback actually happened, so its\npresence is itself the signal.\n\n---\n\n## Routing\n\nA **policy** (called a combo in the API) is a model name your clients can send. Four ship\nby default:\n\n| Slug | Strategy | Optimises for |\n| --- | --- | --- |\n| `auto` | free-first | Exhaust free tiers before spending anything |\n| `free-only` | free-first, $0 ceiling | Never spends money, fails instead |\n| `fast` | fastest | Lowest observed time to first token |\n| `quality` | quality-first | Strongest model available, price ignored |\n\nSeven strategies are available:\n\n| Strategy | Behaviour |\n| --- | --- |\n| `free-first` | Free tier, then cheap, then paid. The default. |\n| `cost-optimized` | Lowest projected USD for this specific request. |\n| `fastest` | Lowest measured p50 time-to-first-token. |\n| `quality-first` | Inverts the cost factor — price becomes a quality signal. |\n| `priority` | Strict chain order. Predictable, ignores live health. |\n| `round-robin` | Spreads load across healthy members to stretch rate limits. |\n| `failover` | First member always; others only on outright failure. |\n\nEvery candidate is scored on eight factors (health, latency, cost, tier, priority, quota,\nthroughput, stability), weighted per strategy. The weights are visible in the dashboard,\nand the full arithmetic is stored with every request.\n\n### The simulator\n\nThe routing page runs the exact expand-and-score path a live request takes, but stops\nbefore sending anything upstream. Drag a member, move the cost ceiling, and watch the\nranking reshuffle — including which candidates were excluded and for what reason.\n\n---\n\n## Architecture\n\n```mermaid\nflowchart LR\n A[Client] --> B[/v1 endpoint/]\n B --> C[Authenticate<br/>key · rate limit · budget]\n C --> D[Expand candidates<br/>connections × models]\n D --> E[Filter<br/>health · context · features · budget]\n E --> F[Score<br/>8 weighted factors]\n F --> G[Execute]\n G -->|success| H[Log + usage rollup]\n G -->|failure| I{Retryable?}\n I -->|yes| G\n I -->|no| H\n H --> A\n```\n\n| Layer | Location |\n| --- | --- |\n| Domain contract | `src/types/core.ts` |\n| Persistence | `src/lib/db/` — `node:sqlite`, WAL, append-only migrations |\n| Providers | `src/lib/providers/` — catalog + 4 wire adapters |\n| Resilience | `src/lib/resilience/`, `src/lib/health/` |\n| Router | `src/lib/router/` — candidates → score → execute |\n| Gateway API | `src/app/v1/` |\n| Management API | `src/app/api/` |\n| Dashboard | `src/app/(dashboard)/`, `src/components/` |\n\nStorage is Node's built-in `node:sqlite` — no native module, no `node-gyp`, no prebuilt\nbinary roulette. `npm install` works the same on Windows as it does on Linux.\n\n---\n\n## Resilience\n\nThree independent layers, so one failing provider never becomes a failing gateway:\n\n1. **Circuit breaker**, per connection. Opens after N consecutive failures — immediately\n on a `401`, since a bad credential will not fix itself. Cooldown doubles on repeat\n opens, capped at 15 minutes, with jitter so recovering providers are not stampeded.\n2. **Model lockout**, per model. A `429` locks out that one model rather than the whole\n connection, honouring `Retry-After` when the provider sends it.\n3. **Fallback walk**, per request. Up to `maxAttempts` candidates, stopping early when\n retrying elsewhere cannot help — a malformed request produces the same `400` on every\n provider.\n\nA streaming response that has already sent its first byte is **never** retried. Once bytes\nare on the wire the client has committed, and a silent provider switch mid-stream would\ncorrupt the response.\n\n---\n\n## Configuration\n\nEverything is optional; sane defaults are generated on first run.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `PORT` | `7272` | Gateway and dashboard |\n| `SWITCHBOARD_DATA_DIR` | `./data` | Database, key vault, logs |\n| `SWITCHBOARD_MASTER_KEY` | generated | Base64 32-byte key for credential encryption |\n| `SWITCHBOARD_ALLOW_REMOTE` | `0` | Allow non-loopback dashboard access |\n| `SWITCHBOARD_PACKAGED` | `0` | Set by the desktop build |\n\nProvider credentials are sealed with **AES-256-GCM** before they touch disk. The master key\nlives in `<data-dir>/master.key` with `0600` permissions. No endpoint returns a stored\ncredential — only a four-character hint.\n\n---\n\n## CLI\n\n```bash\nsb start [--port] [--open] # start the gateway\nsb status # health at a glance\nsb doctor # diagnose the install\nsb providers list # connections with status and spend\nsb providers add groq # prompts for the key, echo off\nsb providers test <id> # force a probe\nsb models [--free] # available models with pricing\nsb combos # routing policies\nsb keys new <name> # create an API key\nsb chat [--model] # streaming REPL\nsb logs [--errors] # recent requests\nsb usage [--days 7] # spend summary\nsb open # open the dashboard\n```\n\n---\n\n## Desktop app\n\n```bash\nnpm run electron:build:win # or :mac / :linux\n```\n\nProduces an installer in `release/`. The desktop build runs the gateway as a child process,\nwrites to a real app-data directory, and lives in the system tray with start-at-login,\ncopy-endpoint and restart controls.\n\nIcons go in `electron/assets/icon.{ico,icns,png}` — the build works without them.\n\n---\n\n## Development\n\n```bash\nnpm run dev # dev server on :7272\nnpm run typecheck # tsc --noEmit, strict\nnpm run build # production build\nnpm run electron:dev # desktop shell against the dev server\n```\n\nRequires **Node 22.13+** — `node:sqlite` appeared in 22.5 but was flag-gated until 22.13.\nBuilt with Next.js 16, React 19,\nTypeScript 5.9 in strict mode with `noUncheckedIndexedAccess`, and Tailwind CSS v4.\n\nAdding a provider is a single entry in `src/lib/providers/catalog.ts` when it speaks the\nOpenAI wire format — see [docs/development.md](docs/development.md).\n\nConventions for humans and agents are in [CLAUDE.md](CLAUDE.md) and [AGENTS.md](AGENTS.md);\ncontribution workflow in [CONTRIBUTING.md](CONTRIBUTING.md).\n\n### Releases\n\n`main` is the development line. Every version is also cut as a `release/vX.Y.Z` branch with\na matching tag and GitHub release, so you can pin to one and keep receiving fixes for that\nline without inheriting whatever landed on `main` today.\n\nBumping `version` in `package.json` on `main` triggers it: CI typechecks, builds, boots the\ngateway, verifies the publish tarball carries no database or key, then branches, tags and\npublishes the release.\n\n### Status\n\nPre-1.0 and honest about it. An external audit put it at **7.2/10** — strong on\narchitecture, resilience and type safety; the standing gaps are **no test suite**,\nin-memory rate limiting, and no structured logging. Those are tracked and being worked\nthrough. Do not run it exposed to the internet without a reverse proxy and auth in front.\n\n---\n\n## License\n\nMIT © 2026 Abeer Meer\n"}, "8d6f538443888810e99769813ce46dfbe9b79407": {"t": 1785771484.134396, "n": 10468, "h": "46ee7074900eccbc", "c": "# Switchboard\n\n[![CI](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml/badge.svg)](https://github.com/abeermeer/switchboard/actions/workflows/ci.yml)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n[![Node](https://img.shields.io/badge/node-%E2%89%A522.13-3ecf8e.svg)](https://nodejs.org)\n\n**One endpoint. Every provider. Free tiers first, automatic fallback, everything local.**\n\nSwitchboard is an AI gateway that runs on your own machine. Point any OpenAI-compatible\ntool at it and it decides — per request — which provider and model should serve the call,\npreferring free tiers, routing around outages, and recording exactly why it chose what it\nchose.\n\n```\nyour tools ──▶ http://127.0.0.1:7272/v1 ──▶ Groq · Cerebras · Google · OpenRouter\n DeepSeek · Mistral · OpenAI · Anthropic …\n```\n\nNothing leaves your machine except the upstream provider calls themselves. No telemetry,\nno account, no cloud control plane.\n\n---\n\n## Demo\n\n[![Switchboard — every candidate, every factor](brag-output/brag.jpg)](brag-output/brag.mp4)\n\n**[▶ Watch the 20-second walkthrough](brag-output/brag.mp4)** — a provider 429s mid-request,\nthe fallback lands on a different one, and the decision trace explains exactly why the\nreplacement won. Every number in it is real output from a running instance.\n\n---\n\n## Why\n\nRunning six provider SDKs across four tools means six sets of keys, six failure modes, and\nno idea what any of it costs. Switchboard collapses that into one endpoint with one key,\nand answers three questions the raw SDKs cannot:\n\n- **Which provider should serve this?** Scored live on health, latency, price and tier.\n- **What happens when it is down?** The next candidate takes it, transparently.\n- **What is this actually costing me?** Per request, per model, per key, against a\n frontier-pricing baseline.\n\n---\n\n## Quickstart\n\n```bash\nnpm install\nnpm run build\nnpm start\n```\n\nOpen **http://localhost:7272** — the dashboard walks you through connecting a provider\nand creating a key.\n\nOr from the CLI:\n\n```bash\nnode bin/sb.mjs start --open\n```\n\nThen connect a free provider (Groq, Cerebras and Google all give real free tiers with no\ncard), create an API key, and point something at it:\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n`\"model\": \"auto\"` hands the decision to the router. You can also send a bare model id\n(`llama-3.3-70b-versatile`), a pinned provider (`groq/llama-3.3-70b-versatile`), or a\npolicy slug you have defined.\n\n---\n\n## Point your tools at it\n\n**Claude Code**\n\n```bash\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:7272/v1\"\nexport ANTHROPIC_API_KEY=\"sb-live-your-key\"\n```\n\n**Cursor** — Settings → Models → Override OpenAI Base URL\n\n```\nBase URL: http://127.0.0.1:7272/v1\nAPI Key: sb-live-your-key\nModel: auto\n```\n\n**OpenAI SDK (Python)**\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(base_url=\"http://127.0.0.1:7272/v1\", api_key=\"sb-live-your-key\")\nresponse = client.chat.completions.create(\n model=\"auto\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\n```\n\n**OpenAI SDK (Node)**\n\n```js\nimport OpenAI from 'openai';\n\nconst client = new OpenAI({\n baseURL: 'http://127.0.0.1:7272/v1',\n apiKey: 'sb-live-your-key',\n});\n```\n\n**Cline / Continue / Aider** — any \"OpenAI-compatible endpoint\" field takes the same base\nURL and key.\n\n---\n\n## Endpoints\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `POST` | `/v1/chat/completions` | Streaming and non-streaming |\n| `POST` | `/v1/completions` | Legacy, shimmed onto chat |\n| `POST` | `/v1/embeddings` | |\n| `POST` | `/v1/images/generations` | |\n| `POST` | `/v1/audio/speech` | Returns audio bytes |\n| `POST` | `/v1/audio/transcriptions` | Multipart upload |\n| `POST` | `/v1/rerank` | |\n| `POST` | `/v1/moderations` | |\n| `GET` | `/v1/models` | Every model across every connected provider |\n| `GET` | `/v1/models/{model}` | |\n\nEvery response carries routing telemetry, so you can debug from curl alone:\n\n```\nx-switchboard-request-id: req_6751a555d0264a6aa7e0\nx-switchboard-provider: groq\nx-switchboard-model: llama-3.3-70b-versatile\nx-switchboard-tier: free\nx-switchboard-attempts: 2\nx-switchboard-cost-usd: 0.000083\nx-switchboard-ttft-ms: 340\nx-switchboard-fallback-reason: Rate limited (429), retry after 12s\n```\n\n`x-switchboard-fallback-reason` only appears when a fallback actually happened, so its\npresence is itself the signal.\n\n---\n\n## Routing\n\nA **policy** (called a combo in the API) is a model name your clients can send. Four ship\nby default:\n\n| Slug | Strategy | Optimises for |\n| --- | --- | --- |\n| `auto` | free-first | Exhaust free tiers before spending anything |\n| `free-only` | free-first, $0 ceiling | Never spends money, fails instead |\n| `fast` | fastest | Lowest observed time to first token |\n| `quality` | quality-first | Strongest model available, price ignored |\n\nSeven strategies are available:\n\n| Strategy | Behaviour |\n| --- | --- |\n| `free-first` | Free tier, then cheap, then paid. The default. |\n| `cost-optimized` | Lowest projected USD for this specific request. |\n| `fastest` | Lowest measured p50 time-to-first-token. |\n| `quality-first` | Inverts the cost factor — price becomes a quality signal. |\n| `priority` | Strict chain order. Predictable, ignores live health. |\n| `round-robin` | Spreads load across healthy members to stretch rate limits. |\n| `failover` | First member always; others only on outright failure. |\n\nEvery candidate is scored on eight factors (health, latency, cost, tier, priority, quota,\nthroughput, stability), weighted per strategy. The weights are visible in the dashboard,\nand the full arithmetic is stored with every request.\n\n### The simulator\n\nThe routing page runs the exact expand-and-score path a live request takes, but stops\nbefore sending anything upstream. Drag a member, move the cost ceiling, and watch the\nranking reshuffle — including which candidates were excluded and for what reason.\n\n---\n\n## Architecture\n\n```mermaid\nflowchart LR\n A[Client] --> B[/v1 endpoint/]\n B --> C[Authenticate<br/>key · rate limit · budget]\n C --> D[Expand candidates<br/>connections × models]\n D --> E[Filter<br/>health · context · features · budget]\n E --> F[Score<br/>8 weighted factors]\n F --> G[Execute]\n G -->|success| H[Log + usage rollup]\n G -->|failure| I{Retryable?}\n I -->|yes| G\n I -->|no| H\n H --> A\n```\n\n| Layer | Location |\n| --- | --- |\n| Domain contract | `src/types/core.ts` |\n| Persistence | `src/lib/db/` — `node:sqlite`, WAL, append-only migrations |\n| Providers | `src/lib/providers/` — catalog + 4 wire adapters |\n| Resilience | `src/lib/resilience/`, `src/lib/health/` |\n| Router | `src/lib/router/` — candidates → score → execute |\n| Gateway API | `src/app/v1/` |\n| Management API | `src/app/api/` |\n| Dashboard | `src/app/(dashboard)/`, `src/components/` |\n\nStorage is Node's built-in `node:sqlite` — no native module, no `node-gyp`, no prebuilt\nbinary roulette. `npm install` works the same on Windows as it does on Linux.\n\n---\n\n## Resilience\n\nThree independent layers, so one failing provider never becomes a failing gateway:\n\n1. **Circuit breaker**, per connection. Opens after N consecutive failures — immediately\n on a `401`, since a bad credential will not fix itself. Cooldown doubles on repeat\n opens, capped at 15 minutes, with jitter so recovering providers are not stampeded.\n2. **Model lockout**, per model. A `429` locks out that one model rather than the whole\n connection, honouring `Retry-After` when the provider sends it.\n3. **Fallback walk**, per request. Up to `maxAttempts` candidates, stopping early when\n retrying elsewhere cannot help — a malformed request produces the same `400` on every\n provider.\n\nA streaming response that has already sent its first byte is **never** retried. Once bytes\nare on the wire the client has committed, and a silent provider switch mid-stream would\ncorrupt the response.\n\n---\n\n## Configuration\n\nEverything is optional; sane defaults are generated on first run.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `PORT` | `7272` | Gateway and dashboard |\n| `SWITCHBOARD_DATA_DIR` | `./data` | Database, key vault, logs |\n| `SWITCHBOARD_MASTER_KEY` | generated | Base64 32-byte key for credential encryption |\n| `SWITCHBOARD_ALLOW_REMOTE` | `0` | Allow non-loopback dashboard access |\n| `SWITCHBOARD_PACKAGED` | `0` | Set by the desktop build |\n\nProvider credentials are sealed with **AES-256-GCM** before they touch disk. The master key\nlives in `<data-dir>/master.key` with `0600` permissions. No endpoint returns a stored\ncredential — only a four-character hint.\n\n---\n\n## CLI\n\n```bash\nsb start [--port] [--open] # start the gateway\nsb status # health at a glance\nsb doctor # diagnose the install\nsb providers list # connections with status and spend\nsb providers add groq # prompts for the key, echo off\nsb providers test <id> # force a probe\nsb models [--free] # available models with pricing\nsb combos # routing policies\nsb keys new <name> # create an API key\nsb chat [--model] # streaming REPL\nsb logs [--errors] # recent requests\nsb usage [--days 7] # spend summary\nsb open # open the dashboard\n```\n\n---\n\n## Desktop app\n\n```bash\nnpm run electron:build:win # or :mac / :linux\n```\n\nProduces an installer in `release/`. The desktop build runs the gateway as a child process,\nwrites to a real app-data directory, and lives in the system tray with start-at-login,\ncopy-endpoint and restart controls.\n\nIcons go in `electron/assets/icon.{ico,icns,png}` — the build works without them.\n\n---\n\n## Development\n\n```bash\nnpm run dev # dev server on :7272\nnpm run typecheck # tsc --noEmit, strict\nnpm run build # production build\nnpm run electron:dev # desktop shell against the dev server\n```\n\nRequires **Node 22.13+** — `node:sqlite` appeared in 22.5 but was flag-gated until 22.13.\nBuilt with Next.js 16, React 19,\nTypeScript 5.9 in strict mode with `noUncheckedIndexedAccess`, and Tailwind CSS v4.\n\nAdding a provider is a single entry in `src/lib/providers/catalog.ts` when it speaks the\nOpenAI wire format — see `docs/development.md`.\n\n---\n\n## License\n\nMIT © 2026 Abeer Meer\n"}, "33e56224ade7414672a15865691b7e5cb688836d": {"t": 1785771485.6583705, "n": 10146, "h": "777108196d2620cd", "c": "# Switchboard\n\n**One endpoint. Every provider. Free tiers first, automatic fallback, everything local.**\n\nSwitchboard is an AI gateway that runs on your own machine. Point any OpenAI-compatible\ntool at it and it decides — per request — which provider and model should serve the call,\npreferring free tiers, routing around outages, and recording exactly why it chose what it\nchose.\n\n```\nyour tools ──▶ http://127.0.0.1:7272/v1 ──▶ Groq · Cerebras · Google · OpenRouter\n DeepSeek · Mistral · OpenAI · Anthropic …\n```\n\nNothing leaves your machine except the upstream provider calls themselves. No telemetry,\nno account, no cloud control plane.\n\n---\n\n## Demo\n\n[![Switchboard — every candidate, every factor](brag-output/brag.jpg)](brag-output/brag.mp4)\n\n**[▶ Watch the 20-second walkthrough](brag-output/brag.mp4)** — a provider 429s mid-request,\nthe fallback lands on a different one, and the decision trace explains exactly why the\nreplacement won. Every number in it is real output from a running instance.\n\n---\n\n## Why\n\nRunning six provider SDKs across four tools means six sets of keys, six failure modes, and\nno idea what any of it costs. Switchboard collapses that into one endpoint with one key,\nand answers three questions the raw SDKs cannot:\n\n- **Which provider should serve this?** Scored live on health, latency, price and tier.\n- **What happens when it is down?** The next candidate takes it, transparently.\n- **What is this actually costing me?** Per request, per model, per key, against a\n frontier-pricing baseline.\n\n---\n\n## Quickstart\n\n```bash\nnpm install\nnpm run build\nnpm start\n```\n\nOpen **http://localhost:7272** — the dashboard walks you through connecting a provider\nand creating a key.\n\nOr from the CLI:\n\n```bash\nnode bin/sb.mjs start --open\n```\n\nThen connect a free provider (Groq, Cerebras and Google all give real free tiers with no\ncard), create an API key, and point something at it:\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n`\"model\": \"auto\"` hands the decision to the router. You can also send a bare model id\n(`llama-3.3-70b-versatile`), a pinned provider (`groq/llama-3.3-70b-versatile`), or a\npolicy slug you have defined.\n\n---\n\n## Point your tools at it\n\n**Claude Code**\n\n```bash\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:7272/v1\"\nexport ANTHROPIC_API_KEY=\"sb-live-your-key\"\n```\n\n**Cursor** — Settings → Models → Override OpenAI Base URL\n\n```\nBase URL: http://127.0.0.1:7272/v1\nAPI Key: sb-live-your-key\nModel: auto\n```\n\n**OpenAI SDK (Python)**\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(base_url=\"http://127.0.0.1:7272/v1\", api_key=\"sb-live-your-key\")\nresponse = client.chat.completions.create(\n model=\"auto\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\n```\n\n**OpenAI SDK (Node)**\n\n```js\nimport OpenAI from 'openai';\n\nconst client = new OpenAI({\n baseURL: 'http://127.0.0.1:7272/v1',\n apiKey: 'sb-live-your-key',\n});\n```\n\n**Cline / Continue / Aider** — any \"OpenAI-compatible endpoint\" field takes the same base\nURL and key.\n\n---\n\n## Endpoints\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `POST` | `/v1/chat/completions` | Streaming and non-streaming |\n| `POST` | `/v1/completions` | Legacy, shimmed onto chat |\n| `POST` | `/v1/embeddings` | |\n| `POST` | `/v1/images/generations` | |\n| `POST` | `/v1/audio/speech` | Returns audio bytes |\n| `POST` | `/v1/audio/transcriptions` | Multipart upload |\n| `POST` | `/v1/rerank` | |\n| `POST` | `/v1/moderations` | |\n| `GET` | `/v1/models` | Every model across every connected provider |\n| `GET` | `/v1/models/{model}` | |\n\nEvery response carries routing telemetry, so you can debug from curl alone:\n\n```\nx-switchboard-request-id: req_6751a555d0264a6aa7e0\nx-switchboard-provider: groq\nx-switchboard-model: llama-3.3-70b-versatile\nx-switchboard-tier: free\nx-switchboard-attempts: 2\nx-switchboard-cost-usd: 0.000083\nx-switchboard-ttft-ms: 340\nx-switchboard-fallback-reason: Rate limited (429), retry after 12s\n```\n\n`x-switchboard-fallback-reason` only appears when a fallback actually happened, so its\npresence is itself the signal.\n\n---\n\n## Routing\n\nA **policy** (called a combo in the API) is a model name your clients can send. Four ship\nby default:\n\n| Slug | Strategy | Optimises for |\n| --- | --- | --- |\n| `auto` | free-first | Exhaust free tiers before spending anything |\n| `free-only` | free-first, $0 ceiling | Never spends money, fails instead |\n| `fast` | fastest | Lowest observed time to first token |\n| `quality` | quality-first | Strongest model available, price ignored |\n\nSeven strategies are available:\n\n| Strategy | Behaviour |\n| --- | --- |\n| `free-first` | Free tier, then cheap, then paid. The default. |\n| `cost-optimized` | Lowest projected USD for this specific request. |\n| `fastest` | Lowest measured p50 time-to-first-token. |\n| `quality-first` | Inverts the cost factor — price becomes a quality signal. |\n| `priority` | Strict chain order. Predictable, ignores live health. |\n| `round-robin` | Spreads load across healthy members to stretch rate limits. |\n| `failover` | First member always; others only on outright failure. |\n\nEvery candidate is scored on eight factors (health, latency, cost, tier, priority, quota,\nthroughput, stability), weighted per strategy. The weights are visible in the dashboard,\nand the full arithmetic is stored with every request.\n\n### The simulator\n\nThe routing page runs the exact expand-and-score path a live request takes, but stops\nbefore sending anything upstream. Drag a member, move the cost ceiling, and watch the\nranking reshuffle — including which candidates were excluded and for what reason.\n\n---\n\n## Architecture\n\n```mermaid\nflowchart LR\n A[Client] --> B[/v1 endpoint/]\n B --> C[Authenticate<br/>key · rate limit · budget]\n C --> D[Expand candidates<br/>connections × models]\n D --> E[Filter<br/>health · context · features · budget]\n E --> F[Score<br/>8 weighted factors]\n F --> G[Execute]\n G -->|success| H[Log + usage rollup]\n G -->|failure| I{Retryable?}\n I -->|yes| G\n I -->|no| H\n H --> A\n```\n\n| Layer | Location |\n| --- | --- |\n| Domain contract | `src/types/core.ts` |\n| Persistence | `src/lib/db/` — `node:sqlite`, WAL, append-only migrations |\n| Providers | `src/lib/providers/` — catalog + 4 wire adapters |\n| Resilience | `src/lib/resilience/`, `src/lib/health/` |\n| Router | `src/lib/router/` — candidates → score → execute |\n| Gateway API | `src/app/v1/` |\n| Management API | `src/app/api/` |\n| Dashboard | `src/app/(dashboard)/`, `src/components/` |\n\nStorage is Node's built-in `node:sqlite` — no native module, no `node-gyp`, no prebuilt\nbinary roulette. `npm install` works the same on Windows as it does on Linux.\n\n---\n\n## Resilience\n\nThree independent layers, so one failing provider never becomes a failing gateway:\n\n1. **Circuit breaker**, per connection. Opens after N consecutive failures — immediately\n on a `401`, since a bad credential will not fix itself. Cooldown doubles on repeat\n opens, capped at 15 minutes, with jitter so recovering providers are not stampeded.\n2. **Model lockout**, per model. A `429` locks out that one model rather than the whole\n connection, honouring `Retry-After` when the provider sends it.\n3. **Fallback walk**, per request. Up to `maxAttempts` candidates, stopping early when\n retrying elsewhere cannot help — a malformed request produces the same `400` on every\n provider.\n\nA streaming response that has already sent its first byte is **never** retried. Once bytes\nare on the wire the client has committed, and a silent provider switch mid-stream would\ncorrupt the response.\n\n---\n\n## Configuration\n\nEverything is optional; sane defaults are generated on first run.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `PORT` | `7272` | Gateway and dashboard |\n| `SWITCHBOARD_DATA_DIR` | `./data` | Database, key vault, logs |\n| `SWITCHBOARD_MASTER_KEY` | generated | Base64 32-byte key for credential encryption |\n| `SWITCHBOARD_ALLOW_REMOTE` | `0` | Allow non-loopback dashboard access |\n| `SWITCHBOARD_PACKAGED` | `0` | Set by the desktop build |\n\nProvider credentials are sealed with **AES-256-GCM** before they touch disk. The master key\nlives in `<data-dir>/master.key` with `0600` permissions. No endpoint returns a stored\ncredential — only a four-character hint.\n\n---\n\n## CLI\n\n```bash\nsb start [--port] [--open] # start the gateway\nsb status # health at a glance\nsb doctor # diagnose the install\nsb providers list # connections with status and spend\nsb providers add groq # prompts for the key, echo off\nsb providers test <id> # force a probe\nsb models [--free] # available models with pricing\nsb combos # routing policies\nsb keys new <name> # create an API key\nsb chat [--model] # streaming REPL\nsb logs [--errors] # recent requests\nsb usage [--days 7] # spend summary\nsb open # open the dashboard\n```\n\n---\n\n## Desktop app\n\n```bash\nnpm run electron:build:win # or :mac / :linux\n```\n\nProduces an installer in `release/`. The desktop build runs the gateway as a child process,\nwrites to a real app-data directory, and lives in the system tray with start-at-login,\ncopy-endpoint and restart controls.\n\nIcons go in `electron/assets/icon.{ico,icns,png}` — the build works without them.\n\n---\n\n## Development\n\n```bash\nnpm run dev # dev server on :7272\nnpm run typecheck # tsc --noEmit, strict\nnpm run build # production build\nnpm run electron:dev # desktop shell against the dev server\n```\n\nRequires **Node 22.13+** — `node:sqlite` appeared in 22.5 but was flag-gated until 22.13.\nBuilt with Next.js 16, React 19,\nTypeScript 5.9 in strict mode with `noUncheckedIndexedAccess`, and Tailwind CSS v4.\n\nAdding a provider is a single entry in `src/lib/providers/catalog.ts` when it speaks the\nOpenAI wire format — see `docs/development.md`.\n\n---\n\n## License\n\nMIT © 2026 Abeer Meer\n"}, "5eb08df300112bcaad221941a7b959b8e5445895": {"t": 1785771487.4956157, "n": 10101, "h": "8b095ada1d1922ab", "c": "# Switchboard\n\n**One endpoint. Every provider. Free tiers first, automatic fallback, everything local.**\n\nSwitchboard is an AI gateway that runs on your own machine. Point any OpenAI-compatible\ntool at it and it decides — per request — which provider and model should serve the call,\npreferring free tiers, routing around outages, and recording exactly why it chose what it\nchose.\n\n```\nyour tools ──▶ http://127.0.0.1:7272/v1 ──▶ Groq · Cerebras · Google · OpenRouter\n DeepSeek · Mistral · OpenAI · Anthropic …\n```\n\nNothing leaves your machine except the upstream provider calls themselves. No telemetry,\nno account, no cloud control plane.\n\n---\n\n## Demo\n\n[![Switchboard — every candidate, every factor](brag-output/brag.jpg)](brag-output/brag.mp4)\n\n**[▶ Watch the 20-second walkthrough](brag-output/brag.mp4)** — a provider 429s mid-request,\nthe fallback lands on a different one, and the decision trace explains exactly why the\nreplacement won. Every number in it is real output from a running instance.\n\n---\n\n## Why\n\nRunning six provider SDKs across four tools means six sets of keys, six failure modes, and\nno idea what any of it costs. Switchboard collapses that into one endpoint with one key,\nand answers three questions the raw SDKs cannot:\n\n- **Which provider should serve this?** Scored live on health, latency, price and tier.\n- **What happens when it is down?** The next candidate takes it, transparently.\n- **What is this actually costing me?** Per request, per model, per key, against a\n frontier-pricing baseline.\n\n---\n\n## Quickstart\n\n```bash\nnpm install\nnpm run build\nnpm start\n```\n\nOpen **http://localhost:7272** — the dashboard walks you through connecting a provider\nand creating a key.\n\nOr from the CLI:\n\n```bash\nnode bin/sb.mjs start --open\n```\n\nThen connect a free provider (Groq, Cerebras and Google all give real free tiers with no\ncard), create an API key, and point something at it:\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n`\"model\": \"auto\"` hands the decision to the router. You can also send a bare model id\n(`llama-3.3-70b-versatile`), a pinned provider (`groq/llama-3.3-70b-versatile`), or a\npolicy slug you have defined.\n\n---\n\n## Point your tools at it\n\n**Claude Code**\n\n```bash\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:7272/v1\"\nexport ANTHROPIC_API_KEY=\"sb-live-your-key\"\n```\n\n**Cursor** — Settings → Models → Override OpenAI Base URL\n\n```\nBase URL: http://127.0.0.1:7272/v1\nAPI Key: sb-live-your-key\nModel: auto\n```\n\n**OpenAI SDK (Python)**\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(base_url=\"http://127.0.0.1:7272/v1\", api_key=\"sb-live-your-key\")\nresponse = client.chat.completions.create(\n model=\"auto\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\n```\n\n**OpenAI SDK (Node)**\n\n```js\nimport OpenAI from 'openai';\n\nconst client = new OpenAI({\n baseURL: 'http://127.0.0.1:7272/v1',\n apiKey: 'sb-live-your-key',\n});\n```\n\n**Cline / Continue / Aider** — any \"OpenAI-compatible endpoint\" field takes the same base\nURL and key.\n\n---\n\n## Endpoints\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `POST` | `/v1/chat/completions` | Streaming and non-streaming |\n| `POST` | `/v1/completions` | Legacy, shimmed onto chat |\n| `POST` | `/v1/embeddings` | |\n| `POST` | `/v1/images/generations` | |\n| `POST` | `/v1/audio/speech` | Returns audio bytes |\n| `POST` | `/v1/audio/transcriptions` | Multipart upload |\n| `POST` | `/v1/rerank` | |\n| `POST` | `/v1/moderations` | |\n| `GET` | `/v1/models` | Every model across every connected provider |\n| `GET` | `/v1/models/{model}` | |\n\nEvery response carries routing telemetry, so you can debug from curl alone:\n\n```\nx-switchboard-request-id: req_6751a555d0264a6aa7e0\nx-switchboard-provider: groq\nx-switchboard-model: llama-3.3-70b-versatile\nx-switchboard-tier: free\nx-switchboard-attempts: 2\nx-switchboard-cost-usd: 0.000083\nx-switchboard-ttft-ms: 340\nx-switchboard-fallback-reason: Rate limited (429), retry after 12s\n```\n\n`x-switchboard-fallback-reason` only appears when a fallback actually happened, so its\npresence is itself the signal.\n\n---\n\n## Routing\n\nA **policy** (called a combo in the API) is a model name your clients can send. Four ship\nby default:\n\n| Slug | Strategy | Optimises for |\n| --- | --- | --- |\n| `auto` | free-first | Exhaust free tiers before spending anything |\n| `free-only` | free-first, $0 ceiling | Never spends money, fails instead |\n| `fast` | fastest | Lowest observed time to first token |\n| `quality` | quality-first | Strongest model available, price ignored |\n\nSeven strategies are available:\n\n| Strategy | Behaviour |\n| --- | --- |\n| `free-first` | Free tier, then cheap, then paid. The default. |\n| `cost-optimized` | Lowest projected USD for this specific request. |\n| `fastest` | Lowest measured p50 time-to-first-token. |\n| `quality-first` | Inverts the cost factor — price becomes a quality signal. |\n| `priority` | Strict chain order. Predictable, ignores live health. |\n| `round-robin` | Spreads load across healthy members to stretch rate limits. |\n| `failover` | First member always; others only on outright failure. |\n\nEvery candidate is scored on eight factors (health, latency, cost, tier, priority, quota,\nthroughput, stability), weighted per strategy. The weights are visible in the dashboard,\nand the full arithmetic is stored with every request.\n\n### The simulator\n\nThe routing page runs the exact expand-and-score path a live request takes, but stops\nbefore sending anything upstream. Drag a member, move the cost ceiling, and watch the\nranking reshuffle — including which candidates were excluded and for what reason.\n\n---\n\n## Architecture\n\n```mermaid\nflowchart LR\n A[Client] --> B[/v1 endpoint/]\n B --> C[Authenticate<br/>key · rate limit · budget]\n C --> D[Expand candidates<br/>connections × models]\n D --> E[Filter<br/>health · context · features · budget]\n E --> F[Score<br/>8 weighted factors]\n F --> G[Execute]\n G -->|success| H[Log + usage rollup]\n G -->|failure| I{Retryable?}\n I -->|yes| G\n I -->|no| H\n H --> A\n```\n\n| Layer | Location |\n| --- | --- |\n| Domain contract | `src/types/core.ts` |\n| Persistence | `src/lib/db/` — `node:sqlite`, WAL, append-only migrations |\n| Providers | `src/lib/providers/` — catalog + 4 wire adapters |\n| Resilience | `src/lib/resilience/`, `src/lib/health/` |\n| Router | `src/lib/router/` — candidates → score → execute |\n| Gateway API | `src/app/v1/` |\n| Management API | `src/app/api/` |\n| Dashboard | `src/app/(dashboard)/`, `src/components/` |\n\nStorage is Node's built-in `node:sqlite` — no native module, no `node-gyp`, no prebuilt\nbinary roulette. `npm install` works the same on Windows as it does on Linux.\n\n---\n\n## Resilience\n\nThree independent layers, so one failing provider never becomes a failing gateway:\n\n1. **Circuit breaker**, per connection. Opens after N consecutive failures — immediately\n on a `401`, since a bad credential will not fix itself. Cooldown doubles on repeat\n opens, capped at 15 minutes, with jitter so recovering providers are not stampeded.\n2. **Model lockout**, per model. A `429` locks out that one model rather than the whole\n connection, honouring `Retry-After` when the provider sends it.\n3. **Fallback walk**, per request. Up to `maxAttempts` candidates, stopping early when\n retrying elsewhere cannot help — a malformed request produces the same `400` on every\n provider.\n\nA streaming response that has already sent its first byte is **never** retried. Once bytes\nare on the wire the client has committed, and a silent provider switch mid-stream would\ncorrupt the response.\n\n---\n\n## Configuration\n\nEverything is optional; sane defaults are generated on first run.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `PORT` | `7272` | Gateway and dashboard |\n| `SWITCHBOARD_DATA_DIR` | `./data` | Database, key vault, logs |\n| `SWITCHBOARD_MASTER_KEY` | generated | Base64 32-byte key for credential encryption |\n| `SWITCHBOARD_ALLOW_REMOTE` | `0` | Allow non-loopback dashboard access |\n| `SWITCHBOARD_PACKAGED` | `0` | Set by the desktop build |\n\nProvider credentials are sealed with **AES-256-GCM** before they touch disk. The master key\nlives in `<data-dir>/master.key` with `0600` permissions. No endpoint returns a stored\ncredential — only a four-character hint.\n\n---\n\n## CLI\n\n```bash\nsb start [--port] [--open] # start the gateway\nsb status # health at a glance\nsb doctor # diagnose the install\nsb providers list # connections with status and spend\nsb providers add groq # prompts for the key, echo off\nsb providers test <id> # force a probe\nsb models [--free] # available models with pricing\nsb combos # routing policies\nsb keys new <name> # create an API key\nsb chat [--model] # streaming REPL\nsb logs [--errors] # recent requests\nsb usage [--days 7] # spend summary\nsb open # open the dashboard\n```\n\n---\n\n## Desktop app\n\n```bash\nnpm run electron:build:win # or :mac / :linux\n```\n\nProduces an installer in `release/`. The desktop build runs the gateway as a child process,\nwrites to a real app-data directory, and lives in the system tray with start-at-login,\ncopy-endpoint and restart controls.\n\nIcons go in `electron/assets/icon.{ico,icns,png}` — the build works without them.\n\n---\n\n## Development\n\n```bash\nnpm run dev # dev server on :7272\nnpm run typecheck # tsc --noEmit, strict\nnpm run build # production build\nnpm run electron:dev # desktop shell against the dev server\n```\n\nRequires **Node 22.5+** (for `node:sqlite`). Built with Next.js 16, React 19,\nTypeScript 5.9 in strict mode with `noUncheckedIndexedAccess`, and Tailwind CSS v4.\n\nAdding a provider is a single entry in `src/lib/providers/catalog.ts` when it speaks the\nOpenAI wire format — see `docs/development.md`.\n\n---\n\n## License\n\nMIT © 2026 Abeer Meer\n"}, "22efba668d70eb8279b6d143bb7d119a01619465": {"t": 1785771488.6917975, "n": 9736, "h": "8040bd0d2b1f64e1", "c": "# Switchboard\n\n**One endpoint. Every provider. Free tiers first, automatic fallback, everything local.**\n\nSwitchboard is an AI gateway that runs on your own machine. Point any OpenAI-compatible\ntool at it and it decides — per request — which provider and model should serve the call,\npreferring free tiers, routing around outages, and recording exactly why it chose what it\nchose.\n\n```\nyour tools ──▶ http://127.0.0.1:7272/v1 ──▶ Groq · Cerebras · Google · OpenRouter\n DeepSeek · Mistral · OpenAI · Anthropic …\n```\n\nNothing leaves your machine except the upstream provider calls themselves. No telemetry,\nno account, no cloud control plane.\n\n---\n\n## Why\n\nRunning six provider SDKs across four tools means six sets of keys, six failure modes, and\nno idea what any of it costs. Switchboard collapses that into one endpoint with one key,\nand answers three questions the raw SDKs cannot:\n\n- **Which provider should serve this?** Scored live on health, latency, price and tier.\n- **What happens when it is down?** The next candidate takes it, transparently.\n- **What is this actually costing me?** Per request, per model, per key, against a\n frontier-pricing baseline.\n\n---\n\n## Quickstart\n\n```bash\nnpm install\nnpm run build\nnpm start\n```\n\nOpen **http://localhost:7272** — the dashboard walks you through connecting a provider\nand creating a key.\n\nOr from the CLI:\n\n```bash\nnode bin/sb.mjs start --open\n```\n\nThen connect a free provider (Groq, Cerebras and Google all give real free tiers with no\ncard), create an API key, and point something at it:\n\n```bash\ncurl http://127.0.0.1:7272/v1/chat/completions \\\n -H \"Authorization: Bearer sb-live-your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n`\"model\": \"auto\"` hands the decision to the router. You can also send a bare model id\n(`llama-3.3-70b-versatile`), a pinned provider (`groq/llama-3.3-70b-versatile`), or a\npolicy slug you have defined.\n\n---\n\n## Point your tools at it\n\n**Claude Code**\n\n```bash\nexport ANTHROPIC_BASE_URL=\"http://127.0.0.1:7272/v1\"\nexport ANTHROPIC_API_KEY=\"sb-live-your-key\"\n```\n\n**Cursor** — Settings → Models → Override OpenAI Base URL\n\n```\nBase URL: http://127.0.0.1:7272/v1\nAPI Key: sb-live-your-key\nModel: auto\n```\n\n**OpenAI SDK (Python)**\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(base_url=\"http://127.0.0.1:7272/v1\", api_key=\"sb-live-your-key\")\nresponse = client.chat.completions.create(\n model=\"auto\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\n```\n\n**OpenAI SDK (Node)**\n\n```js\nimport OpenAI from 'openai';\n\nconst client = new OpenAI({\n baseURL: 'http://127.0.0.1:7272/v1',\n apiKey: 'sb-live-your-key',\n});\n```\n\n**Cline / Continue / Aider** — any \"OpenAI-compatible endpoint\" field takes the same base\nURL and key.\n\n---\n\n## Endpoints\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `POST` | `/v1/chat/completions` | Streaming and non-streaming |\n| `POST` | `/v1/completions` | Legacy, shimmed onto chat |\n| `POST` | `/v1/embeddings` | |\n| `POST` | `/v1/images/generations` | |\n| `POST` | `/v1/audio/speech` | Returns audio bytes |\n| `POST` | `/v1/audio/transcriptions` | Multipart upload |\n| `POST` | `/v1/rerank` | |\n| `POST` | `/v1/moderations` | |\n| `GET` | `/v1/models` | Every model across every connected provider |\n| `GET` | `/v1/models/{model}` | |\n\nEvery response carries routing telemetry, so you can debug from curl alone:\n\n```\nx-switchboard-request-id: req_6751a555d0264a6aa7e0\nx-switchboard-provider: groq\nx-switchboard-model: llama-3.3-70b-versatile\nx-switchboard-tier: free\nx-switchboard-attempts: 2\nx-switchboard-cost-usd: 0.000083\nx-switchboard-ttft-ms: 340\nx-switchboard-fallback-reason: Rate limited (429), retry after 12s\n```\n\n`x-switchboard-fallback-reason` only appears when a fallback actually happened, so its\npresence is itself the signal.\n\n---\n\n## Routing\n\nA **policy** (called a combo in the API) is a model name your clients can send. Four ship\nby default:\n\n| Slug | Strategy | Optimises for |\n| --- | --- | --- |\n| `auto` | free-first | Exhaust free tiers before spending anything |\n| `free-only` | free-first, $0 ceiling | Never spends money, fails instead |\n| `fast` | fastest | Lowest observed time to first token |\n| `quality` | quality-first | Strongest model available, price ignored |\n\nSeven strategies are available:\n\n| Strategy | Behaviour |\n| --- | --- |\n| `free-first` | Free tier, then cheap, then paid. The default. |\n| `cost-optimized` | Lowest projected USD for this specific request. |\n| `fastest` | Lowest measured p50 time-to-first-token. |\n| `quality-first` | Inverts the cost factor — price becomes a quality signal. |\n| `priority` | Strict chain order. Predictable, ignores live health. |\n| `round-robin` | Spreads load across healthy members to stretch rate limits. |\n| `failover` | First member always; others only on outright failure. |\n\nEvery candidate is scored on eight factors (health, latency, cost, tier, priority, quota,\nthroughput, stability), weighted per strategy. The weights are visible in the dashboard,\nand the full arithmetic is stored with every request.\n\n### The simulator\n\nThe routing page runs the exact expand-and-score path a live request takes, but stops\nbefore sending anything upstream. Drag a member, move the cost ceiling, and watch the\nranking reshuffle — including which candidates were excluded and for what reason.\n\n---\n\n## Architecture\n\n```mermaid\nflowchart LR\n A[Client] --> B[/v1 endpoint/]\n B --> C[Authenticate<br/>key · rate limit · budget]\n C --> D[Expand candidates<br/>connections × models]\n D --> E[Filter<br/>health · context · features · budget]\n E --> F[Score<br/>8 weighted factors]\n F --> G[Execute]\n G -->|success| H[Log + usage rollup]\n G -->|failure| I{Retryable?}\n I -->|yes| G\n I -->|no| H\n H --> A\n```\n\n| Layer | Location |\n| --- | --- |\n| Domain contract | `src/types/core.ts` |\n| Persistence | `src/lib/db/` — `node:sqlite`, WAL, append-only migrations |\n| Providers | `src/lib/providers/` — catalog + 4 wire adapters |\n| Resilience | `src/lib/resilience/`, `src/lib/health/` |\n| Router | `src/lib/router/` — candidates → score → execute |\n| Gateway API | `src/app/v1/` |\n| Management API | `src/app/api/` |\n| Dashboard | `src/app/(dashboard)/`, `src/components/` |\n\nStorage is Node's built-in `node:sqlite` — no native module, no `node-gyp`, no prebuilt\nbinary roulette. `npm install` works the same on Windows as it does on Linux.\n\n---\n\n## Resilience\n\nThree independent layers, so one failing provider never becomes a failing gateway:\n\n1. **Circuit breaker**, per connection. Opens after N consecutive failures — immediately\n on a `401`, since a bad credential will not fix itself. Cooldown doubles on repeat\n opens, capped at 15 minutes, with jitter so recovering providers are not stampeded.\n2. **Model lockout**, per model. A `429` locks out that one model rather than the whole\n connection, honouring `Retry-After` when the provider sends it.\n3. **Fallback walk**, per request. Up to `maxAttempts` candidates, stopping early when\n retrying elsewhere cannot help — a malformed request produces the same `400` on every\n provider.\n\nA streaming response that has already sent its first byte is **never** retried. Once bytes\nare on the wire the client has committed, and a silent provider switch mid-stream would\ncorrupt the response.\n\n---\n\n## Configuration\n\nEverything is optional; sane defaults are generated on first run.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `PORT` | `7272` | Gateway and dashboard |\n| `SWITCHBOARD_DATA_DIR` | `./data` | Database, key vault, logs |\n| `SWITCHBOARD_MASTER_KEY` | generated | Base64 32-byte key for credential encryption |\n| `SWITCHBOARD_ALLOW_REMOTE` | `0` | Allow non-loopback dashboard access |\n| `SWITCHBOARD_PACKAGED` | `0` | Set by the desktop build |\n\nProvider credentials are sealed with **AES-256-GCM** before they touch disk. The master key\nlives in `<data-dir>/master.key` with `0600` permissions. No endpoint returns a stored\ncredential — only a four-character hint.\n\n---\n\n## CLI\n\n```bash\nsb start [--port] [--open] # start the gateway\nsb status # health at a glance\nsb doctor # diagnose the install\nsb providers list # connections with status and spend\nsb providers add groq # prompts for the key, echo off\nsb providers test <id> # force a probe\nsb models [--free] # available models with pricing\nsb combos # routing policies\nsb keys new <name> # create an API key\nsb chat [--model] # streaming REPL\nsb logs [--errors] # recent requests\nsb usage [--days 7] # spend summary\nsb open # open the dashboard\n```\n\n---\n\n## Desktop app\n\n```bash\nnpm run electron:build:win # or :mac / :linux\n```\n\nProduces an installer in `release/`. The desktop build runs the gateway as a child process,\nwrites to a real app-data directory, and lives in the system tray with start-at-login,\ncopy-endpoint and restart controls.\n\nIcons go in `electron/assets/icon.{ico,icns,png}` — the build works without them.\n\n---\n\n## Development\n\n```bash\nnpm run dev # dev server on :7272\nnpm run typecheck # tsc --noEmit, strict\nnpm run build # production build\nnpm run electron:dev # desktop shell against the dev server\n```\n\nRequires **Node 22.5+** (for `node:sqlite`). Built with Next.js 16, React 19,\nTypeScript 5.9 in strict mode with `noUncheckedIndexedAccess`, and Tailwind CSS v4.\n\nAdding a provider is a single entry in `src/lib/providers/catalog.ts` when it speaks the\nOpenAI wire format — see `docs/development.md`.\n\n---\n\n## License\n\nMIT © 2026 Abeer Meer\n"}}}}