[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"project-92518":3},{"id":4,"name":5,"fullName":6,"owner":7,"repo":5,"description":8,"homepage":9,"htmlUrl":9,"language":10,"languages":9,"totalLinesOfCode":9,"stars":11,"forks":12,"watchers":13,"openIssues":14,"contributorsCount":15,"subscribersCount":15,"size":15,"stars1d":15,"stars7d":15,"stars30d":16,"stars90d":15,"forks30d":15,"starsTrendScore":15,"compositeScore":17,"rankGlobal":9,"rankLanguage":9,"license":18,"archived":19,"fork":19,"defaultBranch":20,"hasWiki":19,"hasPages":19,"topics":21,"createdAt":9,"pushedAt":9,"updatedAt":22,"readmeContent":23,"aiSummary":24,"trendingCount":15,"starSnapshotCount":15,"syncStatus":14,"lastSyncTime":25,"discoverSource":26},92518,"WayfinderRouter","itsthelore\u002FWayfinderRouter","itsthelore","Simple CLI tool for deterministic routing of queries between local and hosted LLM models",null,"Python",375,21,1,2,0,6,44.63,"Apache License 2.0",false,"main",[],"2026-07-22 04:02:06","\u003Cdiv align=\"center\">\n\n\u003Cpicture>\n  \u003Csource media=\"(prefers-color-scheme: dark)\" srcset=\"docs\u002Fbanner-dark.png\">\n  \u003Cimg alt=\"Wayfinder\" src=\"docs\u002Fbanner-light.png\" width=\"640\">\n\u003C\u002Fpicture>\n\n\u003Cp>\u003Cstrong>A fast, offline hard-or-easy call on every prompt — scored deterministically,\nwith no model call. Route the easy ones to your small\u002Flocal model and the hard ones to your\nbig one, or compose any model-router behind it.\u003C\u002Fstrong>\u003C\u002Fp>\n\n\u003Cp>\n  \u003Ca href=\"#quickstart\">Quickstart\u003C\u002Fa> ·\n  \u003Ca href=\"benchmarks\u002FREADME.md\">Benchmark\u003C\u002Fa> ·\n  \u003Ca href=\"#how-it-compares\">How it compares\u003C\u002Fa> ·\n  \u003Ca href=\"EXPLAINER.md\">Explainer\u003C\u002Fa> ·\n  \u003Ca href=\"CHANGELOG.md\">Changelog\u003C\u002Fa>\n\u003C\u002Fp>\n\n\u003Cp>\n  \u003Ca href=\"https:\u002F\u002Fpypi.org\u002Fproject\u002Fwayfinder-router\u002F\">\u003Cimg src=\"https:\u002F\u002Fimg.shields.io\u002Fpypi\u002Fv\u002Fwayfinder-router.svg\" alt=\"PyPI\">\u003C\u002Fa>\n  \u003Ca href=\"https:\u002F\u002Fpypi.org\u002Fproject\u002Fwayfinder-router\u002F\">\u003Cimg src=\"https:\u002F\u002Fimg.shields.io\u002Fpypi\u002Fpyversions\u002Fwayfinder-router.svg\" alt=\"Python versions\">\u003C\u002Fa>\n  \u003Ca href=\"LICENSE\">\u003Cimg src=\"https:\u002F\u002Fimg.shields.io\u002Fpypi\u002Fl\u002Fwayfinder-router.svg\" alt=\"License\">\u003C\u002Fa>\n  \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fitsthelore\u002Fwayfinder-router\u002Factions\u002Fworkflows\u002Fci.yml\">\u003Cimg src=\"https:\u002F\u002Fgithub.com\u002Fitsthelore\u002Fwayfinder-router\u002Factions\u002Fworkflows\u002Fci.yml\u002Fbadge.svg\" alt=\"CI\">\u003C\u002Fa>\n  \u003Ca href=\"https:\u002F\u002Fmypy-lang.org\u002F\">\u003Cimg src=\"https:\u002F\u002Fimg.shields.io\u002Fbadge\u002Ftypes-Mypy-blue.svg\" alt=\"Typed\">\u003C\u002Fa>\n\u003C\u002Fp>\n\n\u003C\u002Fdiv>\n\n\u003Ctable align=\"center\">\n\u003Ctr>\n\u003Ctd align=\"center\">\u003Cb>No model call\u003C\u002Fb>\u003Cbr>to decide the route\u003C\u002Ftd>\n\u003Ctd align=\"center\">\u003Cb>Deterministic\u003C\u002Fb>\u003Cbr>and fully offline\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd align=\"center\">\u003Cb>Calibrate\u003C\u002Fb>\u003Cbr>on your own data\u003C\u002Ftd>\n\u003Ctd align=\"center\">\u003Cb>Bring your own key\u003C\u002Fb>\u003Cbr>self-hosted\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftable>\n\nWayfinder scores a prompt's structure (length, headings, lists, code) and wording\n(proofs, math, hard constraints) into a `0.0`–`1.0` complexity score, then routes the easy\nones to your small\u002Flocal model and the hard ones to your big one. The decision is the\nproduct: deterministic, sub-millisecond, and entirely offline — no API key, no network, no\nmodel call to make it. What you route *to* is yours: two tiers, an N-tier ladder, or a\nmodel-router composed behind it.\n\nCheap prompts stay local and hard ones go to the expensive model, so you stop paying\ntop-tier prices for \"summarize this\" and \"fix my typo.\"\n\n## How it compares\n\nMost routers decide by calling a model: a trained classifier, an LLM judge, or a\nhosted API. That adds latency, cost, and randomness to the exact step meant to save\nyou money. Wayfinder reads structure and wording instead, so the decision is free\nand the same every time.\n\n| router | decides by | model call? | self-host | calibrate |\n| --- | --- | :-: | :-: | :-: |\n| **Wayfinder** | deterministic structural score | **no** | **yes** | **yes** |\n| RouteLLM | trained classifier (preference data) | yes | yes | retrain |\n| NotDiamond \u002F Martian | learned, hosted | yes | no | via platform |\n| OpenRouter (Auto) | hosted auto-router | yes | no | — |\n| Bifrost \u002F LiteLLM | provider gateway (not complexity-routed) | no | yes | n\u002Fa |\n\nThe gateways in the last two rows (OpenRouter, Bifrost, LiteLLM) answer a different\nquestion: *which provider* serves a call, by price, availability, and failover.\nWayfinder answers *which tier a prompt deserves*: cheap vs expensive, by difficulty,\ndecided offline. The two compose. Run Wayfinder to make the cheap-vs-expensive call,\nand a gateway underneath to reach the providers.\n\nWayfinder isn't chasing a top accuracy number — it gives you a routing decision you\ncan run offline and tune on your own traffic. By default it scores prompt\n*structure* only; it can also read lexical cues (proofs, math, constraints), but\nthose ship **off by default** because a\n[double-blind test](benchmarks\u002Fblind-eval.md) showed the lift doesn't generalize\n(it caught ~20% of unseen hard prompts and lost to a plain word-count baseline). A\nprompt whose difficulty is purely semantic (a subtle code snippet, \"what is the\n100th prime number?\") has no structural tell, and a semantic router will beat it\nthere. The [benchmark](benchmarks\u002FREADME.md) (`make benchmark`) shows where it wins\nand loses against honest baselines and a perfect oracle; the [FAQ](docs\u002Ffaq.md)\ngives the straight version — including that it's no better than random on\nRouterBench's short-but-hard items, and why you'd still run it.\n\n## Try the demo (no keys)\n\nTwo ways to see the routing decision for yourself — no API keys, no models, nothing on the network.\n\n**In your terminal** — a decision-first chat in the Wayfinder palette. The terminal\nchat ships in the default install, so there's nothing extra to add — or run it with\nno install at all via `uvx`:\n\n```bash\nuvx wayfinder-router chat --dry-run      # zero install, zero keys\n# or:  pip install wayfinder-router && wayfinder-router chat\n```\n\n![Wayfinder terminal chat — a routed prompt, the decision, the reply, and the running savings](docs\u002Ftui-chat.png)\n\nEvery turn shows where it routed (`● LOCAL` \u002F `◆ CLOUD`), the structural score and *why*\n(`\u002Fwhy`), and the running savings vs always-cloud. `\u002Finit` sets up models without leaving\nthe chat, `\u002Froute` · `\u002Flocal` · `\u002Fcloud` force a turn, and conversations persist across\nsessions (`\u002Fthreads`).\n\n**In your browser** — the web chat UI with a live threshold slider:\n\n```bash\npip install \"wayfinder-router[gateway]\"\nwayfinder-router webchat --dry-run\n# opens http:\u002F\u002F127.0.0.1:8088\u002Fdemo\n```\n\n`webchat` is a thin launcher over `serve` (the gateway and its `\u002Fdemo` page; `--no-open`,\n`--port`, `--host 0.0.0.0`, `--dry-run`); `serve` is the headless command. With no config\nit's decision-only (`--dry-run`), so you can poke at it with zero setup; to get real\nreplies, run `wayfinder-router init` to scaffold `[gateway.models]` (then\n`wayfinder-router doctor` to confirm your keys resolve) — see [Quickstart](#quickstart).\n\n## Works with any OpenAI-compatible API\n\nWayfinder forwards each call to an OpenAI-style `\u002Fchat\u002Fcompletions` endpoint — so if\nyour provider speaks that (and most do), **it just works.** A tier is one `base_url`,\na model name, and a key read from the environment at request time; no SDK, no\nper-provider code. Pair a free local model with a hosted one, or run two cloud tiers.\n\n\u003Cdiv align=\"center\">\n\n![OpenAI](https:\u002F\u002Fimg.shields.io\u002Fbadge\u002FOpenAI-412991?logo=openai&logoColor=white)\n&nbsp;\n![Claude](https:\u002F\u002Fimg.shields.io\u002Fbadge\u002FClaude-D97757?logo=anthropic&logoColor=white)\n&nbsp;\n![Gemini](https:\u002F\u002Fimg.shields.io\u002Fbadge\u002FGemini-1C69FF?logo=googlegemini&logoColor=white)\n&nbsp;\n![Mistral](https:\u002F\u002Fimg.shields.io\u002Fbadge\u002FMistral-FA520F?logo=mistralai&logoColor=white)\n&nbsp;\n![Ollama](https:\u002F\u002Fimg.shields.io\u002Fbadge\u002FOllama-000000?logo=ollama&logoColor=white)\n\n\u003Csub>…plus Groq, Together, OpenRouter, Fireworks, DeepSeek, and local servers\n(vLLM, LM Studio, llama.cpp) — \u003Cstrong>+ any OpenAI-compatible endpoint\u003C\u002Fstrong>\nthat takes a Bearer key.\u003C\u002Fsub>\n\n\u003C\u002Fdiv>\n\n## Quickstart\n\nPut Wayfinder in front of your models. Your app keeps speaking the OpenAI API; you\njust change one `base_url`.\n\n1. Scaffold a config — `init` writes a starter `wayfinder-router.toml` (keyless local\n   Ollama → Anthropic cloud) plus a `.env.example`, then checks your keys:\n\n   ```bash\n   pip install \"wayfinder-router[gateway]\"\n   wayfinder-router init                 # starter config (hybrid preset)\n   wayfinder-router init --preset openai # two OpenAI tiers (gpt-4o-mini → gpt-4o)\n   wayfinder-router init --preset gemini # two Gemini tiers (gemini-2.5-flash → gemini-2.5-pro)\n   wayfinder-router init --interactive   # pick providers\u002Fmodels step by step\n   ```\n\n   Or describe your two models in `wayfinder-router.toml` by hand:\n\n   ```toml\n   [routing]\n   threshold = 0.5            # below -> local, at\u002Fabove -> cloud\n\n   [gateway.models.local]\n   base_url = \"http:\u002F\u002Flocalhost:11434\u002Fv1\"\n   model = \"llama3.2\"\n\n   [gateway.models.cloud]\n   base_url = \"https:\u002F\u002Fapi.openai.com\u002Fv1\"\n   model = \"gpt-4o\"\n   api_key_env = \"OPENAI_API_KEY\"   # read from this env var, never stored\n   # api_key_cmd = \"op read op:\u002F\u002FPrivate\u002FOpenAI\u002Fcredential\"  # optional: fill it from a vault\n   ```\n\n   Wayfinder never stores secrets: a model names an env var (`api_key_env`) and the key\n   is read from your environment at request time. There is nothing to \"install\" — just\n   export the variable. Prefer not to paste a raw key into your shell? Add an optional\n   `api_key_cmd` and Wayfinder fills that variable from your secret store at startup —\n   `op read …` (1Password), `security …` (macOS Keychain), `secret-tool …` (Linux),\n   `pass`\u002F`gopass`, `vault kv get …`, `aws secretsmanager get-secret-value …`, `bw`,\n   `doppler`, `gcloud secrets …`, or any command that prints the secret. The key is held\n   in memory only, still never written to disk. `wayfinder-router doctor` detects which\n   of these tools you have installed and suggests the exact line.\n\n2. Set your key(s), then run the gateway. `doctor` re-checks the config and whether each\n   model's key resolves (`✓ set` \u002F `✗ not set`) before you start:\n\n   ```bash\n   export ANTHROPIC_API_KEY=sk-...     # or OPENAI_API_KEY, per your config\n   wayfinder-router doctor             # ✓\u002F✗ per model — is each key set?\n   wayfinder-router serve --port 8088\n   ```\n\n3. Point your existing client at it. No code change:\n\n   ```python\n   client = openai.OpenAI(base_url=\"http:\u002F\u002Flocalhost:8088\u002Fv1\", api_key=\"unused\")\n   client.chat.completions.create(model=\"auto\", messages=[{\"role\": \"user\", \"content\": \"...\"}])\n   ```\n\nEasy prompts go local, hard ones go cloud, and every response carries\n`x-wayfinder-router-model` and `x-wayfinder-router-score` so you can see where it\nwent. Want to force a tier for one request? Set `model=\"local\"` or `\"cloud\"` (or\n`prefer-local` \u002F `prefer-hosted`), move the cut for a single call with an\n`X-Wayfinder-Threshold` header, or start a chat message with `\u002Flocal` or `\u002Fcloud`\n(see [Steer a single request](#steer-a-single-request)).\n\nCheck it's working:\n\n```bash\ncurl -s localhost:8088\u002Fhealthz\n# {\"status\":\"ok\",\"models\":[\"cloud\",\"local\"]}\n\ncurl -s -D - -o \u002Fdev\u002Fnull http:\u002F\u002Flocalhost:8088\u002Fv1\u002Fchat\u002Fcompletions \\\n  -H \"Content-Type: application\u002Fjson\" \\\n  -d '{\"model\":\"auto\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}' \\\n  | grep -i x-wayfinder-router\n# x-wayfinder-router-model: local\n# x-wayfinder-router-score: 0.00\n```\n\nNo backends yet? `wayfinder-router serve --dry-run` answers with the routing\ndecision instead of calling an upstream, so you can feel the routing in 30 seconds\nbefore wiring up real models.\n\n## Install\n\n| command | what you get |\n| --- | --- |\n| `pip install wayfinder-router` | scorer, CLI, Python API, **and the terminal chat** (`chat`); the scorer\u002Flibrary imports stay dependency-light |\n| `pip install \"wayfinder-router[gateway]\"` | adds the OpenAI-compatible routing gateway, the common case for serving |\n| `pip install \"wayfinder-router[ui]\"` | adds the local calibrate \u002F explain \u002F configure UI |\n| `pip install \"wayfinder-router[all]\"` | gateway and UI on top of the default install |\n\n## Run it as a local service\n\nMake Wayfinder your machine's always-on LLM endpoint, so every OpenAI-compatible app\ncan share one local `base_url` and you set your keys up once. `service install`\nregisters it with the OS service manager to start at login and restart if it exits:\n\n```bash\nwayfinder-router service install     # macOS (launchd) or Linux (systemd user unit)\nwayfinder-router service status      # is it running? endpoint + \u002Fhealthz\nwayfinder-router service uninstall\n```\n\nThen point your apps at it once — most OpenAI-compatible tools read `OPENAI_BASE_URL`:\n\n```bash\nexport OPENAI_BASE_URL=http:\u002F\u002F127.0.0.1:8088\u002Fv1\n```\n\nmacOS is the primary target; Linux works too. `--print` emits the unit file without\ninstalling, and if no service manager is present it writes the unit and prints the one\ncommand to start it. It's the same gateway, just kept running — the routing decision is\nunchanged.\n\n## How it works\n\nWayfinder sits behind whatever OpenAI-compatible client you already use. You point\nthat client's `base_url` at the gateway once, and from then on it is invisible. The\nsame client serves a request whether it routes local or hosted.\n\n```text\n  your client   (chat app, IDE, agent, or code)\n       |\n       v\n  Wayfinder gateway   scores, picks a model\n       |\n       |-- low  -->  local    (Ollama, vLLM)\n       |-- high -->  hosted   (OpenAI, any \u002Fv1)\n       |\n       v\n  response returns via the same client,\n  with x-wayfinder-router-* headers\n```\n\nA few things follow from this:\n\n- **The interface in front is yours.** A chat GUI (Open WebUI, LibreChat), an IDE\n  assistant with a custom endpoint (Cursor, Continue), an agent framework, or your\n  own code on the OpenAI SDK. Want a chat window today? Put Open WebUI in front and\n  point it at the gateway.\n- **Local and hosted are backends, not apps.** The local model is just a server\n  (Ollama, LM Studio, vLLM, llama.cpp) speaking OpenAI's `\u002Fv1`; the hosted one is\n  the same shape. The user never switches UIs and usually never knows which model\n  answered.\n\nKeys are read from the environment at request time and never touch the config file\nor the scored path.\n\n## Score a prompt from the CLI\n\n```bash\necho \"Summarise this paragraph in one sentence.\" | wayfinder-router route -\n```\n\n```text\nRecommended Model: local\nComplexity Score: 0.00  (mode: tiered)\n\nTiers:\n  >= 0.00  local \u003C-\n  >= 0.50  cloud\n\nContributing Features:\n  Word Count: 6\n  ...\n```\n\nAdd `--json` for machine consumers (an agent reads this and routes to its own\nmodel):\n\n```json\n{\n  \"schema_version\": \"3\",\n  \"score\": 0.66,\n  \"recommendation\": \"cloud\",\n  \"mode\": \"tiered\",\n  \"features\": { \"word_count\": 545, \"heading_count\": 12, \"reasoning_term_count\": 3, \"...\": 0 },\n  \"tiers\": [{ \"min_score\": 0.0, \"model\": \"local\" }, { \"min_score\": 0.5, \"model\": \"cloud\" }]\n}\n```\n\n## Configure routing\n\nWayfinder reads its own `wayfinder-router.toml`, found by walking up from where you\nrun it. There are three modes, in precedence order (classifier > tiers >\nthreshold); the scalar-score `weights` apply to any of them.\n\n**Binary** (the default) is a single cut:\n\n```toml\n[routing]\nthreshold = 0.6\nweights = { word_count = 4.0, list_item_count = 2.5 }\n```\n\n`--threshold N` overrides it for one run; `WAYFINDER_ROUTER_THRESHOLD` overrides it\nfrom the environment.\n\nTo switch the lexical cues on, raise their `weights` and cut at the knee — the one\nheld-out improvement over the structural default on real frontier traffic (skill\n−0.038 → +0.057, 61% cost saved on RouterBench). See\n[`docs\u002Flexical-routing.md`](docs\u002Flexical-routing.md) and the ready-to-edit\n[`examples\u002Fwayfinder-router.lexical.toml`](examples\u002Fwayfinder-router.lexical.toml);\nrecalibrate the threshold to your own traffic (a ~20-prompt bootstrap is only a smoke\ntest — see [`benchmarks\u002Fcalibration-eval.md`](benchmarks\u002Fcalibration-eval.md)).\n\n**Tiered** routes ordered score bands to any number of models:\n\n```toml\n[[routing.tiers]]\nmin_score = 0.0\nmodel = \"llama-3b\"\n[[routing.tiers]]\nmin_score = 0.3\nmodel = \"llama-70b\"\n[[routing.tiers]]\nmin_score = 0.6\nmodel = \"claude-cloud\"\n```\n\n**Classifier** is a fitted multinomial-logistic model, `argmax` over per-model\nlinear scores. You usually generate it with `calibrate` rather than write it by\nhand.\n\nEach `[gateway.models.\u003Cname>]` block maps a routed name to an upstream `base_url`, a\n`model`, and an optional `api_key_env` (the name of an environment variable, never\nthe secret itself). The gateway is the only part that touches keys or the network;\nthe scorer, config, and calibrator stay pure and offline.\n\n## Calibrate on your data\n\nThe cut is a proxy, so tune it against your own traffic. `wayfinder-router\ncalibrate` reads a labeled JSONL dataset (`{\"text\": ..., \"label\": ...}`) and prints\na config fragment. It runs offline and never calls a model; the labels are your\nground truth.\n\n```bash\nwayfinder-router calibrate data.jsonl --mode threshold              # sweep the binary cut\nwayfinder-router calibrate data.jsonl --mode tiers                  # ordinal multi-model\nwayfinder-router calibrate data.jsonl --mode classifier --out wayfinder-router.toml\n```\n\nThe fragment drops straight into `wayfinder-router.toml`; the accuracy and chosen\nbreakpoints print to stderr. The classifier is fit by deterministic L2-regularized\nNewton\u002FIRLS, pure Python, converging in a handful of iterations.\n\nTo pick a cut in cost terms instead of bare accuracy, use a cost-aware objective.\n`--objective knee` chooses the cost-aware knee automatically (it maximizes\nquality-recovered × cost-saved — no target to guess, and it can't collapse to\nalways-routing-to-the-expensive-model the way pure accuracy does on skewed labels);\n`--objective cost-quality --target-savings X` instead holds a specific savings floor.\nAdd `--weights` to score with — and emit — custom feature weights, e.g. the lexical\nopt-in, so the output is a complete, deployable config (see\n[`docs\u002Flexical-routing.md`](docs\u002Flexical-routing.md)):\n\n```bash\nwayfinder-router calibrate data.jsonl --mode threshold --objective knee \\\n  --costs local=0.2,cloud=1.0 \\\n  --weights reasoning_term_count=5,math_symbol_count=3,constraint_term_count=1.5\n```\n\nCost is metadata only — it shapes the calibrated cut and is reported on the\n`\u002Fmetrics` endpoint, but never enters a per-request decision, which stays\ndeterministic and free.\n\n### Steer a single request\n\nThe deployment's config sets the default boundary, but a client can override the\ndecision for one request over plain OpenAI transport. An override only changes\nwhere the request goes; the prompt is still scored, and nothing adds a model call.\n\n- **The `model` field is a routing directive.** `auto` (or any normal model id)\n  lets Wayfinder decide; a configured endpoint name (`local`, `cloud`) pins the\n  request there; `prefer-local` \u002F `prefer-hosted` pin to the low \u002F high end of your\n  router (`prefer-cloud` still works as an alias of `prefer-hosted`).\n- **An `X-Wayfinder-Threshold` header re-cuts the decision** for that request, a\n  number in `0.0`-`1.0` reusing your weights (binary routers only).\n- **An in-message `\u002Fdirective`** (opt-in: `[gateway] slash_directives = true`) lets a\n  plain chat box steer routing — start a message with `\u002Flocal`, `\u002Fcloud`, `\u002Fprefer-hosted`,\n  or `\u002Fauto` and it pins that turn (stripped before the model sees it). Only known\n  directives are acted on; anything else starting with `\u002F` is left as ordinary text\n  (WF-ADR-0036).\n- **Offline mode** keeps you working with no network. Set `[gateway] offline = true`\n  (or send `X-Wayfinder-Offline: true` for one request) and Wayfinder serves the\n  cheapest\u002Flocal tier and never calls the cloud tier — so a request can't hang on a\n  timeout on a plane. The prompt is still scored and reported; only delivery changes\n  (WF-ADR-0039).\n\n```python\n# Pin one call to cloud regardless of score:\nclient.chat.completions.create(model=\"cloud\", messages=[...])\n# Or move the cut for one call (keep model=\"auto\"):\nclient.chat.completions.create(\n    model=\"auto\", messages=[...], extra_headers={\"X-Wayfinder-Threshold\": \"0.8\"}\n)\n```\n\nEach response adds `x-wayfinder-router-mode` (`scored` \u002F `pinned` \u002F\n`threshold-override`) next to the `-model` and `-score` headers, so you can see\nwhich channel decided the route.\n\n## Drive it from a chat UI (no fork)\n\nBecause the `model` field is a routing directive, any OpenAI-compatible chat UI can\ndrive routing with no code change: the app's normal model dropdown becomes a\nper-conversation routing picker (`auto` \u002F `prefer-local` \u002F `prefer-hosted` \u002F a\npinned endpoint). The gateway lists these at `GET \u002Fv1\u002Fmodels`, so a UI discovers\nthem on its own.\n\n- **LibreChat** — copy [`examples\u002Flibrechat.yaml`](examples\u002Flibrechat.yaml) and\n  [`examples\u002Fdocker-compose.override.yml`](examples\u002Fdocker-compose.override.yml)\n  into your checkout, run `docker compose up`, and pick the \"Wayfinder\" endpoint.\n- **Open WebUI** — add an OpenAI connection pointing at the gateway; it\n  auto-discovers the routing options.\n\nSee [`examples\u002F`](examples\u002F) for both. The one thing a stock UI can't express is a\nlive per-conversation threshold slider; that's what the `wayfinder-chat` fork adds,\nand this no-fork path proves it out first.\n\n## See where requests go\n\nWayfinder's controls are spread across the tools you already run, so it's easy not\nto notice it working. Four surfaces show or steer routing:\n\n| surface | what it shows | where |\n| --- | --- | --- |\n| Model dropdown | the routing picker (`auto` \u002F `prefer-local` \u002F `prefer-hosted` \u002F a pinned endpoint) | your client, from `GET \u002Fv1\u002Fmodels` |\n| Response headers | where each request went and why (`-model` \u002F `-score` \u002F `-mode` \u002F `-request-id`) | every response |\n| Debug body field | the decision inside the response body, opt-in | request header `X-Wayfinder-Debug: true` |\n| Dashboard | recent decisions, per-model counts, scores — metadata only, never prompt text | `GET \u002Frouter` (JSON at `\u002Frouter\u002Frecent`) |\n\nThe dashboard is separate from the off-path `wayfinder-router ui` console, which is\nfor tuning, not production traffic.\n\n## Learn from feedback\n\nDon't guess the cut, learn it from your own judgment of local versus hosted output.\nThe loop is: collect judgments, calibrate, route automatically.\n\nBootstrap it with A\u002FB onboarding. For each sample prompt, `wayfinder-router\nonboard` runs both arms and asks which was good enough; the answer is a label:\n\n```bash\nwayfinder-router onboard prompts.jsonl --arms local,cloud --calibrate > wayfinder-router.toml\n```\n\nThe comparison goes to stderr; `--calibrate` prints the resulting config to stdout.\nEach judgment appends a `{\"text\", \"label\"}` line to a feedback log, which is itself\nthe `calibrate` dataset, so the log turns straight into a config.\n\nTo skip the manual grading, let `wayfinder-router judge` label automatically. It runs\nboth tiers and asks an automated judge *\"was the cheaper tier good enough?\"* — the same\nsufficiency question, no person in the loop:\n\n```bash\nwayfinder-router judge prompts.jsonl --arms local,cloud --gold gold.jsonl > wayfinder-router.toml\n```\n\nThe built-in judge is a deterministic text comparator that **abstains** rather than guess\nwhen it can't tell. Because a bad label would silently degrade live routing, `judge` will\nonly emit a config once it **passes trust gates** — agreement with your human-labeled\n`--gold` set (Cohen's κ ≥ 0.6), out-of-fold lift over the majority baseline, and both arms\nrepresented. If the gates fail it prints the confusion matrix and refuses (the labels are\nstill recorded). Pass `--save-comparisons out.jsonl` to also keep the raw responses (off by\ndefault — it's a body store).\n\nOnce you're routing automatically, keep it honest by recording which model was\nactually good enough:\n\n```bash\ncurl localhost:8088\u002Fv1\u002Ffeedback -d '{\"text\": \"...\", \"label\": \"cloud\"}'\n```\n\nThen re-fit on a schedule from cron, a k8s CronJob, or a click in the UI.\nRecalibration rewrites only the `[routing]` section and preserves your `[gateway]`\nendpoints, and a running gateway hot-reloads the result with no restart:\n\n```bash\nwayfinder-router recalibrate                  # log -> calibrate -> write config\nwayfinder-router recalibrate --min-labels 50  # no-op until you have enough signal\n```\n\nThe judging runs models, so it lives in the gateway layer (with your key); the\nscoring core stays untouched and the log carries no secrets.\n\n## Deploy and integrate\n\nThe CLI, onboarding, and UI are for operators and bootstrapping. In production,\nprompts flow through the gateway (transparent) or the library (in-process), so\nrouting happens where prompts already are.\n\nRun the gateway as a service, sidecar or standalone:\n\n```bash\ndocker build -t wayfinder-router . && docker run -p 8088:8088 -v \"$PWD\u002Fdata:\u002Fdata\" wayfinder-router\n# or: docker compose up gateway   (see docker-compose.example.yml)\n```\n\nPoint your existing client at it with no app change. Anything that speaks the\nOpenAI API takes a `base_url`, including agent frameworks (LangChain, LlamaIndex),\nIDE assistants with a custom endpoint (Cursor, Continue), and gateways like LiteLLM:\n\n```python\nclient = openai.OpenAI(base_url=\"http:\u002F\u002Flocalhost:8088\u002Fv1\", api_key=\"unused\")\n```\n\nSee **[Integration recipes](docs\u002Fintegrations.md)** for copy-paste setup across chat UIs\n(Open WebUI, LibreChat, Jan), editors (Continue, Cline, Zed, JetBrains), agent frameworks\n(LangChain, LlamaIndex, CrewAI, AutoGen, the OpenAI Agents SDK, the Vercel AI SDK), and\nCLIs (aider, Copilot CLI) — plus the canonical `OPENAI_BASE_URL` \u002F `OPENAI_API_KEY` pair.\n\n**Claude Code** speaks Anthropic's Messages API rather than OpenAI's, so the gateway exposes a\n`POST \u002Fv1\u002Fmessages` adapter (WF-DESIGN-0011) that translates Anthropic ⇄ OpenAI in both\ndirections — streaming and tool use included. Point it at the gateway root and Claude Code\nroutes through Wayfinder like any other client:\n\n```bash\nexport ANTHROPIC_BASE_URL=\"http:\u002F\u002Flocalhost:8088\"   # client appends \u002Fv1\u002Fmessages\nexport ANTHROPIC_API_KEY=\"unused\"                   # the gateway uses each upstream's own key\nclaude\n```\n\nWire feedback from wherever your users are. Your app, IDE, or chat shows a\nthumbs-up or thumbs-down and posts the judgment; the next recalibration learns from\nit:\n\n```js\nfetch(\"http:\u002F\u002Flocalhost:8088\u002Fv1\u002Ffeedback\", {\n  method: \"POST\",\n  body: JSON.stringify({ text: prompt, label: wasGoodEnough ? \"local\" : \"cloud\" }),\n});\n```\n\nThe gateway forwards asynchronously and streams: a request with `stream: true`\ncomes back as Server-Sent-Events, so chat clients render tokens as they arrive. An\nupstream timeout or connection failure returns an OpenAI-shaped error instead of a\nbare 500, every response carries a request id for tracing, and routing decisions\nand reload failures are logged.\n\nBeyond that it has the production knobs you'd expect — per-request **timeouts**,\nbounded **retries** with a per-target **circuit breaker** and **failover**, a spend\n**budget** cap, an exact-match response **cache**, **rate limiting**, and **virtual\nAPI keys** with per-key budgets and allowlists. They're all off or generous by\ndefault; see **[Gateway configuration reference](docs\u002Fgateway-config.md)** for every\nsetting and the headers each one surfaces.\n\n## Explain and tune\n\nTo see why a prompt routed where it did, ask for the per-feature breakdown: each\nfeature's value, its normalized level, its weight, and its share of the score.\n\n```bash\nwayfinder-router route prompt.md --explain\n```\n\nFor interactive tuning there's a local web UI:\n\n- **Explain** — paste a prompt; see the score, the tier ladder, and contribution\n  bars, and drag a threshold slider to watch routing change live.\n- **Calibrate** — paste a labeled dataset, run a mode, and see accuracy, the sweep\n  curve, and the resulting config fragment.\n- **Configure** — edit `wayfinder-router.toml` with live validation and save.\n- **Onboard** — A\u002FB a local and a hosted model in the browser, judge each, and\n  calibrate from the log (needs `[gateway]` for the model calls).\n\n```bash\npip install \"wayfinder-router[ui]\"\nwayfinder-router ui --port 8099    # then open http:\u002F\u002Flocalhost:8099\n```\n\nThe UI is a thin wrapper over the same pure functions; it never calls a model, and\nno secret appears in it.\n\n## Python API\n\n```python\nfrom wayfinder_router import score_complexity, RoutingConfig, explain_score\n\nresult = score_complexity(prompt_text, config=RoutingConfig.binary(threshold=0.7))\nprint(result.recommendation, result.score, result.features)\nfor fc in explain_score(result.features, RoutingConfig().weights):\n    print(fc.name, fc.contribution)\n```\n\n## Origin\n\nWayfinder started as a `route` experiment inside a larger requirements tool and was\nsplit out because routing is a runtime concern, not a knowledge one: a prompt router\nshouldn't make you install an engine you don't need. The result is a small, focused\ntool whose scoring core stays dependency-free — you can `import wayfinder_router` and\nscore prompts with nothing but the standard library (WF-ADR-0001, WF-ADR-0029).\n\n## Repository layout\n\n```\nwayfinder-router\u002F\n  wayfinder_router\u002F   the package: scorer, tiers + classifier, config loader\u002Fwriter,\n                      offline calibration (Newton\u002FIRLS), explain, the feedback log and\n                      onboarding harness, recalibration, CLI, and the optional gateway\n                      and local UI (the impure layers, behind their extras)\n  tests\u002F              scorer, config, calibration, explain, feedback, onboard,\n                      recalibrate, CLI, gateway, and UI coverage\n  decisions\u002F          design notes behind the tool's own choices\n  docs\u002F               the FAQ and the lexical-routing guide\n  Dockerfile, docker-compose.example.yml   deploy the gateway as a service\n```\n\n## Test\n\n```bash\npip install -e .[dev]   # or: pip install pytest\nmake test\n```\n","WayfinderRouter 是一个轻量级命令行工具，用于在本地与托管大语言模型之间进行确定性查询路由。它通过静态分析提示词的结构（如长度、标题、列表、代码块）和语义特征（如数学表达式、严格约束、证明类表述）生成 0.0–1.0 的离线复杂度评分，无需调用任何模型或网络请求即可完成毫秒级路由决策。支持用户自定义阈值、校准数据集及多层级后端模型组合。适用于成本敏感、低延迟要求或需完全离线运行的混合推理场景，例如本地知识库问答、边缘设备辅助推理和企业私有化LLM网关。","2026-07-09 02:30:12","CREATED_QUERY"]