mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
docs: sync all documentation to v3.8.24 + count-guard & wiki/prose CI (#3804)
Integrated into release/v3.8.26 — reconciled: kept #3900's superset wiki-sync, dropped the superseded sync-wiki-home; counts taken from the newer canonical release docs; net-new value = vale/markdownlint docs-lint infra + 7 new docs (Notion/Obsidian/Plugin-Marketplace/Cost-Tracking/Free-Provider-Rankings/Feature-Flags/Egress-Policy) + check-docs-counts-sync guard.
This commit is contained in:
committed by
GitHub
parent
bed3984ac1
commit
e697538beb
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
@@ -197,6 +197,30 @@ jobs:
|
||||
- name: i18n translation drift (warn)
|
||||
run: node scripts/i18n/check-translation-drift.mjs --warn
|
||||
|
||||
docs-lint:
|
||||
name: Docs Lint (prose — advisory)
|
||||
runs-on: ubuntu-latest
|
||||
# Advisory (warning-first): prose/markdown style must not block merges while the
|
||||
# existing doc corpus is brought up to style. Promote to blocking once it converges.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- name: markdownlint (docs + root, advisory)
|
||||
run: npx --yes markdownlint-cli2 "docs/**/*.md" "*.md" "!docs/i18n" "!docs/research" || true
|
||||
- name: Vale prose lint (Microsoft style, advisory)
|
||||
# Non-fatal: a Vale/reviewdog setup error must not turn this advisory job red.
|
||||
continue-on-error: true
|
||||
uses: errata-ai/vale-action@reviewdog
|
||||
with:
|
||||
files: docs
|
||||
fail_on_error: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
i18n-ui-coverage:
|
||||
name: i18n UI Coverage
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
14
.markdownlint.json
Normal file
14
.markdownlint.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"_comment": "Advisory markdown lint for docs/ + root *.md. Rules that conflict with the existing doc style (heavy inline HTML, long lines, centered headings) are disabled so the gate stays signal-not-noise. Run: npm run lint:md",
|
||||
"default": true,
|
||||
"MD013": false,
|
||||
"MD033": false,
|
||||
"MD041": false,
|
||||
"MD024": { "siblings_only": true },
|
||||
"MD026": false,
|
||||
"MD036": false,
|
||||
"MD040": false,
|
||||
"MD029": false,
|
||||
"MD007": { "indent": 2 },
|
||||
"MD046": false
|
||||
}
|
||||
15
.vale.ini
Normal file
15
.vale.ini
Normal file
@@ -0,0 +1,15 @@
|
||||
# Advisory prose lint for OmniRoute docs (Vale — https://vale.sh).
|
||||
# Warning-first: surfaces vague language, passive voice, and terminology drift without
|
||||
# blocking CI. The Microsoft style is pulled by the CI step (vale sync / vale-action).
|
||||
# Project terms live in the OmniRoute vocabulary so they never read as misspellings.
|
||||
StylesPath = .vale/styles
|
||||
MinAlertLevel = warning
|
||||
Packages = Microsoft
|
||||
Vocab = OmniRoute
|
||||
|
||||
[*.md]
|
||||
BasedOnStyles = Vale, Microsoft
|
||||
|
||||
# Code-heavy reference docs and auto-generated catalogs are noisy under prose rules.
|
||||
[docs/reference/PROVIDER_REFERENCE.md]
|
||||
BasedOnStyles = Vale
|
||||
50
.vale/styles/Vocab/OmniRoute/accept.txt
Normal file
50
.vale/styles/Vocab/OmniRoute/accept.txt
Normal file
@@ -0,0 +1,50 @@
|
||||
OmniRoute
|
||||
combo
|
||||
combos
|
||||
[Cc]ombo
|
||||
[Aa]uto-[Cc]ombo
|
||||
MCP
|
||||
A2A
|
||||
ACP
|
||||
RTK
|
||||
Caveman
|
||||
Troglodita
|
||||
Qdrant
|
||||
FTS5
|
||||
SQLite
|
||||
LowDB
|
||||
OAuth
|
||||
PKCE
|
||||
SSE
|
||||
JSON-RPC
|
||||
LKGP
|
||||
P2C
|
||||
Antigravity
|
||||
Codex
|
||||
Kiro
|
||||
Qoder
|
||||
Pollinations
|
||||
LongCat
|
||||
Cerebras
|
||||
DeepSeek
|
||||
Groq
|
||||
Cline
|
||||
OpenCode
|
||||
Termux
|
||||
Electron
|
||||
Fumadocs
|
||||
Notion
|
||||
Obsidian
|
||||
WebDAV
|
||||
IPv4
|
||||
IPv6
|
||||
SOCKS5
|
||||
loopback
|
||||
backoff
|
||||
changelog
|
||||
i18n
|
||||
locale
|
||||
locales
|
||||
roadmap
|
||||
[Ww]ildcard
|
||||
upstream
|
||||
@@ -3,13 +3,13 @@
|
||||
## Project
|
||||
|
||||
Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support
|
||||
with **232 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
|
||||
with **226 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
|
||||
Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra,
|
||||
SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more)
|
||||
with **MCP Server** (87 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
|
||||
|
||||
> **Live counts (v3.8.24)**: providers 232 · MCP tools 87 · MCP scopes 30 · A2A skills 6 ·
|
||||
> open-sse services 115 · routing strategies 15 · auto-combo scoring factors 12 ·
|
||||
> **Live counts (v3.8.24)**: providers 226 · MCP tools 87 · MCP scopes 30 · A2A skills 6 ·
|
||||
> open-sse services 115 · routing strategies 15 · auto-combo scoring factors 9 ·
|
||||
> DB modules 83 · DB migrations 97 · base tables 17 · search providers 11 ·
|
||||
> i18n locales 42. **Refresh with `npm run check:docs-all`.**
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep archit
|
||||
|
||||
## Project at a Glance
|
||||
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 160+ LLM providers, auto-fallback.
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 226 LLM providers, auto-fallback.
|
||||
|
||||
| Layer | Location | Purpose |
|
||||
| ------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|
||||
@@ -71,11 +71,11 @@ PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
|
||||
|
||||
### Build Output Layout
|
||||
|
||||
| Directory | Contents | Tracked |
|
||||
| ---------- | ------------------------------------------- | ------- |
|
||||
| `src/` | Application source (TypeScript / TSX) | Yes |
|
||||
| `.build/` | Intermediates — `next build` output (gitignored, `distDir = .build/next`) | No |
|
||||
| `dist/` | Shippable bundle — assembled by `assembleStandalone` (gitignored) | No |
|
||||
| Directory | Contents | Tracked |
|
||||
| --------- | ------------------------------------------------------------------------- | ------- |
|
||||
| `src/` | Application source (TypeScript / TSX) | Yes |
|
||||
| `.build/` | Intermediates — `next build` output (gitignored, `distDir = .build/next`) | No |
|
||||
| `dist/` | Shippable bundle — assembled by `assembleStandalone` (gitignored) | No |
|
||||
|
||||
The build pipeline is a single pass:
|
||||
|
||||
@@ -252,7 +252,7 @@ open-sse/ # @omniroute/open-sse workspace
|
||||
electron/ # Electron desktop app (cross-platform)
|
||||
|
||||
tests/
|
||||
├── unit/ # Node.js test runner (122 test files)
|
||||
├── unit/ # Node.js test runner (1,574 test files)
|
||||
├── integration/ # Integration tests
|
||||
├── e2e/ # Playwright tests
|
||||
├── security/ # Security tests
|
||||
|
||||
@@ -932,7 +932,7 @@ All other providers (including custom compatible nodes) use the `DefaultExecutor
|
||||
|
||||
## Provider Compatibility Matrix
|
||||
|
||||
> **Note:** The matrix below is a representative sample of the 177 registered providers in
|
||||
> **Note:** The matrix below is a representative sample of the 226 registered providers in
|
||||
> OmniRoute v3.8.0. For the canonical and continuously-updated list, refer to
|
||||
> [`docs/reference/PROVIDER_REFERENCE.md`](../reference/PROVIDER_REFERENCE.md) (auto-generated) or the source of
|
||||
> truth at `src/shared/constants/providers.ts` (Zod-validated at load).
|
||||
|
||||
@@ -272,46 +272,46 @@ the same `open-sse/handlers/` pipeline).
|
||||
Always import data, sync, OAuth, skill, memory, etc. through these modules. The
|
||||
table groups the actual directories and notable top-level files.
|
||||
|
||||
| Module | Purpose |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `a2a/` | A2A protocol server: `taskManager.ts`, `streaming.ts`, `taskExecution.ts`, `routingLogger.ts`, `skills/` (6 skills: cost analysis, health report, provider discovery, quota management, smart routing, list-capabilities) |
|
||||
| `acp/` | Agent-Control-Protocol: `index.ts`, `manager.ts`, `registry.ts` |
|
||||
| `api/` | Internal API helpers: `requireManagementAuth.ts`, `requireCliToolsAuth.ts`, `errorResponse.ts` |
|
||||
| `auth/` | `managementPassword.ts` (password reset / hashing) |
|
||||
| `batches/` | OpenAI Batches API service (`service.ts`) |
|
||||
| `catalog/` | OpenRouter catalog sync (`openrouterCatalog.ts`) |
|
||||
| `cloudAgent/` | Cloud agent registry: `api.ts`, `baseAgent.ts`, `db.ts`, `index.ts`, `registry.ts`, `types.ts`, `agents/{codex, devin, jules}.ts` |
|
||||
| `combos/` | Combo resolution helpers |
|
||||
| `compliance/` | Audit + provider audit: `index.ts`, `providerAudit.ts` |
|
||||
| `config/` | Runtime config glue |
|
||||
| `db/` | SQLite domain modules (see §3.2.1) |
|
||||
| `display/` | UI/display helpers used by API responses |
|
||||
| `embeddings/` | Embedding service registry |
|
||||
| `env/` | Env loading + introspection |
|
||||
| `evals/` | Eval runtime |
|
||||
| `guardrails/` | `piiMasker.ts`, `promptInjection.ts`, `visionBridge.ts`, `visionBridgeHelpers.ts`, `registry.ts`, `base.ts` |
|
||||
| `jobs/` | Background jobs (`autoUpdate.ts`, …) |
|
||||
| `memory/` | Persistent memory: `store.ts`, `cache.ts`, `retrieval.ts`, `summarization.ts`, `extraction.ts`, `injection.ts`, `qdrant.ts`, `settings.ts`, `verify.ts`, `schemas.ts`, `types.ts` |
|
||||
| `monitoring/` | `observability.ts` |
|
||||
| `oauth/` | OAuth providers (14): `antigravity`, `claude`, `cline`, `codex`, `cursor`, `gemini`, `github`, `gitlab-duo`, `kilocode`, `kimi-coding`, `kiro`, `qoder`, `qwen`, `windsurf` plus `services/`, `utils/{pkce, server, banner, codexAuthFile, ui}`, `constants/oauth.ts` |
|
||||
| `plugins/` | Plugin loader (`index.ts`) |
|
||||
| `promptCache/` | `prefixAnalyzer.ts`, `index.ts` |
|
||||
| `providerModels/` | Managed model lifecycle: `modelDiscovery.ts`, `managedModelImport.ts`, `managedAvailableModels.ts`, `cursorAgent.ts` |
|
||||
| `providers/` | Provider helpers: `catalog.ts`, `validation.ts`, `imageValidation.ts`, `claudeExtraUsage.ts`, `codexConnectionDefaults.ts`, `codexFastTier.ts`, `webCookieAuth.ts`, `managedAvailableModels.ts`, `requestDefaults.ts` |
|
||||
| `resilience/` | `settings.ts` — settings for circuit breaker, cooldown, lockout |
|
||||
| `runtime/` | Runtime feature detection |
|
||||
| `search/` | `executeWebSearch.ts` |
|
||||
| `services/` | Embedded services framework: `ServiceSupervisor.ts` (generic child-process supervisor with operation lock, ring buffer, health checker), `bootstrap.ts` (process-level registration and auto-start), `registry.ts` (tool → supervisor map), `apiKey.ts` (AES-256-GCM key store), `modelSync.ts` (periodic model sync), `ringBuffer.ts` (5 MB circular log buffer), `healthCheck.ts` (HTTP health probe), `types.ts`, `embedWsProxy.ts` (WebSocket proxy), `installers/{ninerouter,cliproxy}.ts`. See `docs/frameworks/EMBEDDED-SERVICES.md` |
|
||||
| Module | Purpose |
|
||||
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `a2a/` | A2A protocol server: `taskManager.ts`, `streaming.ts`, `taskExecution.ts`, `routingLogger.ts`, `skills/` (6 skills: cost analysis, health report, provider discovery, quota management, smart routing, list-capabilities) |
|
||||
| `acp/` | Agent-Control-Protocol: `index.ts`, `manager.ts`, `registry.ts` |
|
||||
| `api/` | Internal API helpers: `requireManagementAuth.ts`, `requireCliToolsAuth.ts`, `errorResponse.ts` |
|
||||
| `auth/` | `managementPassword.ts` (password reset / hashing) |
|
||||
| `batches/` | OpenAI Batches API service (`service.ts`) |
|
||||
| `catalog/` | OpenRouter catalog sync (`openrouterCatalog.ts`) |
|
||||
| `cloudAgent/` | Cloud agent registry: `api.ts`, `baseAgent.ts`, `db.ts`, `index.ts`, `registry.ts`, `types.ts`, `agents/{codex, devin, jules}.ts` |
|
||||
| `combos/` | Combo resolution helpers |
|
||||
| `compliance/` | Audit + provider audit: `index.ts`, `providerAudit.ts` |
|
||||
| `config/` | Runtime config glue |
|
||||
| `db/` | SQLite domain modules (see §3.2.1) |
|
||||
| `display/` | UI/display helpers used by API responses |
|
||||
| `embeddings/` | Embedding service registry |
|
||||
| `env/` | Env loading + introspection |
|
||||
| `evals/` | Eval runtime |
|
||||
| `guardrails/` | `piiMasker.ts`, `promptInjection.ts`, `visionBridge.ts`, `visionBridgeHelpers.ts`, `registry.ts`, `base.ts` |
|
||||
| `jobs/` | Background jobs (`autoUpdate.ts`, …) |
|
||||
| `memory/` | Persistent memory: `store.ts`, `cache.ts`, `retrieval.ts`, `summarization.ts`, `extraction.ts`, `injection.ts`, `qdrant.ts`, `settings.ts`, `verify.ts`, `schemas.ts`, `types.ts` |
|
||||
| `monitoring/` | `observability.ts` |
|
||||
| `oauth/` | OAuth providers (14): `antigravity`, `claude`, `cline`, `codex`, `cursor`, `gemini`, `github`, `gitlab-duo`, `kilocode`, `kimi-coding`, `kiro`, `qoder`, `qwen`, `windsurf` plus `services/`, `utils/{pkce, server, banner, codexAuthFile, ui}`, `constants/oauth.ts` |
|
||||
| `plugins/` | Plugin loader (`index.ts`) |
|
||||
| `promptCache/` | `prefixAnalyzer.ts`, `index.ts` |
|
||||
| `providerModels/` | Managed model lifecycle: `modelDiscovery.ts`, `managedModelImport.ts`, `managedAvailableModels.ts`, `cursorAgent.ts` |
|
||||
| `providers/` | Provider helpers: `catalog.ts`, `validation.ts`, `imageValidation.ts`, `claudeExtraUsage.ts`, `codexConnectionDefaults.ts`, `codexFastTier.ts`, `webCookieAuth.ts`, `managedAvailableModels.ts`, `requestDefaults.ts` |
|
||||
| `resilience/` | `settings.ts` — settings for circuit breaker, cooldown, lockout |
|
||||
| `runtime/` | Runtime feature detection |
|
||||
| `search/` | `executeWebSearch.ts` |
|
||||
| `services/` | Embedded services framework: `ServiceSupervisor.ts` (generic child-process supervisor with operation lock, ring buffer, health checker), `bootstrap.ts` (process-level registration and auto-start), `registry.ts` (tool → supervisor map), `apiKey.ts` (AES-256-GCM key store), `modelSync.ts` (periodic model sync), `ringBuffer.ts` (5 MB circular log buffer), `healthCheck.ts` (HTTP health probe), `types.ts`, `embedWsProxy.ts` (WebSocket proxy), `installers/{ninerouter,cliproxy}.ts`. See `docs/frameworks/EMBEDDED-SERVICES.md` |
|
||||
| `agentSkills/` | Agent Skills catalog + generator: `catalog.ts` (getCatalog/getSkillById/filterCatalog/computeCoverage), `generator.ts` (generateAgentSkills → writes `skills/{id}/SKILL.md`), `openapiParser.ts` (extracts REST endpoints from OpenAPI spec), `cliRegistryParser.ts` (extracts CLI subcommands from bin/cli-registry), `schemas.ts` (Zod: AgentSkillSchema, SkillCoverageSchema, ListQuerySchema, GenerateBodySchema), `types.ts` (AgentSkill, SkillCoverage, SkillMarkdown, GeneratorReport). Consumed by REST routes (`/api/agent-skills/*`), MCP tools (`omniroute_agent_skills_*`), and A2A skill `list-capabilities`. See [AGENT-SKILLS.md](../frameworks/AGENT-SKILLS.md). |
|
||||
| `skills/` | Skill framework: `registry.ts`, `executor.ts`, `interception.ts`, `injection.ts`, `sandbox.ts`, `custom.ts`, `hybrid.ts`, `builtins.ts`, `a2a.ts`, `providerSettings.ts`, `schemas.ts`, `skillssh.ts`, `types.ts`, plus `builtin/browser.ts` |
|
||||
| `spend/` | `batchWriter.ts` (write-behind buffer) |
|
||||
| `sync/` | `bundle.ts`, `tokens.ts` (Cloud Sync) |
|
||||
| `system/` | System-level helpers |
|
||||
| `translator/` | Top-level translator glue (delegates into `open-sse/translator/`) |
|
||||
| `usage/` | Usage accounting: `costCalculator.ts`, `tokenAccounting.ts`, `usageHistory.ts`, `aggregateHistory.ts`, `usageStats.ts`, `callLogs.ts`, `callLogArtifacts.ts`, `fetcher.ts`, `providerLimits.ts`, `migrations.ts` |
|
||||
| `versionManager/` | Auto-update + version manifest |
|
||||
| `ws/` | WebSocket bridge |
|
||||
| `zed-oauth/` | Zed editor OAuth flow |
|
||||
| `skills/` | Skill framework: `registry.ts`, `executor.ts`, `interception.ts`, `injection.ts`, `sandbox.ts`, `custom.ts`, `hybrid.ts`, `builtins.ts`, `a2a.ts`, `providerSettings.ts`, `schemas.ts`, `skillssh.ts`, `types.ts`, plus `builtin/browser.ts` |
|
||||
| `spend/` | `batchWriter.ts` (write-behind buffer) |
|
||||
| `sync/` | `bundle.ts`, `tokens.ts` (Cloud Sync) |
|
||||
| `system/` | System-level helpers |
|
||||
| `translator/` | Top-level translator glue (delegates into `open-sse/translator/`) |
|
||||
| `usage/` | Usage accounting: `costCalculator.ts`, `tokenAccounting.ts`, `usageHistory.ts`, `aggregateHistory.ts`, `usageStats.ts`, `callLogs.ts`, `callLogArtifacts.ts`, `fetcher.ts`, `providerLimits.ts`, `migrations.ts` |
|
||||
| `versionManager/` | Auto-update + version manifest |
|
||||
| `ws/` | WebSocket bridge |
|
||||
| `zed-oauth/` | Zed editor OAuth flow |
|
||||
|
||||
Top-level files in `src/lib/`:
|
||||
|
||||
@@ -492,7 +492,7 @@ open-sse/
|
||||
(shared identity helper) and `index.ts` (registry).
|
||||
|
||||
> Note: providers not listed here are served by `default.ts` using the generic
|
||||
> OpenAI-compatible executor. The full provider catalog (177+ entries) lives in
|
||||
> OpenAI-compatible executor. The full provider catalog (226+ entries) lives in
|
||||
> `src/shared/constants/providers.ts`.
|
||||
|
||||
### 4.3 `open-sse/translator/`
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"COMPRESSION_ENGINES",
|
||||
"RTK_COMPRESSION",
|
||||
"COMPRESSION_LANGUAGE_PACKS",
|
||||
"COMPRESSION_RULES_FORMAT"
|
||||
"COMPRESSION_RULES_FORMAT",
|
||||
"EXTENDING_COMPRESSION"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,14 +14,14 @@ ACP (Agent Client Protocol) is a **"CLI-as-backend" transport** for OmniRoute. I
|
||||
|
||||
### Why Use ACP?
|
||||
|
||||
| Benefit | Description |
|
||||
|---------|-------------|
|
||||
| **No API keys needed** | Uses your existing CLI authentication |
|
||||
| **Native protocol** | Uses each CLI's native input/output format |
|
||||
| **Auto-discovery** | Detects installed CLIs on your system |
|
||||
| **14 built-in agents** | Pre-configured for popular CLI tools |
|
||||
| **Custom agents** | Add your own CLI tools via settings |
|
||||
| **Process management** | Handles lifecycle (spawn, send, kill) |
|
||||
| Benefit | Description |
|
||||
| ---------------------- | ------------------------------------------ |
|
||||
| **No API keys needed** | Uses your existing CLI authentication |
|
||||
| **Native protocol** | Uses each CLI's native input/output format |
|
||||
| **Auto-discovery** | Detects installed CLIs on your system |
|
||||
| **14 built-in agents** | Pre-configured for popular CLI tools |
|
||||
| **Custom agents** | Add your own CLI tools via settings |
|
||||
| **Process management** | Handles lifecycle (spawn, send, kill) |
|
||||
|
||||
---
|
||||
|
||||
@@ -29,22 +29,22 @@ ACP (Agent Client Protocol) is a **"CLI-as-backend" transport** for OmniRoute. I
|
||||
|
||||
ACP supports **14 built-in CLI agents** out of the box:
|
||||
|
||||
| Agent ID | Display Name | Binary | Protocol |
|
||||
|----------|--------------|--------|----------|
|
||||
| `codex` | OpenAI Codex CLI | `codex` | stdio |
|
||||
| `claude` | Claude Code CLI | `claude` | stdio |
|
||||
| `goose` | Goose CLI | `goose` | stdio |
|
||||
| `gemini-cli` | Gemini CLI | `gemini` | stdio |
|
||||
| `openclaw` | OpenClaw | `openclaw` | stdio |
|
||||
| `aider` | Aider | `aider` | stdio |
|
||||
| `opencode` | OpenCode | `opencode` | stdio |
|
||||
| `cline` | Cline | `cline` | stdio |
|
||||
| `qwen-code` | Qwen Code | `qwen` | stdio |
|
||||
| `forge` | ForgeCode | `forge` | stdio |
|
||||
| `amazon-q` | Amazon Q Developer | `q` | stdio |
|
||||
| `interpreter` | Open Interpreter | `interpreter` | stdio |
|
||||
| `cursor-cli` | Cursor CLI | `cursor` | stdio |
|
||||
| `warp` | Warp AI | `warp` | stdio |
|
||||
| Agent ID | Display Name | Binary | Protocol |
|
||||
| ------------- | ------------------ | ------------- | -------- |
|
||||
| `codex` | OpenAI Codex CLI | `codex` | stdio |
|
||||
| `claude` | Claude Code CLI | `claude` | stdio |
|
||||
| `goose` | Goose CLI | `goose` | stdio |
|
||||
| `gemini-cli` | Gemini CLI | `gemini` | stdio |
|
||||
| `openclaw` | OpenClaw | `openclaw` | stdio |
|
||||
| `aider` | Aider | `aider` | stdio |
|
||||
| `opencode` | OpenCode | `opencode` | stdio |
|
||||
| `cline` | Cline | `cline` | stdio |
|
||||
| `qwen-code` | Qwen Code | `qwen` | stdio |
|
||||
| `forge` | ForgeCode | `forge` | stdio |
|
||||
| `amazon-q` | Amazon Q Developer | `q` | stdio |
|
||||
| `interpreter` | Open Interpreter | `interpreter` | stdio |
|
||||
| `cursor-cli` | Cursor CLI | `cursor` | stdio |
|
||||
| `warp` | Warp AI | `warp` | stdio |
|
||||
|
||||
### Custom Agents
|
||||
|
||||
@@ -129,16 +129,16 @@ const agents = detectInstalledAgents();
|
||||
// Returns: CliAgentInfo[]
|
||||
|
||||
interface CliAgentInfo {
|
||||
id: string; // e.g., "codex", "claude"
|
||||
name: string; // Display name
|
||||
binary: string; // Binary name to spawn
|
||||
versionCommand: string; // Version detection command
|
||||
version: string | null; // Detected version (null if not installed)
|
||||
installed: boolean; // Whether the agent is installed
|
||||
providerAlias: string; // Provider ID in OmniRoute
|
||||
spawnArgs: string[]; // Arguments to pass when spawning
|
||||
protocol: "stdio" | "http"; // Communication protocol
|
||||
isCustom?: boolean; // Whether this is a user-defined custom agent
|
||||
id: string; // e.g., "codex", "claude"
|
||||
name: string; // Display name
|
||||
binary: string; // Binary name to spawn
|
||||
versionCommand: string; // Version detection command
|
||||
version: string | null; // Detected version (null if not installed)
|
||||
installed: boolean; // Whether the agent is installed
|
||||
providerAlias: string; // Provider ID in OmniRoute
|
||||
spawnArgs: string[]; // Arguments to pass when spawning
|
||||
protocol: "stdio" | "http"; // Communication protocol
|
||||
isCustom?: boolean; // Whether this is a user-defined custom agent
|
||||
}
|
||||
```
|
||||
|
||||
@@ -193,12 +193,9 @@ Spawns a new CLI agent process.
|
||||
```typescript
|
||||
import { acpManager } from "@/lib/acp";
|
||||
|
||||
const session = acpManager.spawn(
|
||||
"claude",
|
||||
"claude",
|
||||
["--print", "--output-format", "json"],
|
||||
{ /* custom env vars */ }
|
||||
);
|
||||
const session = acpManager.spawn("claude", "claude", ["--print", "--output-format", "json"], {
|
||||
/* custom env vars */
|
||||
});
|
||||
// Returns: AcpSession
|
||||
```
|
||||
|
||||
@@ -214,7 +211,7 @@ import { acpManager } from "@/lib/acp";
|
||||
const response = await acpManager.sendPrompt(
|
||||
"acp-claude-1234567890-abc123",
|
||||
"What is 2+2?",
|
||||
120000 // 2 minutes timeout
|
||||
120000 // 2 minutes timeout
|
||||
);
|
||||
// Returns: Promise<string>
|
||||
```
|
||||
@@ -255,13 +252,13 @@ acpManager.killAll();
|
||||
|
||||
```typescript
|
||||
interface AcpSession {
|
||||
id: string; // Unique session ID
|
||||
agentId: string; // Agent ID (e.g., "claude")
|
||||
process: ChildProcess; // Child process handle
|
||||
alive: boolean; // Whether the process is alive
|
||||
stdoutBuffer: string; // Accumulated stdout buffer
|
||||
stderrBuffer: string; // Accumulated stderr buffer
|
||||
createdAt: Date; // Created timestamp
|
||||
id: string; // Unique session ID
|
||||
agentId: string; // Agent ID (e.g., "claude")
|
||||
process: ChildProcess; // Child process handle
|
||||
alive: boolean; // Whether the process is alive
|
||||
stdoutBuffer: string; // Accumulated stdout buffer
|
||||
stderrBuffer: string; // Accumulated stderr buffer
|
||||
createdAt: Date; // Created timestamp
|
||||
}
|
||||
```
|
||||
|
||||
@@ -412,6 +409,7 @@ Each ACP session runs in its own child process. The process is killed when the s
|
||||
**Problem**: `acpManager.spawn()` throws `Unknown agent: <id>`
|
||||
|
||||
**Solution**: Only 4 agents are allowed in `spawn()`:
|
||||
|
||||
- `claude`
|
||||
- `codex`
|
||||
- `gemini`
|
||||
@@ -448,6 +446,7 @@ await acpManager.sendPrompt(sessionId, prompt, 300000); // 5 minutes
|
||||
**Problem**: `detectInstalledAgents()` doesn't find your CLI
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Check PATH**: Ensure the CLI is in your system PATH
|
||||
2. **Check version command**: Run `claude --version` manually
|
||||
3. **Check permissions**: Ensure the CLI is executable
|
||||
@@ -458,6 +457,7 @@ await acpManager.sendPrompt(sessionId, prompt, 300000); // 5 minutes
|
||||
**Problem**: ACP can't execute the CLI
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Check file permissions**: `chmod +x /usr/local/bin/claude`
|
||||
2. **Check ownership**: Ensure OmniRoute has read/execute permissions
|
||||
3. **Check SELinux/AppArmor**: May block process spawning
|
||||
@@ -477,11 +477,7 @@ const claude = agents.find((a) => a.id === "claude");
|
||||
|
||||
if (claude?.installed) {
|
||||
// Spawn a new session
|
||||
const session = acpManager.spawn(
|
||||
"claude",
|
||||
claude.binary,
|
||||
["--print", "--output-format", "json"]
|
||||
);
|
||||
const session = acpManager.spawn("claude", claude.binary, ["--print", "--output-format", "json"]);
|
||||
|
||||
// Send a prompt
|
||||
const response = await acpManager.sendPrompt(
|
||||
|
||||
131
docs/frameworks/NOTION_CONTEXT.md
Normal file
131
docs/frameworks/NOTION_CONTEXT.md
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: "Notion Context Source"
|
||||
version: 3.8.24
|
||||
lastUpdated: 2026-06-13
|
||||
---
|
||||
|
||||
# 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).
|
||||
191
docs/frameworks/OBSIDIAN_CONTEXT.md
Normal file
191
docs/frameworks/OBSIDIAN_CONTEXT.md
Normal file
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: "Obsidian Context Source"
|
||||
version: 3.8.24
|
||||
lastUpdated: 2026-06-13
|
||||
---
|
||||
|
||||
# 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).
|
||||
348
docs/frameworks/PLUGIN_MARKETPLACE.md
Normal file
348
docs/frameworks/PLUGIN_MARKETPLACE.md
Normal file
@@ -0,0 +1,348 @@
|
||||
---
|
||||
title: "Plugin Marketplace"
|
||||
version: 3.8.24
|
||||
lastUpdated: 2026-06-13
|
||||
---
|
||||
|
||||
# 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
|
||||
@@ -9,7 +9,10 @@
|
||||
"EVALS",
|
||||
"GAMIFICATION",
|
||||
"MEMORY",
|
||||
"NOTION_CONTEXT",
|
||||
"OBSIDIAN_CONTEXT",
|
||||
"OPENCODE",
|
||||
"PLUGIN_MARKETPLACE",
|
||||
"SKILLS",
|
||||
"WEBHOOKS"
|
||||
]
|
||||
|
||||
@@ -10,12 +10,12 @@ Think of a provider like a **phone carrier**. Just as you need a phone carrier t
|
||||
|
||||
### Types of Providers
|
||||
|
||||
| Type | What It Is | Examples | Cost |
|
||||
|------|-----------|----------|------|
|
||||
| **Free** | No payment required | Kiro, OpenCode Free, Pollinations | $0 |
|
||||
| **API Key** | You need an API key | OpenAI, Anthropic, Google | Pay per use |
|
||||
| **OAuth** | Login with your account | Claude Code, GitHub Copilot | Subscription |
|
||||
| **Web Cookie** | Uses your browser session | ChatGPT Web, Gemini Web | $0 (uses your account) |
|
||||
| Type | What It Is | Examples | Cost |
|
||||
| -------------- | ------------------------- | --------------------------------- | ---------------------- |
|
||||
| **Free** | No payment required | Kiro, OpenCode Free, Pollinations | $0 |
|
||||
| **API Key** | You need an API key | OpenAI, Anthropic, Google | Pay per use |
|
||||
| **OAuth** | Login with your account | Claude Code, GitHub Copilot | Subscription |
|
||||
| **Web Cookie** | Uses your browser session | ChatGPT Web, Gemini Web | $0 (uses your account) |
|
||||
|
||||
---
|
||||
|
||||
@@ -64,17 +64,17 @@ Think of a provider like a **phone carrier**. Just as you need a phone carrier t
|
||||
|
||||
These providers offer **free access** with no credit card:
|
||||
|
||||
| Provider | Free Quota | Models | How to Connect |
|
||||
|----------|-----------|--------|----------------|
|
||||
| **Kiro AI** | 50 credits/month | Claude Sonnet 4.5, Haiku 4.5, Opus 4.6 | No auth needed |
|
||||
| **OpenCode Free** | Unlimited | GPT-4o, Claude, Gemini | No auth needed |
|
||||
| **Pollinations** | No key needed | GPT-5, Claude, Gemini, DeepSeek, Llama 4 | No auth needed |
|
||||
| **LongCat** | 50M tokens/day | LongCat-Flash-Lite | No auth needed |
|
||||
| **Cloudflare AI** | 10K neurons/day | 50+ models | No auth needed |
|
||||
| **NVIDIA NIM** | ~40 RPM | 129 models | API key needed |
|
||||
| **Cerebras** | 1M tokens/day | Qwen3 235B, GPT-OSS 120B | API key needed |
|
||||
| **Qwen** | Unlimited | Qwen3-coder-plus/flash/next | No auth needed |
|
||||
| **Qoder** | Unlimited | Kimi-K2, DeepSeek-R1, Qwen3-coder | No auth needed |
|
||||
| Provider | Free Quota | Models | How to Connect |
|
||||
| ----------------- | ---------------- | ---------------------------------------- | -------------- |
|
||||
| **Kiro AI** | 50 credits/month | Claude Sonnet 4.5, Haiku 4.5, Opus 4.6 | No auth needed |
|
||||
| **OpenCode Free** | Unlimited | GPT-4o, Claude, Gemini | No auth needed |
|
||||
| **Pollinations** | No key needed | GPT-5, Claude, Gemini, DeepSeek, Llama 4 | No auth needed |
|
||||
| **LongCat** | 50M tokens/day | LongCat-Flash-Lite | No auth needed |
|
||||
| **Cloudflare AI** | 10K neurons/day | 50+ models | No auth needed |
|
||||
| **NVIDIA NIM** | ~40 RPM | 129 models | API key needed |
|
||||
| **Cerebras** | 1M tokens/day | Qwen3 235B, GPT-OSS 120B | API key needed |
|
||||
| **Qwen** | Unlimited | Qwen3-coder-plus/flash/next | No auth needed |
|
||||
| **Qoder** | Unlimited | Kimi-K2, DeepSeek-R1, Qwen3-coder | No auth needed |
|
||||
|
||||
**Tip**: Connect multiple free providers for **unlimited free AI** with automatic fallback!
|
||||
|
||||
@@ -84,14 +84,14 @@ These providers offer **free access** with no credit card:
|
||||
|
||||
These providers offer **high-quality models** with API keys:
|
||||
|
||||
| Provider | Best Models | Cost | Free Tier |
|
||||
|----------|------------|------|-----------|
|
||||
| **OpenAI** | GPT-5, GPT-4o | $2.50-$10/1M tokens | $5 free credits |
|
||||
| **Anthropic** | Claude Opus 4.6, Sonnet 4.6 | $3-$15/1M tokens | $5 free credits |
|
||||
| **Google** | Gemini 2.5 Pro, Flash | $0.075-$1.25/1M tokens | 1,500 req/day free |
|
||||
| **DeepSeek** | DeepSeek V4 | $0.14-$0.28/1M tokens | 5M free tokens |
|
||||
| **Groq** | Llama 4, Mixtral | $0.05-$0.27/1M tokens | 30 RPM free |
|
||||
| **xAI** | Grok 3 | $0.30-$0.60/1M tokens | — |
|
||||
| Provider | Best Models | Cost | Free Tier |
|
||||
| ------------- | --------------------------- | ---------------------- | ------------------ |
|
||||
| **OpenAI** | GPT-5, GPT-4o | $2.50-$10/1M tokens | $5 free credits |
|
||||
| **Anthropic** | Claude Opus 4.6, Sonnet 4.6 | $3-$15/1M tokens | $5 free credits |
|
||||
| **Google** | Gemini 2.5 Pro, Flash | $0.075-$1.25/1M tokens | 1,500 req/day free |
|
||||
| **DeepSeek** | DeepSeek V4 | $0.14-$0.28/1M tokens | 5M free tokens |
|
||||
| **Groq** | Llama 4, Mixtral | $0.05-$0.27/1M tokens | 30 RPM free |
|
||||
| **xAI** | Grok 3 | $0.30-$0.60/1M tokens | — |
|
||||
|
||||
---
|
||||
|
||||
|
||||
261
docs/guides/COST_TRACKING.md
Normal file
261
docs/guides/COST_TRACKING.md
Normal file
@@ -0,0 +1,261 @@
|
||||
---
|
||||
title: "Cost & Spend Tracking"
|
||||
version: 3.8.24
|
||||
lastUpdated: 2026-06-13
|
||||
---
|
||||
|
||||
# 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).
|
||||
253
docs/guides/FREE_PROVIDER_RANKINGS.md
Normal file
253
docs/guides/FREE_PROVIDER_RANKINGS.md
Normal file
@@ -0,0 +1,253 @@
|
||||
---
|
||||
title: "Free Provider Rankings (Arena ELO)"
|
||||
version: 3.8.24
|
||||
lastUpdated: 2026-06-13
|
||||
---
|
||||
|
||||
# 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.
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
title: "i18n — Internationalization Guide"
|
||||
version: 3.8.2
|
||||
lastUpdated: 2026-05-13
|
||||
version: 3.8.24
|
||||
lastUpdated: 2026-06-13
|
||||
---
|
||||
|
||||
# i18n — Internationalization Guide
|
||||
|
||||
OmniRoute supports **30 languages** with full dashboard UI translation, translated documentation, and RTL support for Arabic and Hebrew.
|
||||
OmniRoute supports **42 languages** with full dashboard UI translation, translated documentation, and RTL support for Arabic and Hebrew.
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](./I18N.md) | 🇧🇷 [Português (Brasil)](../i18n/pt-BR/docs/guides/I18N.md) | 🇪🇸 [Español](../i18n/es/docs/guides/I18N.md) | 🇫🇷 [Français](../i18n/fr/docs/guides/I18N.md) | 🇩🇪 [Deutsch](../i18n/de/docs/guides/I18N.md) | 🇮🇹 [Italiano](../i18n/it/docs/guides/I18N.md) | 🇷🇺 [Русский](../i18n/ru/docs/guides/I18N.md) | 🇨🇳 [中文 (简体)](../i18n/zh-CN/docs/guides/I18N.md) | 🇯🇵 [日本語](../i18n/ja/docs/guides/I18N.md) | 🇰🇷 [한국어](../i18n/ko/docs/guides/I18N.md) | 🇸🇦 [العربية](../i18n/ar/docs/guides/I18N.md) | 🇮🇳 [हिन्दी](../i18n/hi/docs/guides/I18N.md) | 🇹🇭 [ไทย](../i18n/th/docs/guides/I18N.md) | 🇹🇷 [Türkçe](../i18n/tr/docs/guides/I18N.md) | 🇺🇦 [Українська](../i18n/uk-UA/docs/guides/I18N.md) | 🇻🇳 [Tiếng Việt](../i18n/vi/docs/guides/I18N.md) | 🇧🇬 [Български](../i18n/bg/docs/guides/I18N.md) | 🇩🇰 [Dansk](../i18n/da/docs/guides/I18N.md) | 🇫🇮 [Suomi](../i18n/fi/docs/guides/I18N.md) | 🇮🇱 [עברית](../i18n/he/docs/guides/I18N.md) | 🇭🇺 [Magyar](../i18n/hu/docs/guides/I18N.md) | 🇮🇩 [Bahasa Indonesia](../i18n/id/docs/guides/I18N.md) | 🇲🇾 [Bahasa Melayu](../i18n/ms/docs/guides/I18N.md) | 🇳🇱 [Nederlands](../i18n/nl/docs/guides/I18N.md) | 🇳🇴 [Norsk](../i18n/no/docs/guides/I18N.md) | 🇵🇹 [Português (Portugal)](../i18n/pt/docs/guides/I18N.md) | 🇷🇴 [Română](../i18n/ro/docs/guides/I18N.md) | 🇵🇱 [Polski](../i18n/pl/docs/guides/I18N.md) | 🇸🇰 [Slovenčina](../i18n/sk/docs/guides/I18N.md) | 🇸🇪 [Svenska](../i18n/sv/docs/guides/I18N.md) | 🇵🇭 [Filipino](../i18n/phi/docs/guides/I18N.md) | 🇨🇿 [Čeština](../i18n/cs/docs/guides/I18N.md)
|
||||
|
||||
@@ -241,7 +241,7 @@ python3 scripts/i18n/i18n_autotranslate.py \
|
||||
- Scans `docs/i18n/` markdown files for English paragraphs
|
||||
- Skips code blocks, tables, and already-translated content
|
||||
- Sends paragraphs to LLM with technical translation system prompt
|
||||
- Supports all 30 languages
|
||||
- Supports all 42 languages
|
||||
|
||||
## CLI i18n
|
||||
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
"DOCKER_GUIDE",
|
||||
"ELECTRON_GUIDE",
|
||||
"FEATURES",
|
||||
"FREE_PROVIDER_RANKINGS",
|
||||
"COST_TRACKING",
|
||||
"I18N",
|
||||
"KIRO_SETUP",
|
||||
"CODEX-CLI-CONFIGURATION",
|
||||
"PWA_GUIDE",
|
||||
"TERMUX_GUIDE",
|
||||
"UNINSTALL"
|
||||
|
||||
235
docs/reference/FEATURE_FLAGS.md
Normal file
235
docs/reference/FEATURE_FLAGS.md
Normal file
@@ -0,0 +1,235 @@
|
||||
---
|
||||
title: "Feature Flags"
|
||||
version: 3.8.24
|
||||
lastUpdated: 2026-06-13
|
||||
---
|
||||
|
||||
# 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.
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"title": "Reference",
|
||||
"pages": ["API_REFERENCE", "CLI-TOOLS", "ENVIRONMENT", "FREE_TIERS", "PROVIDER_REFERENCE"]
|
||||
"pages": [
|
||||
"API_REFERENCE",
|
||||
"CLI-TOOLS",
|
||||
"ENVIRONMENT",
|
||||
"FEATURE_FLAGS",
|
||||
"FREE_TIERS",
|
||||
"PROVIDER_REFERENCE"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "Routing",
|
||||
"pages": ["AUTO-COMBO", "REASONING_REPLAY"]
|
||||
"pages": ["AUTO-COMBO", "QUOTA_SHARE", "REASONING_REPLAY"]
|
||||
}
|
||||
|
||||
250
docs/security/EGRESS_POLICY.md
Normal file
250
docs/security/EGRESS_POLICY.md
Normal file
@@ -0,0 +1,250 @@
|
||||
---
|
||||
title: "Egress IP Family Policy (IPv4/IPv6)"
|
||||
version: 3.8.24
|
||||
lastUpdated: 2026-06-13
|
||||
---
|
||||
|
||||
# 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
|
||||
@@ -6,7 +6,9 @@
|
||||
"ERROR_SANITIZATION",
|
||||
"ROUTE_GUARD_TIERS",
|
||||
"STEALTH_GUIDE",
|
||||
"EGRESS_POLICY",
|
||||
"COMPLIANCE",
|
||||
"SOCKET_DEV_FINDINGS",
|
||||
"CLI_TOKEN",
|
||||
"CLI_TOKEN_AUTH"
|
||||
]
|
||||
|
||||
@@ -78,6 +78,8 @@
|
||||
"build:release": "rm -rf .build dist && OMNIROUTE_BUILD_SHA=$(git rev-parse --short HEAD) npm run build && npm run build:cli && node scripts/build/write-build-sha.mjs",
|
||||
"start": "node scripts/dev/run-next.mjs start",
|
||||
"lint": "eslint .",
|
||||
"lint:md": "npx --yes markdownlint-cli2 \"docs/**/*.md\" \"*.md\" \"!docs/i18n\" \"!docs/research\"",
|
||||
"lint:prose": "vale docs",
|
||||
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:20128 && cd electron && npm run dev\"",
|
||||
"electron:build": "npm run build && cd electron && npm run build",
|
||||
"electron:build:win": "npm run build && cd electron && npm run build:win",
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
#!/usr/bin/env node
|
||||
// Validates that count-based assertions in docs match the actual code state.
|
||||
// Examples checked:
|
||||
// - executors count in open-sse/executors/
|
||||
// - routing strategies in src/shared/constants/routingStrategies.ts
|
||||
// - OAuth providers in src/lib/oauth/providers/
|
||||
// - A2A skills in src/lib/a2a/skills/
|
||||
// - Cloud agents in src/lib/cloudAgent/agents/
|
||||
//
|
||||
// Exits 0 on success, 1 on detected drift.
|
||||
// Two tiers of checks:
|
||||
// • STRICT (always blocking — exit 1 on drift): high-confidence, slow-moving counts
|
||||
// that historically caused the worst drift across README / AGENTS / docs.
|
||||
// - provider count (source of truth: docs/reference/PROVIDER_REFERENCE.md total,
|
||||
// which is auto-generated from src/shared/constants/providers.ts)
|
||||
// - i18n locale count (source of truth: config/i18n.json `locales`)
|
||||
// • SOFT (heuristic — only fails with --strict): file-count based assertions that can
|
||||
// false-positive.
|
||||
// - executors count in open-sse/executors/
|
||||
// - routing strategies in src/shared/constants/routingStrategies.ts
|
||||
// - OAuth providers in src/lib/oauth/providers/
|
||||
// - A2A skills in src/lib/a2a/skills/
|
||||
// - Cloud agents in src/lib/cloudAgent/agents/
|
||||
//
|
||||
// Exits 0 on success, 1 on STRICT drift (or any drift with --strict).
|
||||
// Run: node scripts/check/check-docs-counts-sync.mjs
|
||||
//
|
||||
// NOTE: the provider check trusts PROVIDER_REFERENCE.md as the canonical total. If a
|
||||
// provider is added to the code but the reference is not regenerated, this guard will
|
||||
// not catch it — regenerate with `npm run gen:provider-reference` before relying on it.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -48,68 +60,144 @@ function countRoutingStrategies() {
|
||||
return (m[1].match(/"[^"]+"/g) || []).length;
|
||||
}
|
||||
|
||||
function docContains(docPath, needle) {
|
||||
const abs = path.join(ROOT, "docs", docPath);
|
||||
if (!fs.existsSync(abs)) return false;
|
||||
return fs.readFileSync(abs, "utf8").includes(needle);
|
||||
// PURE: parse the canonical provider total out of the auto-generated catalog text.
|
||||
export function parseProviderTotal(referenceText) {
|
||||
if (!referenceText) return 0;
|
||||
const m = referenceText.match(/Total providers:\s*\*\*(\d+)\*\*/);
|
||||
return m ? Number(m[1]) : 0;
|
||||
}
|
||||
|
||||
const checks = [
|
||||
{
|
||||
label: "Executors count",
|
||||
actual: countFiles("open-sse/executors"),
|
||||
docKey: "executors",
|
||||
docs: ["architecture/ARCHITECTURE.md", "architecture/CODEBASE_DOCUMENTATION.md"],
|
||||
},
|
||||
{
|
||||
label: "Routing strategies count",
|
||||
actual: countRoutingStrategies(),
|
||||
docKey: "strategies",
|
||||
docs: ["routing/AUTO-COMBO.md", "architecture/RESILIENCE_GUIDE.md"],
|
||||
},
|
||||
{
|
||||
label: "OAuth providers count",
|
||||
actual: countFiles("src/lib/oauth/providers"),
|
||||
docKey: "OAuth providers",
|
||||
docs: ["architecture/ARCHITECTURE.md"],
|
||||
},
|
||||
{
|
||||
label: "A2A skills count",
|
||||
actual: countFiles("src/lib/a2a/skills"),
|
||||
docKey: "A2A skills",
|
||||
docs: ["frameworks/A2A-SERVER.md"],
|
||||
},
|
||||
{
|
||||
label: "Cloud agents count",
|
||||
actual: countFiles("src/lib/cloudAgent/agents"),
|
||||
docKey: "cloud agents",
|
||||
docs: ["frameworks/CLOUD_AGENT.md", "frameworks/AGENT_PROTOCOLS_GUIDE.md"],
|
||||
},
|
||||
];
|
||||
// STRICT: canonical provider total, read from the auto-generated catalog.
|
||||
export function readProviderTotal() {
|
||||
const abs = path.join(ROOT, "docs", "reference", "PROVIDER_REFERENCE.md");
|
||||
if (!fs.existsSync(abs)) return 0;
|
||||
return parseProviderTotal(fs.readFileSync(abs, "utf8"));
|
||||
}
|
||||
|
||||
let drift = 0;
|
||||
console.log("Docs counts sync report");
|
||||
console.log("=======================");
|
||||
|
||||
for (const c of checks) {
|
||||
console.log(`\n• ${c.label}: ${c.actual} (real)`);
|
||||
for (const doc of c.docs) {
|
||||
const found = docContains(doc, String(c.actual));
|
||||
if (found) {
|
||||
console.log(` ✓ docs/${doc} mentions "${c.actual}"`);
|
||||
} else {
|
||||
console.log(` ⚠ docs/${doc} does NOT mention "${c.actual}" for ${c.docKey}`);
|
||||
drift++;
|
||||
}
|
||||
// STRICT: canonical i18n locale count, read from the shared config.
|
||||
export function countLocales() {
|
||||
const abs = path.join(ROOT, "config", "i18n.json");
|
||||
if (!fs.existsSync(abs)) return 0;
|
||||
try {
|
||||
const cfg = JSON.parse(fs.readFileSync(abs, "utf8"));
|
||||
return Array.isArray(cfg.locales) ? cfg.locales.length : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
if (drift > 0) {
|
||||
console.warn(`⚠ ${drift} potential drift(s) detected. Review the docs above.`);
|
||||
// Soft-fail by default (count-based heuristic can false-positive).
|
||||
// To enforce, pass --strict.
|
||||
if (process.argv.includes("--strict")) process.exit(1);
|
||||
} else {
|
||||
console.log("✓ All checks pass.");
|
||||
// PURE: tally STRICT vs SOFT drift for a list of checks, given a content lookup.
|
||||
// `getContent(file) -> string | null`. A check whose `actual` is 0 is skipped (the
|
||||
// source count could not be determined). Returns { strict, soft, lines }.
|
||||
export function tallyDrift(checks, getContent) {
|
||||
let strict = 0;
|
||||
let soft = 0;
|
||||
const lines = [];
|
||||
for (const c of checks) {
|
||||
const tier = c.strict ? "STRICT" : "soft";
|
||||
lines.push(`\n• ${c.label}: ${c.actual} (real) [${tier}]`);
|
||||
if (!c.actual) {
|
||||
lines.push(` ⚠ could not determine ${c.docKey} count from source — skipping`);
|
||||
continue;
|
||||
}
|
||||
for (const f of c.files) {
|
||||
const content = getContent(f);
|
||||
const found = content != null && content.includes(String(c.actual));
|
||||
if (found) {
|
||||
lines.push(` ✓ ${f} mentions "${c.actual}"`);
|
||||
} else {
|
||||
lines.push(` ${c.strict ? "✗" : "⚠"} ${f} does NOT mention "${c.actual}" for ${c.docKey}`);
|
||||
if (c.strict) strict++;
|
||||
else soft++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { strict, soft, lines };
|
||||
}
|
||||
|
||||
export function buildChecks() {
|
||||
return [
|
||||
{
|
||||
label: "Provider count",
|
||||
actual: readProviderTotal(),
|
||||
docKey: "providers",
|
||||
strict: true,
|
||||
files: ["README.md", "AGENTS.md", "CLAUDE.md"],
|
||||
},
|
||||
{
|
||||
label: "i18n locales count",
|
||||
actual: countLocales(),
|
||||
docKey: "i18n locales",
|
||||
strict: true,
|
||||
files: ["docs/README.md", "docs/guides/I18N.md", "AGENTS.md"],
|
||||
},
|
||||
{
|
||||
label: "Executors count",
|
||||
actual: countFiles("open-sse/executors"),
|
||||
docKey: "executors",
|
||||
strict: false,
|
||||
files: ["docs/architecture/ARCHITECTURE.md", "docs/architecture/CODEBASE_DOCUMENTATION.md"],
|
||||
},
|
||||
{
|
||||
label: "Routing strategies count",
|
||||
actual: countRoutingStrategies(),
|
||||
docKey: "strategies",
|
||||
strict: false,
|
||||
files: ["docs/routing/AUTO-COMBO.md", "docs/architecture/RESILIENCE_GUIDE.md"],
|
||||
},
|
||||
{
|
||||
label: "OAuth providers count",
|
||||
actual: countFiles("src/lib/oauth/providers"),
|
||||
docKey: "OAuth providers",
|
||||
strict: false,
|
||||
files: ["docs/architecture/ARCHITECTURE.md"],
|
||||
},
|
||||
{
|
||||
label: "A2A skills count",
|
||||
actual: countFiles("src/lib/a2a/skills"),
|
||||
docKey: "A2A skills",
|
||||
strict: false,
|
||||
files: ["docs/frameworks/A2A-SERVER.md"],
|
||||
},
|
||||
{
|
||||
label: "Cloud agents count",
|
||||
actual: countFiles("src/lib/cloudAgent/agents"),
|
||||
docKey: "cloud agents",
|
||||
strict: false,
|
||||
files: ["docs/frameworks/CLOUD_AGENT.md", "docs/frameworks/AGENT_PROTOCOLS_GUIDE.md"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function main() {
|
||||
const checks = buildChecks();
|
||||
const getContent = (relPath) => {
|
||||
const abs = path.join(ROOT, relPath);
|
||||
return fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : null;
|
||||
};
|
||||
|
||||
console.log("Docs counts sync report");
|
||||
console.log("=======================");
|
||||
const { strict, soft, lines } = tallyDrift(checks, getContent);
|
||||
for (const l of lines) console.log(l);
|
||||
|
||||
console.log();
|
||||
if (strict > 0) {
|
||||
console.error(
|
||||
`✗ ${strict} STRICT drift(s) detected. ` +
|
||||
`Update the docs above to the real counts, or regenerate auto-generated sources ` +
|
||||
`(npm run gen:provider-reference).`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (soft > 0) {
|
||||
console.warn(`⚠ ${soft} potential (soft) drift(s) detected. Review the docs above.`);
|
||||
if (process.argv.includes("--strict")) process.exit(1);
|
||||
} else {
|
||||
console.log("✓ All checks pass.");
|
||||
}
|
||||
}
|
||||
|
||||
const invokedDirectly =
|
||||
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (invokedDirectly) main();
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface OpenApiEndpoint {
|
||||
hasRequestBody: boolean;
|
||||
}
|
||||
|
||||
export const OPENAPI_VERSION = "3.8.7";
|
||||
export const OPENAPI_VERSION = "3.8.24";
|
||||
export const OPENAPI_TITLE = "OmniRoute API";
|
||||
|
||||
export const OPENAPI_ENDPOINTS: OpenApiEndpoint[] = [
|
||||
@@ -173,7 +173,8 @@ export const OPENAPI_ENDPOINTS: OpenApiEndpoint[] = [
|
||||
path: "/api/v1/providers/{provider}/models",
|
||||
method: "GET",
|
||||
summary: "List models for a specific provider",
|
||||
description: "Returns only models for the selected provider with provider prefix removed from each model id.",
|
||||
description:
|
||||
"Returns only models for the selected provider with provider prefix removed from each model id.",
|
||||
tag: "Models",
|
||||
tags: ["Models"],
|
||||
requiresAuth: true,
|
||||
|
||||
100
tests/unit/check-docs-counts-sync.test.ts
Normal file
100
tests/unit/check-docs-counts-sync.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
parseProviderTotal,
|
||||
tallyDrift,
|
||||
readProviderTotal,
|
||||
countLocales,
|
||||
} from "../../scripts/check/check-docs-counts-sync.mjs";
|
||||
|
||||
// Explicit types for the .mjs exports — keep the test at 0 no-explicit-any warnings.
|
||||
const parse = parseProviderTotal as (text: string) => number;
|
||||
const tally = tallyDrift as (
|
||||
checks: {
|
||||
label: string;
|
||||
actual: number;
|
||||
docKey: string;
|
||||
strict: boolean;
|
||||
files: string[];
|
||||
}[],
|
||||
getContent: (file: string) => string | null
|
||||
) => { strict: number; soft: number; lines: string[] };
|
||||
const readTotal = readProviderTotal as () => number;
|
||||
const locales = countLocales as () => number;
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const GATE = path.resolve(here, "../../scripts/check/check-docs-counts-sync.mjs");
|
||||
|
||||
// --- parseProviderTotal (pure) -------------------------------------------------------
|
||||
|
||||
test("parses the canonical provider total from the auto-generated catalog text", () => {
|
||||
assert.equal(parse("Total providers: **226**. See category breakdown below."), 226);
|
||||
});
|
||||
|
||||
test("returns 0 when no total marker is present", () => {
|
||||
assert.equal(parse("# Provider Reference\n\nNo total here."), 0);
|
||||
assert.equal(parse(""), 0);
|
||||
});
|
||||
|
||||
// --- tallyDrift (pure) ---------------------------------------------------------------
|
||||
|
||||
const strictCheck = {
|
||||
label: "Provider count",
|
||||
actual: 226,
|
||||
docKey: "providers",
|
||||
strict: true,
|
||||
files: ["README.md", "AGENTS.md"],
|
||||
};
|
||||
|
||||
test("no drift when every file mentions the real count", () => {
|
||||
const { strict, soft } = tally([strictCheck], () => "we have 226 providers");
|
||||
assert.equal(strict, 0);
|
||||
assert.equal(soft, 0);
|
||||
});
|
||||
|
||||
test("STRICT drift is counted when a file omits the real count", () => {
|
||||
const { strict, soft } = tally([strictCheck], (f) =>
|
||||
f === "README.md" ? "we have 226 providers" : "we have 177 providers"
|
||||
);
|
||||
assert.equal(strict, 1, "AGENTS.md (177) should register one strict drift");
|
||||
assert.equal(soft, 0);
|
||||
});
|
||||
|
||||
test("SOFT drift does not count as strict", () => {
|
||||
const softCheck = { ...strictCheck, strict: false };
|
||||
const { strict, soft } = tally([softCheck], () => "no number here");
|
||||
assert.equal(strict, 0);
|
||||
assert.equal(soft, 2, "both files miss → two soft drifts");
|
||||
});
|
||||
|
||||
test("a check with actual=0 is skipped (source count undetermined)", () => {
|
||||
const zero = { ...strictCheck, actual: 0 };
|
||||
const { strict, soft } = tally([zero], () => null);
|
||||
assert.equal(strict, 0);
|
||||
assert.equal(soft, 0);
|
||||
});
|
||||
|
||||
test("a missing file (null content) registers drift, not a crash", () => {
|
||||
const { strict } = tally([strictCheck], () => null);
|
||||
assert.equal(strict, 2);
|
||||
});
|
||||
|
||||
// --- live source readers (smoke) -----------------------------------------------------
|
||||
|
||||
test("readProviderTotal reads a real, positive total from the catalog", () => {
|
||||
assert.ok(readTotal() > 100, "provider catalog total should be > 100");
|
||||
});
|
||||
|
||||
test("countLocales reads a real, positive locale count from config/i18n.json", () => {
|
||||
assert.ok(locales() >= 40, "i18n config should define at least 40 locales");
|
||||
});
|
||||
|
||||
// --- live gate smoke -----------------------------------------------------------------
|
||||
|
||||
test("the gate exits 0 against the current (synced) repo state", () => {
|
||||
// Throws if exit code is non-zero; current docs are synced so this must pass.
|
||||
assert.doesNotThrow(() => execFileSync("node", [GATE], { encoding: "utf8", stdio: "pipe" }));
|
||||
});
|
||||
Reference in New Issue
Block a user