diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d77889a5d..9980766875 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000000..68ab04a1ff --- /dev/null +++ b/.markdownlint.json @@ -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 +} diff --git a/.vale.ini b/.vale.ini new file mode 100644 index 0000000000..de42bb1bad --- /dev/null +++ b/.vale.ini @@ -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 diff --git a/.vale/styles/Vocab/OmniRoute/accept.txt b/.vale/styles/Vocab/OmniRoute/accept.txt new file mode 100644 index 0000000000..50e154c3ce --- /dev/null +++ b/.vale/styles/Vocab/OmniRoute/accept.txt @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 9dec913a17..04e716ed76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`.** diff --git a/CLAUDE.md b/CLAUDE.md index 174e7e30b2..9ce1c319fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | | ------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba211765d5..25ed28a6a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index d324748a8d..92702f3f79 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -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). diff --git a/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/architecture/CODEBASE_DOCUMENTATION.md index 8c0b5caa28..12c822ade3 100644 --- a/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -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/` diff --git a/docs/compression/meta.json b/docs/compression/meta.json index bfc16e0493..1d3350b9cb 100644 --- a/docs/compression/meta.json +++ b/docs/compression/meta.json @@ -5,6 +5,7 @@ "COMPRESSION_ENGINES", "RTK_COMPRESSION", "COMPRESSION_LANGUAGE_PACKS", - "COMPRESSION_RULES_FORMAT" + "COMPRESSION_RULES_FORMAT", + "EXTENDING_COMPRESSION" ] } diff --git a/docs/frameworks/ACP.md b/docs/frameworks/ACP.md index a33cc4ea85..f5d2182ba3 100644 --- a/docs/frameworks/ACP.md +++ b/docs/frameworks/ACP.md @@ -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 ``` @@ -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: ` **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( diff --git a/docs/frameworks/NOTION_CONTEXT.md b/docs/frameworks/NOTION_CONTEXT.md new file mode 100644 index 0000000000..83986856c8 --- /dev/null +++ b/docs/frameworks/NOTION_CONTEXT.md @@ -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 +> , 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). diff --git a/docs/frameworks/OBSIDIAN_CONTEXT.md b/docs/frameworks/OBSIDIAN_CONTEXT.md new file mode 100644 index 0000000000..a9f6540973 --- /dev/null +++ b/docs/frameworks/OBSIDIAN_CONTEXT.md @@ -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://: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":"","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). diff --git a/docs/frameworks/PLUGIN_MARKETPLACE.md b/docs/frameworks/PLUGIN_MARKETPLACE.md new file mode 100644 index 0000000000..a61b12e5f1 --- /dev/null +++ b/docs/frameworks/PLUGIN_MARKETPLACE.md @@ -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.json + └─ index.js # (or whatever manifest.main points to) +``` + +`getDefaultPluginDir()` (`src/lib/plugins/scanner.ts`) resolves this to +`/.omniroute/plugins`, where `` 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-`, 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-` 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/ → 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 diff --git a/docs/frameworks/meta.json b/docs/frameworks/meta.json index 46b65656e3..76a39c8a6b 100644 --- a/docs/frameworks/meta.json +++ b/docs/frameworks/meta.json @@ -9,7 +9,10 @@ "EVALS", "GAMIFICATION", "MEMORY", + "NOTION_CONTEXT", + "OBSIDIAN_CONTEXT", "OPENCODE", + "PLUGIN_MARKETPLACE", "SKILLS", "WEBHOOKS" ] diff --git a/docs/getting-started/PROVIDERS-GUIDE.md b/docs/getting-started/PROVIDERS-GUIDE.md index cfc25ccde7..7b7138d61f 100644 --- a/docs/getting-started/PROVIDERS-GUIDE.md +++ b/docs/getting-started/PROVIDERS-GUIDE.md @@ -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 | — | --- diff --git a/docs/guides/COST_TRACKING.md b/docs/guides/COST_TRACKING.md new file mode 100644 index 0000000000..216fb9cfa2 --- /dev/null +++ b/docs/guides/COST_TRACKING.md @@ -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 --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 ] # per-provider cost summary +omniroute usage logs [--limit 100] [--follow] [--api-key ] [--search ] +omniroute usage quota [--provider ] [--check] +omniroute usage utilization [--api-key ] +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 [--scope global] [--period monthly] +omniroute usage budget reset [scope] +``` + +### `omniroute pricing` + +```bash +omniroute pricing list [--provider

] [--model ] [--limit 200] +omniroute pricing get +omniroute pricing sync [--provider

] [--force] # POST /api/pricing/sync +omniroute pricing diff [--model ] +omniroute pricing defaults show +omniroute pricing defaults set [--input

] [--output

] [--cache-read

] [--cache-write

] +``` + +> `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). diff --git a/docs/guides/FREE_PROVIDER_RANKINGS.md b/docs/guides/FREE_PROVIDER_RANKINGS.md new file mode 100644 index 0000000000..242c419d3c --- /dev/null +++ b/docs/guides/FREE_PROVIDER_RANKINGS.md @@ -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": "", + "name": "", + "icon": "", + "color": "", + "textIcon": "", + "category": "noauth | oauth | apikey", + "topModel": { + "modelId": "", + "modelName": "", + "score": 0.0, + "eloRaw": 0, + "confidence": "high | medium | low", + "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. diff --git a/docs/guides/I18N.md b/docs/guides/I18N.md index a2d3e0f402..c4d7bd827e 100644 --- a/docs/guides/I18N.md +++ b/docs/guides/I18N.md @@ -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 diff --git a/docs/guides/meta.json b/docs/guides/meta.json index 6603dc1378..9ed8d6c36d 100644 --- a/docs/guides/meta.json +++ b/docs/guides/meta.json @@ -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" diff --git a/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md new file mode 100644 index 0000000000..d64bd539c0 --- /dev/null +++ b/docs/reference/FEATURE_FLAGS.md @@ -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[]`, 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: , 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. diff --git a/docs/reference/meta.json b/docs/reference/meta.json index 5795a7c966..e7d25ec571 100644 --- a/docs/reference/meta.json +++ b/docs/reference/meta.json @@ -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" + ] } diff --git a/docs/routing/meta.json b/docs/routing/meta.json index 27a9b740b9..b785a1b930 100644 --- a/docs/routing/meta.json +++ b/docs/routing/meta.json @@ -1,4 +1,4 @@ { "title": "Routing", - "pages": ["AUTO-COMBO", "REASONING_REPLAY"] + "pages": ["AUTO-COMBO", "QUOTA_SHARE", "REASONING_REPLAY"] } diff --git a/docs/security/EGRESS_POLICY.md b/docs/security/EGRESS_POLICY.md new file mode 100644 index 0000000000..d45a940af1 --- /dev/null +++ b/docs/security/EGRESS_POLICY.md @@ -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 { + 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 diff --git a/docs/security/meta.json b/docs/security/meta.json index f397b3a740..519c9fe33f 100644 --- a/docs/security/meta.json +++ b/docs/security/meta.json @@ -6,7 +6,9 @@ "ERROR_SANITIZATION", "ROUTE_GUARD_TIERS", "STEALTH_GUIDE", + "EGRESS_POLICY", "COMPLIANCE", + "SOCKET_DEV_FINDINGS", "CLI_TOKEN", "CLI_TOKEN_AUTH" ] diff --git a/package.json b/package.json index a21e7df69c..fc5047d649 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check/check-docs-counts-sync.mjs b/scripts/check/check-docs-counts-sync.mjs index c3e6e95f63..242a5d9b84 100644 --- a/scripts/check/check-docs-counts-sync.mjs +++ b/scripts/check/check-docs-counts-sync.mjs @@ -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(); diff --git a/src/app/docs/lib/openapi.generated.ts b/src/app/docs/lib/openapi.generated.ts index 2b839c7922..3681b7770c 100644 --- a/src/app/docs/lib/openapi.generated.ts +++ b/src/app/docs/lib/openapi.generated.ts @@ -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, diff --git a/tests/unit/check-docs-counts-sync.test.ts b/tests/unit/check-docs-counts-sync.test.ts new file mode 100644 index 0000000000..3c7256f6aa --- /dev/null +++ b/tests/unit/check-docs-counts-sync.test.ts @@ -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" })); +});