mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
docs(wiki): auto-sync pages + cover counts with docs
258
Cost-Tracking.md
Normal file
258
Cost-Tracking.md
Normal file
@@ -0,0 +1,258 @@
|
||||
> 🌍 [View in other languages](Languages)
|
||||
|
||||
|
||||
# Cost & Spend Tracking
|
||||
|
||||
How OmniRoute estimates, records, and reports the cost of every request — and why the
|
||||
dashboard number is a **savings tracker**, not a bill.
|
||||
|
||||
See also: [User Guide](./USER_GUIDE.md) · [Features Gallery](./FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
## What it is (and what it is not)
|
||||
|
||||
OmniRoute attributes a per-request USD cost to every completion by multiplying token
|
||||
counts by a model's pricing rates. These numbers power the **Costs** dashboard, the
|
||||
`omniroute cost` / `omniroute usage` CLI, CSV/JSON exports, and per-API-key budgets.
|
||||
|
||||
> **The dashboard "cost" is a savings tracker, not a bill.** OmniRoute never charges you
|
||||
> — it routes your requests to providers you have already connected (your own
|
||||
> subscriptions, free tiers, and API keys). A "$290 total cost" accrued entirely on free
|
||||
> models means roughly **$290 you did _not_ pay** a paid API. The figure is an _estimate_
|
||||
> of what the same traffic would have cost at standard list prices, so you can see where
|
||||
> your usage is concentrated and how much routing to cheaper/free providers is saving you.
|
||||
|
||||
This framing is stated directly in the project [README](../../README.md) ("the dashboard
|
||||
'cost' is a savings tracker, not a bill").
|
||||
|
||||
Because the number is an estimate:
|
||||
|
||||
- It depends on the pricing table OmniRoute has for each model. A model with no pricing
|
||||
entry contributes `0` cost (it shows as a "Legacy / Free" row in the explorer).
|
||||
- Free-tier and subscription traffic still accrues an _estimated_ cost — that is the
|
||||
amount you are saving, not an amount owed.
|
||||
|
||||
---
|
||||
|
||||
## How costs are estimated
|
||||
|
||||
### The pricing source
|
||||
|
||||
Costs come from a pricing table resolved in this precedence order
|
||||
([`src/lib/pricingSync.ts`](../../src/lib/pricingSync.ts)):
|
||||
|
||||
1. **User overrides** — prices you set in the dashboard / via `PATCH /api/pricing`.
|
||||
2. **Synced external pricing** — fetched from LiteLLM's public
|
||||
`model_prices_and_context_window.json` when sync is enabled (stored in a separate
|
||||
`pricing_synced` namespace so it never clobbers your overrides).
|
||||
3. **Hardcoded defaults** — shipped with OmniRoute.
|
||||
|
||||
External pricing sync is **opt-in**, disabled by default. Relevant env vars
|
||||
(see [`.env.example`](../../.env.example)):
|
||||
|
||||
| Env var | Default | Purpose |
|
||||
| ----------------------- | --------- | ---------------------------------------------------------------- |
|
||||
| `PRICING_SYNC_ENABLED` | `false` | Enable the background LiteLLM pricing sync at startup. |
|
||||
| `PRICING_SYNC_INTERVAL` | `86400` | Sync interval in **seconds** (default daily). |
|
||||
| `PRICING_SYNC_SOURCES` | `litellm` | Comma-separated source list (only `litellm` is supported today). |
|
||||
|
||||
### The cost formula
|
||||
|
||||
Cost is computed per request from token counts and per-million-token rates in
|
||||
[`src/lib/usage/costCalculator.ts`](../../src/lib/usage/costCalculator.ts)
|
||||
(`computeCostFromPricing` / `calculateCost`):
|
||||
|
||||
- **Input tokens** (minus cache reads and cache-creation tokens) × `input` rate.
|
||||
- **Cache-read tokens** × `cached` rate (falls back to the input rate).
|
||||
- **Cache-creation tokens** × `cache_creation` rate (falls back to the input rate).
|
||||
- **Output tokens** × `output` rate.
|
||||
- **Reasoning tokens** × `reasoning` rate (falls back to the output rate).
|
||||
|
||||
All rates are interpreted as USD per 1,000,000 tokens. A Codex "fast"/"priority" or
|
||||
"flex" service tier applies a cost multiplier (`getCodexFastCostMultiplier`) — e.g. flex
|
||||
is billed at a 50% token discount, surfaced as **flex savings** in the dashboard.
|
||||
|
||||
Model names are normalized first (provider-path prefixes such as `openai/` or
|
||||
`accounts/fireworks/models/` are stripped) so historical rows still match a price.
|
||||
|
||||
### How spend is recorded
|
||||
|
||||
- The per-request cost is computed after the response and recorded fire-and-forget so it
|
||||
never adds latency to the client. Shared-quota consumption is scheduled on the next
|
||||
event-loop tick via [`src/lib/quota/spendRecorder.ts`](../../src/lib/quota/spendRecorder.ts).
|
||||
- API-key spend is buffered and flushed in batches by the
|
||||
[`SpendBatchWriter`](../../src/lib/spend/batchWriter.ts) (default 60s flush interval,
|
||||
1,000-entry buffer). Tunable via:
|
||||
|
||||
| Env var | Default | Purpose |
|
||||
| ----------------------------------- | ------- | ---------------------------------- |
|
||||
| `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | `60000` | Flush interval in milliseconds. |
|
||||
| `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | `1000` | Max buffered entries before flush. |
|
||||
|
||||
The dashboard's cost figures are **not** read from a stored per-row dollar amount — they
|
||||
are recomputed on the fly from token counts and the current pricing table each time the
|
||||
analytics endpoint runs. That means correcting a wrong price (and re-syncing) updates
|
||||
historical cost estimates retroactively.
|
||||
|
||||
---
|
||||
|
||||
## Dashboard: the Costs page
|
||||
|
||||
The **Costs** page lives at `/dashboard/costs`
|
||||
(`src/app/(dashboard)/dashboard/costs/`).
|
||||
Its main view is the **Cost Overview** tab
|
||||
(`src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx`),
|
||||
which loads everything from `GET /api/usage/analytics`.
|
||||
|
||||
What it shows:
|
||||
|
||||
- **Spend tiles** — estimated spend for _Today (1d)_, _7d_, _30d_, and the selected
|
||||
window. Range selector: `7d`, `30d`, `90d`, `all`.
|
||||
- **Headline metrics** — requests in window, active providers, active models, average
|
||||
cost per request.
|
||||
- **Cost Explorer** — a sortable/filterable table grouped by **provider**, **model**,
|
||||
**API key**, **account**, or **service tier**, with cost, requests, tokens, avg
|
||||
cost/request, and share-of-total %.
|
||||
- **Token usage** — total / input / output tokens and input:output ratio.
|
||||
- **Routing efficiency** — fallback count, fallback rate, and requested-model coverage.
|
||||
- **Monthly forecast** — projects month-end spend from the recent daily average.
|
||||
- **Period comparison** — % change between the first and second half of the window.
|
||||
- **Charts** — daily cost trend, provider share (pie), top providers, top models, cost
|
||||
by API key, cost by account, weekly usage pattern, and an activity heatmap.
|
||||
- **Export** — download the current window as **CSV** or **JSON** (the buttons appear
|
||||
once there is non-zero cost data).
|
||||
|
||||
When there is no priced traffic, rows render as a "Legacy / Free" label instead of `$0`,
|
||||
reflecting the savings-tracker model.
|
||||
|
||||
### Related Costs sub-pages
|
||||
|
||||
The Costs area also hosts (all under `/dashboard/costs/`):
|
||||
|
||||
- **Pricing** (`/dashboard/costs/pricing`) — view and override per-model prices (renders
|
||||
the shared Pricing tab).
|
||||
- **Budget** (`/dashboard/costs/budget`) — set per-scope spend limits (renders the shared
|
||||
Budget tab).
|
||||
- **Quota Share** (`/dashboard/costs/quota-share`) — shared-quota pools and burn-rate
|
||||
views.
|
||||
|
||||
---
|
||||
|
||||
## API endpoints
|
||||
|
||||
All of these require management auth (loopback/JWT, via `requireManagementAuth`) unless
|
||||
noted.
|
||||
|
||||
### Usage & cost analytics
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
| ------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/api/usage/analytics` | Full cost/usage analytics: summary, daily trend, by provider/model/API key/account/tier. Query: `range`, `startDate`, `endDate`, `apiKeyIds`, `presets`. |
|
||||
| `GET` | `/api/usage/utilization` | Per-provider quota utilization over time. Query: `range` (`1h`/`24h`/`7d`/`30d`), `provider`. |
|
||||
| `GET` | `/api/usage/history` | Raw usage history rows. |
|
||||
| `GET` | `/api/usage/call-logs` | Per-request call logs (model, tokens, cost, latency, status). |
|
||||
| `GET` | `/api/usage/quota` | Provider quota status. |
|
||||
| `GET` | `/api/usage/proxy-logs` | Proxy request logs. |
|
||||
|
||||
### Budgets
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
| ------ | ------------------------ | ------------------------------------------------------------------------------ |
|
||||
| `GET` | `/api/usage/budget` | Cost summary + budget check for one API key (`apiKeyId` query param required). |
|
||||
| `POST` | `/api/usage/budget` | Set daily/weekly/monthly USD limits + warning threshold for an API key. |
|
||||
| `GET` | `/api/usage/budget/bulk` | Bulk budget summaries across API keys. |
|
||||
|
||||
> The budget API is scoped per **API key** (`apiKeyId`). Limits returned by
|
||||
> `GET /api/usage/budget` include `dailyLimitUsd`, `weeklyLimitUsd`, `monthlyLimitUsd`,
|
||||
> a `warningThreshold`, and the running totals (`totalCostToday`, `totalCostMonth`, …).
|
||||
|
||||
### Pricing
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
| -------- | ----------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/api/pricing` | Current merged pricing (user + synced + defaults). `?includeSources=1` to see source per entry. |
|
||||
| `PATCH` | `/api/pricing` | Override pricing for `{ provider: { model: { input, output, cached, … } } }`. |
|
||||
| `DELETE` | `/api/pricing` | Reset pricing to defaults (optionally scoped by `?provider=&model=`). |
|
||||
| `GET` | `/api/pricing/defaults` | Show default per-1M fallback rates. |
|
||||
| `GET` | `/api/pricing/models` | Pricing keyed by model. |
|
||||
| `POST` | `/api/pricing/sync` | Trigger a manual sync from external sources (LiteLLM). |
|
||||
| `GET` | `/api/pricing/sync` | Current sync status. |
|
||||
| `DELETE` | `/api/pricing/sync` | Clear all synced pricing data. |
|
||||
|
||||
### Other cost-relevant endpoints
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
| ------ | ----------------------------- | ----------------------------------------------------------------------- |
|
||||
| `GET` | `/api/free-tier/summary` | Free-model token totals, used-this-month, and remaining free allowance. |
|
||||
| `GET` | `/api/quota/pools/[id]/usage` | Usage for a shared-quota pool. |
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
OmniRoute's CLI exposes cost, usage, and pricing commands (registered in
|
||||
[`bin/cli/commands/registry.mjs`](../../bin/cli/commands/registry.mjs)).
|
||||
|
||||
### `omniroute cost`
|
||||
|
||||
A cost report aggregated from `/api/usage/analytics`.
|
||||
|
||||
```bash
|
||||
omniroute cost # last 30d, grouped by provider
|
||||
omniroute cost --period 7d # last 7 days
|
||||
omniroute cost --group-by model # group by provider | model | combo | api-key | day
|
||||
omniroute cost --since 2026-06-01 --until 2026-06-13
|
||||
omniroute cost --api-key <key> --limit 50
|
||||
```
|
||||
|
||||
Columns: group, requests, tokens in/out, cost (USD), and % of total. A grand total line
|
||||
is printed at the end (suppressed with `--quiet` or `--output json`).
|
||||
|
||||
### `omniroute usage`
|
||||
|
||||
```bash
|
||||
omniroute usage analytics --period 30d [--provider <id>] # per-provider cost summary
|
||||
omniroute usage logs [--limit 100] [--follow] [--api-key <k>] [--search <q>]
|
||||
omniroute usage quota [--provider <id>] [--check]
|
||||
omniroute usage utilization [--api-key <k>]
|
||||
omniroute usage history [--limit 100]
|
||||
omniroute usage proxy-logs [--limit 100]
|
||||
|
||||
# Budgets
|
||||
omniroute usage budget list
|
||||
omniroute usage budget get [scope]
|
||||
omniroute usage budget set <amount> [--scope global] [--period monthly]
|
||||
omniroute usage budget reset [scope]
|
||||
```
|
||||
|
||||
### `omniroute pricing`
|
||||
|
||||
```bash
|
||||
omniroute pricing list [--provider <p>] [--model <m>] [--limit 200]
|
||||
omniroute pricing get <model>
|
||||
omniroute pricing sync [--provider <p>] [--force] # POST /api/pricing/sync
|
||||
omniroute pricing diff [--model <m>]
|
||||
omniroute pricing defaults show
|
||||
omniroute pricing defaults set [--input <p>] [--output <p>] [--cache-read <p>] [--cache-write <p>]
|
||||
```
|
||||
|
||||
> `pricing defaults show` reads `GET /api/pricing/defaults`. To edit individual model
|
||||
> prices instead, use the **Pricing** dashboard page or `PATCH /api/pricing`.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **All costs show $0 / "Legacy / Free".** The models in use have no pricing entry.
|
||||
Enable external sync (`PRICING_SYNC_ENABLED=true`) and run `omniroute pricing sync`, or
|
||||
set prices manually via the Pricing page / `PATCH /api/pricing`.
|
||||
- **A historical model is mispriced.** Fix the price (override or re-sync) — cost is
|
||||
recomputed from token counts on every analytics read, so estimates update retroactively.
|
||||
- **Spend lags behind real time.** Per-key spend is batched; lower
|
||||
`OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` if you need fresher numbers.
|
||||
|
||||
---
|
||||
|
||||
For where this fits in the broader dashboard, see the [User Guide](./USER_GUIDE.md) and
|
||||
the [Features Gallery](./FEATURES.md).
|
||||
247
Egress-Policy.md
Normal file
247
Egress-Policy.md
Normal file
@@ -0,0 +1,247 @@
|
||||
> 🌍 [View in other languages](Languages)
|
||||
|
||||
|
||||
# Egress IP Family Policy (IPv4/IPv6)
|
||||
|
||||
> **Pin outbound traffic to a single IP family — `auto`, `ipv4`, or `ipv6` — per proxy, so an IPv6-only egress never silently leaks back to IPv4.**
|
||||
|
||||
> **Source of truth:** `open-sse/utils/proxyFamily.ts`, `open-sse/utils/proxyDispatcher.ts`, `open-sse/utils/proxyFetch.ts`, `open-sse/utils/socksConnectorWithFamily.ts`, `open-sse/utils/proxyFamilyResolve.ts`, `src/shared/validation/schemas.ts`, `src/lib/db/proxies.ts`, `src/lib/db/upstreamProxy.ts`, `src/lib/db/migrations/099_proxy_family.sql`
|
||||
|
||||
OmniRoute lets each proxy carry an **address-family egress directive**. By default the OS picks IPv4 or IPv6 (dual-stack, "Happy Eyeballs"). When you set the directive to `ipv4` or `ipv6`, OmniRoute pins every connection through that proxy to the chosen family and **fails closed** rather than falling back to the other family.
|
||||
|
||||
This page documents what the directive is, why it exists, where you configure it, and how the runtime resolves it.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [What It Is](#what-it-is)
|
||||
- [Why It Exists](#why-it-exists)
|
||||
- [The Three Values](#the-three-values)
|
||||
- [How to Configure It](#how-to-configure-it)
|
||||
- [How `auto` Resolves](#how-auto-resolves)
|
||||
- [How `ipv4` / `ipv6` Are Enforced](#how-ipv4--ipv6-are-enforced)
|
||||
- [SOCKS5 Compatibility](#socks5-compatibility)
|
||||
- [Fail-Closed Behavior](#fail-closed-behavior)
|
||||
- [Data Model](#data-model)
|
||||
- [Related Documentation](#related-documentation)
|
||||
|
||||
---
|
||||
|
||||
## What It Is
|
||||
|
||||
Every proxy in the registry has a `family` field with three possible values, validated by a Zod enum:
|
||||
|
||||
```ts
|
||||
// src/shared/validation/schemas.ts
|
||||
family: z.enum(["auto", "ipv4", "ipv6"]).optional().default("auto"),
|
||||
```
|
||||
|
||||
The field defaults to `"auto"`, which preserves the prior dual-stack behavior. Setting it to `ipv4` or `ipv6` pins the connect family for that proxy.
|
||||
|
||||
The directive is normalized everywhere through a single helper so any unknown value collapses to `auto`:
|
||||
|
||||
```ts
|
||||
// open-sse/utils/proxyFamily.ts
|
||||
export type ProxyFamily = "auto" | "ipv4" | "ipv6";
|
||||
|
||||
export function parseProxyFamily(value: unknown): ProxyFamily {
|
||||
return value === "ipv4" || value === "ipv6" ? value : "auto";
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Why It Exists
|
||||
|
||||
Introduced in PR [#3777](https://github.com/diegosouzapw/OmniRoute/pull/3777). The motivating problems:
|
||||
|
||||
| Problem | What the directive fixes |
|
||||
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **IPv6-only egress leaking to IPv4** | When a proxy host has both A and AAAA records (or the OS prefers IPv4), Happy Eyeballs can dial out over IPv4 even when you intend an IPv6-only path. Pinning `ipv6` removes that leak. |
|
||||
| **Shared-egress anomaly revocation** | Rotating providers (codex/openai) revoke tokens when many accounts egress through the **same** IP at high volume. Controlling the egress family is part of keeping accounts on distinct, predictable egress paths (see [`src/lib/proxyEgress.ts`](../../src/lib/proxyEgress.ts) for the egress-IP diagnostics that pair with this). |
|
||||
| **Deterministic egress for compliance/testing** | When you must guarantee traffic leaves over a specific family, `auto` is not enough. |
|
||||
|
||||
The directive is intentionally **per-proxy**, not global — different proxies in your pool can have different policies.
|
||||
|
||||
---
|
||||
|
||||
## The Three Values
|
||||
|
||||
| Value | UI label | Behavior |
|
||||
| ------ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `auto` | `Auto (dual-stack)` | OS picks the family. For an IP-literal proxy host, the family is intrinsic to the literal; for a hostname, both families are eligible. This is the default. |
|
||||
| `ipv4` | `IPv4 only` | Pins the connection to IPv4. Fails closed if the proxy host has no IPv4 (A) record. |
|
||||
| `ipv6` | `IPv6 only` | Pins the connection to IPv6. Fails closed if the proxy host has no IPv6 (AAAA) record. |
|
||||
|
||||
UI strings live in `src/i18n/messages/en.json` (`labelFamily`, `familyAuto`, `familyIpv4`, `familyIpv6`, `familyHint`).
|
||||
|
||||
---
|
||||
|
||||
## How to Configure It
|
||||
|
||||
### Dashboard
|
||||
|
||||
The selector is in the proxy form of the **Proxy Pool** tab:
|
||||
|
||||
1. Open **Dashboard → Settings → Proxy → Proxy Pool**
|
||||
2. Add or edit a proxy
|
||||
3. Set the **IP family** dropdown to `Auto (dual-stack)`, `IPv4 only`, or `IPv6 only`
|
||||
4. Save
|
||||
|
||||
The control is rendered by `ProxyRegistryManager.tsx` (mounted in `proxy/ProxyPoolTab.tsx`).
|
||||
|
||||
### API
|
||||
|
||||
The `family` field is part of the proxy registry create/update payloads, validated by `createProxyRegistrySchema` / `updateProxyRegistrySchema` (`src/shared/validation/schemas.ts`) and handled by `POST` / `PATCH /api/v1/management/proxies`:
|
||||
|
||||
```bash
|
||||
# Create an IPv6-only proxy
|
||||
curl -X POST http://localhost:20128/api/v1/management/proxies \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "IPv6 egress",
|
||||
"type": "socks5",
|
||||
"host": "proxy.example.com",
|
||||
"port": 1080,
|
||||
"family": "ipv6"
|
||||
}'
|
||||
|
||||
# Change an existing proxy to IPv4-only
|
||||
curl -X PATCH http://localhost:20128/api/v1/management/proxies \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "id": "proxy-uuid-here", "family": "ipv4" }'
|
||||
```
|
||||
|
||||
The same field is also accepted by the inline proxy config object used for upstream-proxy entries (`upstream_proxy_config.family`, see [Data Model](#data-model)).
|
||||
|
||||
For the rest of the proxy CRUD/assignment API, see [PROXY_GUIDE.md](../ops/PROXY_GUIDE.md).
|
||||
|
||||
---
|
||||
|
||||
## How `auto` Resolves
|
||||
|
||||
When `family` is `auto`, OmniRoute does **not** append any directive — the proxy URL is used as-is and the connect family is determined intrinsically.
|
||||
|
||||
At URL-build time (`proxyConfigToUrl` / `normalizeProxyUrl` in `open-sse/utils/proxyDispatcher.ts`), an `auto` proxy yields a plain URL with no marker:
|
||||
|
||||
```ts
|
||||
// open-sse/utils/proxyDispatcher.ts
|
||||
const fam = parseProxyFamily(config.family);
|
||||
const normalized = normalizeProxyUrl(proxyUrlStr, "context proxy", { allowSocks5 });
|
||||
return fam === "auto" ? normalized : `${normalized}?family=${fam}`;
|
||||
```
|
||||
|
||||
At dispatch time (`resolveDispatcherFamily`), `auto` resolves to the intrinsic family of an IP-literal host, or `null` (let the OS decide) for a hostname:
|
||||
|
||||
```ts
|
||||
// open-sse/utils/proxyDispatcher.ts
|
||||
function resolveDispatcherFamily(parsed: URL): 4 | 6 | null {
|
||||
const directive = parseProxyFamily(parsed.searchParams.get("family") ?? undefined);
|
||||
const literal = detectIpLiteralFamily(parsed.hostname);
|
||||
if (directive === "auto") return literal; // null for a hostname → OS picks
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
So:
|
||||
|
||||
- `auto` + IP-literal host (`192.0.2.1` / `[2001:db8::1]`) → family of that literal.
|
||||
- `auto` + hostname → `null` → standard dual-stack OS resolution.
|
||||
|
||||
---
|
||||
|
||||
## How `ipv4` / `ipv6` Are Enforced
|
||||
|
||||
A non-`auto` directive travels as a single synthetic query marker — `?family=ipv4` or `?family=ipv6` — appended once to the normalized proxy URL. `normalizeProxyUrl` is careful to strip and re-append this marker exactly once so it never corrupts port parsing.
|
||||
|
||||
When the dispatcher is built, the marker is read and converted to a concrete connect family. If the host is an IP literal of the **opposite** family, OmniRoute throws (contradiction is fail-closed):
|
||||
|
||||
```ts
|
||||
// open-sse/utils/proxyDispatcher.ts
|
||||
const want = directive === "ipv6" ? 6 : 4;
|
||||
if (literal !== null && literal !== want) {
|
||||
throw new Error(
|
||||
`[ProxyDispatcher] Proxy family directive ${directive} contradicts ${literal === 6 ? "IPv6" : "IPv4"} literal host`
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The concrete family is then pinned on the connector:
|
||||
|
||||
- **HTTP/HTTPS proxies** (`ProxyAgent`): `proxyTls: { family, autoSelectFamily: false }` — disables Happy Eyeballs so the chosen family is the only one dialed.
|
||||
- **SOCKS5 proxies**: a custom connector threads `socket_options: { family, autoSelectFamily: false }` into the SOCKS client (see [SOCKS5 Compatibility](#socks5-compatibility)).
|
||||
|
||||
---
|
||||
|
||||
## SOCKS5 Compatibility
|
||||
|
||||
The family pin works with SOCKS5 proxies, but stock `fetch-socks` does not expose the socket options needed to pin the family of the proxy hop. OmniRoute ships its own connector for that:
|
||||
|
||||
```ts
|
||||
// open-sse/utils/socksConnectorWithFamily.ts
|
||||
export function buildSocksFamilySocketOptions(family: 4 | 6 | null): Record<string, unknown> {
|
||||
if (family === 6) return { family: 6, autoSelectFamily: false };
|
||||
if (family === 4) return { family: 4, autoSelectFamily: false };
|
||||
return {};
|
||||
}
|
||||
```
|
||||
|
||||
`createProxyDispatcher` chooses the connector based on whether a family is pinned:
|
||||
|
||||
- `family === null` (i.e. `auto` over a hostname) → stock `socksDispatcher` from `fetch-socks`.
|
||||
- `family === 4 | 6` → `createSocksDispatcherWithFamily`, which threads `socket_options` into `SocksClient.createConnection` so Happy Eyeballs cannot pick IPv4 for an IPv6-only egress policy.
|
||||
|
||||
SOCKS5 support itself is on by default (opt-out via `ENABLE_SOCKS5_PROXY=false`); see [PROXY_GUIDE.md → Environment Variables](../ops/PROXY_GUIDE.md#environment-variables).
|
||||
|
||||
---
|
||||
|
||||
## Fail-Closed Behavior
|
||||
|
||||
The whole point of the directive is to **refuse** rather than silently fall back to the wrong family. Two guards enforce this:
|
||||
|
||||
1. **Literal contradiction** — a directive that contradicts an IP-literal host throws at dispatcher build time (`resolveDispatcherFamily`, shown above).
|
||||
|
||||
2. **Hostname pre-flight DNS check** — for a hostname proxy with a pinned family, `proxyFetch.ts` verifies the hostname actually has a record in the required family **before** egressing, via `assertHostnameSupportsFamily`:
|
||||
|
||||
```ts
|
||||
// open-sse/utils/proxyFamilyResolve.ts
|
||||
const hasFamily = records.some((r) => r.family === family);
|
||||
if (!hasFamily) {
|
||||
throw new Error(
|
||||
`[ProxyFamily] Proxy host ${host} has no ${family === 6 ? "IPv6 (AAAA)" : "IPv4 (A)"} record; ` +
|
||||
`refusing ${family === 6 ? "IPv6" : "IPv4"}-only egress (fail-closed)`
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
On failure, `proxyFetch.ts` tags the error with `code = "PROXY_FAMILY_UNAVAILABLE"` and `statusCode = 503`. A DNS resolution failure is likewise treated as fail-closed (refuse to egress).
|
||||
|
||||
IP-literal hosts are a no-op for the DNS pre-flight — their family is intrinsic and needs no lookup.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
The `family` column was added by migration `099_proxy_family.sql` to **two** tables:
|
||||
|
||||
```sql
|
||||
-- src/lib/db/migrations/099_proxy_family.sql
|
||||
ALTER TABLE proxy_registry ADD COLUMN family TEXT NOT NULL DEFAULT 'auto';
|
||||
ALTER TABLE upstream_proxy_config ADD COLUMN family TEXT NOT NULL DEFAULT 'auto';
|
||||
```
|
||||
|
||||
- `proxy_registry.family` — the per-proxy directive for registry entries (`src/lib/db/proxies.ts`). Resolution queries select `family` alongside the other proxy columns, and a missing/non-string value is coerced to `"auto"`.
|
||||
- `upstream_proxy_config.family` — the directive for upstream-proxy entries (`src/lib/db/upstreamProxy.ts`), with the same `"auto"` default.
|
||||
|
||||
When a resolved proxy object carries a non-`auto` `family`, `proxyConfigToUrl` appends the `?family=` marker so the pin survives all the way to the dispatcher.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
> 📖 **Related documentation:**
|
||||
>
|
||||
> - [Proxy Guide](../ops/PROXY_GUIDE.md) — full proxy system: registry CRUD, 4-level resolution, rotation, health checking, API reference
|
||||
> - [Stealth Guide](./STEALTH_GUIDE.md) — TLS fingerprint and CLI fingerprint layers that ride on top of the proxy
|
||||
> - [Route Guard Tiers](./ROUTE_GUARD_TIERS.md) — loopback enforcement for local-only routes
|
||||
232
Feature-Flags.md
Normal file
232
Feature-Flags.md
Normal file
@@ -0,0 +1,232 @@
|
||||
> 🌍 [View in other languages](Languages)
|
||||
|
||||
|
||||
# Feature Flags
|
||||
|
||||
> Runtime toggles that change OmniRoute's behavior **without a redeploy**.
|
||||
> Every flag listed here is defined in
|
||||
> [`src/shared/constants/featureFlagDefinitions.ts`](../../src/shared/constants/featureFlagDefinitions.ts)
|
||||
> — the single source of truth. The dashboard and the REST API both read from
|
||||
> that file, so the table below is generated to match it 1:1.
|
||||
|
||||
---
|
||||
|
||||
## What Feature Flags Are
|
||||
|
||||
A feature flag is a named toggle (boolean or enum) whose value can be changed at
|
||||
runtime and persisted in the database, with no process redeploy required. Each
|
||||
flag is described by a `FeatureFlagDefinition` with a `key`, `label`,
|
||||
`description`, `category`, `defaultValue`, `type`, and a `requiresRestart` hint.
|
||||
|
||||
### Resolution Order
|
||||
|
||||
The **effective value** of a flag is resolved by
|
||||
[`resolveFeatureFlag()`](../../src/shared/utils/featureFlags.ts) with this
|
||||
precedence (highest wins):
|
||||
|
||||
1. **DB override** — a value stored in the `key_value` table under the
|
||||
`feature_flags` namespace (set via the dashboard or the REST API).
|
||||
2. **Environment variable** — `process.env[<KEY>]`, if set and non-empty.
|
||||
3. **Definition default** — the `defaultValue` from `featureFlagDefinitions.ts`.
|
||||
|
||||
A boolean flag is considered **enabled** when its effective value is `"true"`,
|
||||
`"1"`, or `"yes"` (see `isFeatureFlagEnabled()`).
|
||||
|
||||
> [!NOTE]
|
||||
> Most flags also have a matching environment variable of the **same name**
|
||||
> documented in [`ENVIRONMENT.md`](./ENVIRONMENT.md). The flag's DB override
|
||||
> takes precedence over that environment variable. A flag with
|
||||
> `requiresRestart: true` is persisted immediately but only re-read at process
|
||||
> startup — toggling it surfaces a **"Restart Server"** banner in the dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Flag Catalog
|
||||
|
||||
31 flags across 6 categories. **Default** is the definition default — the value
|
||||
used when neither a DB override nor an environment variable is present.
|
||||
|
||||
### Security (7)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| -------------------------------- | ------- | -------- | ----------------------------------------------------------------------------- |
|
||||
| `REQUIRE_API_KEY` | boolean | `false` | Require an API key for all incoming requests. |
|
||||
| `INPUT_SANITIZER_ENABLED` | boolean | `true` | Enable input sanitization for all requests. |
|
||||
| `INJECTION_GUARD_MODE` | enum | `off` | Prompt injection guard mode. Values: `off`, `warn`, `block`, `redact`. |
|
||||
| `PII_REDACTION_ENABLED` | boolean | `false` | Redact personally identifiable information from requests. |
|
||||
| `PII_RESPONSE_SANITIZATION` | boolean | `false` | Sanitize PII from provider responses. |
|
||||
| `PII_RESPONSE_SANITIZATION_MODE` | enum | `redact` | Mode for PII response sanitization. Values: `redact`, `warn`, `block`, `off`. |
|
||||
| `OUTBOUND_SSRF_GUARD_ENABLED` | boolean | `true` | Block outbound requests to private/internal IP ranges. |
|
||||
|
||||
### Network (6)
|
||||
|
||||
| Key | Type | Default | Restart | Description |
|
||||
| --------------------------------------- | ------- | ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ENABLE_TLS_FINGERPRINT` | boolean | `false` | ✓ | Enable TLS fingerprint stealth mode. |
|
||||
| `ONEPROXY_ENABLED` | boolean | `true` | | Enable 1proxy request proxying. |
|
||||
| `PROXY_AUTO_SELECT_ENABLED` | boolean | `false` | | When no proxy is assigned to a connection, auto-select the first working proxy from the registry. Off by default (otherwise any registry proxy becomes a global fallback — #3332). |
|
||||
| `MITM_DISABLE_TLS_VERIFY` | boolean | `false` | ✓ | Disable TLS certificate verification for the MITM proxy. **Danger.** |
|
||||
| `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | boolean | `false` | | Allow provider URLs pointing to private/internal networks. |
|
||||
| `ENABLE_CC_COMPATIBLE_PROVIDER` | boolean | `false` | ✓ | Enable Claude Code compatible provider mode. |
|
||||
|
||||
### Policies (3)
|
||||
|
||||
| Key | Type | Default | Restart | Description |
|
||||
| ----------------------------------------- | ------- | ---------- | ------- | ---------------------------------------------------------------------- |
|
||||
| `TOOL_POLICY_MODE` | enum | `disabled` | | Tool-use policy enforcement mode. Values: `disabled`, `warn`, `block`. |
|
||||
| `RATE_LIMIT_AUTO_ENABLE` | boolean | `false` | | Automatically enable rate limiting based on usage patterns. |
|
||||
| `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` | boolean | `false` | ✓ | Allow multiple connections per compatibility node. |
|
||||
|
||||
### Runtime (9)
|
||||
|
||||
| Key | Type | Default | Restart | Description |
|
||||
| ------------------------------------------- | ------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `OMNIROUTE_MCP_ENFORCE_SCOPES` | boolean | `true` | | Enforce scope restrictions on MCP tool access. |
|
||||
| `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | boolean | `false` | | Compress MCP tool descriptions to reduce token usage. |
|
||||
| `OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS` | boolean | `false` | | Enable background task processing at runtime. |
|
||||
| `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | boolean | `false` | ✓ | Disable all background services (quota refresh, sync, etc). |
|
||||
| `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | boolean | `false` | | Trust project-level RTK filters without validation. |
|
||||
| `OMNIROUTE_ENABLE_LIVE_WS` | boolean | `true` | ✓ | Start the real-time dashboard WebSocket server on import (port 20129 by default). |
|
||||
| `OMNIROUTE_CODEX_WS_ENABLED` | boolean | `true` | | Allow Codex to use the Responses-over-WebSocket transport. When off, Codex falls back to HTTP Responses. |
|
||||
| `OMNIROUTE_EMERGENCY_FALLBACK` | boolean | `true` | | Route budget-exhausted requests to the emergency free fallback provider/model. (See [Emergency Budget Fallback](#emergency-budget-fallback) below.) |
|
||||
| `MODEL_CATALOG_INCLUDE_NAMES` | boolean | `true` | | Include display-friendly name fields in `/v1/models` responses. Disable for clients that expect model IDs only. |
|
||||
|
||||
### CLI (3)
|
||||
|
||||
| Key | Type | Default | Restart | Description |
|
||||
| ---------------------------- | ------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `CLI_COMPAT_ALL` | boolean | `false` | ✓ | Enable compatibility mode for all CLI clients. |
|
||||
| `MODEL_ALIAS_COMPAT_ENABLED` | boolean | `false` | | Enable model alias compatibility layer. |
|
||||
| `PRICING_SYNC_ENABLED` | boolean | `false` | | Enable automatic pricing data synchronization (also requires the `PRICING_SYNC_ENABLED` environment variable). |
|
||||
|
||||
### Health (3)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| ------------------------------------- | ------- | ------- | -------------------------------------------------------- |
|
||||
| `OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK` | boolean | `false` | Disable the local instance health check endpoint. |
|
||||
| `OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK` | boolean | `false` | Disable the token validation health check. |
|
||||
| `SKILLS_SANDBOX_NETWORK_ENABLED` | boolean | `false` | Enable network access in the skills sandbox environment. |
|
||||
|
||||
> [!NOTE]
|
||||
> The `Restart` column marks flags with `requiresRestart: true` — the value is
|
||||
> persisted instantly but only takes effect after the process reloads. Enum
|
||||
> flags reject any value outside their allowed set (validated server-side in
|
||||
> both `setFeatureFlagOverride()` and the REST `PUT` handler).
|
||||
|
||||
---
|
||||
|
||||
## Toggling Flags
|
||||
|
||||
### Dashboard
|
||||
|
||||
Navigate to **Dashboard → Settings → Feature Flags**
|
||||
(`/dashboard/settings/feature-flags`). The grid
|
||||
(`src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx`)
|
||||
supports:
|
||||
|
||||
- **Search** by key or description, and **filter** by category (plus a synthetic
|
||||
**Requires Restart** view).
|
||||
- A **toggle** for boolean flags and a **dropdown** for enum flags
|
||||
(`src/app/(dashboard)/dashboard/settings/components/FeatureFlagCard.tsx`).
|
||||
- A **source badge** per flag — `DB`, `ENV`, or `DEF` — showing where the
|
||||
effective value came from.
|
||||
- A **Reset** button (shown only for `DB`-sourced flags) to drop the override,
|
||||
and a **Reset All Overrides** button at the bottom.
|
||||
- A **Restart Server** banner when a `requiresRestart` flag is changed.
|
||||
|
||||
### REST API
|
||||
|
||||
All operations go through a single route:
|
||||
[`src/app/api/settings/feature-flags/route.ts`](../../src/app/api/settings/feature-flags/route.ts).
|
||||
Every method requires an authenticated dashboard session (`401` otherwise).
|
||||
|
||||
#### `GET /api/settings/feature-flags`
|
||||
|
||||
Returns every flag with its effective value, source, and a summary.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"flags": [
|
||||
{
|
||||
"key": "REQUIRE_API_KEY",
|
||||
"label": "Require API Key",
|
||||
"description": "Require an API key for all incoming requests",
|
||||
"category": "security",
|
||||
"type": "boolean",
|
||||
"enumValues": null,
|
||||
"defaultValue": "false",
|
||||
"effectiveValue": "false",
|
||||
"source": "default", // "db" | "env" | "default"
|
||||
"requiresRestart": false,
|
||||
"warningLevel": "caution",
|
||||
},
|
||||
// ... all 31 flags
|
||||
],
|
||||
"summary": {
|
||||
"total": 31,
|
||||
"active": 0,
|
||||
"inactive": 0,
|
||||
"overriddenByDb": 0,
|
||||
"overriddenByEnv": 0,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### `PUT /api/settings/feature-flags`
|
||||
|
||||
Set or remove a single override. Body: `{ key: string; value?: string }`.
|
||||
Omitting `value` removes the override (restoring env / default).
|
||||
|
||||
```bash
|
||||
# Set a DB override
|
||||
curl -X PUT http://localhost:20128/api/settings/feature-flags \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"key":"REQUIRE_API_KEY","value":"true"}'
|
||||
|
||||
# Remove the override (no "value")
|
||||
curl -X PUT http://localhost:20128/api/settings/feature-flags \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"key":"REQUIRE_API_KEY"}'
|
||||
```
|
||||
|
||||
The response echoes the new `effectiveValue`/`source`, the `previousValue`/
|
||||
`previousSource`, and `requiresRestart`. Unknown keys and out-of-range enum
|
||||
values are rejected with `400`.
|
||||
|
||||
#### `DELETE /api/settings/feature-flags`
|
||||
|
||||
Clears **all** DB overrides at once, restoring every flag to its env / default
|
||||
value. Returns `{ cleared: <count>, message: "..." }`.
|
||||
|
||||
> [!NOTE]
|
||||
> Flags with `requiresRestart: true` only take effect after a process reload.
|
||||
> The dashboard's restart flow calls `POST /api/restart` and then polls
|
||||
> `GET /api/health/ping` until the server is back up.
|
||||
|
||||
---
|
||||
|
||||
## Emergency Budget Fallback
|
||||
|
||||
`OMNIROUTE_EMERGENCY_FALLBACK` (category `runtime`, default `true`) controls the
|
||||
emergency free-fallback path in
|
||||
[`open-sse/services/emergencyFallback.ts`](../../open-sse/services/emergencyFallback.ts).
|
||||
When enabled, requests that exhaust their budget are routed to a free fallback
|
||||
provider/model instead of failing outright. Set it to `false` (or `0`) — via the
|
||||
dashboard toggle, a DB override, or the `OMNIROUTE_EMERGENCY_FALLBACK`
|
||||
environment variable — to disable the behavior and let budget-exhausted requests
|
||||
fail. (Surfaced as a dashboard toggle in PRs #3741 / #3752.)
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Environment Variables Reference](./ENVIRONMENT.md) — most flags have a
|
||||
same-named environment variable documented there (the DB override takes
|
||||
precedence over it).
|
||||
- [`src/shared/constants/featureFlagDefinitions.ts`](../../src/shared/constants/featureFlagDefinitions.ts)
|
||||
— source of truth for every flag.
|
||||
- [`src/shared/utils/featureFlags.ts`](../../src/shared/utils/featureFlags.ts)
|
||||
— resolution logic (`resolveFeatureFlag`, `isFeatureFlagEnabled`,
|
||||
`resolveAllFeatureFlags`).
|
||||
- [`src/lib/db/featureFlags.ts`](../../src/lib/db/featureFlags.ts) — DB override
|
||||
persistence in the `feature_flags` namespace of the `key_value` table.
|
||||
250
Free-Provider-Rankings.md
Normal file
250
Free-Provider-Rankings.md
Normal file
@@ -0,0 +1,250 @@
|
||||
> 🌍 [View in other languages](Languages)
|
||||
|
||||
|
||||
# Free Provider Rankings (Arena ELO)
|
||||
|
||||
> **TL;DR**: OmniRoute ranks its **free** providers by model quality using **Arena AI
|
||||
> (LMArena-style) ELO scores**. Open the **Free Provider Rankings** page in the
|
||||
> dashboard to see which free providers ship the strongest models for your task —
|
||||
> overall, or filtered by category (coding, review, documentation, debugging).
|
||||
|
||||
---
|
||||
|
||||
## What It Is
|
||||
|
||||
OmniRoute aggregates 160+ providers, many of which expose a **free tier** (no-auth,
|
||||
free-tier OAuth, or free-tier API key — see the
|
||||
[Free Tiers Guide](../getting-started/FREE-TIERS-GUIDE.md) and the full
|
||||
[Free Tiers directory](../reference/FREE_TIERS.md)). The catch: free providers vary
|
||||
wildly in model quality. A no-auth provider serving a frontier model is far more useful
|
||||
than one serving a small legacy model.
|
||||
|
||||
**Free Provider Rankings** answers "**which free provider gives me the best model?**" by
|
||||
joining each free provider's catalog with **crowd-sourced quality scores** from the
|
||||
**Arena AI leaderboard** (human-preference ELO, the same idea behind the LMArena
|
||||
chatbot arena). Providers are then ranked by the strength of their **best free model**.
|
||||
|
||||
The ranking is computed from three real sources:
|
||||
|
||||
1. The free-provider lists — `NOAUTH_PROVIDERS`, plus `OAUTH_PROVIDERS` /
|
||||
`APIKEY_PROVIDERS` entries flagged `hasFree`
|
||||
(`src/shared/constants/providers.ts`).
|
||||
2. Each provider's model catalog from the provider registry
|
||||
(`open-sse/config/providerRegistry.ts`).
|
||||
3. ELO-derived task-fit scores stored in the `model_intelligence` DB table by the
|
||||
Arena ELO sync engine (`src/lib/arenaEloSync.ts`).
|
||||
|
||||
The join logic lives in `src/lib/freeProviderRankings.ts`.
|
||||
|
||||
---
|
||||
|
||||
## How to Access
|
||||
|
||||
### Dashboard page
|
||||
|
||||
Open the dashboard and go to **Costs → Free Provider Rankings**, or navigate directly to:
|
||||
|
||||
```
|
||||
/dashboard/free-provider-rankings
|
||||
```
|
||||
|
||||
The page (`src/app/(dashboard)/dashboard/free-provider-rankings/page.tsx`) shows:
|
||||
|
||||
- A **top-3 podium** (🥇 🥈 🥉) of the best-ranked free providers.
|
||||
- A full **ranking table** with columns: **Rank**, **Provider**, **Top Model**,
|
||||
**Score**, **Avg Score**, **Models**, **Type**.
|
||||
- **Category filter buttons**: _All Categories_, _Default_, _Coding_, _Review_,
|
||||
_Documentation_, _Debugging_.
|
||||
|
||||
Each provider's **Type** badge tells you how it is free:
|
||||
|
||||
| Badge | Meaning |
|
||||
| -------- | --------------------------------------------- |
|
||||
| `NOAUTH` | Always free, no credentials needed |
|
||||
| `OAUTH` | OAuth provider with a free tier (`hasFree`) |
|
||||
| `APIKEY` | API-key provider with a free tier (`hasFree`) |
|
||||
|
||||
Scores are shown as human-readable labels (e.g. _Elite_, _Excellent_, _Very Good_,
|
||||
_Good_, _Average_) rather than raw numbers, because the underlying value is a relative
|
||||
ranking quality, not a percentage.
|
||||
|
||||
### API endpoint
|
||||
|
||||
The page is backed by a public read endpoint
|
||||
(`src/app/api/free-provider-rankings/route.ts`):
|
||||
|
||||
```
|
||||
GET /api/free-provider-rankings
|
||||
GET /api/free-provider-rankings?category=coding
|
||||
GET /api/free-provider-rankings?category=coding&limit=20
|
||||
```
|
||||
|
||||
Query parameters (validated with Zod):
|
||||
|
||||
| Param | Type | Default | Notes |
|
||||
| ---------- | ------ | ------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `category` | string | (none) | One of `default`, `coding`, `review`, `documentation`, `debugging`. Omit for the combined ranking. |
|
||||
| `limit` | number | `50` | Clamped to the range `1–100`. |
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"rankings": [
|
||||
{
|
||||
"id": "<provider-id>",
|
||||
"name": "<provider name>",
|
||||
"icon": "<icon>",
|
||||
"color": "<hex color>",
|
||||
"textIcon": "<short label>",
|
||||
"category": "noauth | oauth | apikey",
|
||||
"topModel": {
|
||||
"modelId": "<registry model id>",
|
||||
"modelName": "<model display name>",
|
||||
"score": 0.0,
|
||||
"eloRaw": 0,
|
||||
"confidence": "high | medium | low",
|
||||
"category": "<task category>"
|
||||
},
|
||||
"averageScore": 0.0,
|
||||
"modelCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`eloRaw` is the original Arena ELO value; `score` is the normalized task-fit value
|
||||
(see below). Providers with no scored models are omitted from the response.
|
||||
|
||||
---
|
||||
|
||||
## How the Scores Work
|
||||
|
||||
### Source: Arena AI leaderboard
|
||||
|
||||
The Arena ELO sync engine (`src/lib/arenaEloSync.ts`) fetches two leaderboards — `text`
|
||||
and `code` — from the Arena AI leaderboard API
|
||||
(`https://api.wulong.dev/arena-ai-leaderboards/v1/leaderboard`). Each leaderboard entry
|
||||
carries a model name, vendor, ELO `score`, confidence interval, and vote count.
|
||||
|
||||
Leaderboard categories map to OmniRoute task categories:
|
||||
|
||||
| Arena leaderboard | OmniRoute task categories |
|
||||
| ----------------- | ------------------------------------------------- |
|
||||
| `text` | `default`, `review`, `documentation`, `debugging` |
|
||||
| `code` | `coding` |
|
||||
|
||||
### Normalization (task-fit score)
|
||||
|
||||
Raw ELO scores are normalized per leaderboard into a **task-fit value in `[0.4, 0.98]`**:
|
||||
|
||||
```
|
||||
taskFit = 0.4 + 0.58 * ((elo - minElo) / (maxElo - minElo))
|
||||
```
|
||||
|
||||
The score never reaches `0` or `1`, leaving headroom for user overrides. This is the
|
||||
`score` field you see in the API response and the label shown on the dashboard.
|
||||
|
||||
### Confidence
|
||||
|
||||
Each entry gets a confidence level based on Arena vote count:
|
||||
|
||||
| Confidence | Votes |
|
||||
| ---------- | ------- |
|
||||
| `high` | ≥ 5,000 |
|
||||
| `medium` | ≥ 1,000 |
|
||||
| `low` | < 1,000 |
|
||||
|
||||
### Storage and freshness
|
||||
|
||||
Normalized entries are written to the `model_intelligence` DB table with
|
||||
`source = "arena_elo"` (`src/lib/db/modelIntelligence.ts`). Entries **expire after
|
||||
7 days**, so a provider that stops syncing eventually drops out rather than serving
|
||||
stale data.
|
||||
|
||||
The sync runs **on by default**:
|
||||
|
||||
- It runs once at server startup and then on a periodic timer
|
||||
(`src/lib/arenaEloSync.ts`, wired from `src/server-init.ts`).
|
||||
- It is **non-blocking and never fatal** — if the upstream fetch fails, OmniRoute keeps
|
||||
running and the rankings simply show the last good data (or an empty state).
|
||||
|
||||
Two environment variables control it (documented in
|
||||
[`docs/reference/ENVIRONMENT.md`](../reference/ENVIRONMENT.md)):
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| ------------------------- | ------------- | ----------------------------------------------- |
|
||||
| `ARENA_ELO_SYNC_ENABLED` | `true` | Set to `false` to opt out of the outbound sync. |
|
||||
| `ARENA_ELO_SYNC_INTERVAL` | `86400` (24h) | Sync interval, in seconds. |
|
||||
|
||||
### Manual sync / status / clear
|
||||
|
||||
For operators, an authenticated management endpoint exposes manual control
|
||||
(`src/app/api/intelligence/sync/route.ts` — requires management auth):
|
||||
|
||||
```
|
||||
GET /api/intelligence/sync # current sync status (enabled, lastSync, nextSync, intervalMs)
|
||||
POST /api/intelligence/sync # trigger a manual sync; body: { "dryRun": true } to preview without writing
|
||||
DELETE /api/intelligence/sync # clear all synced arena_elo intelligence entries
|
||||
```
|
||||
|
||||
If the rankings page is empty, a manual `POST /api/intelligence/sync` (or simply
|
||||
restarting the server) repopulates it.
|
||||
|
||||
### Matching models to the leaderboard
|
||||
|
||||
Registry model IDs and Arena model names don't always match exactly. The ranking uses
|
||||
flexible matching (`findMatchingIntelligence` in `src/lib/freeProviderRankings.ts`):
|
||||
|
||||
1. Exact match on the normalized model ID.
|
||||
2. Match after stripping a trailing version suffix (e.g. `kimi-k2.6` → `kimi-k2`).
|
||||
3. Prefix match (a leaderboard model name is a prefix of the registry ID).
|
||||
|
||||
On the sync side, known vendor prefixes (`anthropic/`, `openai/`, `google/`, …) are
|
||||
stripped and a small alias map expands canonical names into the variants OmniRoute uses
|
||||
internally, so models stay findable under any name.
|
||||
|
||||
### How a provider is ranked
|
||||
|
||||
For each free provider, the engine scores every model in its catalog, then:
|
||||
|
||||
- **Top Model** = the provider's highest-scoring model.
|
||||
- **Avg Score** = the mean score across all of that provider's scored models.
|
||||
- **Models** = how many of the provider's models had an Arena score.
|
||||
|
||||
Providers are sorted by **top-model score** first, then by average score. This rewards a
|
||||
provider that ships at least one strong free model.
|
||||
|
||||
---
|
||||
|
||||
## Using It to Choose Free Providers
|
||||
|
||||
1. **Pick the right category.** Use the **Coding** filter for agentic/code workloads, or
|
||||
leave it on **All Categories** / **Default** for general chat. The same provider can
|
||||
rank differently across categories because its top model differs per leaderboard.
|
||||
2. **Prefer the podium for one-shot setups.** If you only want to connect one or two free
|
||||
providers, start with the top-ranked ones for your category.
|
||||
3. **Check the Type badge.** `NOAUTH` providers are the fastest to connect (no
|
||||
credentials). `OAUTH` / `APIKEY` free tiers need a quick sign-up but often expose
|
||||
stronger models. See [Free Tiers Guide](../getting-started/FREE-TIERS-GUIDE.md) for
|
||||
connection steps.
|
||||
4. **Connect several and let Auto-Combo decide.** The same Arena ELO data that powers
|
||||
this page also feeds the **task-fitness factor** of the Auto-Combo scoring engine
|
||||
(`open-sse/services/autoCombo/taskFitness.ts`, resolution order
|
||||
`user_override → arena_elo → models_dev_tier → static table`). So after you connect
|
||||
the top free providers, routing with `model: "auto"` (e.g. `auto/coding`) will
|
||||
automatically prefer the higher-quality free models per request. See
|
||||
[Auto-Combo](../routing/AUTO-COMBO.md) for the full 9-factor scoring.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Free Tiers Guide](../getting-started/FREE-TIERS-GUIDE.md) — how to connect free
|
||||
providers, no credit card required.
|
||||
- [Free Tiers directory](../reference/FREE_TIERS.md) — full catalog of free providers
|
||||
and their limits.
|
||||
- [Auto-Combo](../routing/AUTO-COMBO.md) — the 9-factor routing engine that consumes the
|
||||
same Arena ELO task-fitness data.
|
||||
- [Environment variables](../reference/ENVIRONMENT.md) — `ARENA_ELO_SYNC_ENABLED` /
|
||||
`ARENA_ELO_SYNC_INTERVAL` reference.
|
||||
128
Notion-Context.md
Normal file
128
Notion-Context.md
Normal file
@@ -0,0 +1,128 @@
|
||||
> 🌍 [View in other languages](Languages)
|
||||
|
||||
|
||||
# Notion Context Source
|
||||
|
||||
> **Source of truth:** `src/lib/notion/api.ts` (REST client), `src/lib/db/notion.ts`
|
||||
> (token persistence), `open-sse/mcp-server/tools/notionTools.ts` (6 MCP tools),
|
||||
> `src/app/api/settings/notion/route.ts` (settings API). Tool registration and scope
|
||||
> wiring lives in `open-sse/mcp-server/server.ts`.
|
||||
|
||||
## What it is
|
||||
|
||||
OmniRoute can connect to a **Notion** workspace as a **context source** — a read/write
|
||||
knowledge base that agents reach through the built-in MCP server. Once a Notion
|
||||
integration token is configured, the MCP tools let an LLM search pages and databases,
|
||||
read page content and block trees, query databases with filters/sorts, and append new
|
||||
blocks — all proxied through OmniRoute (with retry, timeout, and error classification)
|
||||
so the model never touches the Notion API directly.
|
||||
|
||||
The integration is a thin, hardened wrapper over the official Notion REST API
|
||||
(`https://api.notion.com/v1`, `Notion-Version: 2026-03-11`). The client
|
||||
(`src/lib/notion/api.ts`) adds:
|
||||
|
||||
- **Retry with exponential backoff** (up to 3 attempts) for `429` and `5xx`.
|
||||
- **55-second request timeout** via `AbortController`.
|
||||
- **Typed error classification** — `NotionAuthError` (401/403),
|
||||
`NotionNotFoundError` (404), `NotionRateLimitError` (429, honors `retry after`
|
||||
hints), `NotionValidationError` (400/409), `NotionServerError` (5xx),
|
||||
`NotionTimeoutError`.
|
||||
- **Message sanitization** that strips stack-trace-like fragments before surfacing.
|
||||
|
||||
## Setup
|
||||
|
||||
There is **no environment variable** for the Notion token — it is stored in the
|
||||
SQLite `key_value` table (namespace `notion`, key `integration_token`) via
|
||||
`src/lib/db/notion.ts`. Configure it from the **Context Sources** tab of the Endpoint
|
||||
dashboard (`ObsidianSourceCard`'s sibling `NotionSourceCard`), or via the settings REST API.
|
||||
|
||||
> [!NOTE]
|
||||
> The token is a **Notion internal integration token**. Create an integration at
|
||||
> <https://www.notion.com/my-integrations>, then share the pages/databases you want
|
||||
> OmniRoute to access with that integration (Notion's permission model is share-based,
|
||||
> not workspace-wide).
|
||||
|
||||
### Configure via REST
|
||||
|
||||
```bash
|
||||
# Save + validate the integration token (POST validates by issuing a test search)
|
||||
curl -X POST http://localhost:20128/api/settings/notion \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"token":"ntn_xxx"}'
|
||||
|
||||
# Check connection status
|
||||
curl http://localhost:20128/api/settings/notion
|
||||
|
||||
# Disconnect (clears the stored token)
|
||||
curl -X DELETE http://localhost:20128/api/settings/notion
|
||||
```
|
||||
|
||||
All three methods require dashboard authentication (`isAuthenticated`). On `POST`,
|
||||
OmniRoute saves the token and immediately runs a 1-result test search; if Notion
|
||||
returns an error object the token is cleared and the call fails with `400`.
|
||||
|
||||
## MCP tools (6)
|
||||
|
||||
Defined in `open-sse/mcp-server/tools/notionTools.ts`. The token is resolved at call
|
||||
time via `getNotionToken()`; if none is configured the tool throws
|
||||
`"Notion integration token not configured. Set it in Settings > Context Sources."`
|
||||
|
||||
| Tool | Scope | Description |
|
||||
| ---------------------------- | -------------- | --------------------------------------------------------------------------------- |
|
||||
| `notion_search` | `read:notion` | Search pages and databases by text query (returns titles, IDs, URLs). Paginated. |
|
||||
| `notion_get_page` | `read:notion` | Get content and metadata of a page by its ID. |
|
||||
| `notion_list_block_children` | `read:notion` | List all block children of a block or page (the block tree). Paginated. |
|
||||
| `notion_query_database` | `read:notion` | Query a database with optional `filter` + `sorts` (Notion API format). Paginated. |
|
||||
| `notion_get_database` | `read:notion` | Get the schema/metadata of a database by ID. |
|
||||
| `notion_append_blocks` | `write:notion` | Append block children to an existing block or page (max 100 blocks per request). |
|
||||
|
||||
### Input parameters
|
||||
|
||||
- `notion_search` — `query` (1–500 chars), `pageSize` (1–100, default 20),
|
||||
`startCursor` (optional).
|
||||
- `notion_get_page` — `pageId` (32-char hex or UUID).
|
||||
- `notion_list_block_children` — `blockId`, `pageSize` (1–100, default 50),
|
||||
`startCursor` (optional).
|
||||
- `notion_query_database` — `databaseId`, `filter` (optional, Notion filter format),
|
||||
`sorts` (optional array), `pageSize` (1–100, default 50), `startCursor` (optional).
|
||||
- `notion_get_database` — `databaseId`.
|
||||
- `notion_append_blocks` — `blockId`, `children` (array of block objects),
|
||||
`after` (optional position).
|
||||
|
||||
### Scopes
|
||||
|
||||
The read tools require `read:notion` and the write tool requires `write:notion`.
|
||||
Scopes are enforced by `withScopeEnforcement()` in
|
||||
`open-sse/mcp-server/server.ts` only when `OMNIROUTE_MCP_ENFORCE_SCOPES=true`; the
|
||||
caller's allowed scopes come from `OMNIROUTE_MCP_SCOPES` (comma-separated) or the
|
||||
authenticated API key's scope context. See [MCP-SERVER.md](./MCP-SERVER.md) for the
|
||||
full scope model.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| -------- | ---------------------- | -------------------------------------- |
|
||||
| `GET` | `/api/settings/notion` | Return `{ connected, hasToken }`. |
|
||||
| `POST` | `/api/settings/notion` | Save + validate the integration token. |
|
||||
| `DELETE` | `/api/settings/notion` | Disconnect (clear the stored token). |
|
||||
|
||||
> These are dashboard settings routes. There is **no public `/v1` Notion proxy
|
||||
> endpoint** — Notion is reached exclusively through the MCP tools above.
|
||||
|
||||
## Use cases
|
||||
|
||||
- **Knowledge-grounded answers** — let an agent `notion_search` the workspace and
|
||||
`notion_get_page` the top hit before answering, so responses cite real internal docs.
|
||||
- **Database-backed workflows** — `notion_query_database` a tasks/CRM database with
|
||||
filters + sorts, then summarize or triage the rows.
|
||||
- **Write-back / logging** — `notion_append_blocks` to append meeting notes, run
|
||||
summaries, or agent output into an existing page (append-only; no destructive edits).
|
||||
- **Structure exploration** — `notion_list_block_children` to walk a page's block tree,
|
||||
or `notion_get_database` to discover a database's property schema before querying it.
|
||||
|
||||
## Related
|
||||
|
||||
- [MCP Server](./MCP-SERVER.md) — transports, scope enforcement, full tool inventory.
|
||||
- [Obsidian Context Source](./OBSIDIAN_CONTEXT.md) — the other built-in context source.
|
||||
- [Memory System](./MEMORY.md) — persistent conversational memory (complementary
|
||||
context layer, injected automatically rather than tool-fetched).
|
||||
188
Obsidian-Context.md
Normal file
188
Obsidian-Context.md
Normal file
@@ -0,0 +1,188 @@
|
||||
> 🌍 [View in other languages](Languages)
|
||||
|
||||
|
||||
# Obsidian Context Source
|
||||
|
||||
> **Source of truth:** `src/lib/obsidian/api.ts` (REST + sync client),
|
||||
> `src/lib/db/obsidian.ts` (token / base-URL / WebDAV persistence),
|
||||
> `src/lib/obsidianSync.ts` (WebDAV vault sync), `open-sse/mcp-server/tools/obsidianTools.ts`
|
||||
> (22 MCP tools), `src/app/api/settings/obsidian/route.ts` +
|
||||
> `src/app/api/settings/obsidian/webdav/route.ts` (settings APIs). Tool registration
|
||||
> and scope wiring lives in `open-sse/mcp-server/server.ts`.
|
||||
|
||||
## What it is
|
||||
|
||||
OmniRoute connects to an **Obsidian** vault as a **context source** — a local Markdown
|
||||
knowledge base that agents read and write through the built-in MCP server. The
|
||||
integration talks to the **Obsidian Local REST API** community plugin running inside the
|
||||
desktop app, so agents can search notes, read/write/patch files, list the vault, work
|
||||
with daily/weekly periodic notes, manage tags, run Obsidian commands, and (optionally)
|
||||
coordinate a bidirectional desktop↔mobile vault sync.
|
||||
|
||||
The client (`src/lib/obsidian/api.ts`) wraps the Local REST API with:
|
||||
|
||||
- **Retry with backoff** for transient `5xx`, **30-second timeout** via `AbortController`.
|
||||
- **Typed error classification** — `ObsidianAuthError` (401/403),
|
||||
`ObsidianNotFoundError` (404), `ObsidianServerError` (5xx), `ObsidianTimeoutError`.
|
||||
- A **friendly "cannot reach Obsidian" hint** that calls out the common port mistake
|
||||
(HTTP on `27123`, **not** the MCP endpoint on `27124`) and the Tailscale form.
|
||||
- Vault-relative **path encoding** so note paths with spaces/slashes are safe.
|
||||
|
||||
## Setup
|
||||
|
||||
There is **no environment variable** for the Obsidian token or base URL — both are
|
||||
stored in the SQLite `key_value` table (namespace `obsidian`) via
|
||||
`src/lib/db/obsidian.ts`. The token is **encrypted at rest** (AES-256-GCM, with
|
||||
plaintext backward-compat fallback). Configure from the **Context Sources** tab of the
|
||||
Endpoint dashboard (`ObsidianSourceCard`), or via the settings REST API.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The **Obsidian Local REST API** plugin must be installed and running. Its REST
|
||||
> interface listens on **HTTP `127.0.0.1:27123`** (the default base URL). Port `27124`
|
||||
> is a _separate_ MCP/HTTPS endpoint and is explicitly rejected by the settings route.
|
||||
> If connecting from another device, use `http://<tailscale-ip>:27123`.
|
||||
|
||||
### Configuration keys (SQLite `key_value`, namespace `obsidian`)
|
||||
|
||||
| Key | Purpose | Encrypted |
|
||||
| ----------------- | ------------------------------------------------ | --------- |
|
||||
| `api_key` | Local REST API bearer token | yes |
|
||||
| `base_url` | REST base URL (default `http://127.0.0.1:27123`) | no |
|
||||
| `vault_path` | Absolute path to the vault directory (for sync) | no |
|
||||
| `webdav_username` | Generated WebDAV username (vault sync) | no |
|
||||
| `webdav_password` | Generated WebDAV password (vault sync) | yes |
|
||||
| `webdav_enabled` | Whether WebDAV vault sync is enabled | no |
|
||||
|
||||
### Configure via REST
|
||||
|
||||
```bash
|
||||
# Save + validate the Local REST API token (POST validates via a status check)
|
||||
curl -X POST http://localhost:20128/api/settings/obsidian \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"token":"<obsidian-rest-api-key>","baseUrl":"http://127.0.0.1:27123"}'
|
||||
|
||||
# Check connection status (returns connected, hasToken, baseUrl, vaultPath)
|
||||
curl http://localhost:20128/api/settings/obsidian
|
||||
|
||||
# Disconnect (clears the stored token)
|
||||
curl -X DELETE http://localhost:20128/api/settings/obsidian
|
||||
```
|
||||
|
||||
All methods require dashboard authentication. `POST` rejects any URL on port `27124`
|
||||
and validates the token by calling the Local REST API status endpoint before persisting.
|
||||
|
||||
### WebDAV vault sync
|
||||
|
||||
`src/app/api/settings/obsidian/webdav/route.ts` manages an optional WebDAV-backed
|
||||
vault sync (driven by `src/lib/obsidianSync.ts`). Enabling it points OmniRoute at a
|
||||
local vault directory and mints a random WebDAV username/password pair:
|
||||
|
||||
```bash
|
||||
# Enable WebDAV sync for a vault directory (mints username/password)
|
||||
curl -X POST http://localhost:20128/api/settings/obsidian/webdav \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"vaultPath":"/home/me/MyVault"}'
|
||||
|
||||
# Get WebDAV sync status (credentials returned only while enabled)
|
||||
curl http://localhost:20128/api/settings/obsidian/webdav
|
||||
|
||||
# Disable WebDAV sync (clears credentials + managed .stignore)
|
||||
curl -X DELETE http://localhost:20128/api/settings/obsidian/webdav
|
||||
```
|
||||
|
||||
### Per-API-key context source (optional)
|
||||
|
||||
Obsidian config can be scoped **per API key** via the `api_key_context_sources` table
|
||||
(`src/lib/db/apiKeyContextSources.ts`). When an MCP call carries an authenticated API
|
||||
key id, `getObsidianConfigForApiKey()` prefers that key's own token/base-URL/vault-path
|
||||
(`source: "api_key"`) and otherwise falls back to the global config (`source: "global"`).
|
||||
|
||||
## MCP tools (22)
|
||||
|
||||
Defined in `open-sse/mcp-server/tools/obsidianTools.ts`. The token/base-URL are resolved
|
||||
per call (per-API-key first, then global). Tools that hit the OmniRoute **sync server**
|
||||
(the four `obsidian_sync_*` tools) additionally require the sync auth token configured
|
||||
in OmniRoute settings.
|
||||
|
||||
### Read tools (`read:obsidian`)
|
||||
|
||||
| Tool | Description |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `obsidian_check_status` | Check whether the Local REST API is reachable and authenticated. |
|
||||
| `obsidian_search_simple` | Full-text search of note content; returns snippets with file paths. |
|
||||
| `obsidian_search_structured` | Search using a JSON Logic expression (and/or/regex/path filters). |
|
||||
| `obsidian_read_note` | Read a note by vault-relative path; optionally a specific heading/block/frontmatter. |
|
||||
| `obsidian_list_vault` | List files and directories in the vault (tree of entries). |
|
||||
| `obsidian_get_document_map` | Get the note's heading structure as a map of headings → line numbers. |
|
||||
| `obsidian_get_note_metadata` | Get frontmatter, tags, links, char/word count without the full content. |
|
||||
| `obsidian_get_active_file` | Get the path + content of the currently active file in Obsidian. |
|
||||
| `obsidian_get_periodic_note` | Get the daily/weekly/monthly periodic note for a date (today if omitted). |
|
||||
| `obsidian_get_tags` | List all vault tags with their frequencies. |
|
||||
| `obsidian_list_commands` | List available Obsidian command IDs (use with `obsidian_execute_command`). |
|
||||
| `obsidian_sync_status` | OmniRoute sync server status: running, vault name, port, uptime, last sync. |
|
||||
| `obsidian_sync_conflicts` | List unresolved sync conflicts (path, conflict path, detected-at). |
|
||||
|
||||
### Write tools (`write:obsidian`)
|
||||
|
||||
| Tool | Description |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `obsidian_write_note` | Create or overwrite a note with given Markdown content. |
|
||||
| `obsidian_append_note` | Append content to a note; optionally to a specific heading/block. |
|
||||
| `obsidian_patch_note` | Surgically append/prepend/replace at a heading, block, or frontmatter field. |
|
||||
| `obsidian_delete_note` | Permanently delete a note from the vault. |
|
||||
| `obsidian_move_note` | Move or rename a note within the vault. |
|
||||
| `obsidian_execute_command` | Execute an Obsidian command by its command ID. |
|
||||
| `obsidian_open_file` | Open a file in Obsidian (creates it if it does not exist). |
|
||||
| `obsidian_sync_trigger` | Trigger an immediate bidirectional desktop↔mobile vault sync. |
|
||||
| `obsidian_sync_resolve_conflict` | Resolve a sync conflict: keep `local` (mobile), `remote` (desktop), or `keep-both`. |
|
||||
|
||||
> [!NOTE]
|
||||
> `obsidian_patch_note` targets accept `targetType` of `heading | block | frontmatter`
|
||||
> and `operation` of `append | prepend | replace`, with an optional
|
||||
> `createTargetIfMissing`. The four `obsidian_sync_*` tools talk to the local sync
|
||||
> server (`http://127.0.0.1:27781` by default) and require the sync token.
|
||||
|
||||
### Scopes
|
||||
|
||||
Read tools require `read:obsidian`; write tools require `write:obsidian`. Enforcement
|
||||
is identical to Notion — handled by `withScopeEnforcement()` in
|
||||
`open-sse/mcp-server/server.ts`, gated on `OMNIROUTE_MCP_ENFORCE_SCOPES=true`, with
|
||||
allowed scopes sourced from `OMNIROUTE_MCP_SCOPES` or the API key's scope context. See
|
||||
[MCP-SERVER.md](./MCP-SERVER.md).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| -------- | ------------------------------- | ----------------------------------------------------- |
|
||||
| `GET` | `/api/settings/obsidian` | Return `{ connected, hasToken, baseUrl, vaultPath }`. |
|
||||
| `POST` | `/api/settings/obsidian` | Save + validate token (rejects port `27124`). |
|
||||
| `DELETE` | `/api/settings/obsidian` | Disconnect (clear stored token). |
|
||||
| `GET` | `/api/settings/obsidian/webdav` | WebDAV sync status + credentials (while enabled). |
|
||||
| `POST` | `/api/settings/obsidian/webdav` | Enable WebDAV sync for a vault directory. |
|
||||
| `DELETE` | `/api/settings/obsidian/webdav` | Disable WebDAV sync. |
|
||||
|
||||
> These are dashboard settings routes. The vault itself is reached through the Obsidian
|
||||
> Local REST API (the configured `base_url`) and through the MCP tools above — there is
|
||||
> no public `/v1` Obsidian proxy endpoint.
|
||||
|
||||
## Use cases
|
||||
|
||||
- **Vault-grounded answers** — `obsidian_search_simple` / `obsidian_search_structured`
|
||||
then `obsidian_read_note` so an agent answers from your real notes.
|
||||
- **Note authoring / journaling** — `obsidian_write_note`, `obsidian_append_note`, or
|
||||
the surgical `obsidian_patch_note` to log agent output, summaries, or daily notes
|
||||
(`obsidian_get_periodic_note`) into the vault.
|
||||
- **Vault navigation** — `obsidian_list_vault`, `obsidian_get_document_map`, and
|
||||
`obsidian_get_tags` to explore structure before reading/writing.
|
||||
- **Obsidian automation** — `obsidian_list_commands` + `obsidian_execute_command` to
|
||||
drive plugins/commands from an agent; `obsidian_open_file` to surface a note in the UI.
|
||||
- **Mobile sync** — enable WebDAV sync, then `obsidian_sync_trigger` /
|
||||
`obsidian_sync_status` / `obsidian_sync_conflicts` / `obsidian_sync_resolve_conflict`
|
||||
to coordinate desktop↔mobile and resolve conflicts.
|
||||
|
||||
## Related
|
||||
|
||||
- [MCP Server](./MCP-SERVER.md) — transports, scope enforcement, full tool inventory.
|
||||
- [Notion Context Source](./NOTION_CONTEXT.md) — the other built-in context source.
|
||||
- [Memory System](./MEMORY.md) — persistent conversational memory (complementary
|
||||
context layer, injected automatically rather than tool-fetched).
|
||||
345
Plugin-Marketplace.md
Normal file
345
Plugin-Marketplace.md
Normal file
@@ -0,0 +1,345 @@
|
||||
> 🌍 [View in other languages](Languages)
|
||||
|
||||
|
||||
# Plugin Marketplace
|
||||
|
||||
> **Source of truth:** `src/lib/plugins/` (`marketplace.ts`, `manager.ts`, `manifest.ts`,
|
||||
> `scanner.ts`, `loader.ts`), `src/app/api/plugins/`, and
|
||||
> `src/app/(dashboard)/dashboard/plugins/`
|
||||
> **Last updated:** 2026-06-13 — v3.8.24
|
||||
|
||||
OmniRoute ships a WordPress-style plugin system. Plugins are self-contained
|
||||
directories — each with a `plugin.json` manifest and an entry file — that hook
|
||||
into the request pipeline (`onRequest` / `onResponse` / `onError`) and into
|
||||
lifecycle events (`onInstall` / `onActivate` / `onDeactivate` / `onUninstall`).
|
||||
|
||||
The **Plugin Marketplace** is the discovery layer on top of that system. It
|
||||
exposes a browsable catalog of installable plugins. By default the catalog is a
|
||||
small built-in seed registry; an operator can point it at a custom remote
|
||||
registry URL, in which case the fetch is hardened by a DNS-resolving SSRF guard
|
||||
(see [Security](#security)).
|
||||
|
||||
Every plugin route is **loopback-only** (Tier 1 — `LOCAL_ONLY`): plugins load
|
||||
and execute code in child processes, so the routes are unreachable from a
|
||||
non-loopback origin regardless of auth. See
|
||||
[`docs/security/ROUTE_GUARD_TIERS.md`](../security/ROUTE_GUARD_TIERS.md).
|
||||
|
||||
## How It Fits Together
|
||||
|
||||
```
|
||||
Dashboard (/dashboard/plugins)
|
||||
├─ "Installed" tab → GET /api/plugins (listPlugins)
|
||||
│ POST /api/plugins/scan (pluginManager.scan)
|
||||
│ POST /api/plugins/{name}/activate|deactivate
|
||||
│ DELETE /api/plugins/{name} (uninstall)
|
||||
└─ "Marketplace" tab → GET /api/plugins/marketplace
|
||||
→ listMarketplacePlugins()
|
||||
├─ no custom URL → built-in SEED_REGISTRY
|
||||
└─ custom URL → isSafeMarketplaceUrl() SSRF guard
|
||||
→ safeOutboundFetch(guard:"public-only")
|
||||
```
|
||||
|
||||
- **Registry layer** — `src/lib/plugins/marketplace.ts`: lists / searches the
|
||||
catalog, falling back to the seed registry on any failure.
|
||||
- **Lifecycle layer** — `src/lib/plugins/manager.ts` (`pluginManager` singleton):
|
||||
install, upgrade, activate, deactivate, uninstall, scan, startup load.
|
||||
- **Manifest layer** — `src/lib/plugins/manifest.ts`: Zod schema + defaults for
|
||||
`plugin.json`.
|
||||
- **Scanner** — `src/lib/plugins/scanner.ts`: discovers plugins on disk under
|
||||
the plugin directory.
|
||||
- **Loader** — `src/lib/plugins/loader.ts`: spawns each plugin in an isolated
|
||||
child process and brokers hook calls over IPC.
|
||||
|
||||
## Marketplace Catalog
|
||||
|
||||
`listMarketplacePlugins()` (`src/lib/plugins/marketplace.ts`) returns a list of
|
||||
`MarketplaceEntry` objects:
|
||||
|
||||
| Field | Type | Notes |
|
||||
| ------------- | -------- | ------------------------------------ |
|
||||
| `name` | string | kebab-case plugin name |
|
||||
| `version` | string | semver |
|
||||
| `description` | string | Short summary |
|
||||
| `author` | string | Author / org |
|
||||
| `license` | string | SPDX-style license id |
|
||||
| `downloadUrl` | string | Source download URL (may be empty) |
|
||||
| `repository` | string? | Optional repository URL |
|
||||
| `tags` | string[] | Search/filter tags |
|
||||
| `downloads` | number | Download count |
|
||||
| `rating` | number | 0–5 |
|
||||
| `verified` | boolean | Whether the entry is marked verified |
|
||||
| `lastUpdated` | string | ISO-ish date string |
|
||||
|
||||
When no custom registry URL is configured, the catalog is the built-in
|
||||
`SEED_REGISTRY` (currently `request-logger`, `rate-limiter`, `cost-tracker`, and
|
||||
`theme-manager`). The seed registry is always available — if a configured remote
|
||||
registry is unreachable, returns a non-`200` status, or returns an unrecognized
|
||||
body, `listMarketplacePlugins()` logs a warning and falls back to the seed list.
|
||||
|
||||
> Note: the marketplace **catalog** (browse/search) is wired end to end, but
|
||||
> one-click marketplace **install** from the catalog is not yet implemented — the
|
||||
> dashboard's "Install" button on a marketplace entry currently shows a
|
||||
> "coming soon" notice. Installation today goes through the local-path install
|
||||
> flow (`POST /api/plugins`) and on-disk discovery (`POST /api/plugins/scan`).
|
||||
|
||||
## REST API
|
||||
|
||||
All endpoints require management auth (`requireManagementAuth`) **and** are
|
||||
loopback-only — `/api/plugins` and `/api/plugins/` are listed in
|
||||
`LOCAL_ONLY_API_PREFIXES` (`src/server/authz/routeGuard.ts`).
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| -------------------------------- | ------ | --------------------------------------------------- |
|
||||
| `/api/plugins` | GET | List installed plugins (optional `?status=` filter) |
|
||||
| `/api/plugins` | POST | Install a plugin from an absolute local path |
|
||||
| `/api/plugins/scan` | POST | Scan the plugin directory and register new plugins |
|
||||
| `/api/plugins/marketplace` | GET | List marketplace catalog entries |
|
||||
| `/api/plugins/[name]` | GET | Get installed plugin details |
|
||||
| `/api/plugins/[name]` | DELETE | Uninstall a plugin |
|
||||
| `/api/plugins/[name]/activate` | POST | Activate (load + register hooks) |
|
||||
| `/api/plugins/[name]/deactivate` | POST | Deactivate (fire `onDeactivate`, unregister hooks) |
|
||||
| `/api/plugins/[name]/config` | GET | Get plugin config + config schema |
|
||||
| `/api/plugins/[name]/config` | PUT | Update plugin config (validated against schema) |
|
||||
|
||||
The `GET /api/plugins` `status` filter accepts one of
|
||||
`installed` / `active` / `inactive` / `error`. An invalid value returns `400`.
|
||||
|
||||
### List installed plugins
|
||||
|
||||
```bash
|
||||
curl http://localhost:20128/api/plugins \
|
||||
-H "Cookie: auth_token=..."
|
||||
```
|
||||
|
||||
### Install from a local path
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:20128/api/plugins \
|
||||
-H "Cookie: auth_token=..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "path": "/absolute/path/to/my-plugin" }'
|
||||
```
|
||||
|
||||
The `path` must be **absolute** and may not contain `..` traversal segments or
|
||||
null bytes (enforced by Zod). The source directory must contain a valid
|
||||
`plugin.json` (or be a parent of one). On success the response is `201` with the
|
||||
installed plugin row.
|
||||
|
||||
### Browse the marketplace
|
||||
|
||||
```bash
|
||||
curl http://localhost:20128/api/plugins/marketplace \
|
||||
-H "Cookie: auth_token=..."
|
||||
```
|
||||
|
||||
### Update plugin config
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:20128/api/plugins/my-plugin/config \
|
||||
-H "Cookie: auth_token=..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "config": { "level": "debug", "maxItems": 100 } }'
|
||||
```
|
||||
|
||||
`PUT .../config` validates each provided value against the plugin's
|
||||
`configSchema` (declared in the manifest): `number` fields honor `min`/`max`,
|
||||
`select` fields must match the declared `enum`. Keys not present in the schema
|
||||
are allowed through.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Plugin directory
|
||||
|
||||
Plugins live under the OmniRoute data directory:
|
||||
|
||||
```
|
||||
~/.omniroute/plugins/<plugin-name>/
|
||||
├─ plugin.json
|
||||
└─ index.js # (or whatever manifest.main points to)
|
||||
```
|
||||
|
||||
`getDefaultPluginDir()` (`src/lib/plugins/scanner.ts`) resolves this to
|
||||
`<home>/.omniroute/plugins`, where `<home>` is taken from the `HOME` /
|
||||
`USERPROFILE` environment variables. `POST /api/plugins/scan` discovers any
|
||||
subdirectory there that holds a valid `plugin.json` and registers it.
|
||||
|
||||
### Custom marketplace registry URL
|
||||
|
||||
The marketplace catalog source is read from the `pluginMarketplaceUrl` setting
|
||||
(`src/lib/plugins/marketplace.ts` reads `settings.pluginMarketplaceUrl`). When
|
||||
set to an `http(s)` URL, `listMarketplacePlugins()` fetches that URL and accepts
|
||||
either a top-level JSON array of entries or an object with a `plugins` array;
|
||||
entries without a string `name` are filtered out. When unset (or when the fetch
|
||||
fails the SSRF guard / returns a bad response), the built-in seed registry is
|
||||
used.
|
||||
|
||||
The dashboard "Marketplace" tab exposes a field for this URL (read back from
|
||||
`GET /api/settings`).
|
||||
|
||||
> Implementation note: the dashboard "Save" action sends
|
||||
> `pluginMarketplaceUrl` to `PATCH /api/settings`. At the time of writing this
|
||||
> key is not declared in `updateSettingsSchema`
|
||||
> (`src/shared/validation/settingsSchemas.ts`), so verify persistence in your
|
||||
> release before relying on it — the **read** path (`getSettings()` →
|
||||
> `listMarketplacePlugins()`) honors the key once it is present in the settings
|
||||
> store.
|
||||
|
||||
## Security
|
||||
|
||||
### Route tier — loopback only
|
||||
|
||||
Plugins execute code in spawned child processes, so the entire `/api/plugins`
|
||||
surface is classified `LOCAL_ONLY` (Tier 1). Loopback enforcement runs
|
||||
unconditionally **before** any auth check, so a leaked management token reaching
|
||||
the box over a tunnel still cannot install, activate, or uninstall a plugin.
|
||||
See [`docs/security/ROUTE_GUARD_TIERS.md`](../security/ROUTE_GUARD_TIERS.md) and
|
||||
Hard Rules #15 / #17.
|
||||
|
||||
### Marketplace registry SSRF guard
|
||||
|
||||
A custom registry URL is attacker-influenceable configuration, so before
|
||||
fetching it `listMarketplacePlugins()` runs it through two layers:
|
||||
|
||||
1. **`isSafeMarketplaceUrl(url)`** (`src/lib/plugins/marketplace.ts`):
|
||||
- Rejects anything that is not `http:` / `https:`.
|
||||
- Rejects literal private/loopback/link-local/ULA hosts (IPv4 **and** IPv6,
|
||||
including IPv4-mapped) via the canonical `isPrivateHost`
|
||||
(`src/shared/network/outboundUrlGuard.ts`).
|
||||
- Resolves **both** `A` and `AAAA` records and rejects if **any** resolved
|
||||
address is private — closing the public-hostname → private-IP bypass.
|
||||
- **Fails closed**: a DNS resolution failure rejects the URL.
|
||||
2. **`safeOutboundFetch(url, { guard: "public-only", timeoutMs: 5000 })`**
|
||||
(`src/shared/network/safeOutboundFetch.ts`): re-applies the public-only URL
|
||||
guard at fetch time and **blocks redirects** (no public → private `30x`
|
||||
pivot).
|
||||
|
||||
A URL that fails either layer does not abort the request — the marketplace
|
||||
silently falls back to the built-in seed registry and logs a warning.
|
||||
|
||||
> This guard was hardened in PR #3774 specifically to resolve A + AAAA and use
|
||||
> the canonical `isPrivateHost` instead of an IPv4-only check.
|
||||
|
||||
### Plugin execution isolation
|
||||
|
||||
- **Process isolation** — `loadPlugin()` (`src/lib/plugins/loader.ts`) spawns
|
||||
each plugin in a separate Node.js child process and communicates over IPC.
|
||||
Hook calls have a timeout with `SIGTERM` → `SIGKILL` escalation.
|
||||
- **Env allowlist** — the child receives only an allowlisted set of environment
|
||||
variables; the broader set is only granted when the manifest requests the
|
||||
`env` permission.
|
||||
- **Path containment** — install/upgrade/uninstall assert that the plugin
|
||||
directory and `manifest.main` resolve **within** the managed plugin root
|
||||
before any copy or recursive delete (guards against tampered DB paths and
|
||||
`../` traversal in `manifest.main`). Activation resolves symlinks via
|
||||
`realpath` and refuses to load an entry point that escapes the plugin
|
||||
directory.
|
||||
- **Optional integrity pin** — a manifest may declare an `integrity`
|
||||
(`sha256-<base64>`, SRI format) field. When present, the loader verifies the
|
||||
entry file hash at load time and refuses to activate on mismatch. It is
|
||||
opt-in tamper-detection, **not** a security boundary — loopback-only routing
|
||||
and the permission model are the real boundaries.
|
||||
|
||||
## Manifest (`plugin.json`)
|
||||
|
||||
Validated by `PluginManifestSchema` (`src/lib/plugins/manifest.ts`):
|
||||
|
||||
| Field | Type | Notes |
|
||||
| ------------------ | --------- | ----------------------------------------------------------- |
|
||||
| `name` | string | Required; kebab-case (`^[a-z0-9-]+$`), 1–100 chars |
|
||||
| `version` | string | Required; semver (`MAJOR.MINOR.PATCH`) |
|
||||
| `description` | string? | ≤ 500 chars |
|
||||
| `author` | string? | ≤ 200 chars |
|
||||
| `license` | string? | Defaults to `MIT` |
|
||||
| `main` | string? | Entry file; defaults to `index.js` |
|
||||
| `source` | enum? | `local` \| `marketplace` (defaults to `local`) |
|
||||
| `tags` | string[]? | Search tags |
|
||||
| `requires` | object? | `{ omniroute?, permissions[] }` |
|
||||
| `hooks` | object? | Booleans declaring which hooks the plugin implements |
|
||||
| `skills` | object[]? | Optional skill definitions |
|
||||
| `enabledByDefault` | boolean? | Auto-activate on install |
|
||||
| `configSchema` | object? | Map of config fields (`string`/`number`/`boolean`/`select`) |
|
||||
| `integrity` | string? | Optional `sha256-<base64>` entry-file pin |
|
||||
|
||||
Permissions are drawn from the enum
|
||||
`network` / `file-read` / `file-write` / `env` / `exec`.
|
||||
|
||||
## Lifecycle Flow
|
||||
|
||||
```
|
||||
install (POST /api/plugins, path)
|
||||
→ scan/validate manifest → copy to staging → assert main within dir
|
||||
→ atomic rename into ~/.omniroute/plugins/<name> → insert DB row
|
||||
→ fire onInstall → if enabledByDefault: activate
|
||||
|
||||
activate (POST /api/plugins/{name}/activate)
|
||||
→ realpath containment check → loadPlugin() (spawn child process)
|
||||
→ register declared hooks → status = "active" → fire onActivate
|
||||
|
||||
deactivate (POST /api/plugins/{name}/deactivate)
|
||||
→ fire onDeactivate (BEFORE unregister) → unregister hooks
|
||||
→ kill child process → status = "inactive"
|
||||
|
||||
uninstall (DELETE /api/plugins/{name})
|
||||
→ deactivate if active → fire onUninstall
|
||||
→ containment-checked recursive delete of plugin dir → delete DB row
|
||||
```
|
||||
|
||||
Re-running `install` against a directory whose manifest version is **strictly
|
||||
newer** than the installed version auto-upgrades (clean reinstall; config resets
|
||||
to defaults). A same-or-older version is rejected.
|
||||
|
||||
## Database
|
||||
|
||||
Table `plugins` (migration `076_create_plugins.sql`):
|
||||
|
||||
| Column | Type | Notes |
|
||||
| --------------- | ------- | ------------------------------------------------ |
|
||||
| `id` | TEXT PK | UUID |
|
||||
| `name` | TEXT | Unique |
|
||||
| `version` | TEXT | semver; default `1.0.0` |
|
||||
| `description` | TEXT | Optional |
|
||||
| `author` | TEXT | Optional |
|
||||
| `license` | TEXT | Default `MIT` |
|
||||
| `main` | TEXT | Entry file; default `index.js` |
|
||||
| `source` | TEXT | Default `local` |
|
||||
| `tags` | TEXT | JSON array; default `[]` |
|
||||
| `status` | TEXT | `installed` \| `active` \| `inactive` \| `error` |
|
||||
| `enabled` | INT | 0/1; default 0 |
|
||||
| `manifest` | TEXT | Full manifest JSON |
|
||||
| `config` | TEXT | JSON; default `{}` |
|
||||
| `config_schema` | TEXT | JSON; default `{}` |
|
||||
| `hooks` | TEXT | JSON array of declared hook names; default `[]` |
|
||||
| `permissions` | TEXT | JSON array; default `[]` |
|
||||
| `plugin_dir` | TEXT | Absolute install directory |
|
||||
| `error_message` | TEXT | Set when `status = "error"` |
|
||||
| `installed_at` | TEXT | `datetime('now')` |
|
||||
| `updated_at` | TEXT | `datetime('now')` |
|
||||
| `activated_at` | TEXT | Set on activation |
|
||||
|
||||
Plugin metrics/analytics are tracked in additional tables
|
||||
(`090_plugin_metrics.sql`, `091_plugin_analytics.sql`).
|
||||
|
||||
## Dashboard
|
||||
|
||||
The dashboard page at `/dashboard/plugins`
|
||||
(`src/app/(dashboard)/dashboard/plugins/page.tsx`) provides two tabs:
|
||||
|
||||
- **Installed** — lists installed plugins with their declared hooks, an
|
||||
activate/deactivate toggle, an uninstall button, and a "Scan for plugins"
|
||||
action (`POST /api/plugins/scan`).
|
||||
- **Marketplace** — shows the catalog from `GET /api/plugins/marketplace` with a
|
||||
field to set the custom registry URL.
|
||||
|
||||
A per-plugin config page lives at `/dashboard/plugins/[name]/config`
|
||||
(`src/app/(dashboard)/dashboard/plugins/[name]/config/page.tsx`).
|
||||
|
||||
## See Also
|
||||
|
||||
- [`docs/security/ROUTE_GUARD_TIERS.md`](../security/ROUTE_GUARD_TIERS.md) —
|
||||
why `/api/plugins` is loopback-only (Tier 1)
|
||||
- [`docs/frameworks/SKILLS.md`](./SKILLS.md) — the related skills framework
|
||||
(`src/lib/skills/`); plugins may declare skills in their manifest
|
||||
- [`docs/frameworks/WEBHOOKS.md`](./WEBHOOKS.md) — event-driven outbound
|
||||
integrations
|
||||
- [`docs/security/ERROR_SANITIZATION.md`](../security/ERROR_SANITIZATION.md) —
|
||||
the `buildErrorBody()` pattern every plugin route uses for error responses
|
||||
Reference in New Issue
Block a user