diff --git a/.env.example b/.env.example index ec7a157df8..2e129f54ea 100644 --- a/.env.example +++ b/.env.example @@ -242,6 +242,7 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # CLI_CLINE_BIN=cline # CLI_CONTINUE_BIN=cn # CLI_QODER_BIN=qoder +# CLI_QWEN_BIN=qwen # ═══════════════════════════════════════════════════════════════════════════════ @@ -354,6 +355,7 @@ QODER_OAUTH_CLIENT_SECRET=4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW # QODER_OAUTH_TOKEN_URL= # QODER_OAUTH_USERINFO_URL= # QODER_OAUTH_CLIENT_ID= +# QODER_OAUTH_CLIENT_SECRET= # ── Qoder Personal Access Token (direct API key fallback) ── # Used by: open-sse/executors/qoder.ts — bypasses OAuth when set. @@ -362,14 +364,15 @@ QODER_OAUTH_CLIENT_SECRET=4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW # OMNIROUTE_QODER_WORKSPACE= # ───────────────────────────────────────────────────────────────────────────── -# ⚠️ GOOGLE OAUTH (Antigravity, Gemini CLI) — IMPORTANT FOR REMOTE SERVERS +# ⚠️ GOOGLE OAUTH (Antigravity, Gemini CLI) & OTHER PROVIDERS — REMOTE SERVERS # ───────────────────────────────────────────────────────────────────────────── -# The credentials above ONLY work when OmniRoute runs on localhost. -# For remote hosting: -# 1. Go to https://console.cloud.google.com/apis/credentials -# 2. Create an OAuth 2.0 Client ID (type: "Web application") -# 3. Add your server URL as Authorized redirect URI -# 4. Replace the values above with your credentials. +# The default Client IDs above ONLY work when OmniRoute runs on localhost. +# For remote/VPS hosting (including Docker containers on remote servers): +# 1. By default, the browser will attempt OAuth redirects back to localhost, which will fail. +# 2. Set NEXT_PUBLIC_BASE_URL=https://your-domain.com to fix the redirect URI. +# 3. You MUST create your own OAuth App in each provider's developer console (Google Cloud, etc.) +# and set the Authorized redirect URI to your domain (e.g., https://your-domain.com/callback). +# 4. Replace the _OAUTH_CLIENT_ID and _SECRET values above with your own credentials. # ───────────────────────────────────────────────────────────────────────────── # ── OAuth sidecar/CLI bridge (internal) ── diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 121d5091d5..a8a5146b66 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -165,7 +165,7 @@ body: description: "Which commands or tests should prove this bug is fixed?" placeholder: | Example: - - node --import tsx/esm --test tests/unit/my-file.test.mjs + - node --import tsx/esm --test tests/unit/my-file.test.ts - npm run test:coverage validations: required: false diff --git a/.github/ISSUE_TEMPLATE/test_coverage_task.yml b/.github/ISSUE_TEMPLATE/test_coverage_task.yml index 57b03c4404..1430618206 100644 --- a/.github/ISSUE_TEMPLATE/test_coverage_task.yml +++ b/.github/ISSUE_TEMPLATE/test_coverage_task.yml @@ -26,7 +26,7 @@ body: Example: - open-sse/handlers/chatCore.ts - open-sse/services/combo.ts - - tests/integration/chat-pipeline.test.mjs + - tests/integration/chat-pipeline.test.ts validations: required: true @@ -59,7 +59,7 @@ body: description: "List the commands that must pass before this issue can be closed." placeholder: | Example: - - node --import tsx/esm --test tests/unit/my-suite.test.mjs + - node --import tsx/esm --test tests/unit/my-suite.test.ts - npm run test:coverage validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54357e91cc..6778e667fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,9 @@ concurrency: permissions: contents: read +env: + CI_NODE_VERSION: "22.22.2" + jobs: lint: name: Lint @@ -22,9 +25,10 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci + - run: npm run check:node-runtime - run: npm run lint - run: npm run check:cycles - run: npm run check:route-validation:t06 @@ -82,7 +86,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: ${{ env.CI_NODE_VERSION }} - name: Fetch base branch run: git fetch --no-tags origin "${GITHUB_BASE_REF}" --depth=1 - name: Validate source changes include tests @@ -112,16 +116,17 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci + - run: npm run check:node-runtime - name: Dependency audit - run: npm audit --audit-level=high --omit=dev || true + run: npm audit --audit-level=high --omit=dev - name: Check for known vulnerabilities - run: npx is-my-node-vulnerable || true + run: npx is-my-node-vulnerable - name: Run Snyk Vulnerability checks if: github.actor != 'dependabot[bot]' @@ -138,11 +143,29 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci + - run: npm run check:node-runtime - run: npm run build + package-artifact: + name: Package Artifact + runs-on: ubuntu-latest + needs: build + env: + JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.CI_NODE_VERSION }} + cache: npm + - run: npm ci + - run: npm run check:node-runtime + - run: npm run build:cli + - run: npm run check:pack-artifact + test-unit: name: Unit Tests runs-on: ubuntu-latest @@ -151,13 +174,15 @@ jobs: env: JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-test-api-key-secret-long + DISABLE_SQLITE_AUTO_BACKUP: "true" steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci + - run: npm run check:node-runtime - run: npm run test:unit test-coverage: @@ -168,13 +193,15 @@ jobs: env: JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-test-api-key-secret-long + DISABLE_SQLITE_AUTO_BACKUP: "true" steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci + - run: npm run check:node-runtime - name: Run coverage gate run: npm run test:coverage - name: Build coverage summary @@ -322,13 +349,15 @@ jobs: env: JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-test-api-key-secret-long + DISABLE_SQLITE_AUTO_BACKUP: "true" steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci + - run: npm run check:node-runtime - run: npx playwright install --with-deps chromium - run: npm run build - run: npx playwright test tests/e2e/*.spec.ts --shard=${{ matrix.shard }}/4 @@ -343,13 +372,15 @@ jobs: API_KEY_SECRET: ci-test-api-key-secret-long INITIAL_PASSWORD: ci-test-password-for-integration DATA_DIR: /tmp/omniroute-ci + DISABLE_SQLITE_AUTO_BACKUP: "true" steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci + - run: npm run check:node-runtime - run: npm run test:integration test-security: @@ -359,13 +390,15 @@ jobs: env: JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-test-api-key-secret-long + DISABLE_SQLITE_AUTO_BACKUP: "true" steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci + - run: npm run check:node-runtime - run: npm run test:security ci-summary: @@ -378,6 +411,7 @@ jobs: - pr-test-policy - advanced-security - build + - package-artifact - test-unit - test-coverage - sonarqube @@ -424,6 +458,7 @@ jobs: echo "| Job | Status |" >> "$GITHUB_STEP_SUMMARY" echo "|-----|--------|" >> "$GITHUB_STEP_SUMMARY" echo "| Build Matrix | $(status '${{ needs.build.result }}') |" >> "$GITHUB_STEP_SUMMARY" + echo "| Package Artifact | $(status '${{ needs.package-artifact.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "## 🧪 Tests" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 5e6c189af6..bb56d71bc7 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -37,6 +37,9 @@ permissions: id-token: write packages: write +env: + NPM_PUBLISH_NODE_VERSION: "22.22.2" + jobs: publish: runs-on: ubuntu-latest @@ -48,7 +51,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 22 + node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }} registry-url: https://registry.npmjs.org - name: Install dependencies (skip scripts to avoid heavy build) @@ -88,7 +91,10 @@ jobs: - name: Build CLI bundle (standalone app) env: JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation - run: node scripts/prepublish.mjs + run: npm run build:cli + + - name: Validate npm package artifact + run: npm run check:pack-artifact - name: Publish to npm run: | diff --git a/.gitignore b/.gitignore index 992eaf5f0f..c4abb01ea6 100644 --- a/.gitignore +++ b/.gitignore @@ -169,3 +169,4 @@ docs/superpowers/ # GitNexus local index .gitnexus +.worktrees diff --git a/.npmignore b/.npmignore index 69b962ff51..e0a5b88654 100644 --- a/.npmignore +++ b/.npmignore @@ -72,3 +72,7 @@ vscode-extension/ # Root-level underscore-prefixed directories (private/draft — never publish) /_*/ +app/_*/ +app/coverage/ +app/logs/ +app/tests/ diff --git a/AGENTS.md b/AGENTS.md index 2b3504b13f..f80682d4f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,13 +44,13 @@ with **MCP Server** (25 tools), **A2A v0.3 Protocol**, and **Electron desktop ap npm run test:all # Single test file (Node.js native test runner — most tests use this) -node --import tsx/esm --test tests/unit/your-file.test.mjs -node --import tsx/esm --test tests/unit/plan3-p0.test.mjs -node --import tsx/esm --test tests/unit/fixes-p1.test.mjs -node --import tsx/esm --test tests/unit/security-fase01.test.mjs +node --import tsx/esm --test tests/unit/your-file.test.ts +node --import tsx/esm --test tests/unit/plan3-p0.test.ts +node --import tsx/esm --test tests/unit/fixes-p1.test.ts +node --import tsx/esm --test tests/unit/security-fase01.test.ts # Integration tests -node --import tsx/esm --test tests/integration/*.test.mjs +node --import tsx/esm --test tests/integration/*.test.ts # Vitest (MCP server, autoCombo) npm run test:vitest diff --git a/CHANGELOG.md b/CHANGELOG.md index 55de93ba1f..6032187f86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,66 @@ --- +## [3.6.6] — 2026-04-15 + +### ✨ New Features + +- **feat(storage):** Add database backup cleanup controls, UI management, and customizable retention period env vars (#1304) +- **feat(providers):** Add Freepik Pikaso image generation provider with support for cookie/subscription-based auth modes (#1277) +- **feat(providers): Add Perplexity Web (Session) Provider** — Routes through Perplexity's internal SSE API using a session cookie, giving native proxy access without separate API costs to GPT-5.4, Claude Opus, Gemini 3.1 Pro, and Nemotron via preferences mapping (#1289) +- **feat(api): Sync Tokens & V1 WebSocket Bridge** — Dedicated sync token storage, issuance, revocation, and bundle download routes backed by stable config bundle versioning with ETag support. Exposes `/v1/ws` WebSocket upgrade route and a custom Next.js server bridge (`scripts/v1-ws-bridge.mjs`) so OpenAI-compatible WebSocket traffic can be proxied through the gateway. Compliance auditing expanded with structured metadata, pagination, request context, auth/provider credential events, and SSRF-blocked validation logging. New migrations: `024_create_sync_tokens.sql`. New modules: `syncTokens.ts`, `src/lib/sync/bundle.ts`, `src/lib/sync/tokens.ts`, `src/lib/ws/handshake.ts`, `src/lib/apiBridgeServer.ts`, `src/lib/compliance/providerAudit.ts`. +- **feat(models): GLM Thinking Preset & Hybrid Token Counting** — GLM Thinking (`glmt`) registered as a first-class provider preset with shared GLM model metadata, pricing, per-connection usage sync, dashboard support, and `maxTokens: 65536 / thinkingBudgetTokens: 24576` request defaults with 900s extended timeout. Provider-side `/messages/count_tokens` endpoint used when a Claude-compatible upstream supports it; gracefully falls back to estimation on missing models, missing credentials, or upstream failures. Startup seeding of default model aliases (`src/lib/modelAliasSeed.ts`) normalizes common cross-proxy model dialects so canonical slash-based model IDs are not misrouted. New file `open-sse/config/glmProvider.ts`. +- **feat(core): Hardened Outbound Provider Calls & Cooldown Retries** — Guarded outbound fetch helpers (`src/shared/network/safeOutboundFetch.ts`, `src/shared/network/outboundUrlGuard.ts`) blocking private/local URLs with configurable retry, timeout normalisation, and route-level status propagation for provider validation and model discovery. Cooldown-aware chat retries (`src/sse/services/cooldownAwareRetry.ts`) with configurable `requestRetry` and `maxRetryIntervalSec` settings and model-scoped cooldown responses. Improved rate-limit learning from headers and error bodies so short upstream lockouts can recover automatically. Runtime environment validation (`src/lib/env/runtimeEnv.ts`) checks env at startup. Pollinations now requires an API key. Antigravity and Codex header handling aligned via `open-sse/config/antigravityUpstream.ts` and `open-sse/config/codexClient.ts`. Gemini tool names restored in translated responses; synthetic Claude text block injected when upstream SSE completes empty. +- **feat(logs):** Add TPS (Tokens Per Second) metric to log details modal metadata grid (#1182) +- **feat(memory+skills):** Full-featured Memory & Skills systems with FTS5 SQLite search, dynamic UI pagination, backend observability, and extensive test coverage (#1228) +- **feat(bailian-quota):** Add Alibaba Coding Plan quota monitoring, multi-window quota extraction, and UI credential validation (#1235) +- **feat(storage): Call Log Storage Refactor** — Extracted heavy request/response JSON payloads from the core SQLite database (`storage.sqlite`) into filesystem artifacts stored within `DATA_DIR/call_logs`. This massively reduces WAL bloat and eliminates `SQLITE_FULL` crashes on high-traffic nodes (#1307). +- **feat(providers): Add Grok Web (Subscription) Provider** — Routes through the xAI web interface for subscription users via cookie session mapping (#1295). +- **feat(api): Advanced Media Support** — Extends OpenAI generic proxy layer to natively support `image`, `embeddings`, `audio-transcriptions`, and `audio-speech` workflows (#1297). +- **feat(cli-tools): Qwen Code CLI Integration** — Full integration for Qwen Code local execution mapping, model resolution, and dynamic API key fetching (#1266, #1263). +- **feat(oauth):** Supports `cursor-agent` CLI as a native Cursor credential source alongside the standard configuration (#1258). +- **feat(models):** Custom and imported models now merge correctly into filter lists for all available global providers (#1191). + +### 🐛 Bug Fixes + +- **fix(providers):** match correct endpoint api.xiaomimimo.com for Xiaomi MiMo (#1303) +- **fix(core):** strip provider alias routing prefix from payload for custom endpoints to fix Azure OpenAI 400 errors (#1261) +- **fix(core):** ProxyFetch Undici dispatcher automatically bypasses LAN/local addresses, preventing fetch failures on internal OpenRouter requests (#1254) +- **fix(core):** Gemini thought stream signature detection upgraded to use native part.thought boolean, preventing reasoning text leaks (#1298) +- **deps:** bump hono from 4.12.12 to 4.12.14 to resolve CVE SSR HTML injection vulnerability (#1306, #59) +- **deps:** update dompurify to 3.4.0 in frontend overrides mitigating XSS HTML Injection (CVE-XYZ / Dependabot #60) +- **test:** Disable SQLite automatic backups during continuous integration (CI) tests to resolve E2E timeout issues limiting runner scaling (#24481475058) +- **feat(core): Proactive Context Compression** — `chatCore` now proactively compresses oversized message contexts before hitting upstream providers to dramatically reduce `context_length_exceeded` errors. Employs binary-search message pruning with structural integrity guarantees tracking explicit `tool_use` boundaries ensuring truncated tool inputs drop paired outputs appropriately (#1292, #1293) + +- **fix(cli):** Resolve codex routing config parsing by strictly quoting section keys array, enforcing responses wire_api with fallback, and standardizing select-model button positioning mirroring Claude UI +- **fix(providers):** Correct Lobehub provider icons rendering by removing unsupported local references ensuring local SVG/PNG fallback mechanism invokes natively +- **fix(db):** Implement Database migration tracking safety abort safeguards (pre-migration backups via `VACUUM INTO` and mass renumbering warnings) to protect existing database structures on startup upgrades (#1281) +- **fix(dashboard):** Cleaned up target codex `config.toml` structure preventing recursive section rendering by enforcing quotes on section dot paths and mapping correct UI `OMNIROUTE_API_KEY` names. +- **fix(mcp):** Add dedicated explicit timeout constraint overrides for search handlers (#1280) +- **fix(crypto):** Add validation guard to encryption layer to surface clear UI errors when cryptographic environment variables are missing, replacing raw Node.js TypeErrors. Legacy env vars `OMNIROUTE_CRYPT_KEY` and `OMNIROUTE_API_KEY_BASE64` now also accepted as fallbacks (#1165) +- **fix(providers):** Update Pollinations provider definition to require API keys and specify their new limited pollen/hour free tier (#1177) +- **Streaming `\n\n` Artifact Fix (#1211):** Changed `` tag-stripping regex from `?` to `*` quantifier across `combo.ts`, `comboAgentMiddleware.ts`, and `contextHandoff.ts` to greedily strip all accumulated JSON-escaped newline sequences surrounding the tag. This prevents literal `\n\n` prefix artifacts from appearing in consumer streaming responses +- **E2E Combo Test Locator:** Fixed Playwright strict-mode violation in `combo-unification.spec.ts` by replacing ambiguous `getByRole` locator with a compound filter locator for the "All" strategy tab +- **fix(cc-compatible):** Trim beta flags and preserve cache passthrough for third-party HTTP proxy compatibility (#1230) +- **fix(providers):** Update Xiaomi MiMo endpoints to the live token-plan, migrating away from dead API URLs (#1238) +- **fix:** Forward client `x-initiator` header to GitHub Copilot upstream to accurately distinguish agent vs user turns (#1227) +- **fix:** Resolve backlog bugs including streaming edge cases, unhandled rejections, and quota parse failures (#1206, #1220, #1231, #1175, #1187, #1218, #1202) +- **fix(tests):** Resolve memory migration and skills route pagination bugs arising from PR overlaps +- **fix(i18n):** Add missing Chinese i18n support to dashboard components (`DataTable`, `EmptyState`, etc), update `en.json/zh-CN.json` routing keys, and natively resolve JSX defaults via `next-intl` (#1274) + +### 🔧 Internal Improvements + +- **Compliance Audit Expansion:** `src/lib/compliance/index.ts` expanded with structured metadata, pagination support, request context enrichment, and new `providerAudit.ts` module logging auth and provider credential events, SSRF-blocked validation attempts, and provider CRUD operations +- **Config Sync Bundle:** `src/lib/sync/bundle.ts` exports `buildConfigBundle()` generating a versioned JSON snapshot of settings, provider connections, nodes, model aliases, combos, and API keys (passwords redacted) with ETag support for bandwidth-efficient polling +- **Codex Client Constants:** Centralized `CODEX_CLIENT_VERSION`, `CODEX_USER_AGENT_PLATFORM`, and pattern-validated env overrides (`CODEX_CLIENT_VERSION`, `CODEX_USER_AGENT`) in `open-sse/config/codexClient.ts` +- **Antigravity Upstream Constants:** `open-sse/config/antigravityUpstream.ts` consolidates all Antigravity base URLs and model/fetchAvailableModels discovery path builders +- **Model Alias Seed:** `src/lib/modelAliasSeed.ts` seeds 30+ cross-proxy model dialect aliases (e.g. `openai/gpt-5` → `gpt-5`, `anthropic/claude-opus-4-6` → `cc/claude-opus-4-6`) at startup via idempotent `upsert` +- **Test Coverage:** 15+ new unit test suites covering sync routes, WebSocket bridge, compliance index, GLM provider config, cooldown-aware retry, safe outbound fetch, stream utilities, Codex executor, provider validation branches, model cross-proxy compatibility, and model alias seeding +- **TypeScript Migration:** Finalized migration of remaining JS tests (`proxy-load` and `testFromFile`) to TypeScript ES modules, ensuring a fully synchronized TS stack. +- **Reliability & Resilience:** Added exponential backoff to `models.dev` auto-sync to combat transient network failures, raised interval floor to 1 hour, and added LKGP debug logging for enhanced observability during routing. (#1286) + +--- + ## [3.6.5] — 2026-04-13 ### ✨ New Features diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 153da4b24c..86a8d21f6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,7 +119,7 @@ Scopes: `db`, `sse`, `oauth`, `dashboard`, `api`, `cli`, `docker`, `ci`, `mcp`, npm run test:all # Single test file (Node.js native test runner — most tests use this) -node --import tsx/esm --test tests/unit/your-file.test.mjs +node --import tsx/esm --test tests/unit/your-file.test.ts # Vitest (MCP server, autoCombo, cache) npm run test:vitest diff --git a/README.md b/README.md index e4411783c1..7236061516 100644 --- a/README.md +++ b/README.md @@ -324,8 +324,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -387,10 +387,10 @@ When a call fails, the dev doesn't know if it was a rate limit, expired token, w - **Unified Logs Dashboard** — 4 tabs: Request Logs, Proxy Logs, Audit Logs, Console - **Console Log Viewer** — Real-time terminal-style viewer with color-coded levels, auto-scroll, search, filter -- **SQLite Proxy Logs** — Persistent logs that survive server restarts +- **SQLite Summary Logs** — Request and proxy log indexes stay queryable across restarts without loading large payload blobs into SQLite - **Translator Playground** — 4 debugging modes: Playground (format translation), Chat Tester (round-trip), Test Bench (batch), Live Monitor (real-time) - **Request Telemetry** — p50/p95/p99 latency + X-Request-Id tracing -- **File-Based Logging with Rotation** — App logs rotate by size, retention days, and archive count; call log artifacts rotate by retention days and file count +- **File-Based Detail Artifacts** — App logs rotate by size, retention days, and archive count; detailed request/response payloads live in `DATA_DIR/call_logs/` and rotate independently of SQLite summaries - **System Info Report** — `npm run system-info` generates `system-info.txt` with your full environment (Node version, OmniRoute version, OS, CLI tools, Docker/PM2 status). Attach it when reporting issues for instant triage. @@ -676,6 +676,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -792,20 +805,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +835,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1324,18 +1344,28 @@ OmniRoute v3.6 is built as an operational platform, not just a relay proxy. ### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal scripts for all install methods | -| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing environment variables and fixes broken auth state | -| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` now shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop app close | -| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | -| 📧 **Email Privacy Masking** | OAuth account emails masked in provider dashboard (`di*****@g****.com`), full address visible on hover | -| 🔗 **Context Relay Strategy** | Combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1416,24 +1446,28 @@ OmniRoute v3.6 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1448,6 +1482,7 @@ OmniRoute v3.6 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1467,6 +1502,8 @@ OmniRoute v3.6 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -1986,8 +2023,10 @@ opencode **No request logs** -- Request artifacts are written to `DATA_DIR/call_logs/` as one JSON file per request +- `call_logs` in SQLite stores summary metadata for the Request Logs table and analytics views +- Detailed request/response payloads are written to `DATA_DIR/call_logs/` as one JSON artifact per request - Enable pipeline capture from Dashboard → Logs → Request Logs if you need detailed per-stage payloads +- `Export Logs` reads the artifact files on demand, while `Export All` includes the `call_logs/` directory alongside `storage.sqlite` - Set `APP_LOG_TO_FILE=true` if you also want application console logs in `logs/application/app.log` - Adjust `APP_LOG_MAX_FILE_SIZE`, `APP_LOG_RETENTION_DAYS`, `APP_LOG_MAX_FILES`, and `CALL_LOG_MAX_ENTRIES` as needed diff --git a/audit-report.json b/audit-report.json new file mode 100644 index 0000000000..0d8c271630 --- /dev/null +++ b/audit-report.json @@ -0,0 +1,48 @@ +{ + "auditReportVersion": 2, + "vulnerabilities": { + "follow-redirects": { + "name": "follow-redirects", + "severity": "moderate", + "isDirect": false, + "via": [ + { + "source": 1116560, + "name": "follow-redirects", + "dependency": "follow-redirects", + "title": "follow-redirects leaks Custom Authentication Headers to Cross-Domain Redirect Targets", + "url": "https://github.com/advisories/GHSA-r4q5-vmmm-2653", + "severity": "moderate", + "cwe": ["CWE-200"], + "cvss": { + "score": 0, + "vectorString": null + }, + "range": "<=1.15.11" + } + ], + "effects": [], + "range": "<=1.15.11", + "nodes": ["node_modules/follow-redirects"], + "fixAvailable": true + } + }, + "metadata": { + "vulnerabilities": { + "info": 0, + "low": 0, + "moderate": 1, + "high": 0, + "critical": 0, + "total": 1 + }, + "dependencies": { + "prod": 421, + "dev": 480, + "optional": 158, + "peer": 480, + "peerOptional": 0, + "total": 1462 + } + } +} diff --git a/bin/omniroute.mjs b/bin/omniroute.ts similarity index 96% rename from bin/omniroute.mjs rename to bin/omniroute.ts index b9bece189f..808f485a25 100755 --- a/bin/omniroute.mjs +++ b/bin/omniroute.ts @@ -18,6 +18,10 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { homedir, platform } from "node:os"; import { isNativeBinaryCompatible } from "../scripts/native-binary-compat.mjs"; +import { + getNodeRuntimeSupport, + getNodeRuntimeWarning, +} from "../src/shared/utils/nodeRuntimeSupport.ts"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -169,15 +173,14 @@ console.log(` \x1b[0m`); // ── Node.js version check ────────────────────────────────── -const nodeMajor = parseInt(process.versions.node.split(".")[0], 10); -if (nodeMajor >= 24) { +const nodeSupport = getNodeRuntimeSupport(); +if (!nodeSupport.nodeCompatible) { + const runtimeWarning = getNodeRuntimeWarning() || "Unsupported Node.js runtime detected."; console.warn(`\x1b[33m ⚠ Warning: You are running Node.js ${process.versions.node}. - OmniRoute uses better-sqlite3, a native addon that does not yet - have compatible prebuilt binaries for Node.js 24+. - You may experience errors like "is not a valid Win32 application" - or "NODE_MODULE_VERSION mismatch". + ${runtimeWarning} - Recommended: use Node.js 22 LTS (or 20 LTS). + Supported secure runtimes: ${nodeSupport.supportedDisplay} + Recommended: use Node.js ${nodeSupport.recommendedVersion} or newer on the 22.x LTS line. Workaround: npm rebuild better-sqlite3\x1b[0m `); } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 90dee6ce94..7900209f28 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ 🌐 **Languages:** 🇺🇸 [English](ARCHITECTURE.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/ARCHITECTURE.md) | 🇪🇸 [Español](i18n/es/ARCHITECTURE.md) | 🇫🇷 [Français](i18n/fr/ARCHITECTURE.md) | 🇮🇹 [Italiano](i18n/it/ARCHITECTURE.md) | 🇷🇺 [Русский](i18n/ru/ARCHITECTURE.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/ARCHITECTURE.md) | 🇩🇪 [Deutsch](i18n/de/ARCHITECTURE.md) | 🇮🇳 [हिन्दी](i18n/in/ARCHITECTURE.md) | 🇹🇭 [ไทย](i18n/th/ARCHITECTURE.md) | 🇺🇦 [Українська](i18n/uk-UA/ARCHITECTURE.md) | 🇸🇦 [العربية](i18n/ar/ARCHITECTURE.md) | 🇯🇵 [日本語](i18n/ja/ARCHITECTURE.md) | 🇻🇳 [Tiếng Việt](i18n/vi/ARCHITECTURE.md) | 🇧🇬 [Български](i18n/bg/ARCHITECTURE.md) | 🇩🇰 [Dansk](i18n/da/ARCHITECTURE.md) | 🇫🇮 [Suomi](i18n/fi/ARCHITECTURE.md) | 🇮🇱 [עברית](i18n/he/ARCHITECTURE.md) | 🇭🇺 [Magyar](i18n/hu/ARCHITECTURE.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/ARCHITECTURE.md) | 🇰🇷 [한국어](i18n/ko/ARCHITECTURE.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/ARCHITECTURE.md) | 🇳🇱 [Nederlands](i18n/nl/ARCHITECTURE.md) | 🇳🇴 [Norsk](i18n/no/ARCHITECTURE.md) | 🇵🇹 [Português (Portugal)](i18n/pt/ARCHITECTURE.md) | 🇷🇴 [Română](i18n/ro/ARCHITECTURE.md) | 🇵🇱 [Polski](i18n/pl/ARCHITECTURE.md) | 🇸🇰 [Slovenčina](i18n/sk/ARCHITECTURE.md) | 🇸🇪 [Svenska](i18n/sv/ARCHITECTURE.md) | 🇵🇭 [Filipino](i18n/phi/ARCHITECTURE.md) | 🇨🇿 [Čeština](i18n/cs/ARCHITECTURE.md) -_Last updated: 2026-04-12_ +_Last updated: 2026-04-15_ ## Executive Summary @@ -62,6 +62,15 @@ Core capabilities: - Modular OAuth providers (13 individual modules under `src/lib/oauth/providers/`) - Uninstall/full-uninstall scripts - OAuth environment repair action +- WebSocket bridge for OpenAI-compatible WS clients (`/v1/ws`) +- Sync token management (issue/revoke, ETag-versioned config bundle download) +- GLM Thinking (`glmt`) first-class provider preset +- Hybrid token counting (provider-side `/messages/count_tokens` with estimation fallback) +- Model alias auto-seeding (30+ cross-proxy dialect normalizations at startup) +- Safe outbound fetch with SSRF guard, private URL blocking, and configurable retry +- Cooldown-aware chat retries with configurable `requestRetry` and `maxRetryIntervalSec` +- Runtime environment validation with Zod at startup +- Compliance audit v2 with pagination, provider CRUD events, and SSRF-blocked validation logging Primary runtime model: @@ -203,9 +212,12 @@ Management domains: - Telemetry: `src/app/api/telemetry/summary` (GET) - Budget: `src/app/api/usage/budget` (GET/POST) - Fallback chains: `src/app/api/fallback/chains` (GET/POST/DELETE) -- Compliance audit: `src/app/api/compliance/audit-log` (GET) +- Compliance audit: `src/app/api/compliance/audit-log` (GET, with pagination + structured metadata) - Evals: `src/app/api/evals` (GET/POST), `src/app/api/evals/[suiteId]` (GET) - Policies: `src/app/api/policies` (GET/POST) +- Sync tokens: `src/app/api/sync/tokens` (GET/POST), `src/app/api/sync/tokens/[id]` (GET/DELETE) +- Config bundle: `src/app/api/sync/bundle` (GET, ETag-versioned snapshot of settings/providers/combos/keys) +- WebSocket: `src/app/api/v1/ws/route.ts` — Upgrade handler for OpenAI-compatible WS clients ## 2) SSE + Translation Core @@ -242,6 +254,14 @@ Services (business logic): - Circuit breaker: `open-sse/services/circuitBreaker.ts` - Context handoff: `open-sse/services/contextHandoff.ts` — handoff summary generation and injection for context-relay strategy - Codex quota fetcher: `open-sse/services/codexQuotaFetcher.ts` — fetches Codex quota for context-relay handoff decisions +- Cooldown-aware retry: `src/sse/services/cooldownAwareRetry.ts` — per-model cooldown retries with configurable `requestRetry` / `maxRetryIntervalSec` +- Safe outbound fetch: `src/shared/network/safeOutboundFetch.ts` — guarded provider/model fetch with SSRF guard, private-URL blocking, retry, and timeout +- Outbound URL guard: `src/shared/network/outboundUrlGuard.ts` — validates provider URLs against private/localhost CIDR ranges +- Provider request defaults: `open-sse/services/providerRequestDefaults.ts` — provider-level `maxTokens`, `temperature`, `thinkingBudgetTokens` defaults +- GLM provider constants: `open-sse/config/glmProvider.ts` — shared GLM models, quota URLs, GLMT timeout/defaults +- Antigravity upstream: `open-sse/config/antigravityUpstream.ts` — base URL and discovery path constants +- Codex client constants: `open-sse/config/codexClient.ts` — versioned user-agent and client-version values +- Model alias seed: `src/lib/modelAliasSeed.ts` — seeds 30+ cross-proxy dialect aliases at startup Domain layer modules: @@ -293,6 +313,10 @@ Domain State DB (SQLite): - API key generation/verification: `src/shared/utils/apiKey.ts` - Provider secrets persisted in `providerConnections` entries - Outbound proxy support via `open-sse/utils/proxyFetch.ts` (env vars) and `open-sse/utils/networkProxy.ts` (configurable per-provider or global) +- SSRF / outbound URL guard: `src/shared/network/outboundUrlGuard.ts` — blocks private/loopback/link-local ranges for all provider calls +- Runtime env validation: `src/lib/env/runtimeEnv.ts` — Zod schema for all environment variables, surfaced as startup errors/warnings +- Sync tokens: `src/lib/db/syncTokens.ts` — scoped tokens for config bundle download endpoints; backed by `sync_tokens` SQLite table (migration `024_create_sync_tokens.sql`) +- WebSocket handshake auth: `src/lib/ws/handshake.ts` — validates WS upgrade requests via API key or session cookie ## 5) Cloud Sync @@ -610,6 +634,10 @@ flowchart LR - `src/app/api/settings/system-prompt`: global system prompt (GET/PUT) - `src/app/api/sessions`: active session listing (GET) - `src/app/api/rate-limits`: per-account rate limit status (GET) +- `src/app/api/sync/tokens`: sync token CRUD (GET/POST) +- `src/app/api/sync/tokens/[id]`: sync token get/delete (GET/DELETE) +- `src/app/api/sync/bundle`: config bundle download (GET, ETag versioning) +- `src/app/api/v1/ws`: WebSocket upgrade handler for OpenAI-compatible WS clients ### Routing and Execution Core @@ -791,6 +819,12 @@ legacy compatibility. The current runtime contract uses: - SQLite schema migrations and auto-upgrade hooks at startup - legacy JSON → SQLite migration compatibility path +## 6) SSRF / Outbound URL Guard + +- `src/shared/network/outboundUrlGuard.ts` blocks all private/loopback/link-local target URLs before they reach provider executors +- Provider model discovery and validation routes use `src/shared/network/safeOutboundFetch.ts` which applies the guard before every outbound request +- Guard errors surface as `URL_GUARD_BLOCKED` with HTTP 422 and are logged to the compliance audit trail via `providerAudit.ts` + ## Observability and Operational Signals Runtime visibility sources: @@ -845,7 +879,7 @@ Environment variables actively used by code: 8. Settings page is organized into 5 tabs: Security, Routing (6 global strategies: fill-first, round-robin, p2c, random, least-used, cost-optimized), Resilience (editable rate limits, circuit breaker, policies, **Context Relay** handoff config), AI (thinking budget, system prompt, prompt cache), Advanced (proxy). 9. **Context Relay** strategy (`context-relay`) is split across two layers: `combo.ts` decides if a handoff should be generated, `chat.ts` injects the handoff after account resolution. Handoff data lives in `context_handoffs` SQLite table. This split is intentional because only `chat.ts` knows whether the actual account changed. 10. **Proxy enforcement** is now comprehensive: `tokenHealthCheck.ts` resolves proxy per connection, `/api/providers/validate` uses `runWithProxyContext`, and `proxyFetch.ts` uses `undici.fetch()` to maintain dispatcher compatibility on Node 22. -11. **Node.js 24+ detection**: `/api/settings/require-login` returns `nodeVersion` and `nodeCompatible` fields. The login page renders a warning banner when the runtime is incompatible. +11. **Node.js runtime policy detection**: `/api/settings/require-login` returns `nodeVersion` and `nodeCompatible` fields. The login page renders a warning banner when the runtime falls outside the supported secure Node.js lines. ## Operational Verification Checklist diff --git a/docs/CLI-TOOLS.md b/docs/CLI-TOOLS.md index 0ff2eb803c..bda30bd231 100644 --- a/docs/CLI-TOOLS.md +++ b/docs/CLI-TOOLS.md @@ -32,31 +32,32 @@ Claude / Codex / OpenCode / Cline / KiloCode / Continue / Kiro / Cursor / Copilo The dashboard cards in `/dashboard/cli-tools` are generated from `src/shared/constants/cliTools.ts`. Current list (v3.0.0-rc.16): -| Tool | ID | Command | Setup Mode | Install Method | -| ---------------- | ------------- | ------------ | ---------- | -------------- | -| **Claude Code** | `claude` | `claude` | env | npm | -| **OpenAI Codex** | `codex` | `codex` | custom | npm | -| **Factory Droid**| `droid` | `droid` | custom | bundled/CLI | -| **OpenClaw** | `openclaw` | `openclaw` | custom | bundled/CLI | -| **Cursor** | `cursor` | app | guide | desktop app | -| **Cline** | `cline` | `cline` | custom | npm | -| **Kilo Code** | `kilo` | `kilocode` | custom | npm | -| **Continue** | `continue` | extension | guide | VS Code | -| **Antigravity** | `antigravity` | internal | mitm | OmniRoute | -| **GitHub Copilot**| `copilot` | extension | custom | VS Code | -| **OpenCode** | `opencode` | `opencode` | guide | npm | -| **Kiro AI** | `kiro` | app/cli | mitm | desktop/CLI | +| Tool | ID | Command | Setup Mode | Install Method | +| ------------------ | ------------- | ---------- | ---------- | -------------- | +| **Claude Code** | `claude` | `claude` | env | npm | +| **OpenAI Codex** | `codex` | `codex` | custom | npm | +| **Factory Droid** | `droid` | `droid` | custom | bundled/CLI | +| **OpenClaw** | `openclaw` | `openclaw` | custom | bundled/CLI | +| **Cursor** | `cursor` | app | guide | desktop app | +| **Cline** | `cline` | `cline` | custom | npm | +| **Kilo Code** | `kilo` | `kilocode` | custom | npm | +| **Continue** | `continue` | extension | guide | VS Code | +| **Antigravity** | `antigravity` | internal | mitm | OmniRoute | +| **GitHub Copilot** | `copilot` | extension | custom | VS Code | +| **OpenCode** | `opencode` | `opencode` | guide | npm | +| **Kiro AI** | `kiro` | app/cli | mitm | desktop/CLI | +| **Qwen Code** | `qwen` | `qwen` | custom | npm | ### CLI fingerprint sync (Agents + Settings) `/dashboard/agents` and `Settings > CLI Fingerprint` use `src/shared/constants/cliCompatProviders.ts`. This keeps provider IDs aligned with CLI cards and legacy IDs. -| CLI ID | Fingerprint Provider ID | -| ------ | ----------------------- | -| `kilo` | `kilocode` | -| `copilot` | `github` | -| `claude` / `codex` / `antigravity` / `kiro` / `cursor` / `cline` / `opencode` / `droid` / `openclaw` | same ID | +| CLI ID | Fingerprint Provider ID | +| ---------------------------------------------------------------------------------------------------- | ----------------------- | +| `kilo` | `kilocode` | +| `copilot` | `github` | +| `claude` / `codex` / `antigravity` / `kiro` / `cursor` / `cline` / `opencode` / `droid` / `openclaw` | same ID | Legacy IDs still accepted for compatibility: `copilot`, `kimi-coding`, `qwen`. @@ -253,6 +254,55 @@ kiro-cli status --- +### Qwen Code (Alibaba) + +Qwen Code supports OpenAI-compatible API endpoints via environment variables or `settings.json`. + +**Option 1: Environment variables (`~/.qwen/.env`)** + +```bash +mkdir -p ~/.qwen && cat > ~/.qwen/.env << EOF +OPENAI_API_KEY="sk-your-omniroute-key" +OPENAI_BASE_URL="http://localhost:20128/v1" +OPENAI_MODEL="auto" +EOF +``` + +**Option 2: `settings.json` with model providers** + +```json +// ~/.qwen/settings.json +{ + "env": { + "OPENAI_API_KEY": "sk-your-omniroute-key", + "OPENAI_BASE_URL": "http://localhost:20128/v1" + }, + "modelProviders": { + "openai": [ + { + "id": "omniroute-default", + "name": "OmniRoute (Auto)", + "envKey": "OPENAI_API_KEY", + "baseUrl": "http://localhost:20128/v1" + } + ] + } +} +``` + +**Option 3: Inline CLI flags** + +```bash +OPENAI_BASE_URL="http://localhost:20128/v1" \ +OPENAI_API_KEY="sk-your-omniroute-key" \ +OPENAI_MODEL="auto" \ +qwen +``` + +> For a **remote server** replace `localhost:20128` with the server IP or domain. + +**Test:** `qwen "say hello"` + ### Cursor (Desktop App) > **Note:** Cursor routes requests through its cloud. For OmniRoute integration, @@ -322,7 +372,7 @@ They run as internal routes and use OmniRoute's model routing automatically. OMNIROUTE_URL="http://localhost:20128/v1" OMNIROUTE_KEY="sk-your-omniroute-key" -npm install -g @anthropic-ai/claude-code @openai/codex opencode-ai cline kilocode +npm install -g @anthropic-ai/claude-code @openai/codex opencode-ai cline kilocode @qwen-code/qwen-code # Kiro CLI apt-get install -y unzip 2>/dev/null; curl -fsSL https://cli.kiro.dev/install | bash diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index f19f3ead2f..9a9150f339 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -69,6 +69,8 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `STORAGE_ENCRYPTION_KEY` | _(empty = disabled)_ | `src/lib/db/encryption.ts` | AES key for full SQLite database encryption at rest. Generate with `openssl rand -hex 32`. | | `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/bootstrap-env.mjs`, `electron/main.js` | Version label for the encryption key. Increment when performing key rotation to support decryption of old backups. | | `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | When `true`, skips the automatic database backup that runs before migrations on every startup. | +| `OMNIROUTE_CRYPT_KEY` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** for `STORAGE_ENCRYPTION_KEY`. Accepted as a fallback when the primary variable is absent. | +| `OMNIROUTE_API_KEY_BASE64` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** (Base64-encoded form) accepted as a fallback. Decoded automatically before use. | ### Scenarios @@ -122,15 +124,16 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari ## 4. Security & Authentication -| Variable | Default | Source File | Description | -| ---------------------- | --------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | `src/lib/auth` | Salt combined with hardware identifiers for machine fingerprinting. Change per-deployment for isolation. | -| `AUTH_COOKIE_SECURE` | `false` | `src/lib/auth` | Sets the `Secure` flag on session cookies. **Must be `true`** when running behind HTTPS. | -| `REQUIRE_API_KEY` | `false` | API middleware | When `true`, all `/v1/*` proxy requests must include a valid API key. | -| `ALLOW_API_KEY_REVEAL` | `false` | Dashboard providers page | Allows revealing full API key values in the Dashboard UI. Security risk on shared instances. | -| `NO_LOG_API_KEY_IDS` | _(empty)_ | `src/lib/compliance/index.ts` | Comma-separated API key IDs that bypass request logging (GDPR compliance). | -| `MAX_BODY_SIZE_BYTES` | `10485760` (10 MB) | `src/shared/middleware/bodySizeGuard.ts` | Maximum allowed request body size. Rejects payloads exceeding this limit. | -| `CORS_ORIGIN` | `*` | Next.js middleware | CORS `Access-Control-Allow-Origin` value. Restrict for production. | +| Variable | Default | Source File | Description | +| ----------------------------- | --------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | `src/lib/auth` | Salt combined with hardware identifiers for machine fingerprinting. Change per-deployment for isolation. | +| `AUTH_COOKIE_SECURE` | `false` | `src/lib/auth` | Sets the `Secure` flag on session cookies. **Must be `true`** when running behind HTTPS. | +| `REQUIRE_API_KEY` | `false` | API middleware | When `true`, all `/v1/*` proxy requests must include a valid API key. | +| `ALLOW_API_KEY_REVEAL` | `false` | Dashboard providers page | Allows revealing full API key values in the Dashboard UI. Security risk on shared instances. | +| `NO_LOG_API_KEY_IDS` | _(empty)_ | `src/lib/compliance/index.ts` | Comma-separated API key IDs that bypass request logging (GDPR compliance). | +| `MAX_BODY_SIZE_BYTES` | `10485760` (10 MB) | `src/shared/middleware/bodySizeGuard.ts` | Maximum allowed request body size. Rejects payloads exceeding this limit. | +| `CORS_ORIGIN` | `*` | Next.js middleware | CORS `Access-Control-Allow-Origin` value. Restrict for production. | +| `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | Block provider calls targeting private/loopback/link-local IP ranges. Disable only in isolated test envs. | ### Hardening Checklist @@ -330,17 +333,18 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] > **Source:** `open-sse/executors/base.ts` → `buildHeaders()` -| Variable | Default Value | When to Update | -| ------------------------ | -------------------------------------------- | ----------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/1.0.83 (external, cli)` | When Anthropic releases a new CLI version | -| `CODEX_USER_AGENT` | `codex-cli/0.92.0 (Windows 10.0.26100; x64)` | When OpenAI updates the Codex CLI | -| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.26.7` | When GitHub Copilot Chat updates | -| `ANTIGRAVITY_USER_AGENT` | `antigravity/1.104.0 darwin/arm64` | When Antigravity IDE updates | -| `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | When Kiro IDE updates | -| `QODER_USER_AGENT` | `Qoder-Cli` | When Qoder CLI updates | -| `QWEN_USER_AGENT` | `QwenCode/0.12.3 (linux; x64)` | When Qwen Code updates | -| `CURSOR_USER_AGENT` | `connect-es/1.6.1` | When Cursor updates | -| `GEMINI_CLI_USER_AGENT` | `google-api-nodejs-client/9.15.1` | When Google API client updates | +| Variable | Default Value | When to Update | +| ------------------------ | -------------------------------------------- | ------------------------------------------------------------- | +| `CLAUDE_USER_AGENT` | `claude-cli/1.0.83 (external, cli)` | When Anthropic releases a new CLI version | +| `CODEX_USER_AGENT` | `codex-cli/0.92.0 (Windows 10.0.26100; x64)` | When OpenAI updates the Codex CLI | +| `CODEX_CLIENT_VERSION` | `0.92.0` | Override Codex client version independently of full UA string | +| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.26.7` | When GitHub Copilot Chat updates | +| `ANTIGRAVITY_USER_AGENT` | `antigravity/1.104.0 darwin/arm64` | When Antigravity IDE updates | +| `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | When Kiro IDE updates | +| `QODER_USER_AGENT` | `Qoder-Cli` | When Qoder CLI updates | +| `QWEN_USER_AGENT` | `QwenCode/0.12.3 (linux; x64)` | When Qwen Code updates | +| `CURSOR_USER_AGENT` | `connect-es/1.6.1` | When Cursor updates | +| `GEMINI_CLI_USER_AGENT` | `google-api-nodejs-client/9.15.1` | When Google API client updates | > [!TIP] > You can add User-Agent overrides for **any** provider using the pattern `{PROVIDER_ID}_USER_AGENT`. The executor dynamically constructs the env var name. @@ -544,11 +548,13 @@ Automatic model pricing data synchronization from external sources. ## 21. Proxy Health -| Variable | Default | Source File | Description | -| ---------------------------- | ---------------- | --------------------------------------- | ----------------------------------------------------- | -| `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | Fast-fail health check timeout. | -| `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | Health check result cache TTL. | -| `RATE_LIMIT_MAX_WAIT_MS` | `120000` (2 min) | `open-sse/services/rateLimitManager.ts` | Max time to wait on a 429 before failing the request. | +| Variable | Default | Source File | Description | +| ---------------------------- | ---------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | Fast-fail health check timeout. | +| `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | Health check result cache TTL. | +| `RATE_LIMIT_MAX_WAIT_MS` | `120000` (2 min) | `open-sse/services/rateLimitManager.ts` | Max time to wait on a 429 before failing the request. | +| `REQUEST_RETRY` | `2` | `src/sse/services/cooldownAwareRetry.ts` | Number of automatic retries on model-scoped cooldown responses before returning error to client. | +| `MAX_RETRY_INTERVAL_SEC` | `30` | `src/sse/services/cooldownAwareRetry.ts` | Max backoff interval (seconds) between cooldown retries. Capped by this value regardless of upstream `Retry-After`. | --- diff --git a/docs/FEATURES.md b/docs/FEATURES.md index c1e44fad5c..3dc803380e 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -211,3 +211,58 @@ Key features: - **Graceful shutdown** — Electron `before-quit` shuts down Next.js cleanly, preventing SQLite WAL database locks (v3.6.2+) 📖 See [`electron/README.md`](../electron/README.md) for full documentation. + +--- + +## 🌐 V1 WebSocket Bridge _(v3.6.6+)_ + +OmniRoute now supports **OpenAI-compatible WebSocket clients** via the `/v1/ws` upgrade endpoint. The custom `scripts/v1-ws-bridge.mjs` server wraps Next.js and upgrades WS connections to full bidirectional streaming sessions. Authentication uses the same API key or session cookie as HTTP requests. + +Key behaviours: + +- WS upgrade validated by `src/lib/ws/handshake.ts` before the connection is established +- Streams terminated cleanly on session close or upstream error +- Works alongside the existing HTTP+SSE streaming path simultaneously + +--- + +## 🔑 Sync Tokens & Config Bundle _(v3.6.6+)_ + +Multi-device and external operator access is now possible via **scoped sync tokens**: + +- **`POST /api/sync/tokens`** — Issue a new sync token (scoped, with optional expiry) +- **`DELETE /api/sync/tokens/:id`** — Revoke a token +- **`GET /api/sync/bundle`** — Download a versioned, ETag-keyed JSON snapshot of all non-sensitive settings (passwords redacted) + +The config bundle is built by `src/lib/sync/bundle.ts`. Consumers compare the `ETag` response header to detect changes without re-downloading the full payload. + +--- + +## 🧠 GLM Thinking Preset _(v3.6.6+)_ + +**GLM Thinking (`glmt`)** is now a registered first-class provider: 65 536 max output tokens, 24 576 thinking budget, 900 s default timeout, Claude-compatible API format, and shared usage sync with the GLM family. + +**Hybrid token counting** also lands in v3.6.6: when a Claude-compatible provider exposes `/messages/count_tokens`, OmniRoute calls it before large requests with graceful estimation fallback. + +--- + +## 🛡️ Safe Outbound Fetch & SSRF Guard _(v3.6.6+)_ + +All provider validation and model discovery calls now go through a two-layer outbound guard: + +1. **URL guard** (`src/shared/network/outboundUrlGuard.ts`) — Blocks private/loopback/link-local IP ranges before the socket is opened. +2. **Safe fetch wrapper** (`src/shared/network/safeOutboundFetch.ts`) — Applies the URL guard, normalises timeouts, and retries transient errors with exponential backoff. + +Guard violations surface as HTTP 422 (`URL_GUARD_BLOCKED`) and are written to the compliance audit log via `providerAudit.ts`. + +--- + +## 🔄 Cooldown-Aware Retries _(v3.6.6+)_ + +Chat requests now **automatically retry** when an upstream provider returns a model-scoped cooldown. Configurable via `REQUEST_RETRY` (default: 2) and `MAX_RETRY_INTERVAL_SEC` (default: 30 s). Rate-limit header learning improved across `x-ratelimit-reset-requests`, `x-ratelimit-reset-tokens`, and `Retry-After` — per-model cooldown state is visible in the Resilience dashboard. + +--- + +## 📋 Compliance Audit v2 _(v3.6.6+)_ + +The audit log has been expanded with cursor-based pagination, request context enrichment (request ID, user agent, IP), structured auth events, provider CRUD events with diff context, and SSRF-blocked validation logging. New events emitted by `src/lib/compliance/providerAudit.ts`. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index afab1e8a7e..dc22298381 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -20,7 +20,14 @@ Use this checklist before tagging or publishing a new OmniRoute release. 1. Review `docs/ARCHITECTURE.md` for storage/runtime drift. 2. Review `docs/TROUBLESHOOTING.md` for env var and operational drift. -3. Update localized docs if source docs changed significantly. +3. Verify the release/runtime Node.js version still satisfies the supported secure floor: + - `>=20.20.2 <21` or `>=22.22.2 <23` + - `npm run check:node-runtime` +4. Validate the npm publish artifact after building the standalone package: + - `npm run build:cli` + - `npm run check:pack-artifact` + - confirm no `app.__qa_backup`, `scripts/scratch`, `package-lock.json`, or other local residue +5. Update localized docs if source docs changed significantly. ## Automated Check diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index aa28a08fcf..8a9c13fa62 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -8,16 +8,16 @@ Common problems and solutions for OmniRoute. ## Quick Fixes -| Problem | Solution | -| ----------------------------- | ----------------------------------------------------------------------------------------- | -| First login not working | Set `INITIAL_PASSWORD` in `.env` (no hardcoded default) | -| Dashboard opens on wrong port | Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128` | -| No logs written to disk | Set `APP_LOG_TO_FILE=true` and verify call log capture is enabled | -| EACCES: permission denied | Set `DATA_DIR=/path/to/writable/dir` to override `~/.omniroute` | -| Routing strategy not saving | Update to v1.4.11+ (Zod schema fix for settings persistence) | -| Login crash / blank page | You may be on Node.js 24+ — see [Node.js Compatibility](#nodejs-compatibility) below | +| Problem | Solution | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| First login not working | Set `INITIAL_PASSWORD` in `.env` (no hardcoded default) | +| Dashboard opens on wrong port | Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128` | +| No logs written to disk | Set `APP_LOG_TO_FILE=true` and verify call log capture is enabled | +| EACCES: permission denied | Set `DATA_DIR=/path/to/writable/dir` to override `~/.omniroute` | +| Routing strategy not saving | Update to v1.4.11+ (Zod schema fix for settings persistence) | +| Login crash / blank page | You may be on Node.js 24+ — see [Node.js Compatibility](#nodejs-compatibility) below | | `dlopen` / `slice is not valid mach-o file` (macOS) | Run `cd $(npm root -g)/omniroute/app && npm rebuild better-sqlite3 && omniroute` — see [macOS native module rebuild](#macos-native-module-rebuild) below | -| Proxy "fetch failed" | Ensure proxy config is set at the correct level — see [Proxy Issues](#proxy-issues) below | +| Proxy "fetch failed" | Ensure proxy config is set at the correct level — see [Proxy Issues](#proxy-issues) below | --- @@ -27,26 +27,29 @@ Common problems and solutions for OmniRoute. ### Login page crashes or shows "Module self-registration" error -**Cause:** You are running Node.js 24+. The `better-sqlite3` native binary is not compatible with Node.js 24, which causes a fatal crash when the server tries to initialize the database. +**Cause:** You are running a Node.js version outside OmniRoute's approved secure runtime floor. Two cases matter: + +1. **Node.js 24+**: `better-sqlite3` is not supported here and startup can fail hard. +2. **Older Node 20/22 patch levels**: the runtime may start, but it falls below the patched security floor OmniRoute now requires. **Symptoms:** - Login page shows a blank screen or a server error - Console shows `Error: Module did not self-register` or similar native binding errors -- Starting with v3.5.5, the login page shows an **orange warning banner** with your Node version if incompatibility is detected +- The login page shows an **orange warning banner** with your Node version if the runtime is outside the supported secure policy **Fix:** -1. Install Node.js 22 LTS (recommended): +1. Install a patched Node.js 22 LTS release (recommended): ```bash - nvm install 22 - nvm use 22 + nvm install 22.22.2 + nvm use 22.22.2 ``` -2. Verify your version: `node --version` should show `v22.x.x` +2. Verify your version: `node --version` should show `v22.22.2` or newer on the 22.x LTS line 3. Reinstall OmniRoute: `npm install -g omniroute` 4. Restart: `omniroute` -> **Supported versions:** Node.js 18, 20, or 22 LTS. Node.js 24+ is **not supported**. +> **Supported secure versions:** `>=20.20.2 <21` or `>=22.22.2 <23`. Node.js 24+ is **not supported**. ### macOS: `dlopen` / "slice is not valid mach-o file" @@ -72,7 +75,7 @@ npm rebuild better-sqlite3 omniroute ``` -> **Note:** This recompiles the native binding against your local Node.js version and CPU architecture, resolving the binary mismatch. The officially supported range remains **Node.js 18, 20, or 22 LTS** (`engines` field in `package.json`). If you are on Node.js 24, the rebuild may silence this specific startup error but other issues can still occur — downgrading to Node.js 22 LTS remains the recommended path. +> **Note:** This recompiles the native binding against your local Node.js version and CPU architecture, resolving the binary mismatch. The officially supported secure range is now **`>=20.20.2 <21` or `>=22.22.2 <23`** (`engines` field in `package.json`). If you are on Node.js 24, the rebuild may silence this specific startup error but other issues can still occur — moving to a patched Node.js 22 LTS release remains the recommended path. --- diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 15b66446e5..b4892aabb3 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -343,9 +343,9 @@ The CLI automatically loads `.env` from `~/.omniroute/.env` or `./.env`. When you no longer need OmniRoute, we provide two quick scripts for a clean removal: -| Command | Action | -| --- | --- | -| `npm run uninstall` | Removes the system app but **keeps your DB and configurations** in `~/.omniroute`. | +| Command | Action | +| ------------------------ | ----------------------------------------------------------------------------------- | +| `npm run uninstall` | Removes the system app but **keeps your DB and configurations** in `~/.omniroute`. | | `npm run uninstall:full` | Removes the app AND permanently **erases all configurations, keys, and databases**. | > Note: To run these commands, navigate to the OmniRoute project folder (if you cloned it) and run them. Alternatively, if globally installed, you can simply run `npm uninstall -g omniroute`. @@ -759,10 +759,11 @@ Configure via **Dashboard → Settings → Resilience**. OmniRoute implements provider-level resilience with four components: 1. **Provider Profiles** — Per-provider configuration for: - - Failure threshold (how many failures before opening) - - Cooldown duration - - Rate limit detection sensitivity - - Exponential backoff parameters + - **Transient Cooldown** — Base cooldown for transient upstream failures + - **Rate Limit Cooldown** — Base cooldown for `429`-driven lockouts + - **Max Backoff Level** — Maximum exponential backoff level for repeated failures + - **CB Threshold** — Failure count before model quarantine / provider circuit breaker escalates + - **CB Reset Time** — Failure counting window and breaker reset timer 2. **Editable Rate Limits** — System-level defaults configurable in the dashboard: - **Requests Per Minute (RPM)** — Maximum requests per minute per account @@ -770,14 +771,18 @@ OmniRoute implements provider-level resilience with four components: - **Max Concurrent Requests** — Maximum simultaneous requests per account - Click **Edit** to modify, then **Save** or **Cancel**. Values persist via the resilience API. -3. **Circuit Breaker** — Tracks failures per provider and automatically opens the circuit when a threshold is reached: +3. **Circuit Breaker** — Tracks failures per provider and automatically opens the circuit when the configured threshold is reached: - **CLOSED** (Healthy) — Requests flow normally - **OPEN** — Provider is temporarily blocked after repeated failures - **HALF_OPEN** — Testing if provider has recovered + The same provider profile also drives model-scoped lockouts: + - Account/model lockouts react immediately to authoritative `429` / `404` signals and use the configured cooldown + backoff values + - Global provider/model quarantine only activates after repeated exhaustion hits the configured **CB Threshold** within **CB Reset Time** + 4. **Policies & Locked Identifiers** — Shows circuit breaker status and locked identifiers with force-unlock capability. -5. **Rate Limit Auto-Detection** — Monitors `429` and `Retry-After` headers to proactively avoid hitting provider rate limits. +5. **Rate Limit Auto-Detection** — Monitors `429` and `Retry-After` headers to proactively avoid hitting provider rate limits. When an upstream provider returns an explicit wait window, that authoritative `Retry-After` value overrides the base cooldown from the provider profile. **Pro Tip:** Use **Reset All** button to clear all circuit breakers and cooldowns when a provider recovers from an outage. diff --git a/docs/i18n/ar/README.md b/docs/i18n/ar/README.md index caccb8a1f9..2bc7e5c735 100644 --- a/docs/i18n/ar/README.md +++ b/docs/i18n/ar/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## التوثيق -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/bg/README.md b/docs/i18n/bg/README.md index 42a3ddd668..a945f5f653 100644 --- a/docs/i18n/bg/README.md +++ b/docs/i18n/bg/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Документация -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/cs/README.md b/docs/i18n/cs/README.md index 91c33edf8b..8f71ab92e5 100644 --- a/docs/i18n/cs/README.md +++ b/docs/i18n/cs/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Dokumentace -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/da/README.md b/docs/i18n/da/README.md index 6586c6ba53..0bd4e851d2 100644 --- a/docs/i18n/da/README.md +++ b/docs/i18n/da/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Dokumentation -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/de/README.md b/docs/i18n/de/README.md index 19275daa06..c6f2522062 100644 --- a/docs/i18n/de/README.md +++ b/docs/i18n/de/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Dokumentation -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/es/README.md b/docs/i18n/es/README.md index fc063c7225..1413a9f524 100644 --- a/docs/i18n/es/README.md +++ b/docs/i18n/es/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Documentación -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/fi/README.md b/docs/i18n/fi/README.md index 12830bf482..3a2e734af3 100644 --- a/docs/i18n/fi/README.md +++ b/docs/i18n/fi/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Dokumentaatio -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/fr/README.md b/docs/i18n/fr/README.md index 54e89a0a11..eba1e678cb 100644 --- a/docs/i18n/fr/README.md +++ b/docs/i18n/fr/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Documentation -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/he/README.md b/docs/i18n/he/README.md index 93c6c7f46e..35f0694a4c 100644 --- a/docs/i18n/he/README.md +++ b/docs/i18n/he/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## תיעוד -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/hi/README.md b/docs/i18n/hi/README.md index c58a1ab4c0..389bd89398 100644 --- a/docs/i18n/hi/README.md +++ b/docs/i18n/hi/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## दस्तावेज़ -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/hu/README.md b/docs/i18n/hu/README.md index a5e31a307f..fe08239b56 100644 --- a/docs/i18n/hu/README.md +++ b/docs/i18n/hu/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Dokumentáció -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/id/README.md b/docs/i18n/id/README.md index fb6252da9d..04b3599250 100644 --- a/docs/i18n/id/README.md +++ b/docs/i18n/id/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Dokumentasi -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/in/README.md b/docs/i18n/in/README.md index 8f53b7eb84..76895c005f 100644 --- a/docs/i18n/in/README.md +++ b/docs/i18n/in/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## दस्तावेज़ -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/it/README.md b/docs/i18n/it/README.md index 5f0c6a7da1..48894de638 100644 --- a/docs/i18n/it/README.md +++ b/docs/i18n/it/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Documentazione -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/ja/README.md b/docs/i18n/ja/README.md index 05e7ff88f7..641437f1e3 100644 --- a/docs/i18n/ja/README.md +++ b/docs/i18n/ja/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## ドキュメント -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/ko/README.md b/docs/i18n/ko/README.md index c454ce9e31..5e8ec64d57 100644 --- a/docs/i18n/ko/README.md +++ b/docs/i18n/ko/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## 문서 -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/ms/README.md b/docs/i18n/ms/README.md index 868e6e23f4..c7d4127a01 100644 --- a/docs/i18n/ms/README.md +++ b/docs/i18n/ms/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Dokumentasi -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/nl/README.md b/docs/i18n/nl/README.md index bf33214875..d365a7334e 100644 --- a/docs/i18n/nl/README.md +++ b/docs/i18n/nl/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Documentatie -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/no/README.md b/docs/i18n/no/README.md index b0a58ca685..9fe0cc5dcf 100644 --- a/docs/i18n/no/README.md +++ b/docs/i18n/no/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Dokumentasjon -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/phi/README.md b/docs/i18n/phi/README.md index 7ed5602714..fccfc3e4d0 100644 --- a/docs/i18n/phi/README.md +++ b/docs/i18n/phi/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Dokumentasyon -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/pl/README.md b/docs/i18n/pl/README.md index 1f86e435d3..5afb01053a 100644 --- a/docs/i18n/pl/README.md +++ b/docs/i18n/pl/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Dokumentacja -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/pt-BR/README.md b/docs/i18n/pt-BR/README.md index 631ba3b11d..5b470d996c 100644 --- a/docs/i18n/pt-BR/README.md +++ b/docs/i18n/pt-BR/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Documentação -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/pt/README.md b/docs/i18n/pt/README.md index d336002b13..b8d9b68239 100644 --- a/docs/i18n/pt/README.md +++ b/docs/i18n/pt/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Documentação -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/ro/README.md b/docs/i18n/ro/README.md index 271c725191..555bd3e43b 100644 --- a/docs/i18n/ro/README.md +++ b/docs/i18n/ro/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Documentație -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/ru/README.md b/docs/i18n/ru/README.md index c2d9d14538..3259aa803e 100644 --- a/docs/i18n/ru/README.md +++ b/docs/i18n/ru/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Документация -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/sk/README.md b/docs/i18n/sk/README.md index 278e1dea48..083a05d2ed 100644 --- a/docs/i18n/sk/README.md +++ b/docs/i18n/sk/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Dokumentácia -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/sv/README.md b/docs/i18n/sv/README.md index c7dc01f180..776568bea4 100644 --- a/docs/i18n/sv/README.md +++ b/docs/i18n/sv/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Dokumentation -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/th/README.md b/docs/i18n/th/README.md index a1f5d5e22e..b7d99896bb 100644 --- a/docs/i18n/th/README.md +++ b/docs/i18n/th/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## เอกสาร -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/tr/README.md b/docs/i18n/tr/README.md index d001842570..69eaf2957f 100644 --- a/docs/i18n/tr/README.md +++ b/docs/i18n/tr/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Belgeler -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/uk-UA/README.md b/docs/i18n/uk-UA/README.md index da60188481..900406ab97 100644 --- a/docs/i18n/uk-UA/README.md +++ b/docs/i18n/uk-UA/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Документація -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/vi/README.md b/docs/i18n/vi/README.md index 5896b41197..b36da8f6bd 100644 --- a/docs/i18n/vi/README.md +++ b/docs/i18n/vi/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## Tài liệu -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md index beb3b381a4..89313fd751 100644 --- a/docs/i18n/zh-CN/README.md +++ b/docs/i18n/zh-CN/README.md @@ -248,6 +248,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve - **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI - **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next - **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets +- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use - **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard @@ -326,8 +328,8 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit **How OmniRoute solves it:** -- **Circuit Breaker per-model** — Auto-open/close with configurable thresholds and cooldown (Closed/Open/Half-Open), scoped per-model to avoid cascading blocks -- **Exponential Backoff** — Progressive retry delays +- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority +- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine - **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms - **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention - **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain @@ -678,6 +680,19 @@ Teams lose velocity when stitching multiple ad-hoc services and scripts. +
+📚 31. "My long sessions crash with 'context_length_exceeded' limits" + +During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. + +**How OmniRoute solves it:** + +- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. +- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. +- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. + +
+ ### Example Playbooks (Integrated Use Cases) **Playbook A: Maximize paid subscription + cheap backup** @@ -794,18 +809,25 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. + +For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default +`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, +only forwards client-provided `cache_control` markers. If the request does not include +`cache_control`, OmniRoute does not inject bridge-owned markers. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | @@ -817,6 +839,8 @@ Advanced overrides are available if you need finer control: | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). + If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. @@ -1073,7 +1097,7 @@ volumes: | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `3.6.2` | ~250MB | Current version | --- @@ -1320,17 +1344,32 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video ## 💡 Key Features -OmniRoute v3.5 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 is built as an operational platform, not just a relay proxy. -### 🆕 New — v3.5.5 Highlights (Apr 2026) +### 🆕 New — v3.6.x Highlights (Apr 2026) -| Feature | What It Does | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 🔗 **Context Relay Strategy** | New combo strategy that preserves session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config — no more bypass in restricted envs | -| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner with instructions | -| 📎 **Gemini PDF Attachments** | PDF files attached in chat messages are now correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| Feature | What It Does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | +| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | +| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | +| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | +| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | +| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | +| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | +| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | +| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | +| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | +| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | +| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | +| 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | +| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | ### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) @@ -1411,24 +1450,28 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. ### 🛡️ Resilience, Security & Governance -| Feature | What It Does | -| ----------------------------------- | -------------------------------------------------------------------------------------- | -| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | -| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | -| ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | -| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | -| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | -| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| Feature | What It Does | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| 🔌 **Circuit Breakers** | Per-model trip/recover with threshold controls | +| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | +| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| ⚡ **Request Idempotency** | Duplicate protection window | +| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | +| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | +| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence | +| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | +| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | +| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically | +| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | +| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | +| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | +| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` | +| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | ### 📊 Observability & Analytics @@ -1443,6 +1486,7 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | | 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | | 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform @@ -1462,6 +1506,8 @@ OmniRoute v3.5 is built as an operational platform, not just a relay proxy. | 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | | 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | | 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | +| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | ### Feature Deep Dive @@ -2188,7 +2234,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux - **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) - **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs + MCP audit + routing decisions) +- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) - **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) - **Streaming**: Server-Sent Events (SSE) @@ -2206,37 +2252,40 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux ## 文档 -| Document | Description | -| ----------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | +| Document | Description | +| -------------------------------------------------------- | --------------------------------------------------- | +| [User Guide](docs/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [API Reference](docs/API_REFERENCE.md) | All endpoints with examples | +| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [Auto-Combo Engine](docs/auto-combo.md) | 6-factor scoring, mode packs, self-healing | +| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and solutions | +| [Architecture](docs/ARCHITECTURE.md) | System architecture and internals | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| [Uninstall Guide](docs/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Release Checklist](docs/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas: +OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: -| Category | Planned Features | Highlights | -| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | -| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | +| Category | Planned Features | Highlights | +| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | +| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | +| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | +| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing | +| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | ### 🔜 Coming Soon diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 5350b10d54..898f507efb 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.6.5 + version: 3.6.6 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/electron/package.json b/electron/package.json index c8a6cfdb02..dda6a100b9 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.6.5", + "version": "3.6.6", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/eslint.config.mjs b/eslint.config.mjs index 37fbd5f5c2..a90263b07c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -32,6 +32,7 @@ const eslintConfig = [ "src/.next/**", "out/**", "build/**", + "coverage/**", "next-env.d.ts", // Scripts and binaries "scripts/**", @@ -40,6 +41,7 @@ const eslintConfig = [ "node_modules/**", // VS Code extension and its large test fixtures "vscode-extension/**", + "_references/**", "_mono_repo/**", // Electron app "electron/**", @@ -51,7 +53,11 @@ const eslintConfig = [ "playwright-report/**", "test-results/**", // Subdirectory .next build output (app/ subdir) + "app/**", "app/.next/**", + "app/bin/**", + "app.__qa_backup/**", + "app/app.__qa_backup/**", // CLI package copy directory "clipr/**", ], diff --git a/llm.txt b/llm.txt index a26b5c1118..8df769de43 100644 --- a/llm.txt +++ b/llm.txt @@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.6.4 +**Current version:** 3.6.6 ## Tech Stack @@ -279,7 +279,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.6.4) +## Key Features (v3.6.6) ### Core Proxy - **60+ AI providers** with automatic format translation @@ -302,6 +302,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **122 unit test files** with comprehensive coverage (55% statements/lines/functions, 60% branches) ### Security +- **Data Loss Prevention**: SQLite migration safety bounds abort startup on dangerous massive schema overrides. Pre-migration `VACUUM INTO` backups isolate rollback snapshots. - **CodeQL security**: Fixed 10+ CodeQL alerts (polynomial-redos, insecure-randomness, shell-injection, SSRF, incomplete URLs) - **Web Crypto session IDs**: `generateSessionId` uses `crypto.getRandomValues()` instead of `Math.random()` - **Route validation**: All API routes validated with Zod v4 schemas + `validateBody()` diff --git a/open-sse/config/antigravityUpstream.ts b/open-sse/config/antigravityUpstream.ts new file mode 100644 index 0000000000..a4ca845301 --- /dev/null +++ b/open-sse/config/antigravityUpstream.ts @@ -0,0 +1,20 @@ +export const ANTIGRAVITY_BASE_URLS = Object.freeze([ + "https://daily-cloudcode-pa.googleapis.com", + "https://daily-cloudcode-pa.sandbox.googleapis.com", + "https://cloudcode-pa.googleapis.com", +]); + +const ANTIGRAVITY_MODELS_PATH = "/v1internal:models"; +const ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels"; + +function buildAntigravityUrls(path: string): string[] { + return ANTIGRAVITY_BASE_URLS.map((baseUrl) => `${baseUrl}${path}`); +} + +export function getAntigravityModelsDiscoveryUrls(): string[] { + return buildAntigravityUrls(ANTIGRAVITY_MODELS_PATH); +} + +export function getAntigravityFetchAvailableModelsUrls(): string[] { + return buildAntigravityUrls(ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH); +} diff --git a/open-sse/config/codexClient.ts b/open-sse/config/codexClient.ts new file mode 100644 index 0000000000..73c26776ea --- /dev/null +++ b/open-sse/config/codexClient.ts @@ -0,0 +1,41 @@ +const DEFAULT_CODEX_CLIENT_VERSION = "0.120.0"; +const DEFAULT_CODEX_USER_AGENT_PLATFORM = "Windows 10.0.26100"; +const DEFAULT_CODEX_USER_AGENT_ARCH = "x64"; +const CODEX_VERSION_OVERRIDE_ENV = "CODEX_CLIENT_VERSION"; +const CODEX_USER_AGENT_OVERRIDE_ENV = "CODEX_USER_AGENT"; +const SAFE_HEADER_TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/; +const SAFE_HEADER_VALUE_PATTERN = /^[\x20-\x7E]{1,200}$/; + +function getSafeEnvValue(name: string, pattern: RegExp): string | null { + const raw = process.env[name]; + if (typeof raw !== "string") return null; + const normalized = raw.trim(); + if (!normalized || !pattern.test(normalized)) { + return null; + } + return normalized; +} + +export function getCodexClientVersion(): string { + return ( + getSafeEnvValue(CODEX_VERSION_OVERRIDE_ENV, SAFE_HEADER_TOKEN_PATTERN) || + DEFAULT_CODEX_CLIENT_VERSION + ); +} + +export function getCodexUserAgent(): string { + const override = getSafeEnvValue(CODEX_USER_AGENT_OVERRIDE_ENV, SAFE_HEADER_VALUE_PATTERN); + if (override) { + return override; + } + + return `codex-cli/${getCodexClientVersion()} (${DEFAULT_CODEX_USER_AGENT_PLATFORM}; ${DEFAULT_CODEX_USER_AGENT_ARCH})`; +} + +export function getCodexDefaultHeaders(): Record { + return { + Version: getCodexClientVersion(), + "Openai-Beta": "responses=experimental", + "User-Agent": getCodexUserAgent(), + }; +} diff --git a/open-sse/config/constants.ts b/open-sse/config/constants.ts index 52ae37916e..be34098c09 100644 --- a/open-sse/config/constants.ts +++ b/open-sse/config/constants.ts @@ -6,7 +6,9 @@ const upstreamTimeouts = getUpstreamTimeoutConfig(process.env, (message) => { console.warn(`[open-sse] ${message}`); }); -// Timeout for non-streaming fetch requests (ms). Prevents stalled connections. +// Timeout for receiving the initial upstream response (ms). +// After headers arrive, active SSE streams are governed by STREAM_IDLE_TIMEOUT_MS +// and Undici's bodyTimeout instead of this one-shot startup timer. export const FETCH_TIMEOUT_MS = upstreamTimeouts.fetchTimeoutMs; // Idle timeout for SSE streams (ms). Closes stream if no data for this duration. diff --git a/open-sse/config/glmProvider.ts b/open-sse/config/glmProvider.ts new file mode 100644 index 0000000000..afef06a7e7 --- /dev/null +++ b/open-sse/config/glmProvider.ts @@ -0,0 +1,56 @@ +type JsonRecord = Record; + +export type GlmApiRegion = "international" | "china"; + +export const GLM_SHARED_HEADERS = Object.freeze({ + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", +}); + +export const GLM_SHARED_MODELS = Object.freeze([ + { id: "glm-5.1", name: "GLM 5.1", contextLength: 204800 }, + { id: "glm-5", name: "GLM 5" }, + { id: "glm-5-turbo", name: "GLM 5 Turbo" }, + { id: "glm-4.7-flash", name: "GLM 4.7 Flash" }, + { id: "glm-4.7", name: "GLM 4.7" }, + { id: "glm-4.6v", name: "GLM 4.6V (Vision)", contextLength: 128000 }, + { id: "glm-4.6", name: "GLM 4.6" }, + { id: "glm-4.5v", name: "GLM 4.5V (Vision)", contextLength: 16000 }, + { id: "glm-4.5", name: "GLM 4.5", contextLength: 128000 }, + { id: "glm-4.5-air", name: "GLM 4.5 Air", contextLength: 128000 }, +]); + +export const GLM_MODELS_URLS = Object.freeze({ + international: "https://api.z.ai/api/coding/paas/v4/models", + china: "https://open.bigmodel.cn/api/coding/paas/v4/models", +}); + +export const GLM_QUOTA_URLS = Object.freeze({ + international: "https://api.z.ai/api/monitor/usage/quota/limit", + china: "https://open.bigmodel.cn/api/monitor/usage/quota/limit", +}); + +export const GLMT_TIMEOUT_MS = 900_000; + +export const GLMT_REQUEST_DEFAULTS = Object.freeze({ + maxTokens: 65_536, + temperature: 0.2, + thinkingBudgetTokens: 24_576, +}); + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +export function getGlmApiRegion(providerSpecificData: unknown): GlmApiRegion { + const data = asRecord(providerSpecificData); + return data.apiRegion === "china" ? "china" : "international"; +} + +export function getGlmModelsUrl(providerSpecificData: unknown): string { + return GLM_MODELS_URLS[getGlmApiRegion(providerSpecificData)]; +} + +export function getGlmQuotaUrl(providerSpecificData: unknown): string { + return GLM_QUOTA_URLS[getGlmApiRegion(providerSpecificData)]; +} diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 484987d735..c9d8c40411 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -150,6 +150,26 @@ export const IMAGE_PROVIDERS = { ], supportedSizes: ["1024x1024", "1024x1792", "1792x1024", "256x256", "512x512"], }, + + pollinations: { + id: "pollinations", + alias: "pol", + baseUrl: "https://gen.pollinations.ai/v1/images/generations", + authType: "apikey", + authHeader: "bearer", + format: "openai", + models: [ + { id: "flux", name: "Flux Schnell" }, + { id: "zimage", name: "Z-Image Turbo" }, + { id: "klein", name: "FLUX.2 Klein 4B" }, + { id: "gptimage", name: "GPT Image 1 Mini" }, + { id: "qwen-image", name: "Qwen Image Plus" }, + { id: "wan-image", name: "Wan 2.7 Image" }, + { id: "kontext", name: "FLUX.1 Kontext" }, + { id: "gptimage-large", name: "GPT Image 1.5" }, + ], + supportedSizes: ["1024x1024", "512x512"], + }, }; /** @@ -171,6 +191,10 @@ export function parseImageModel(modelStr) { if (modelStr.startsWith(providerId + "/")) { return { provider: providerId, model: modelStr.slice(providerId.length + 1) }; } + // Check alias if available + if (config.alias && modelStr.startsWith(config.alias + "/")) { + return { provider: providerId, model: modelStr.slice(config.alias.length + 1) }; + } } // No provider prefix — try to find the model in every provider diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index fce6b480c2..7ddaa5009c 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -7,6 +7,16 @@ */ import { platform, arch } from "os"; +import { ANTIGRAVITY_BASE_URLS } from "./antigravityUpstream.ts"; +import { getCodexDefaultHeaders } from "./codexClient.ts"; +import { + GLMT_REQUEST_DEFAULTS, + GLMT_TIMEOUT_MS, + GLM_SHARED_HEADERS, + GLM_SHARED_MODELS, +} from "./glmProvider.ts"; +import { antigravityUserAgent } from "../services/antigravityHeaders.ts"; +import type { ProviderRequestDefaults } from "../services/providerRequestDefaults.ts"; // ── Types ───────────────────────────────────────────────────────────────── @@ -63,11 +73,13 @@ export interface RegistryEntry { authPrefix?: string; headers?: Record; extraHeaders?: Record; + requestDefaults?: ProviderRequestDefaults; oauth?: RegistryOAuth; models: RegistryModel[]; modelsUrl?: string; chatPath?: string; clientVersion?: string; + timeoutMs?: number; passthroughModels?: boolean; /** Default context window for all models in this provider (can be overridden per-model) */ defaultContextLength?: number; @@ -79,6 +91,7 @@ interface LegacyProvider { baseUrls?: string[]; responsesBaseUrl?: string; headers?: Record; + requestDefaults?: ProviderRequestDefaults; clientId?: string; clientSecret?: string; tokenUrl?: string; @@ -86,6 +99,7 @@ interface LegacyProvider { authUrl?: string; chatPath?: string; clientVersion?: string; + timeoutMs?: number; } const KIMI_CODING_SHARED = { @@ -185,7 +199,7 @@ const CHAT_OPENAI_COMPAT_MODELS: Record = { codestral: buildModels(["codestral-2405", "codestral-latest"]), upstage: buildModels(["solar-pro", "solar-mini", "solar-docvision", "solar-embedding-1-large"]), maritalk: buildModels(["sabia-3", "sabia-3-small"]), - "xiaomi-mimo": buildModels(["MiMo-7B-RL", "MiMo-7B-SFT"]), + "xiaomi-mimo": buildModels(["mimo-v2-pro", "mimo-v2-omni", "mimo-v2-tts"]), "inference-net": buildModels([ "meta-llama/Llama-3.3-70B-Instruct", "deepseek-ai/DeepSeek-R1", @@ -259,7 +273,7 @@ export const REGISTRY: Record = { }, oauth: { clientIdEnv: "CLAUDE_OAUTH_CLIENT_ID", - clientIdDefault: "", + clientIdDefault: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", tokenUrl: "https://console.anthropic.com/v1/oauth/token", }, models: [ @@ -286,9 +300,9 @@ export const REGISTRY: Record = { defaultContextLength: 1048576, oauth: { clientIdEnv: "GEMINI_OAUTH_CLIENT_ID", - clientIdDefault: "", + clientIdDefault: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", clientSecretEnv: "GEMINI_OAUTH_CLIENT_SECRET", - clientSecretDefault: "", + clientSecretDefault: "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", }, models: [], // Models are populated from Google's API via sync-models (per API key). @@ -310,9 +324,9 @@ export const REGISTRY: Record = { defaultContextLength: 1048576, oauth: { clientIdEnv: "GEMINI_CLI_OAUTH_CLIENT_ID", - clientIdDefault: "", + clientIdDefault: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", clientSecretEnv: "GEMINI_CLI_OAUTH_CLIENT_SECRET", - clientSecretDefault: "", + clientSecretDefault: "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", }, models: [ { id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" }, @@ -335,14 +349,10 @@ export const REGISTRY: Record = { authType: "oauth", authHeader: "bearer", defaultContextLength: 400000, - headers: { - Version: "0.92.0", - "Openai-Beta": "responses=experimental", - "User-Agent": "codex-cli/0.92.0 (Windows 10.0.26100; x64)", - }, + headers: getCodexDefaultHeaders(), oauth: { clientIdEnv: "CODEX_OAUTH_CLIENT_ID", - clientIdDefault: "", + clientIdDefault: "app_EMoamEEZ73f0CkXaXp7hrann", clientSecretEnv: "CODEX_OAUTH_CLIENT_SECRET", clientSecretDefault: "", tokenUrl: "https://auth.openai.com/oauth/token", @@ -393,7 +403,7 @@ export const REGISTRY: Record = { }, oauth: { clientIdEnv: "QWEN_OAUTH_CLIENT_ID", - clientIdDefault: "", + clientIdDefault: "f0304373b74a44d2b584a3fb70ca9e56", tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token", authUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code", }, @@ -445,11 +455,7 @@ export const REGISTRY: Record = { alias: undefined, format: "antigravity", executor: "antigravity", - baseUrls: [ - "https://daily-cloudcode-pa.googleapis.com", - "https://daily-cloudcode-pa.sandbox.googleapis.com", - "https://cloudcode-pa.googleapis.com", - ], + baseUrls: [...ANTIGRAVITY_BASE_URLS], urlBuilder: (base, model, stream) => { const path = stream ? "/v1internal:streamGenerateContent?alt=sse" @@ -459,13 +465,13 @@ export const REGISTRY: Record = { authType: "oauth", authHeader: "bearer", headers: { - "User-Agent": `antigravity/1.107.0 ${platform()}/${arch()}`, + "User-Agent": antigravityUserAgent(), }, oauth: { clientIdEnv: "ANTIGRAVITY_OAUTH_CLIENT_ID", - clientIdDefault: "", + clientIdDefault: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com", clientSecretEnv: "ANTIGRAVITY_OAUTH_CLIENT_SECRET", - clientSecretDefault: "", + clientSecretDefault: "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", }, models: [ { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" }, @@ -706,22 +712,24 @@ export const REGISTRY: Record = { urlSuffix: "?beta=true", authType: "apikey", authHeader: "x-api-key", - headers: { - "Anthropic-Version": "2023-06-01", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - }, - models: [ - { id: "glm-5.1", name: "GLM 5.1", contextLength: 204800 }, - { id: "glm-5", name: "GLM 5" }, - { id: "glm-5-turbo", name: "GLM 5 Turbo" }, - { id: "glm-4.7-flash", name: "GLM 4.7 Flash" }, - { id: "glm-4.7", name: "GLM 4.7" }, - { id: "glm-4.6v", name: "GLM 4.6V (Vision)", contextLength: 128000 }, - { id: "glm-4.6", name: "GLM 4.6" }, - { id: "glm-4.5v", name: "GLM 4.5V (Vision)", contextLength: 16000 }, - { id: "glm-4.5", name: "GLM 4.5", contextLength: 128000 }, - { id: "glm-4.5-air", name: "GLM 4.5 Air", contextLength: 128000 }, - ], + headers: GLM_SHARED_HEADERS, + models: [...GLM_SHARED_MODELS], + }, + + glmt: { + id: "glmt", + alias: "glmt", + format: "claude", + executor: "default", + baseUrl: "https://api.z.ai/api/anthropic/v1/messages", + defaultContextLength: 200000, + urlSuffix: "?beta=true", + authType: "apikey", + authHeader: "x-api-key", + headers: GLM_SHARED_HEADERS, + requestDefaults: GLMT_REQUEST_DEFAULTS, + timeoutMs: GLMT_TIMEOUT_MS, + models: [...GLM_SHARED_MODELS], }, "bailian-coding-plan": { @@ -793,7 +801,7 @@ export const REGISTRY: Record = { authType: "oauth", oauth: { clientIdEnv: "KIMI_CODING_OAUTH_CLIENT_ID", - clientIdDefault: "", + clientIdDefault: "17e5f671-d194-4dfb-9706-5516cb48c098", tokenUrl: "https://auth.kimi.com/api/oauth/token", refreshUrl: "https://auth.kimi.com/api/oauth/token", authUrl: "https://auth.kimi.com/api/oauth/device_authorization", @@ -1092,6 +1100,25 @@ export const REGISTRY: Record = { ], }, + "perplexity-web": { + id: "perplexity-web", + alias: "pplx-web", + format: "openai", + executor: "perplexity-web", + baseUrl: "https://www.perplexity.ai/rest/sse/perplexity_ask", + authType: "apikey", + authHeader: "cookie", + models: [ + { id: "pplx-auto", name: "Perplexity Auto (Free)" }, + { id: "pplx-sonar", name: "Perplexity Sonar" }, + { id: "pplx-gpt", name: "GPT-5.4 (via Perplexity)" }, + { id: "pplx-gemini", name: "Gemini 3.1 Pro (via Perplexity)" }, + { id: "pplx-sonnet", name: "Claude Sonnet 4.6 (via Perplexity)" }, + { id: "pplx-opus", name: "Claude Opus 4.6 (via Perplexity)" }, + { id: "pplx-nemotron", name: "Nemotron 3 Super (via Perplexity)" }, + ], + }, + together: { id: "together", alias: "together", @@ -1411,17 +1438,17 @@ export const REGISTRY: Record = { alias: "pol", format: "openai", executor: "pollinations", - // No API key required for basic use. Proxy to GPT-5, Claude, Gemini, DeepSeek, Llama 4. + // API key required. Free Spore tier currently grants 0.01 pollen/hour. baseUrl: "https://text.pollinations.ai/openai/chat/completions", - authType: "apikey", // Optional — works without one too + authType: "apikey", authHeader: "bearer", models: [ - { id: "openai", name: "GPT-5 via Pollinations (🆓)" }, - { id: "claude", name: "Claude via Pollinations (🆓)" }, - { id: "gemini", name: "Gemini via Pollinations (🆓)" }, - { id: "deepseek", name: "DeepSeek V3 via Pollinations (🆓)" }, - { id: "llama", name: "Llama 4 via Pollinations (🆓)" }, - { id: "mistral", name: "Mistral via Pollinations (🆓)" }, + { id: "openai", name: "GPT-5 via Pollinations (Spore)" }, + { id: "claude", name: "Claude via Pollinations (Spore)" }, + { id: "gemini", name: "Gemini via Pollinations (Spore)" }, + { id: "deepseek", name: "DeepSeek V3 via Pollinations (Spore)" }, + { id: "llama", name: "Llama 4 via Pollinations (Spore)" }, + { id: "mistral", name: "Mistral via Pollinations (Spore)" }, ], }, @@ -1828,7 +1855,7 @@ export const REGISTRY: Record = { alias: "mimo", format: "openai", executor: "default", - baseUrl: "https://api.xiaomi.com/v1/chat/completions", + baseUrl: "https://api.xiaomimimo.com/v1", authType: "apikey", authHeader: "bearer", models: CHAT_OPENAI_COMPAT_MODELS["xiaomi-mimo"], @@ -1916,6 +1943,12 @@ export function generateLegacyProviders(): Record { if (entry.responsesBaseUrl) { p.responsesBaseUrl = entry.responsesBaseUrl; } + if (entry.requestDefaults) { + p.requestDefaults = entry.requestDefaults; + } + if (typeof entry.timeoutMs === "number") { + p.timeoutMs = entry.timeoutMs; + } // Headers const mergedHeaders = { diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 20e149f8dc..ee953afe10 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -2,6 +2,7 @@ import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts"; import { getRotatingApiKey } from "../services/apiKeyRotator.ts"; import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts"; +import type { ProviderRequestDefaults } from "../services/providerRequestDefaults.ts"; import { signRequestBody } from "../services/claudeCodeCCH.ts"; /** @@ -33,6 +34,8 @@ export type ProviderConfig = { refreshUrl?: string; authUrl?: string; headers?: Record; + requestDefaults?: ProviderRequestDefaults; + timeoutMs?: number; }; export type ProviderCredentials = { @@ -62,6 +65,16 @@ export type ExecuteInput = { extendedContext?: boolean; /** Merged after auth + CLI fingerprint headers (values override same-named defaults). */ upstreamExtraHeaders?: Record | null; + /** Original client request headers (read-only). Executors may forward select headers upstream. */ + clientHeaders?: Record | null; +}; + +export type CountTokensInput = { + body: Record; + credentials: ProviderCredentials; + log?: ExecutorLog | null; + model: string; + signal?: AbortSignal | null; }; /** Apply model-level extra upstream headers (e.g. Authentication, X-Custom-Auth). */ @@ -109,19 +122,23 @@ export function applyConfiguredUserAgent( export function mergeAbortSignals(primary: AbortSignal, secondary: AbortSignal): AbortSignal { const controller = new AbortController(); - const abortBoth = () => { + const abortFrom = (source: AbortSignal) => { if (!controller.signal.aborted) { - controller.abort(); + controller.abort(source.reason); } }; - if (primary.aborted || secondary.aborted) { - abortBoth(); + if (primary.aborted) { + abortFrom(primary); + return controller.signal; + } + if (secondary.aborted) { + abortFrom(secondary); return controller.signal; } - primary.addEventListener("abort", abortBoth, { once: true }); - secondary.addEventListener("abort", abortBoth, { once: true }); + primary.addEventListener("abort", () => abortFrom(primary), { once: true }); + secondary.addEventListener("abort", () => abortFrom(secondary), { once: true }); return controller.signal; } @@ -151,6 +168,14 @@ export class BaseExecutor { return this.getBaseUrls().length || 1; } + getTimeoutMs() { + const configured = this.config?.timeoutMs; + if (typeof configured !== "number" || !Number.isFinite(configured)) { + return FETCH_TIMEOUT_MS; + } + return Math.max(1, Math.floor(configured)); + } + buildUrl( model: string, stream: boolean, @@ -231,6 +256,9 @@ export class BaseExecutor { // Intra-URL retry config: retry same URL before falling back to next node static readonly RETRY_CONFIG = { maxAttempts: 2, delayMs: 2000 }; + // Timeout for receiving the initial upstream response headers. Once the response + // starts streaming, STREAM_IDLE_TIMEOUT_MS / Undici bodyTimeout handle stalls. + static FETCH_START_TIMEOUT_MS = FETCH_TIMEOUT_MS; // Override in subclass for provider-specific refresh async refreshCredentials(credentials: ProviderCredentials, log: ExecutorLog | null) { @@ -249,6 +277,76 @@ export class BaseExecutor { return { status: response.status, message: bodyText || `HTTP ${response.status}` }; } + buildCountTokensUrl(model: string, credentials: ProviderCredentials | null = null) { + void model; + void credentials; + const baseUrl = this.buildUrl(model, false, 0, credentials); + if (typeof baseUrl !== "string" || baseUrl.length === 0) return null; + if (this.config?.format !== "claude" || !baseUrl.includes("/messages")) return null; + + const [path, query = ""] = baseUrl.split("?"); + const normalizedPath = path.endsWith("/messages") + ? `${path}/count_tokens` + : `${path}/count_tokens`; + return query ? `${normalizedPath}?${query}` : normalizedPath; + } + + async countTokens({ model, body, credentials, signal, log }: CountTokensInput) { + const url = this.buildCountTokensUrl(model, credentials); + if (!url) return null; + + const headers = this.buildHeaders(credentials, false); + const requestBody = + body && typeof body === "object" + ? { + ...body, + model, + } + : { model }; + + let timeoutId: ReturnType | null = null; + let activeSignal = signal || null; + let controller: AbortController | null = null; + const timeoutMs = this.getTimeoutMs(); + + if (!activeSignal) { + controller = new AbortController(); + timeoutId = setTimeout(() => controller?.abort(), timeoutMs); + activeSignal = controller.signal; + } + + try { + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(requestBody), + signal: activeSignal || undefined, + }); + + const text = await response.text(); + if (!response.ok) { + const parsedError = this.parseError(response, text); + throw new Error(parsedError.message); + } + + const parsed = text ? JSON.parse(text) : {}; + const inputTokens = Number(parsed?.input_tokens); + if (!Number.isFinite(inputTokens)) { + throw new Error("Provider count_tokens response missing input_tokens"); + } + + return { input_tokens: inputTokens, provider: this.provider, source: "provider" }; + } catch (error) { + log?.debug?.( + "COUNT_TOKENS", + `${this.provider}/${model} real count unavailable: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } finally { + if (timeoutId) clearTimeout(timeoutId); + } + } + async execute({ model, body, @@ -313,12 +411,26 @@ export class BaseExecutor { const transformedBody = await this.transformRequest(model, body, stream, activeCredentials); try { - // Apply timeout to all requests. Non-streaming requests need this to prevent - // stalled connections. Streaming requests also need it for the initial fetch() call - // to prevent hanging on unresponsive providers (e.g. 300s TCP default timeout — #769). - // Stream idle detection (STREAM_IDLE_TIMEOUT_MS) handles stalls after data starts flowing. - const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); - const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal; + // Only enforce the timeout while waiting for the initial fetch() response. + // Once headers arrive, active streams must not be cut off by total elapsed time; + // post-start stalls are handled separately by STREAM_IDLE_TIMEOUT_MS / bodyTimeout. + const fetchStartTimeoutMs = this.getTimeoutMs(); + const timeoutController = fetchStartTimeoutMs > 0 ? new AbortController() : null; + let timeoutId: ReturnType | null = null; + if (timeoutController) { + timeoutId = setTimeout(() => { + const timeoutError = new Error( + `Fetch timeout after ${fetchStartTimeoutMs}ms on ${url}` + ); + timeoutError.name = "TimeoutError"; + timeoutController.abort(timeoutError); + }, fetchStartTimeoutMs); + } + const timeoutSignal = timeoutController?.signal ?? null; + const combinedSignal = + signal && timeoutSignal + ? mergeAbortSignals(signal, timeoutSignal) + : signal || timeoutSignal; // Apply CLI fingerprint ordering if enabled for this provider let finalHeaders = headers; @@ -346,7 +458,15 @@ export class BaseExecutor { }; if (combinedSignal) fetchOptions.signal = combinedSignal; - const response = await fetch(url, fetchOptions); + let response; + try { + response = await fetch(url, fetchOptions); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + } // Intra-URL retry: if 429 and we haven't exhausted per-URL retries, wait and retry the same URL if ( @@ -375,7 +495,7 @@ export class BaseExecutor { // Distinguish timeout errors from other abort errors const err = error instanceof Error ? error : new Error(String(error)); if (err.name === "TimeoutError") { - log?.warn?.("TIMEOUT", `Fetch timeout after ${FETCH_TIMEOUT_MS}ms on ${url}`); + log?.warn?.("TIMEOUT", `Fetch timeout after ${this.getTimeoutMs()}ms on ${url}`); } lastError = err; if (urlIndex + 1 < fallbackCount) { diff --git a/open-sse/executors/cliproxyapi.ts b/open-sse/executors/cliproxyapi.ts index 83549c8334..69e2f05bb8 100644 --- a/open-sse/executors/cliproxyapi.ts +++ b/open-sse/executors/cliproxyapi.ts @@ -126,10 +126,7 @@ export class CliproxyapiExecutor extends BaseExecutor { ? mergeAbortSignals(input.signal, timeoutSignal) : timeoutSignal; - input.log?.info?.( - "CPA", - `CLIProxyAPI → ${url} (model: ${input.model})` - ); + input.log?.info?.("CPA", `CLIProxyAPI → ${url} (model: ${input.model})`); const response = await fetch(url, { method: "POST", diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 967cbc990d..9a631bcfda 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -2,9 +2,10 @@ import { getCodexRequestDefaults, isOpenAIResponsesStoreEnabled, } from "@/lib/providers/requestDefaults"; -import { BaseExecutor } from "./base.ts"; +import { BaseExecutor, setUserAgentHeader } from "./base.ts"; import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.ts"; import { PROVIDERS } from "../config/constants.ts"; +import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts"; import { refreshCodexToken } from "../services/tokenRefresh.ts"; import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts"; @@ -365,6 +366,8 @@ export class CodexExecutor extends BaseExecutor { buildHeaders(credentials, stream = true) { const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath); const headers = super.buildHeaders(credentials, isCompactRequest ? false : true); + headers.Version = getCodexClientVersion(); + setUserAgentHeader(headers, getCodexUserAgent()); // Add workspace binding header if workspaceId is persisted const workspaceId = credentials?.providerSpecificData?.workspaceId; @@ -507,6 +510,7 @@ export class CodexExecutor extends BaseExecutor { delete body.n; delete body.seed; delete body.max_tokens; + delete body.max_output_tokens; // Responses API translator maps max_tokens -> max_output_tokens, but Codex rejects it delete body.user; // Cursor sends this but Codex doesn't support it delete body.prompt_cache_retention; // Cursor sends this but Codex doesn't support it delete body.metadata; // Cursor sends this but Codex doesn't support it diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index 2f4f7ed2a9..c3faf2b290 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -28,6 +28,7 @@ import { extractTextFromResponse, } from "../utils/cursorProtobuf.ts"; import { estimateUsage } from "../utils/usageTracking.ts"; +import { getCursorVersion } from "../utils/cursorVersionDetector.ts"; import { FORMATS } from "../translator/formats.ts"; import crypto from "crypto"; import { v5 as uuidv5 } from "uuid"; @@ -240,12 +241,13 @@ export class CursorExecutor extends BaseExecutor { buildHeaders(credentials) { const accessToken = credentials.accessToken; - const machineId = credentials.providerSpecificData?.machineId; const ghostMode = credentials.providerSpecificData?.ghostMode !== false; - if (!machineId) { - throw new Error("Machine ID is required for Cursor API"); - } + // Use stored machineId, or derive a stable one from the access token + // (cursor-agent imports don't provide a machineId) + const machineId = + credentials.providerSpecificData?.machineId || + crypto.createHash("sha256").update(accessToken).digest("hex"); const cleanToken = accessToken.includes("::") ? accessToken.split("::")[1] : accessToken; @@ -254,11 +256,11 @@ export class CursorExecutor extends BaseExecutor { "connect-accept-encoding": "gzip", "connect-protocol-version": "1", "content-type": "application/connect+proto", - "user-agent": CURSOR_USER_AGENT, + "user-agent": `Cursor/${getCursorVersion()}`, "x-amzn-trace-id": `Root=${crypto.randomUUID()}`, "x-client-key": crypto.createHash("sha256").update(cleanToken).digest("hex"), "x-cursor-checksum": this.generateChecksum(machineId), - "x-cursor-client-version": CURSOR_CLIENT_VERSION, + "x-cursor-client-version": getCursorVersion(), "x-cursor-client-type": "ide", "x-cursor-client-os": process.platform === "win32" @@ -268,7 +270,7 @@ export class CursorExecutor extends BaseExecutor { : "linux", "x-cursor-client-arch": process.arch === "arm64" ? "aarch64" : "x64", "x-cursor-client-device-type": "desktop", - "x-cursor-user-agent": CURSOR_USER_AGENT, + "x-cursor-user-agent": `Cursor/${getCursorVersion()}`, "x-cursor-config-version": crypto.randomUUID(), "x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", "x-ghost-mode": ghostMode ? "true" : "false", diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 2e2f76b99a..76367a5008 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -8,6 +8,7 @@ import { joinClaudeCodeCompatibleUrl, } from "../services/claudeCodeCompatible.ts"; import { getGigachatAccessToken } from "../services/gigachatAuth.ts"; +import { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts"; import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts"; import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts"; @@ -33,6 +34,11 @@ function normalizeDatabricksChatUrl(baseUrl) { return `${normalized}/chat/completions`; } +function normalizeXiaomiMimoChatUrl(baseUrl) { + const normalized = normalizeBaseUrl(baseUrl).replace(/\/chat\/completions$/, ""); + return `${normalized}/chat/completions`; +} + function normalizeSnowflakeChatUrl(baseUrl) { const normalized = normalizeBaseUrl(baseUrl) .replace(/\/cortex\/inference:complete$/, "") @@ -92,6 +98,10 @@ export class DefaultExecutor extends BaseExecutor { const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; return normalizeDatabricksChatUrl(baseUrl); } + case "xiaomi-mimo": { + const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; + return normalizeXiaomiMimoChatUrl(baseUrl); + } case "snowflake": { const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; return normalizeSnowflakeChatUrl(baseUrl); @@ -102,6 +112,7 @@ export class DefaultExecutor extends BaseExecutor { } case "claude": case "glm": + case "glmt": case "kimi-coding": case "minimax": case "minimax-cn": @@ -148,11 +159,13 @@ export class DefaultExecutor extends BaseExecutor { headers["Authorization"] = `Bearer ${credentials.accessToken || effectiveKey}`; break; case "claude": + case "anthropic": effectiveKey ? (headers["x-api-key"] = effectiveKey) : (headers["Authorization"] = `Bearer ${credentials.accessToken}`); break; case "glm": + case "glmt": case "kimi-coding": case "bailian-coding-plan": case "kimi-coding-apikey": @@ -207,10 +220,11 @@ export class DefaultExecutor extends BaseExecutor { void model; void stream; void credentials; + const withDefaults = applyProviderRequestDefaults(body, this.config.requestDefaults); if (this.provider === "qwen" && typeof body === "object" && body !== null) { - return sanitizeQwenThinkingToolChoice(body, "QwenExecutor"); + return sanitizeQwenThinkingToolChoice(withDefaults, "QwenExecutor"); } - return body; + return withDefaults; } /** diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index 8d0083a495..88ed0a71ad 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -3,6 +3,9 @@ import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts"; import { getModelTargetFormat } from "../config/providerModels.ts"; export class GithubExecutor extends BaseExecutor { + /** Stashed per-request so buildHeaders() can read the client's x-initiator value. */ + private _clientHeaders: Record | null = null; + constructor() { super("github", PROVIDERS.github); } @@ -84,54 +87,72 @@ export class GithubExecutor extends BaseExecutor { } async execute(input: ExecuteInput) { - const result = await super.execute(input); - if (!result || !result.response) return result; + this._clientHeaders = input.clientHeaders ?? null; + try { + const result = await super.execute(input); + if (!result || !result.response) return result; + + if (!input.stream) { + // wreq-js clone/text semantics consume the original response body. Materialize + // non-streaming responses immediately so downstream code always sees a native + // fetch Response with a readable body. + const status = result.response.status; + const statusText = result.response.statusText; + const headers = new Headers(result.response.headers); + const payload = await result.response.text(); + result.response = new Response(payload, { status, statusText, headers }); + return result; + } + + if (!result.response.body) return result; + + const isStreaming = input.stream === true; + const contentType = (result.response.headers.get("content-type") || "").toLowerCase(); + if (isStreaming && result.response.ok && contentType.includes("text/event-stream")) { + // Preserve the original response body for downstream error handling. + const sourceResponse = result.response.clone(); + if (!sourceResponse.body) return result; + + const decoder = new TextDecoder(); + const transformStream = new TransformStream({ + transform(chunk, controller) { + const text = decoder.decode(chunk, { stream: true }); + if (text.includes("data: [DONE]")) { + return; + } + controller.enqueue(chunk); + }, + }); + + const newResponse = new Response(sourceResponse.body.pipeThrough(transformStream), { + status: sourceResponse.status, + statusText: sourceResponse.statusText, + headers: new Headers(sourceResponse.headers), + }); + result.response = newResponse; + } - if (!input.stream) { - // wreq-js clone/text semantics consume the original response body. Materialize - // non-streaming responses immediately so downstream code always sees a native - // fetch Response with a readable body. - const status = result.response.status; - const statusText = result.response.statusText; - const headers = new Headers(result.response.headers); - const payload = await result.response.text(); - result.response = new Response(payload, { status, statusText, headers }); return result; + } finally { + this._clientHeaders = null; } - - if (!result.response.body) return result; - - const isStreaming = input.stream === true; - const contentType = (result.response.headers.get("content-type") || "").toLowerCase(); - if (isStreaming && result.response.ok && contentType.includes("text/event-stream")) { - // Preserve the original response body for downstream error handling. - const sourceResponse = result.response.clone(); - if (!sourceResponse.body) return result; - - const decoder = new TextDecoder(); - const transformStream = new TransformStream({ - transform(chunk, controller) { - const text = decoder.decode(chunk, { stream: true }); - if (text.includes("data: [DONE]")) { - return; - } - controller.enqueue(chunk); - }, - }); - - const newResponse = new Response(sourceResponse.body.pipeThrough(transformStream), { - status: sourceResponse.status, - statusText: sourceResponse.statusText, - headers: new Headers(sourceResponse.headers), - }); - result.response = newResponse; - } - - return result; } buildHeaders(credentials, stream = true) { const token = this.getCopilotToken(credentials) || credentials.accessToken; + + // Forward the client's x-initiator header when present. OpenCode and other + // Copilot-aware clients use this to distinguish user-initiated turns + // (x-initiator: user) from autonomous tool-call continuations + // (x-initiator: agent). GitHub Copilot's billing treats "agent" turns as + // free, so forwarding the value avoids burning a premium request on every + // tool-call round-trip. Fall back to "user" when the header is absent to + // preserve the existing default behaviour. + const ch = this._clientHeaders; + const clientInitiator = ch?.["x-initiator"] || ch?.["X-Initiator"]; + const initiator = + clientInitiator === "agent" || clientInitiator === "user" ? clientInitiator : "user"; + return { Authorization: `Bearer ${token}`, "Content-Type": "application/json", @@ -144,7 +165,7 @@ export class GithubExecutor extends BaseExecutor { "x-request-id": crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`, "x-vscode-user-agent-library-version": "electron-fetch", - "X-Initiator": "user", + "X-Initiator": initiator, Accept: stream ? "text/event-stream" : "application/json", }; } diff --git a/open-sse/executors/grok-web.ts b/open-sse/executors/grok-web.ts index c11e509ca4..99996ee976 100644 --- a/open-sse/executors/grok-web.ts +++ b/open-sse/executors/grok-web.ts @@ -12,7 +12,12 @@ * - Grok API Research Report (headers, Cloudflare bypass techniques) */ -import { BaseExecutor, mergeUpstreamExtraHeaders, mergeAbortSignals, type ExecuteInput } from "./base.ts"; +import { + BaseExecutor, + mergeUpstreamExtraHeaders, + mergeAbortSignals, + type ExecuteInput, +} from "./base.ts"; import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; // ─── Constants ────────────────────────────────────────────────────────────── @@ -31,20 +36,52 @@ interface GrokModelInfo { } const MODEL_MAP: Record = { - "grok-3": { grokModel: "grok-3", modelMode: "MODEL_MODE_GROK_3", isThinking: false }, - "grok-3-mini": { grokModel: "grok-3", modelMode: "MODEL_MODE_GROK_3_MINI_THINKING", isThinking: true }, - "grok-3-thinking": { grokModel: "grok-3", modelMode: "MODEL_MODE_GROK_3_THINKING", isThinking: true }, - "grok-4": { grokModel: "grok-4", modelMode: "MODEL_MODE_GROK_4", isThinking: false }, - "grok-4-mini": { grokModel: "grok-4-mini", modelMode: "MODEL_MODE_GROK_4_MINI_THINKING", isThinking: true }, - "grok-4-thinking": { grokModel: "grok-4", modelMode: "MODEL_MODE_GROK_4_THINKING", isThinking: true }, - "grok-4-heavy": { grokModel: "grok-4", modelMode: "MODEL_MODE_HEAVY", isThinking: true }, - "grok-4.1-mini": { grokModel: "grok-4-1-thinking-1129", modelMode: "MODEL_MODE_GROK_4_1_MINI_THINKING", isThinking: true }, - "grok-4.1-fast": { grokModel: "grok-4-1-thinking-1129", modelMode: "MODEL_MODE_FAST", isThinking: false }, - "grok-4.1-expert": { grokModel: "grok-4-1-thinking-1129", modelMode: "MODEL_MODE_EXPERT", isThinking: true }, - "grok-4.1-thinking": { grokModel: "grok-4-1-thinking-1129", modelMode: "MODEL_MODE_GROK_4_1_THINKING", isThinking: true }, - "grok-4.2": { grokModel: "grok-420", modelMode: "MODEL_MODE_GROK_420", isThinking: false }, - "grok-4.20": { grokModel: "grok-420", modelMode: "MODEL_MODE_GROK_420", isThinking: false }, - "grok-4.20-beta": { grokModel: "grok-420", modelMode: "MODEL_MODE_GROK_420", isThinking: false }, + "grok-3": { grokModel: "grok-3", modelMode: "MODEL_MODE_GROK_3", isThinking: false }, + "grok-3-mini": { + grokModel: "grok-3", + modelMode: "MODEL_MODE_GROK_3_MINI_THINKING", + isThinking: true, + }, + "grok-3-thinking": { + grokModel: "grok-3", + modelMode: "MODEL_MODE_GROK_3_THINKING", + isThinking: true, + }, + "grok-4": { grokModel: "grok-4", modelMode: "MODEL_MODE_GROK_4", isThinking: false }, + "grok-4-mini": { + grokModel: "grok-4-mini", + modelMode: "MODEL_MODE_GROK_4_MINI_THINKING", + isThinking: true, + }, + "grok-4-thinking": { + grokModel: "grok-4", + modelMode: "MODEL_MODE_GROK_4_THINKING", + isThinking: true, + }, + "grok-4-heavy": { grokModel: "grok-4", modelMode: "MODEL_MODE_HEAVY", isThinking: true }, + "grok-4.1-mini": { + grokModel: "grok-4-1-thinking-1129", + modelMode: "MODEL_MODE_GROK_4_1_MINI_THINKING", + isThinking: true, + }, + "grok-4.1-fast": { + grokModel: "grok-4-1-thinking-1129", + modelMode: "MODEL_MODE_FAST", + isThinking: false, + }, + "grok-4.1-expert": { + grokModel: "grok-4-1-thinking-1129", + modelMode: "MODEL_MODE_EXPERT", + isThinking: true, + }, + "grok-4.1-thinking": { + grokModel: "grok-4-1-thinking-1129", + modelMode: "MODEL_MODE_GROK_4_1_THINKING", + isThinking: true, + }, + "grok-4.2": { grokModel: "grok-420", modelMode: "MODEL_MODE_GROK_420", isThinking: false }, + "grok-4.20": { grokModel: "grok-420", modelMode: "MODEL_MODE_GROK_420", isThinking: false }, + "grok-4.20-beta": { grokModel: "grok-420", modelMode: "MODEL_MODE_GROK_420", isThinking: false }, }; // ─── Statsig ID generation ────────────────────────────────────────────────── @@ -61,9 +98,10 @@ function randomString(length: number, alphanumeric = false): string { } function generateStatsigId(): string { - const msg = Math.random() < 0.5 - ? `e:TypeError: Cannot read properties of null (reading 'children["${randomString(5, true)}"]')` - : `e:TypeError: Cannot read properties of undefined (reading '${randomString(10)}')`; + const msg = + Math.random() < 0.5 + ? `e:TypeError: Cannot read properties of null (reading 'children["${randomString(5, true)}"]')` + : `e:TypeError: Cannot read properties of undefined (reading '${randomString(10)}')`; return btoa(msg); } @@ -146,7 +184,7 @@ interface GrokStreamEvent { async function* readGrokNdjsonEvents( body: ReadableStream, - signal?: AbortSignal | null, + signal?: AbortSignal | null ): AsyncGenerator { const reader = body.getReader(); const decoder = new TextDecoder(); @@ -204,7 +242,7 @@ interface ContentChunk { async function* extractContent( eventStream: ReadableStream, isThinkingModel: boolean, - signal?: AbortSignal | null, + signal?: AbortSignal | null ): AsyncGenerator { let fingerprint = ""; let responseId = ""; @@ -273,7 +311,7 @@ function buildStreamingResponse( cid: string, created: number, isThinkingModel: boolean, - signal?: AbortSignal | null, + signal?: AbortSignal | null ): ReadableStream { const encoder = new TextEncoder(); @@ -284,11 +322,16 @@ function buildStreamingResponse( controller.enqueue( encoder.encode( sseChunk({ - id: cid, object: "chat.completion.chunk", created, model, + id: cid, + object: "chat.completion.chunk", + created, + model, system_fingerprint: null, - choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null, logprobs: null }], - }), - ), + choices: [ + { index: 0, delta: { role: "assistant" }, finish_reason: null, logprobs: null }, + ], + }) + ) ); let fp = ""; @@ -297,42 +340,112 @@ function buildStreamingResponse( if (chunk.fingerprint) fp = chunk.fingerprint; if (chunk.error) { - controller.enqueue(encoder.encode(sseChunk({ - id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: fp || null, - choices: [{ index: 0, delta: { content: `[Error: ${chunk.error}]` }, finish_reason: null, logprobs: null }], - }))); + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: fp || null, + choices: [ + { + index: 0, + delta: { content: `[Error: ${chunk.error}]` }, + finish_reason: null, + logprobs: null, + }, + ], + }) + ) + ); break; } if (chunk.thinking) { - controller.enqueue(encoder.encode(sseChunk({ - id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: fp || null, - choices: [{ index: 0, delta: { reasoning_content: chunk.thinking }, finish_reason: null, logprobs: null }], - }))); + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: fp || null, + choices: [ + { + index: 0, + delta: { reasoning_content: chunk.thinking }, + finish_reason: null, + logprobs: null, + }, + ], + }) + ) + ); continue; } if (chunk.done) break; if (chunk.delta) { - controller.enqueue(encoder.encode(sseChunk({ - id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: fp || null, - choices: [{ index: 0, delta: { content: chunk.delta }, finish_reason: null, logprobs: null }], - }))); + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: fp || null, + choices: [ + { + index: 0, + delta: { content: chunk.delta }, + finish_reason: null, + logprobs: null, + }, + ], + }) + ) + ); } } // Stop chunk - controller.enqueue(encoder.encode(sseChunk({ - id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: fp || null, - choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }], - }))); + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: fp || null, + choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }], + }) + ) + ); controller.enqueue(encoder.encode("data: [DONE]\n\n")); } catch (err) { - controller.enqueue(encoder.encode(sseChunk({ - id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, - choices: [{ index: 0, delta: { content: `[Stream error: ${err instanceof Error ? err.message : String(err)}]` }, finish_reason: "stop", logprobs: null }], - }))); + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [ + { + index: 0, + delta: { + content: `[Stream error: ${err instanceof Error ? err.message : String(err)}]`, + }, + finish_reason: "stop", + logprobs: null, + }, + ], + }) + ) + ); controller.enqueue(encoder.encode("data: [DONE]\n\n")); } finally { controller.close(); @@ -347,7 +460,7 @@ async function buildNonStreamingResponse( cid: string, created: number, isThinkingModel: boolean, - signal?: AbortSignal | null, + signal?: AbortSignal | null ): Promise { let fullContent = ""; let fingerprint = ""; @@ -358,8 +471,10 @@ async function buildNonStreamingResponse( if (chunk.error) { return new Response( - JSON.stringify({ error: { message: chunk.error, type: "upstream_error", code: "GROK_ERROR" } }), - { status: 502, headers: { "Content-Type": "application/json" } }, + JSON.stringify({ + error: { message: chunk.error, type: "upstream_error", code: "GROK_ERROR" }, + }), + { status: 502, headers: { "Content-Type": "application/json" } } ); } if (chunk.thinking) { @@ -384,12 +499,19 @@ async function buildNonStreamingResponse( return new Response( JSON.stringify({ - id: cid, object: "chat.completion", created, model, + id: cid, + object: "chat.completion", + created, + model, system_fingerprint: fingerprint || null, choices: [{ index: 0, message: msg, finish_reason: "stop", logprobs: null }], - usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: promptTokens + completionTokens }, + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }, }), - { status: 200, headers: { "Content-Type": "application/json" } }, + { status: 200, headers: { "Content-Type": "application/json" } } ); } @@ -400,14 +522,24 @@ export class GrokWebExecutor extends BaseExecutor { super("grok-web", { id: "grok-web", baseUrl: GROK_CHAT_API }); } - async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders }: ExecuteInput) { + async execute({ + model, + body, + stream, + credentials, + signal, + log, + upstreamExtraHeaders, + }: ExecuteInput) { const messages = (body as Record).messages as | Array> | undefined; if (!messages || !Array.isArray(messages) || messages.length === 0) { const errResp = new Response( - JSON.stringify({ error: { message: "Missing or empty messages array", type: "invalid_request" } }), - { status: 400, headers: { "Content-Type": "application/json" } }, + JSON.stringify({ + error: { message: "Missing or empty messages array", type: "invalid_request" }, + }), + { status: 400, headers: { "Content-Type": "application/json" } } ); return { response: errResp, url: GROK_CHAT_API, headers: {}, transformedBody: body }; } @@ -423,8 +555,10 @@ export class GrokWebExecutor extends BaseExecutor { const message = parseOpenAIMessages(messages); if (!message.trim()) { const errResp = new Response( - JSON.stringify({ error: { message: "Empty query after processing", type: "invalid_request" } }), - { status: 400, headers: { "Content-Type": "application/json" } }, + JSON.stringify({ + error: { message: "Empty query after processing", type: "invalid_request" }, + }), + { status: 400, headers: { "Content-Type": "application/json" } } ); return { response: errResp, url: GROK_CHAT_API, headers: {}, transformedBody: body }; } @@ -468,15 +602,16 @@ export class GrokWebExecutor extends BaseExecutor { const spanId = randomHex(8); const headers: Record = { - "Accept": "*/*", + Accept: "*/*", "Accept-Encoding": "gzip, deflate, br, zstd", "Accept-Language": "en-US,en;q=0.9", - "Baggage": "sentry-environment=production,sentry-release=d6add6fb0460641fd482d767a335ef72b9b6abb8,sentry-public_key=b311e0f2690c81f25e2c4cf6d4f7ce1c", + Baggage: + "sentry-environment=production,sentry-release=d6add6fb0460641fd482d767a335ef72b9b6abb8,sentry-public_key=b311e0f2690c81f25e2c4cf6d4f7ce1c", "Cache-Control": "no-cache", "Content-Type": "application/json", - "Origin": "https://grok.com", - "Pragma": "no-cache", - "Referer": "https://grok.com/", + Origin: "https://grok.com", + Pragma: "no-cache", + Referer: "https://grok.com/", "Sec-Ch-Ua": '"Google Chrome";v="136", "Chromium";v="136", "Not(A:Brand";v="24"', "Sec-Ch-Ua-Mobile": "?0", "Sec-Ch-Ua-Platform": '"macOS"', @@ -486,7 +621,7 @@ export class GrokWebExecutor extends BaseExecutor { "User-Agent": GROK_USER_AGENT, "x-statsig-id": generateStatsigId(), "x-xai-request-id": crypto.randomUUID(), - "traceparent": `00-${traceId}-${spanId}-00`, + traceparent: `00-${traceId}-${spanId}-00`, }; // Cookie auth — strip "sso=" prefix if user included it @@ -501,7 +636,7 @@ export class GrokWebExecutor extends BaseExecutor { log?.info?.( "GROK-WEB", - `Query to ${model} (grok=${grokModel}, mode=${modelMode}), len=${message.length}`, + `Query to ${model} (grok=${grokModel}, mode=${modelMode}), len=${message.length}` ); // Apply fetch timeout @@ -520,15 +655,15 @@ export class GrokWebExecutor extends BaseExecutor { try { response = await fetch(GROK_CHAT_API, fetchOptions); } catch (err) { - log?.error?.( - "GROK-WEB", - `Fetch failed: ${err instanceof Error ? err.message : String(err)}`, - ); + log?.error?.("GROK-WEB", `Fetch failed: ${err instanceof Error ? err.message : String(err)}`); const errResp = new Response( JSON.stringify({ - error: { message: `Grok connection failed: ${err instanceof Error ? err.message : String(err)}`, type: "upstream_error" }, + error: { + message: `Grok connection failed: ${err instanceof Error ? err.message : String(err)}`, + type: "upstream_error", + }, }), - { status: 502, headers: { "Content-Type": "application/json" } }, + { status: 502, headers: { "Content-Type": "application/json" } } ); return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload }; } @@ -537,22 +672,27 @@ export class GrokWebExecutor extends BaseExecutor { const status = response.status; let errMsg = `Grok returned HTTP ${status}`; if (status === 401 || status === 403) { - errMsg = "Grok auth failed — SSO cookie may be expired. Re-paste your sso cookie value from grok.com."; + errMsg = + "Grok auth failed — SSO cookie may be expired. Re-paste your sso cookie value from grok.com."; } else if (status === 429) { errMsg = "Grok rate limited. Wait a moment and retry, or rotate cookies."; } log?.warn?.("GROK-WEB", errMsg); const errResp = new Response( - JSON.stringify({ error: { message: errMsg, type: "upstream_error", code: `HTTP_${status}` } }), - { status, headers: { "Content-Type": "application/json" } }, + JSON.stringify({ + error: { message: errMsg, type: "upstream_error", code: `HTTP_${status}` }, + }), + { status, headers: { "Content-Type": "application/json" } } ); return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload }; } if (!response.body) { const errResp = new Response( - JSON.stringify({ error: { message: "Grok returned empty response body", type: "upstream_error" } }), - { status: 502, headers: { "Content-Type": "application/json" } }, + JSON.stringify({ + error: { message: "Grok returned empty response body", type: "upstream_error" }, + }), + { status: 502, headers: { "Content-Type": "application/json" } } ); return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload }; } @@ -564,15 +704,29 @@ export class GrokWebExecutor extends BaseExecutor { let finalResponse: Response; if (stream) { const sseStream = buildStreamingResponse( - response.body, model, cid, created, isThinking, signal, + response.body, + model, + cid, + created, + isThinking, + signal ); finalResponse = new Response(sseStream, { status: 200, - headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" }, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, }); } else { finalResponse = await buildNonStreamingResponse( - response.body, model, cid, created, isThinking, signal, + response.body, + model, + cid, + created, + isThinking, + signal ); } diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 46ef454312..d6386eb066 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -12,6 +12,7 @@ import { OpencodeExecutor } from "./opencode.ts"; import { PuterExecutor } from "./puter.ts"; import { VertexExecutor } from "./vertex.ts"; import { CliproxyapiExecutor } from "./cliproxyapi.ts"; +import { PerplexityWebExecutor } from "./perplexity-web.ts"; import { GrokWebExecutor } from "./grok-web.ts"; const executors = { @@ -34,6 +35,8 @@ const executors = { vertex: new VertexExecutor(), cliproxyapi: new CliproxyapiExecutor(), cpa: new CliproxyapiExecutor(), // Alias + "perplexity-web": new PerplexityWebExecutor(), + "pplx-web": new PerplexityWebExecutor(), // Alias "grok-web": new GrokWebExecutor(), }; @@ -64,4 +67,5 @@ export { OpencodeExecutor } from "./opencode.ts"; export { PuterExecutor } from "./puter.ts"; export { CliproxyapiExecutor } from "./cliproxyapi.ts"; export { VertexExecutor } from "./vertex.ts"; +export { PerplexityWebExecutor } from "./perplexity-web.ts"; export { GrokWebExecutor } from "./grok-web.ts"; diff --git a/open-sse/executors/perplexity-web.ts b/open-sse/executors/perplexity-web.ts new file mode 100644 index 0000000000..b0232dd5c2 --- /dev/null +++ b/open-sse/executors/perplexity-web.ts @@ -0,0 +1,824 @@ +/** + * PerplexityWebExecutor — Perplexity Web Session Provider + * + * Routes requests through Perplexity's internal SSE API using a Pro/Max + * subscription session cookie or JWT, translating between OpenAI chat + * completions format and Perplexity's internal protocol. + */ + +import { BaseExecutor, type ExecuteInput } from "./base.ts"; + +const PPLX_SSE_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask"; +const PPLX_API_VERSION = "2.18"; +const PPLX_USER_AGENT = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"; + +const MODEL_MAP: Record = { + "pplx-auto": ["concise", "pplx_pro"], + "pplx-sonar": ["copilot", "experimental"], + "pplx-gpt": ["copilot", "gpt54"], + "pplx-gemini": ["copilot", "gemini31pro_high"], + "pplx-sonnet": ["copilot", "claude46sonnet"], + "pplx-opus": ["copilot", "claude46opus"], + "pplx-nemotron": ["copilot", "nv_nemotron_3_super"], +}; + +const THINKING_MAP: Record = { + "pplx-gpt": "gpt54_thinking", + "pplx-sonnet": "claude46sonnetthinking", + "pplx-opus": "claude46opusthinking", +}; + +const CITATION_RE = /\[\d+\]/g; +const GROK_TAG_RE = /]*>.*?<\/grok:[^>]*>/gs; +const GROK_SELF_RE = /]*\/>/g; +const XML_DECL_RE = /<[?]xml[^?]*[?]>/g; +const SCRIPT_RE = /]*>.*?<\/script>/gs; +const SCRIPT_TAG_RE = /<\/?script[^>]*>/g; +const RESPONSE_TAG_RE = /<\/?response[^>]*>/g; +const MULTI_SPACE = / {2,}/g; +const MULTI_NL = /\n{3,}/g; + +// ─── Session continuity ───────────────────────────────────────────────────── + +const SESSION_MAX_AGE_MS = 3600_000; +const SESSION_MAX_ENTRIES = 200; + +interface SessionEntry { + backendUuid: string; + ts: number; +} + +const sessionCache = new Map(); + +function sessionKey(history: Array<{ role: string; content: string }>): string { + const parts = history.map((h) => `${h.role}:${h.content}`).join("\n"); + let hash = 0x811c9dc5; + for (let i = 0; i < parts.length; i++) { + hash ^= parts.charCodeAt(i); + hash = (hash * 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, "0"); +} + +function sessionLookup(history: Array<{ role: string; content: string }>): string | null { + if (history.length === 0) return null; + const key = sessionKey(history); + const entry = sessionCache.get(key); + if (!entry) return null; + if (Date.now() - entry.ts > SESSION_MAX_AGE_MS) { + sessionCache.delete(key); + return null; + } + return entry.backendUuid; +} + +function sessionStore( + history: Array<{ role: string; content: string }>, + currentMsg: string, + responseText: string, + backendUuid: string | null +): void { + if (!backendUuid) return; + const full = [ + ...history, + { role: "user", content: currentMsg }, + { role: "assistant", content: responseText }, + ]; + const key = sessionKey(full); + sessionCache.set(key, { backendUuid, ts: Date.now() }); + if (sessionCache.size > SESSION_MAX_ENTRIES) { + let oldestKey: string | null = null; + let oldestTs = Infinity; + for (const [k, v] of sessionCache) { + if (v.ts < oldestTs) { + oldestTs = v.ts; + oldestKey = k; + } + } + if (oldestKey) sessionCache.delete(oldestKey); + } +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function cleanResponse(text: string, strip = true): string { + let t = text; + t = t.replace(XML_DECL_RE, ""); + t = t.replace(CITATION_RE, ""); + t = t.replace(GROK_TAG_RE, ""); + t = t.replace(GROK_SELF_RE, ""); + t = t.replace(RESPONSE_TAG_RE, ""); + t = t.replace(SCRIPT_RE, ""); + t = t.replace(SCRIPT_TAG_RE, ""); + if (strip) { + t = t.replace(MULTI_SPACE, " "); + t = t.replace(MULTI_NL, "\n\n"); + t = t.trim(); + } + return t; +} + +// ─── SSE types ────────────────────────────────────────────────────────────── + +interface PplxBlock { + intended_usage?: string; + markdown_block?: { + answer?: string; + chunks?: string[]; + progress?: string; + chunk_starting_offset?: number; + }; + web_result_block?: { + web_results?: Array<{ url?: string; name?: string; snippet?: string }>; + }; + plan_block?: { + steps?: Array<{ + step_type?: string; + search_web_content?: { queries?: Array<{ query?: string }> }; + read_results_content?: { urls?: string[] }; + }>; + goals?: Array<{ description?: string }>; + }; +} + +interface PplxStreamEvent { + status?: string; + final?: boolean; + text?: string; + blocks?: PplxBlock[]; + backend_uuid?: string; + web_results?: Array<{ url?: string; name?: string }>; + error_code?: string; + error_message?: string; + display_model?: string; +} + +// ─── SSE parsing ──────────────────────────────────────────────────────────── + +async function* readPplxSseEvents( + body: ReadableStream, + signal?: AbortSignal | null +): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let dataLines: string[] = []; + + function flush(): PplxStreamEvent | null | "done" { + if (dataLines.length === 0) return null; + const payload = dataLines.join("\n"); + dataLines = []; + const trimmed = payload.trim(); + if (!trimmed || trimmed === "[DONE]") return "done"; + try { + return JSON.parse(trimmed) as PplxStreamEvent; + } catch { + return null; + } + } + + try { + while (true) { + if (signal?.aborted) return; + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + while (true) { + const idx = buffer.indexOf("\n"); + if (idx < 0) break; + const rawLine = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + + if (line === "") { + const parsed = flush(); + if (parsed === "done") return; + if (parsed) yield parsed; + continue; + } + if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trimStart()); + } + if (line === "event: end_of_stream") { + return; + } + } + } + + buffer += decoder.decode(); + if (buffer.trim().startsWith("data:")) { + dataLines.push(buffer.trim().slice(5).trimStart()); + } + const tail = flush(); + if (tail && tail !== "done") yield tail; + } finally { + reader.releaseLock(); + } +} + +// ─── OpenAI → Perplexity translation ──────────────────────────────────────── + +interface ParsedMessages { + systemMsg: string; + history: Array<{ role: string; content: string }>; + currentMsg: string; +} + +function parseOpenAIMessages(messages: Array>): ParsedMessages { + let systemMsg = ""; + const history: Array<{ role: string; content: string }> = []; + + for (const msg of messages) { + let role = String(msg.role || "user"); + if (role === "developer") role = "system"; + + let content = ""; + if (typeof msg.content === "string") { + content = msg.content; + } else if (Array.isArray(msg.content)) { + content = (msg.content as Array>) + .filter((c) => c.type === "text") + .map((c) => String(c.text || "")) + .join(" "); + } + if (!content.trim()) continue; + + if (role === "system") { + systemMsg += content + "\n"; + } else if (role === "user" || role === "assistant") { + history.push({ role, content }); + } + } + + let currentMsg = ""; + if (history.length > 0 && history[history.length - 1].role === "user") { + currentMsg = history.pop()!.content; + } + + return { systemMsg, history, currentMsg }; +} + +function buildPplxRequestBody( + query: string, + mode: string, + modelPref: string, + followUpUuid: string | null +): Record { + const tz = typeof Intl !== "undefined" ? Intl.DateTimeFormat().resolvedOptions().timeZone : "UTC"; + + return { + query_str: query, + params: { + query_str: query, + search_focus: "internet", + mode, + model_preference: modelPref, + sources: ["web"], + attachments: [], + frontend_uuid: crypto.randomUUID(), + frontend_context_uuid: crypto.randomUUID(), + version: PPLX_API_VERSION, + language: "en-US", + timezone: tz, + search_recency_filter: null, + is_incognito: true, + use_schematized_api: true, + last_backend_uuid: followUpUuid, + }, + }; +} + +function buildQuery(parsed: ParsedMessages, followUpUuid: string | null): string { + if (followUpUuid) return parsed.currentMsg; + + const obj: Record = {}; + if (parsed.systemMsg.trim()) { + obj.instructions = [ + parsed.systemMsg.trim(), + "You have built-in web search. Answer questions directly using search results.", + ]; + } + if (parsed.history.length > 0) { + obj.history = parsed.history; + } + if (parsed.currentMsg) { + obj.query = parsed.currentMsg; + } else if (parsed.history.length === 0) { + obj.query = ""; + } + const json = JSON.stringify(obj); + return json.length > 96000 ? json.slice(-96000) : json; +} + +// ─── Content extraction ───────────────────────────────────────────────────── + +interface ContentChunk { + delta?: string; + answer?: string; + backendUuid?: string; + thinking?: string; + error?: string; + done?: boolean; +} + +async function* extractContent( + eventStream: ReadableStream, + signal?: AbortSignal | null +): AsyncGenerator { + let fullAnswer = ""; + let backendUuid: string | null = null; + let seenLen = 0; + const seenThinking = new Set(); + + for await (const event of readPplxSseEvents(eventStream, signal)) { + if (event.error_code || event.error_message) { + yield { + error: event.error_message || `Perplexity error: ${event.error_code}`, + done: true, + }; + return; + } + + if (event.backend_uuid) backendUuid = event.backend_uuid; + + const blocks = event.blocks ?? []; + for (const block of blocks) { + const usage = block.intended_usage ?? ""; + + // Thinking: search steps + if (usage === "pro_search_steps" && block.plan_block?.steps) { + for (const step of block.plan_block.steps) { + if (step.step_type === "SEARCH_WEB") { + for (const q of step.search_web_content?.queries ?? []) { + const qr = q.query ?? ""; + if (qr && !seenThinking.has(qr)) { + seenThinking.add(qr); + yield { thinking: `Searching: ${qr}`, backendUuid: backendUuid ?? undefined }; + } + } + } else if (step.step_type === "READ_RESULTS") { + for (const u of (step.read_results_content?.urls ?? []).slice(0, 3)) { + if (u && !seenThinking.has(u)) { + seenThinking.add(u); + yield { thinking: `Reading: ${u}`, backendUuid: backendUuid ?? undefined }; + } + } + } + } + } + + // Thinking: plan goals + if (usage === "plan" && block.plan_block?.goals) { + for (const goal of block.plan_block.goals) { + const desc = goal.description ?? ""; + if (desc && !seenThinking.has(desc)) { + seenThinking.add(desc); + yield { thinking: desc, backendUuid: backendUuid ?? undefined }; + } + } + } + + // Content: markdown blocks + if (!usage.includes("markdown")) continue; + const mb = block.markdown_block; + if (!mb) continue; + const chunks = mb.chunks ?? []; + if (chunks.length === 0) continue; + + if (mb.progress === "DONE") { + fullAnswer = chunks.join(""); + } else { + const chunkText = chunks.join(""); + const cumulative = fullAnswer + chunkText; + if (cumulative.length > seenLen) { + const delta = cumulative.slice(seenLen); + fullAnswer = cumulative; + seenLen = cumulative.length; + yield { delta, answer: fullAnswer, backendUuid: backendUuid ?? undefined }; + } + } + } + + // Fallback: text field + if (blocks.length === 0 && event.text) { + const t = event.text.trim(); + if (t.length > seenLen) { + const delta = t.slice(seenLen); + fullAnswer = t; + seenLen = t.length; + yield { delta, answer: fullAnswer, backendUuid: backendUuid ?? undefined }; + } + } + + if (event.final || event.status === "COMPLETED") break; + } + + yield { delta: "", answer: fullAnswer, backendUuid: backendUuid ?? undefined, done: true }; +} + +// ─── OpenAI SSE format ────────────────────────────────────────────────────── + +function sseChunk(data: unknown): string { + return `data: ${JSON.stringify(data)}\n\n`; +} + +function buildStreamingResponse( + eventStream: ReadableStream, + model: string, + cid: string, + created: number, + history: Array<{ role: string; content: string }>, + currentMsg: string, + signal?: AbortSignal | null +): ReadableStream { + const encoder = new TextEncoder(); + + return new ReadableStream({ + async start(controller) { + try { + // Initial role chunk + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [ + { index: 0, delta: { role: "assistant" }, finish_reason: null, logprobs: null }, + ], + }) + ) + ); + + let fullAnswer = ""; + let respBackendUuid: string | null = null; + + for await (const chunk of extractContent(eventStream, signal)) { + if (chunk.backendUuid) respBackendUuid = chunk.backendUuid; + + if (chunk.error) { + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [ + { + index: 0, + delta: { content: `[Error: ${chunk.error}]` }, + finish_reason: null, + logprobs: null, + }, + ], + }) + ) + ); + break; + } + + if (chunk.thinking) { + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [ + { + index: 0, + delta: { reasoning_content: chunk.thinking + "\n" }, + finish_reason: null, + logprobs: null, + }, + ], + }) + ) + ); + continue; + } + + if (chunk.done) { + fullAnswer = chunk.answer || fullAnswer; + break; + } + + let dt = chunk.delta || ""; + if (dt) { + dt = cleanResponse(dt, false); + if (dt) { + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [ + { index: 0, delta: { content: dt }, finish_reason: null, logprobs: null }, + ], + }) + ) + ); + } + } + if (chunk.answer) fullAnswer = chunk.answer; + } + + // Stop chunk + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }], + }) + ) + ); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + + sessionStore(history, currentMsg, cleanResponse(fullAnswer), respBackendUuid); + } catch (err) { + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [ + { + index: 0, + delta: { + content: `[Stream error: ${err instanceof Error ? err.message : String(err)}]`, + }, + finish_reason: "stop", + logprobs: null, + }, + ], + }) + ) + ); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + } finally { + controller.close(); + } + }, + }); +} + +async function buildNonStreamingResponse( + eventStream: ReadableStream, + model: string, + cid: string, + created: number, + history: Array<{ role: string; content: string }>, + currentMsg: string, + signal?: AbortSignal | null +): Promise { + let fullAnswer = ""; + let respBackendUuid: string | null = null; + const thinkingParts: string[] = []; + + for await (const chunk of extractContent(eventStream, signal)) { + if (chunk.backendUuid) respBackendUuid = chunk.backendUuid; + if (chunk.error) { + return new Response( + JSON.stringify({ + error: { message: chunk.error, type: "upstream_error", code: "PPLX_ERROR" }, + }), + { status: 502, headers: { "Content-Type": "application/json" } } + ); + } + if (chunk.thinking) { + thinkingParts.push(chunk.thinking); + continue; + } + if (chunk.done) { + fullAnswer = chunk.answer || fullAnswer; + break; + } + if (chunk.answer) fullAnswer = chunk.answer; + } + + fullAnswer = cleanResponse(fullAnswer); + sessionStore(history, currentMsg, fullAnswer, respBackendUuid); + + const reasoningContent = thinkingParts.length > 0 ? thinkingParts.join("\n") : undefined; + const msg: Record = { role: "assistant", content: fullAnswer }; + if (reasoningContent) msg.reasoning_content = reasoningContent; + + const promptTokens = Math.ceil(currentMsg.length / 4); + const completionTokens = Math.ceil(fullAnswer.length / 4); + + return new Response( + JSON.stringify({ + id: cid, + object: "chat.completion", + created, + model, + system_fingerprint: null, + choices: [{ index: 0, message: msg, finish_reason: "stop", logprobs: null }], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); +} + +// ─── Executor ─────────────────────────────────────────────────────────────── + +export class PerplexityWebExecutor extends BaseExecutor { + constructor() { + super("perplexity-web", { id: "perplexity-web", baseUrl: PPLX_SSE_ENDPOINT }); + } + + async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) { + const messages = (body as Record).messages as + | Array> + | undefined; + if (!messages || !Array.isArray(messages) || messages.length === 0) { + const errResp = new Response( + JSON.stringify({ + error: { message: "Missing or empty messages array", type: "invalid_request" }, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + return { response: errResp, url: PPLX_SSE_ENDPOINT, headers: {}, transformedBody: body }; + } + + // Resolve thinking mode + const bodyObj = body as Record; + const thinking = + bodyObj.thinking === true || + (bodyObj.reasoning_effort != null && bodyObj.reasoning_effort !== "none"); + + let pplxMode: string; + let modelPref: string; + if (thinking && THINKING_MAP[model]) { + pplxMode = "copilot"; + modelPref = THINKING_MAP[model]; + log?.info?.("PPLX-WEB", `Thinking mode → ${model} using ${modelPref}`); + } else if (MODEL_MAP[model]) { + [pplxMode, modelPref] = MODEL_MAP[model]; + } else { + pplxMode = "copilot"; + modelPref = model; + log?.info?.("PPLX-WEB", `Unmapped model ${model}, using as raw preference`); + } + + // Parse messages and check session continuity + const parsed = parseOpenAIMessages(messages); + const followUpUuid = sessionLookup(parsed.history); + if (followUpUuid) { + log?.info?.("PPLX-WEB", `Session continue: ${followUpUuid.slice(0, 12)}...`); + } + + const query = buildQuery(parsed, followUpUuid); + if (!query.trim()) { + const errResp = new Response( + JSON.stringify({ + error: { message: "Empty query after processing", type: "invalid_request" }, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + return { response: errResp, url: PPLX_SSE_ENDPOINT, headers: {}, transformedBody: body }; + } + + // Build Perplexity request + const pplxBody = buildPplxRequestBody(query, pplxMode, modelPref, followUpUuid); + + const headers: Record = { + "Content-Type": "application/json", + Accept: "text/event-stream", + Origin: "https://www.perplexity.ai", + Referer: "https://www.perplexity.ai/", + "User-Agent": PPLX_USER_AGENT, + "X-App-ApiClient": "default", + "X-App-ApiVersion": PPLX_API_VERSION, + }; + + if (credentials.accessToken) { + headers["Authorization"] = `Bearer ${credentials.accessToken}`; + } else if (credentials.apiKey) { + headers["Cookie"] = `__Secure-next-auth.session-token=${credentials.apiKey}`; + } + + log?.info?.( + "PPLX-WEB", + `Query to ${model} (pref=${modelPref}, mode=${pplxMode}), len=${query.length}` + ); + + // Fetch from Perplexity + const fetchOptions: RequestInit = { + method: "POST", + headers, + body: JSON.stringify(pplxBody), + }; + if (signal) fetchOptions.signal = signal; + + let response: Response; + try { + response = await fetch(PPLX_SSE_ENDPOINT, fetchOptions); + } catch (err) { + log?.error?.("PPLX-WEB", `Fetch failed: ${err instanceof Error ? err.message : String(err)}`); + const errResp = new Response( + JSON.stringify({ + error: { + message: `Perplexity connection failed: ${err instanceof Error ? err.message : String(err)}`, + type: "upstream_error", + }, + }), + { status: 502, headers: { "Content-Type": "application/json" } } + ); + return { response: errResp, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody }; + } + + if (!response.ok) { + const status = response.status; + let errMsg = `Perplexity returned HTTP ${status}`; + if (status === 401 || status === 403) { + errMsg = + "Perplexity auth failed — session cookie may be expired. Re-paste your __Secure-next-auth.session-token."; + } else if (status === 429) { + errMsg = "Perplexity rate limited. Wait a moment and retry."; + } + log?.warn?.("PPLX-WEB", errMsg); + const errResp = new Response( + JSON.stringify({ + error: { message: errMsg, type: "upstream_error", code: `HTTP_${status}` }, + }), + { status, headers: { "Content-Type": "application/json" } } + ); + return { response: errResp, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody }; + } + + if (!response.body) { + const errResp = new Response( + JSON.stringify({ + error: { message: "Perplexity returned empty response body", type: "upstream_error" }, + }), + { status: 502, headers: { "Content-Type": "application/json" } } + ); + return { response: errResp, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody }; + } + + // Build OpenAI-compatible response + const cid = `chatcmpl-pplx-${crypto.randomUUID().slice(0, 12)}`; + const created = Math.floor(Date.now() / 1000); + + let finalResponse: Response; + if (stream) { + const sseStream = buildStreamingResponse( + response.body, + model, + cid, + created, + parsed.history, + parsed.currentMsg, + signal + ); + finalResponse = new Response(sseStream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + }); + } else { + finalResponse = await buildNonStreamingResponse( + response.body, + model, + cid, + created, + parsed.history, + parsed.currentMsg, + signal + ); + } + + return { + response: finalResponse, + url: PPLX_SSE_ENDPOINT, + headers, + transformedBody: pplxBody, + }; + } +} diff --git a/open-sse/executors/pollinations.ts b/open-sse/executors/pollinations.ts index 55f9c6180e..5b4e41a8b7 100644 --- a/open-sse/executors/pollinations.ts +++ b/open-sse/executors/pollinations.ts @@ -2,9 +2,9 @@ import { BaseExecutor } from "./base.ts"; import { PROVIDERS } from "../config/constants.ts"; /** - * PollinationsExecutor — handles optional API key auth. - * Pollinations AI works WITHOUT any API key for basic use (1 req/15s). - * If an API key is provided, higher rate limits apply. + * PollinationsExecutor — Pollinations now requires API key auth. + * The free Spore tier grants 0.01 pollen/hour, so keep the messaging + * aligned with a key-backed free tier instead of anonymous access. * * Endpoint: https://text.pollinations.ai/openai/chat/completions * Docs: https://pollinations.ai/docs @@ -19,16 +19,16 @@ export class PollinationsExecutor extends BaseExecutor { } buildHeaders(credentials: any, stream = true): Record { + const key = credentials?.apiKey || credentials?.accessToken; + if (!key) { + throw new Error("Pollinations API key is required"); + } + const headers: Record = { "Content-Type": "application/json", + Authorization: `Bearer ${key}`, }; - // API key is OPTIONAL — skip Authorization header if no key provided - const key = credentials?.apiKey || credentials?.accessToken; - if (key) { - headers["Authorization"] = `Bearer ${key}`; - } - if (stream) { headers["Accept"] = "text/event-stream"; } @@ -37,8 +37,7 @@ export class PollinationsExecutor extends BaseExecutor { } transformRequest(model: string, body: any, _stream: boolean, _credentials: any): any { - // Pollinations uses model names directly like "openai", "claude", "deepseek", etc. - // No transformation needed — the model name is already the Pollinations alias. + // Pollinations uses provider aliases directly: "openai", "claude", "gemini", etc. return body; } } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 1f37789219..24532fda14 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -51,15 +51,15 @@ import { getModelPreserveOpenAIDeveloperRole, getModelUpstreamExtraHeaders, getUpstreamProxyConfig, - getCachedSettings, } from "@/lib/localDb"; import { getExecutor } from "../executors/index.ts"; +import { getCacheControlSettings } from "@/lib/cacheControlSettings"; import { shouldPreserveCacheControl, providerSupportsCaching, - type CacheControlMode, } from "../utils/cacheControlPolicy.ts"; import { getCacheMetrics } from "@/lib/db/settings.ts"; +import { getCachedSettings } from "@/lib/db/readCache"; import { parseCodexQuotaHeaders, @@ -79,13 +79,15 @@ import { sanitizeOpenAIResponse } from "./responseSanitizer.ts"; import { withRateLimit, updateFromHeaders, + updateFromResponseBody, initializeRateLimits, } from "../services/rateLimitManager.ts"; import { generateSignature, getCachedResponse, setCachedResponse, - isCacheable, + isCacheableForRead, + isCacheableForWrite, } from "@/lib/semanticCache"; import { getIdempotencyKey, checkIdempotency, saveIdempotency } from "@/lib/idempotencyLayer"; import { createProgressTransform, wantsProgress } from "../utils/progressTracker.ts"; @@ -97,6 +99,7 @@ import { getModelFamily, } from "../services/modelFamilyFallback.ts"; import { computeRequestHash, deduplicate, shouldDeduplicate } from "../services/requestDedup.ts"; +import { compressContext, estimateTokens, getTokenLimit } from "../services/contextManager.ts"; import { getBackgroundTaskReason, getDegradedModel, @@ -129,11 +132,6 @@ import { isClaudeCodeCompatibleProvider, resolveClaudeCodeCompatibleSessionId, } from "../services/claudeCodeCompatible.ts"; -import { remapToolNamesInRequest } from "../services/claudeCodeToolRemapper.ts"; -import { - enforceThinkingTemperature, - disableThinkingIfToolChoiceForced, -} from "../services/claudeCodeConstraints.ts"; function extractMemoryTextFromResponse( response: Record | null | undefined @@ -272,6 +270,42 @@ function getSkillsModelIdForFormat(format: string): string { } } +function parseNonStreamingSSEPayload( + rawBody: string, + preferredFormat: string, + fallbackModel: string +): { body: Record; format: string } | null { + const formatsToTry: string[] = []; + const seen = new Set(); + const queueFormat = (format: string) => { + if (!format || seen.has(format)) return; + seen.add(format); + formatsToTry.push(format); + }; + + queueFormat(preferredFormat); + queueFormat(FORMATS.OPENAI_RESPONSES); + queueFormat(FORMATS.CLAUDE); + queueFormat(FORMATS.OPENAI); + + for (const format of formatsToTry) { + const parsed = + format === FORMATS.OPENAI_RESPONSES + ? parseSSEToResponsesOutput(rawBody, fallbackModel) + : format === FORMATS.CLAUDE + ? parseSSEToClaudeResponse(rawBody, fallbackModel) + : parseSSEToOpenAIResponse(rawBody, fallbackModel); + if (parsed && typeof parsed === "object") { + return { + body: parsed as Record, + format, + }; + } + } + + return null; +} + function getHeaderValueCaseInsensitive( headers: Record | null | undefined, targetName: string @@ -694,6 +728,7 @@ export async function handleChatCore({ clientResponse, claudeCacheMeta, claudeCacheUsageMeta, + cacheSource, }: { status: number; tokens?: unknown; @@ -704,6 +739,7 @@ export async function handleChatCore({ clientResponse?: unknown; claudeCacheMeta?: Record; claudeCacheUsageMeta?: Record; + cacheSource?: "upstream" | "semantic"; }) => { const callLogId = generateRequestId(); const pipelinePayloads = detailedLoggingEnabled ? reqLogger?.getPipelinePayloads?.() : null; @@ -755,6 +791,7 @@ export async function handleChatCore({ comboName, comboStepId, comboExecutionKey, + cacheSource: cacheSource === "semantic" ? "semantic" : "upstream", apiKeyId: apiKeyInfo?.id || null, apiKeyName: apiKeyInfo?.name || null, noLog: noLogEnabled, @@ -817,36 +854,11 @@ export async function handleChatCore({ delete b.disable_stream; delete b.disable_streaming; delete b.streaming; - delete b.prompt_cache_retention; } const stream = resolveStreamFlag(body?.stream, acceptHeader); - const runtimeSettings = await getCachedSettings().catch(() => ({}) as Record); - const semanticCacheEnabled = runtimeSettings.semanticCacheEnabled !== false; - const cacheControlMode = - runtimeSettings.alwaysPreserveClientCache === "always" || - runtimeSettings.alwaysPreserveClientCache === "never" - ? (runtimeSettings.alwaysPreserveClientCache as CacheControlMode) - : "auto"; - - // ── Phase 9.1: Semantic cache check (non-streaming, temp=0 only) ── - if (semanticCacheEnabled && isCacheable(body, clientRawRequest?.headers)) { - const signature = generateSignature(model, body.messages, body.temperature, body.top_p); - const cached = getCachedResponse(signature); - if (cached) { - log?.debug?.("CACHE", `Semantic cache HIT for ${model}`); - return { - success: true, - response: new Response(JSON.stringify(cached), { - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), - "X-OmniRoute-Cache": "HIT", - }, - }), - }; - } - } + const settings = await getCachedSettings(); + const semanticCacheEnabled = settings.semanticCacheEnabled !== false; // Create request logger for this session: sourceFormat_targetFormat_model const reqLogger = await createRequestLogger(sourceFormat, targetFormat, model); @@ -862,6 +874,41 @@ export async function handleChatCore({ log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`); + // ── Phase 9.1: Semantic cache check (temp=0, any streaming mode) ── + // Streaming responses are cached after assembly; cache hits return JSON regardless of stream flag. + if (semanticCacheEnabled && isCacheableForRead(body, clientRawRequest?.headers)) { + const signature = generateSignature( + model, + body.messages ?? body.input, + body.temperature, + body.top_p + ); + const cached = getCachedResponse(signature); + if (cached) { + log?.debug?.("CACHE", `Semantic cache HIT for ${model} (stream=${stream})`); + reqLogger.logConvertedResponse(cached as Record); + persistAttemptLogs({ + status: 200, + tokens: (cached as Record)?.usage, + responseBody: cached, + providerRequest: null, + providerResponse: null, + clientResponse: cached, + cacheSource: "semantic", + }); + return { + success: true, + response: new Response(JSON.stringify(cached), { + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": getCorsOrigin(), + "X-OmniRoute-Cache": "HIT", + }, + }), + }; + } + } + // ── Common input sanitization (runs for ALL paths including passthrough) ── // #994: Normalize max_output_tokens to max_tokens for universal compatibility if (body.max_output_tokens !== undefined) { @@ -962,13 +1009,12 @@ export async function handleChatCore({ let translatedBody = body; const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE; const isClaudeCodeCompatible = isClaudeCodeCompatibleProvider(provider); - // Respect the client's explicit non-streaming intent for CC-compatible providers. - // Most upstreams can answer JSON directly; the SSE->JSON fallback remains as a - // compatibility path when an upstream still responds with event-stream. - const upstreamStream = stream; + const upstreamStream = stream || isClaudeCodeCompatible; let ccSessionId: string | null = null; // Determine if we should preserve client-side cache_control headers + // Fetch settings from DB to get user preference + const cacheControlMode = await getCacheControlSettings().catch(() => "auto" as const); const preserveCacheControl = shouldPreserveCacheControl({ userAgent, isCombo, @@ -1030,17 +1076,6 @@ export async function handleChatCore({ now: new Date(), preserveCacheControl, }); - - // Apply PR #1188 parity pipeline (synchronous steps — CCH signing is async and - // runs later in BaseExecutor over the serialized string). - // Only thinking constraints and tool remapping are applied here; cache-control - // limit enforcement (enforceCacheControlLimit) is intentionally omitted because - // the billing-header system block added by buildClaudeCodeCompatibleRequest counts - // toward the 4-block cap and would strip legitimate client cache markers. - remapToolNamesInRequest(translatedBody); - enforceThinkingTemperature(translatedBody); - disableThinkingIfToolChoiceForced(translatedBody); - log?.debug?.("FORMAT", "claude-code-compatible bridge enabled"); } else if (isClaudePassthrough && preserveCacheControl) { // Pure passthrough: when preserveCacheControl is true, forward the body @@ -1183,6 +1218,40 @@ export async function handleChatCore({ } } + // OpenAI-compatible providers only support function tools. + // Non-function tool types (computer, mcp, web_search, custom, etc.) are handled: + // - tools with a name → converted to function format in-place before translation + // - tools without a name AND without .function → dropped (unconvertible) + // This must happen before translateRequest, which validates and throws on unknown types. + if (provider?.startsWith("openai-compatible-") && Array.isArray(translatedBody.tools)) { + const before = (translatedBody.tools as unknown[]).length; + translatedBody.tools = (translatedBody.tools as Record[]) + .filter((t) => !t.type || t.type === "function" || !!t.function || !!t.name) + .map((t) => { + if (!t.type || t.type === "function" || t.function) return t; + // Named non-function tool: normalise to function format so the translator + // does not throw on the unknown type. + return { + type: "function", + function: { + name: t.name, + ...(t.description !== undefined ? { description: t.description } : {}), + ...(t.parameters !== undefined || t.input_schema !== undefined + ? { parameters: t.parameters ?? t.input_schema ?? {} } + : {}), + ...(t.strict !== undefined ? { strict: t.strict } : {}), + }, + }; + }); + const dropped = before - (translatedBody.tools as unknown[]).length; + if (dropped > 0) { + log?.debug?.( + "TOOLS", + `Dropped ${dropped} unconvertible tool(s) for openai-compatible provider` + ); + } + } + const normalizeToolCallId = getModelNormalizeToolCallId( provider || "", model || "", @@ -1256,7 +1325,14 @@ export async function handleChatCore({ delete translatedBody._disableToolPrefix; // Update model in body — use resolved alias so the provider gets the correct model ID (#472) - translatedBody.model = effectiveModel; + // Strip provider/alias prefix if it exactly matches the routing prefix so upstream receives the raw model name (#1261) + let finalModelToUpstream = effectiveModel; + if (finalModelToUpstream.startsWith(`${provider}/`)) { + finalModelToUpstream = finalModelToUpstream.slice(provider.length + 1); + } else if (alias && finalModelToUpstream.startsWith(`${alias}/`)) { + finalModelToUpstream = finalModelToUpstream.slice(alias.length + 1); + } + translatedBody.model = finalModelToUpstream; // Strip unsupported parameters for reasoning models (o1, o3, etc.) const unsupported = getUnsupportedParams(provider, model); @@ -1289,6 +1365,53 @@ export async function handleChatCore({ } } + if (translatedBody && translatedBody.messages && Array.isArray(translatedBody.messages)) { + const estimatedTokens = estimateTokens(JSON.stringify(translatedBody.messages)); + const contextLimit = getTokenLimit(provider, effectiveModel); + const COMPRESSION_THRESHOLD = 0.85; + const threshold = Math.floor(contextLimit * COMPRESSION_THRESHOLD); + + if (estimatedTokens > threshold) { + log?.info?.( + "CONTEXT", + `Proactive compression triggered: ${estimatedTokens} tokens > ${threshold} threshold (${contextLimit} limit)` + ); + + const compressionResult = compressContext(translatedBody, { + provider, + model: effectiveModel, + maxTokens: contextLimit, + }); + + if (compressionResult.compressed) { + translatedBody = compressionResult.body; + const stats = compressionResult.stats; + const layersInfo = + stats && "layers" in stats && Array.isArray(stats.layers) + ? ` (layers: ${stats.layers.map((l: { name: string }) => l.name).join(", ")})` + : ""; + + log?.info?.( + "CONTEXT", + `Context compressed: ${stats.original} → ${stats.final} tokens${layersInfo}` + ); + + logAuditEvent({ + action: "context.proactive_compression", + actor: apiKeyInfo?.name || "system", + target: connectionId || provider || "chat", + details: { + provider, + model: effectiveModel, + original_tokens: stats.original, + final_tokens: stats.final, + layers: "layers" in stats ? stats.layers : undefined, + }, + }); + } + } + } + // Resolve executor with optional upstream proxy (CLIProxyAPI) routing. // mode="native" (default): returns the native executor unchanged. // mode="cliproxyapi": returns the CLIProxyAPI executor instead. @@ -1383,6 +1506,21 @@ export async function handleChatCore({ ? translatedBody : { ...translatedBody, model: modelToCall }; + // Qwen OAuth rejects requests without a non-empty `user` field. + // Some minimal OpenAI-compatible clients omit it, so we backfill a + // stable default only for OAuth mode (API key mode is unaffected). + const hasValidQwenUser = + typeof bodyToSend.user === "string" && bodyToSend.user.trim().length > 0; + const isQwenOAuthRequest = + provider === "qwen" && + !credentials?.apiKey && + typeof credentials?.accessToken === "string" && + credentials.accessToken.trim().length > 0; + if (isQwenOAuthRequest && !hasValidQwenUser) { + bodyToSend = { ...bodyToSend, user: "omniroute-qwen-oauth" }; + log?.debug?.("QWEN", "Injected fallback user for OAuth request"); + } + // Inject prompt_cache_key only for providers that support it if ( targetFormat === FORMATS.OPENAI && @@ -1535,6 +1673,7 @@ export async function handleChatCore({ providerRequest: finalBody || translatedBody, clientResponse: buildErrorBody(failureStatus, failureMessage), claudeCacheMeta: claudePromptCacheLogMeta, + cacheSource: "upstream", }); if (error.name === "AbortError") { streamController.handleError(error); @@ -1684,23 +1823,24 @@ export async function handleChatCore({ // For providers with per-model quotas (passthrough providers, Gemini), // each model has independent quota. A 429 on one model must NOT lock out // the entire connection — other models may still have quota available. + const rateLimitCooldownMs = retryAfterMs || COOLDOWN_MS.rateLimit; if ( lockModelIfPerModelQuota( provider, connectionId, model, "rate_limited", - retryAfterMs || COOLDOWN_MS.rateLimit + rateLimitCooldownMs ) ) { console.warn( - `[provider] Node ${connectionId} model-only rate limited (${statusCode}) for ${model} - ${Math.ceil((retryAfterMs || COOLDOWN_MS.rateLimit) / 1000)}s (connection stays active)` + `[provider] Node ${connectionId} model-only rate limited (${statusCode}) for ${model} - ${Math.ceil(rateLimitCooldownMs / 1000)}s (connection stays active)` ); } else { - const rateLimitedUntil = new Date(Date.now() + retryAfterMs).toISOString(); + const rateLimitedUntil = new Date(Date.now() + rateLimitCooldownMs).toISOString(); await updateProviderConnection(connectionId, { rateLimitedUntil: rateLimitedUntil, - testStatus: "credits_exhausted", + testStatus: "unavailable", lastErrorType: errorType, lastError: message, errorCode: statusCode, @@ -1802,6 +1942,9 @@ export async function handleChatCore({ // Update rate limiter from error response headers updateFromHeaders(provider, connectionId, providerResponse.headers, statusCode, model); + if (connectionId && upstreamErrorBody !== null && upstreamErrorBody !== undefined) { + updateFromResponseBody(provider, connectionId, upstreamErrorBody, statusCode, model); + } // ── T5: Intra-family model fallback ────────────────────────────────────── // Before returning a model-unavailable error upstream, try sibling models @@ -1835,6 +1978,7 @@ export async function handleChatCore({ providerRequest: finalBody || translatedBody, providerResponse: upstreamErrorBody, clientResponse: buildErrorBody(statusCode, errMsg), + cacheSource: "upstream", }); persistFailureUsage(statusCode, "model_unavailable"); return createErrorResult(statusCode, errMsg, retryAfterMs); @@ -1846,6 +1990,7 @@ export async function handleChatCore({ providerRequest: finalBody || translatedBody, providerResponse: upstreamErrorBody, clientResponse: buildErrorBody(statusCode, errMsg), + cacheSource: "upstream", }); persistFailureUsage(statusCode, "model_unavailable"); return createErrorResult(statusCode, errMsg, retryAfterMs); @@ -1857,6 +2002,7 @@ export async function handleChatCore({ providerRequest: finalBody || translatedBody, providerResponse: upstreamErrorBody, clientResponse: buildErrorBody(statusCode, errMsg), + cacheSource: "upstream", }); persistFailureUsage(statusCode, "model_unavailable"); return createErrorResult(statusCode, errMsg, retryAfterMs); @@ -1892,6 +2038,7 @@ export async function handleChatCore({ providerRequest: finalBody || translatedBody, providerResponse: upstreamErrorBody, clientResponse: buildErrorBody(statusCode, errMsg), + cacheSource: "upstream", }); persistFailureUsage(statusCode, "context_overflow"); return createErrorResult(statusCode, errMsg, retryAfterMs); @@ -1903,6 +2050,7 @@ export async function handleChatCore({ providerRequest: finalBody || translatedBody, providerResponse: upstreamErrorBody, clientResponse: buildErrorBody(statusCode, errMsg), + cacheSource: "upstream", }); persistFailureUsage(statusCode, "context_overflow"); return createErrorResult(statusCode, errMsg, retryAfterMs); @@ -1914,6 +2062,7 @@ export async function handleChatCore({ providerRequest: finalBody || translatedBody, providerResponse: upstreamErrorBody, clientResponse: buildErrorBody(statusCode, errMsg), + cacheSource: "upstream", }); persistFailureUsage(statusCode, "context_overflow"); return createErrorResult(statusCode, errMsg, retryAfterMs); @@ -1925,6 +2074,7 @@ export async function handleChatCore({ providerRequest: finalBody || translatedBody, providerResponse: upstreamErrorBody, clientResponse: buildErrorBody(statusCode, errMsg), + cacheSource: "upstream", }); persistFailureUsage(statusCode, `upstream_${statusCode}`); @@ -2009,7 +2159,7 @@ export async function handleChatCore({ trackPendingRequest(model, provider, connectionId, false); const contentType = (providerResponse.headers.get("content-type") || "").toLowerCase(); let responseBody; - let responseFormatForTranslation = targetFormat; + let responsePayloadFormat = targetFormat; const rawBody = await providerResponse.text(); const normalizedProviderPayload = normalizePayloadForLog(rawBody); const looksLikeSSE = @@ -2017,21 +2167,7 @@ export async function handleChatCore({ if (looksLikeSSE) { // Upstream returned SSE even though stream=false; convert best-effort to JSON. - const looksLikeResponsesSSE = - targetFormat === FORMATS.OPENAI_RESPONSES || - provider === "codex" || - /(^|\n)\s*(?:event:\s*response\.|data:\s*\{.*"type"\s*:\s*"response\.)/m.test(rawBody); - responseFormatForTranslation = looksLikeResponsesSSE - ? FORMATS.OPENAI_RESPONSES - : targetFormat === FORMATS.CLAUDE - ? FORMATS.CLAUDE - : FORMATS.OPENAI; - const parsedFromSSE = - responseFormatForTranslation === FORMATS.OPENAI_RESPONSES - ? parseSSEToResponsesOutput(rawBody, model) - : responseFormatForTranslation === FORMATS.CLAUDE - ? parseSSEToClaudeResponse(rawBody, model) - : parseSSEToOpenAIResponse(rawBody, model); + const parsedFromSSE = parseNonStreamingSSEPayload(rawBody, targetFormat, model); if (!parsedFromSSE) { appendRequestLog({ @@ -2047,41 +2183,34 @@ export async function handleChatCore({ providerRequest: finalBody || translatedBody, providerResponse: normalizedProviderPayload, clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, invalidSseMessage), + cacheSource: "upstream", }); persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "invalid_sse_payload"); return createErrorResult(HTTP_STATUS.BAD_GATEWAY, invalidSseMessage); } - responseBody = parsedFromSSE; + responseBody = parsedFromSSE.body; + responsePayloadFormat = parsedFromSSE.format; } else { try { responseBody = rawBody ? JSON.parse(rawBody) : {}; } catch { - const isHtmlResponse = - rawBody && typeof rawBody === "string" && /^\s*(?:\s| {}); - - const invalidJsonMessage = isHtmlResponse - ? "Provider returned HTML error page instead of JSON - check credentials and endpoint" - : "Invalid JSON response from provider"; + const invalidJsonMessage = "Invalid JSON response from provider"; persistAttemptLogs({ status: HTTP_STATUS.BAD_GATEWAY, error: invalidJsonMessage, providerRequest: finalBody || translatedBody, - providerResponse: isHtmlResponse - ? { _raw: rawBody?.slice(0, 500) } - : normalizedProviderPayload, + providerResponse: normalizedProviderPayload, clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, invalidJsonMessage), + cacheSource: "upstream", }); - persistFailureUsage( - HTTP_STATUS.BAD_GATEWAY, - isHtmlResponse ? "html_error_response" : "invalid_json_payload" - ); + persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "invalid_json_payload"); return createErrorResult(HTTP_STATUS.BAD_GATEWAY, invalidJsonMessage); } } @@ -2101,6 +2230,7 @@ export async function handleChatCore({ providerRequest: finalBody || translatedBody, providerResponse: normalizedProviderPayload, clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, emptyContentMessage), + cacheSource: "upstream", }); persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "empty_content"); @@ -2221,10 +2351,10 @@ export async function handleChatCore({ // Translate response to client's expected format (usually OpenAI) // Pass toolNameMap so Claude OAuth proxy_ prefix is stripped in tool_use blocks (#605) - let translatedResponse = needsTranslation(responseFormatForTranslation, clientResponseFormat) + let translatedResponse = needsTranslation(responsePayloadFormat, clientResponseFormat) ? translateNonStreamingResponse( responseBody, - responseFormatForTranslation, + responsePayloadFormat, clientResponseFormat, toolNameMap as Map | null ) @@ -2309,8 +2439,13 @@ export async function handleChatCore({ } // ── Phase 9.1: Cache store (non-streaming, temp=0) ── - if (semanticCacheEnabled && isCacheable(body, clientRawRequest?.headers)) { - const signature = generateSignature(model, body.messages, body.temperature, body.top_p); + if (semanticCacheEnabled && isCacheableForWrite(body, clientRawRequest?.headers)) { + const signature = generateSignature( + model, + body.messages ?? body.input, + body.temperature, + body.top_p + ); const tokensSaved = usage?.prompt_tokens + usage?.completion_tokens || 0; setCachedResponse(signature, model, translatedResponse, tokensSaved); log?.debug?.("CACHE", `Stored response for ${model} (${tokensSaved} tokens)`); @@ -2334,6 +2469,7 @@ export async function handleChatCore({ clientResponse: translatedResponse, claudeCacheMeta: claudePromptCacheLogMeta, claudeCacheUsageMeta: cacheUsageLogMeta, + cacheSource: "upstream", }); return { @@ -2424,6 +2560,7 @@ export async function handleChatCore({ clientResponse: clientPayload ?? streamResponseBody ?? undefined, claudeCacheMeta: claudePromptCacheLogMeta, claudeCacheUsageMeta: cacheUsageLogMeta, + cacheSource: "upstream", }); if (apiKeyInfo?.id && streamUsage) { @@ -2433,6 +2570,32 @@ export async function handleChatCore({ }) .catch(() => {}); } + + // Semantic cache: store assembled streaming response for future cache hits + if ( + semanticCacheEnabled && + streamStatus === 200 && + streamResponseBody && + isCacheableForWrite(body, clientRawRequest?.headers) + ) { + try { + const cleanBody = { ...streamResponseBody }; + delete cleanBody._streamed; + const sig = generateSignature( + model, + body.messages ?? body.input, + body.temperature, + body.top_p + ); + const u = streamUsage as Record | null; + const tokensSaved = + (Number(u?.prompt_tokens ?? 0) || 0) + (Number(u?.completion_tokens ?? 0) || 0); + setCachedResponse(sig, model, cleanBody, tokensSaved); + log?.debug?.("CACHE", `Stored streaming response for ${model} (${tokensSaved} tokens)`); + } catch { + // Cache write failed — non-critical + } + } }; // For providers using Responses API format, translate stream back to openai (Chat Completions) format diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 2792245015..603e7c64da 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -272,11 +272,13 @@ export function translateNonStreamingResponse( if (partObj.functionCall) { const fn = toRecord(partObj.functionCall); + const rawName = toString(fn.name); + const restoredName = toolNameMap?.get(rawName) ?? rawName; toolCalls.push({ - id: `call_${toString(fn.name, "unknown")}_${Date.now()}_${toolCalls.length}`, + id: `call_${toString(restoredName, "unknown")}_${Date.now()}_${toolCalls.length}`, type: "function", function: { - name: toString(fn.name), + name: restoredName, arguments: JSON.stringify(fn.args || {}), }, }); diff --git a/open-sse/handlers/sseParser.ts b/open-sse/handlers/sseParser.ts index 4ab94cfbbc..44beadf0da 100644 --- a/open-sse/handlers/sseParser.ts +++ b/open-sse/handlers/sseParser.ts @@ -74,6 +74,7 @@ function toNumber(value, fallback = 0) { export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { const lines = String(rawSSE || "").split("\n"); const chunks = []; + let sawChoices = false; for (const line of lines) { const trimmed = line.trim(); @@ -81,13 +82,17 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { const payload = trimmed.slice(5).trim(); if (!payload || payload === "[DONE]") continue; try { - chunks.push(JSON.parse(payload)); + const parsed = JSON.parse(payload); + if (Array.isArray(parsed?.choices)) { + sawChoices = true; + } + chunks.push(parsed); } catch { // Ignore malformed SSE lines and continue best-effort parsing. } } - if (chunks.length === 0) return null; + if (chunks.length === 0 || !sawChoices) return null; const first = chunks[0]; const contentParts = []; @@ -230,6 +235,7 @@ export function parseSSEToClaudeResponse(rawSSE, fallbackModel) { let role = "assistant"; let stopReason = "end_turn"; let stopSequence = null; + let sawClaudeEvent = false; const mergeUsage = (incoming) => { const usageRecord = toRecord(incoming); @@ -255,6 +261,7 @@ export function parseSSEToClaudeResponse(rawSSE, fallbackModel) { for (const payload of payloads) { const eventType = toString(payload.type); if (eventType === "message_start") { + sawClaudeEvent = true; const message = toRecord(payload.message); messageId = toString(message.id, messageId || `msg_${Date.now()}`); model = toString(message.model, model); @@ -264,6 +271,7 @@ export function parseSSEToClaudeResponse(rawSSE, fallbackModel) { } if (eventType === "content_block_start") { + sawClaudeEvent = true; const index = toNumber(payload.index, blocks.size); const contentBlock = toRecord(payload.content_block); const blockType = toString(contentBlock.type); @@ -296,6 +304,7 @@ export function parseSSEToClaudeResponse(rawSSE, fallbackModel) { } if (eventType === "content_block_delta") { + sawClaudeEvent = true; const index = toNumber(payload.index, 0); const delta = toRecord(payload.delta); const deltaType = toString(delta.type); @@ -342,6 +351,7 @@ export function parseSSEToClaudeResponse(rawSSE, fallbackModel) { } if (eventType === "message_delta") { + sawClaudeEvent = true; const delta = toRecord(payload.delta); stopReason = toString(delta.stop_reason, stopReason); stopSequence = @@ -353,6 +363,8 @@ export function parseSSEToClaudeResponse(rawSSE, fallbackModel) { mergeUsage(payload.usage); } + if (!sawClaudeEvent) return null; + const content = [...blocks.values()] .sort((a, b) => a.index - b.index) .flatMap((block) => { diff --git a/open-sse/handlers/usageExtractor.ts b/open-sse/handlers/usageExtractor.ts index 52c2a1c758..da1f393807 100644 --- a/open-sse/handlers/usageExtractor.ts +++ b/open-sse/handlers/usageExtractor.ts @@ -19,8 +19,12 @@ export function extractUsageFromResponse(responseBody, provider) { return { prompt_tokens: responseBody.usage.prompt_tokens || 0, completion_tokens: responseBody.usage.completion_tokens || 0, - cached_tokens: responseBody.usage.prompt_tokens_details?.cached_tokens, - reasoning_tokens: responseBody.usage.completion_tokens_details?.reasoning_tokens, + cached_tokens: + responseBody.usage.prompt_tokens_details?.cached_tokens ?? + responseBody.usage.input_tokens_details?.cached_tokens, + reasoning_tokens: + responseBody.usage.completion_tokens_details?.reasoning_tokens ?? + responseBody.usage.output_tokens_details?.reasoning_tokens, }; } @@ -60,10 +64,13 @@ export function extractUsageFromResponse(responseBody, provider) { cache_read_input_tokens: responsesUsage.cache_read_input_tokens, cached_tokens: responsesUsage.input_tokens_details?.cached_tokens ?? + responsesUsage.prompt_tokens_details?.cached_tokens ?? responsesUsage.cache_read_input_tokens, cache_creation_input_tokens: responsesUsage.cache_creation_input_tokens, reasoning_tokens: - responsesUsage.reasoning_tokens || responsesUsage.output_tokens_details?.reasoning_tokens, + responsesUsage.output_tokens_details?.reasoning_tokens ?? + responsesUsage.completion_tokens_details?.reasoning_tokens ?? + responsesUsage.reasoning_tokens, }; } diff --git a/open-sse/mcp-server/__tests__/glmCodingProviderConfig.test.ts b/open-sse/mcp-server/__tests__/glmCodingProviderConfig.test.ts index c49a1acb05..3cd7fcb751 100644 --- a/open-sse/mcp-server/__tests__/glmCodingProviderConfig.test.ts +++ b/open-sse/mcp-server/__tests__/glmCodingProviderConfig.test.ts @@ -23,6 +23,24 @@ describe("GLM Coding provider registry surfaces", () => { expect(entry?.headers?.["Anthropic-Version"]).toBe("2023-06-01"); }); + it("registers GLMT as an explicit high-budget preset over the GLM transport", () => { + const entry = getRegistryEntry("glmt"); + + expect(entry).toBeDefined(); + expect(entry?.id).toBe("glmt"); + expect(entry?.alias).toBe("glmt"); + expect(entry?.format).toBe("claude"); + expect(entry?.baseUrl).toBe("https://api.z.ai/api/anthropic/v1/messages"); + expect(entry?.authType).toBe("apikey"); + expect(entry?.authHeader).toBe("x-api-key"); + expect(entry?.requestDefaults).toEqual({ + maxTokens: 65536, + temperature: 0.2, + thinkingBudgetTokens: 24576, + }); + expect(entry?.timeoutMs).toBe(900000); + }); + it("exposes the same GLM model inventory through registry-derived model helpers", () => { const byProviderId = getModelsByProviderId("glm"); const byAlias = getProviderModels("glm"); diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 3cc53ad4ea..d716d2c3fd 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -138,7 +138,8 @@ async function omniRouteFetch(path: string, options: RequestInit = {}): Promise< ...((options.headers as Record) || {}), }; - const response = await fetch(url, { ...options, headers, signal: AbortSignal.timeout(10000) }); + const signal = options.signal || AbortSignal.timeout(10000); + const response = await fetch(url, { ...options, headers, signal }); if (!response.ok) { const errorText = await response.text().catch(() => "Unknown error"); @@ -538,6 +539,7 @@ async function handleWebSearch(args: { const result = await omniRouteFetch("/v1/search", { method: "POST", body: JSON.stringify(body), + signal: AbortSignal.timeout(60000), }); await logToolCall("omniroute_web_search", args, result, Date.now() - start, true); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; diff --git a/open-sse/mcp-server/tools/memoryTools.ts b/open-sse/mcp-server/tools/memoryTools.ts index 4863e9d758..9ac7dd9ce2 100644 --- a/open-sse/mcp-server/tools/memoryTools.ts +++ b/open-sse/mcp-server/tools/memoryTools.ts @@ -90,15 +90,20 @@ export const memoryTools = { description: "Clear memories for an API key, optionally filtered by type or age", inputSchema: MemoryClearSchema, handler: async (args: z.infer) => { - const memories = await listMemories({ + const result = await listMemories({ apiKeyId: args.apiKeyId, type: args.type as MemoryType | undefined, }); + const existingMemories = Array.isArray(result) + ? result + : Array.isArray(result?.data) + ? result.data + : []; - let toDelete = memories; + let toDelete = existingMemories; if (args.olderThan) { const cutoff = new Date(args.olderThan); - toDelete = memories.filter((m) => new Date(m.createdAt) < cutoff); + toDelete = existingMemories.filter((m) => new Date(m.createdAt) < cutoff); } let deletedCount = 0; diff --git a/open-sse/package.json b/open-sse/package.json index 46e3ce00a4..06de1ec9fe 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.6.5", + "version": "3.6.6", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 8e8f75236c..cfd621dcaa 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -6,7 +6,23 @@ import { HTTP_STATUS, PROVIDER_PROFILES, } from "../config/constants.ts"; -import { getProviderCategory } from "../config/providerRegistry.ts"; +import { getPassthroughProviders, getProviderCategory } from "../config/providerRegistry.ts"; + +type ProviderProfile = (typeof PROVIDER_PROFILES)["oauth"]; +type JsonRecord = Record; +type ModelLockoutEntry = { + reason: string; + until: number; + lockedAt: number; + failureCount: number; + lastFailureAt: number; + resetAfterMs: number; +}; +type ModelFailureState = { + failureCount: number; + lastFailureAt: number; + resetAfterMs: number; +}; // T06 (sub2api PR #1037): Signals that indicate permanent account deactivation. // When a 401 body contains these strings, the account is permanently dead @@ -82,9 +98,116 @@ export function getProviderProfile(provider) { return PROVIDER_PROFILES[category] ?? PROVIDER_PROFILES.apikey; } +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function isCompatibleProvider(provider: string | null | undefined): boolean { + return ( + typeof provider === "string" && + (provider.startsWith("openai-compatible-") || provider.startsWith("anthropic-compatible-")) + ); +} + +function mergeProviderProfile(fallback: ProviderProfile, overrides: unknown): ProviderProfile { + const record = asRecord(overrides); + return { + transientCooldown: + typeof record.transientCooldown === "number" + ? record.transientCooldown + : fallback.transientCooldown, + rateLimitCooldown: + typeof record.rateLimitCooldown === "number" + ? record.rateLimitCooldown + : fallback.rateLimitCooldown, + maxBackoffLevel: + typeof record.maxBackoffLevel === "number" + ? record.maxBackoffLevel + : fallback.maxBackoffLevel, + circuitBreakerThreshold: + typeof record.circuitBreakerThreshold === "number" + ? record.circuitBreakerThreshold + : fallback.circuitBreakerThreshold, + circuitBreakerReset: + typeof record.circuitBreakerReset === "number" + ? record.circuitBreakerReset + : fallback.circuitBreakerReset, + }; +} + +export async function getRuntimeProviderProfile(provider: string | null | undefined) { + const fallback = getProviderProfile(provider); + try { + const { getCachedSettings } = await import("@/lib/db/readCache"); + const settings = await getCachedSettings(); + const profiles = asRecord(settings.providerProfiles); + const category = getProviderCategory(provider); + return mergeProviderProfile(fallback, profiles[category]); + } catch { + return fallback; + } +} + // ─── Per-Model Lockout Tracking ───────────────────────────────────────────── // In-memory map: "provider:connectionId:model" → { reason, until, lockedAt } -const modelLockouts = new Map(); +const modelLockouts = new Map(); +const modelFailureState = new Map(); + +function getModelLockKey(provider: string, connectionId: string, model: string) { + return `${provider}:${connectionId}:${model}`; +} + +function getFailureWindowMs(profile: ProviderProfile | null = null, fallbackMs = 30 * 60 * 1000) { + const configured = profile?.circuitBreakerReset; + return typeof configured === "number" && configured > 0 ? configured : fallbackMs; +} + +function cleanupModelLockKey(key: string, now = Date.now()) { + const entry = modelLockouts.get(key); + if (entry && now > entry.until) { + modelLockouts.delete(key); + } + + const failure = modelFailureState.get(key); + if (!failure) return; + if (now - failure.lastFailureAt <= failure.resetAfterMs) return; + if (modelLockouts.has(key)) return; + modelFailureState.delete(key); +} + +function getModelLockBaseCooldown( + status: number, + fallbackCooldownMs: number, + profile: ProviderProfile | null = null +) { + if (status === HTTP_STATUS.RATE_LIMITED) { + if (typeof profile?.rateLimitCooldown === "number" && profile.rateLimitCooldown > 0) { + return profile.rateLimitCooldown; + } + if (Number.isFinite(fallbackCooldownMs) && fallbackCooldownMs > 0) { + return fallbackCooldownMs; + } + return getQuotaCooldown(0); + } + + if (typeof profile?.transientCooldown === "number" && profile.transientCooldown > 0) { + return profile.transientCooldown; + } + if (Number.isFinite(fallbackCooldownMs) && fallbackCooldownMs > 0) { + return fallbackCooldownMs; + } + return COOLDOWN_MS.transientInitial; +} + +function getScaledCooldown( + baseCooldownMs: number, + failureCount: number, + maxBackoffLevel = BACKOFF_CONFIG.maxLevel +) { + const safeBase = Number.isFinite(baseCooldownMs) && baseCooldownMs > 0 ? baseCooldownMs : 1000; + const exponent = Math.min(Math.max(0, failureCount - 1), Math.max(0, maxBackoffLevel)); + return safeBase * Math.pow(2, exponent); +} // Auto-cleanup expired lockouts every 15 seconds (lazy init for Cloudflare Workers compatibility) let _cleanupTimer: ReturnType | null = null; @@ -94,9 +217,8 @@ function ensureCleanupTimer() { try { _cleanupTimer = setInterval(() => { const now = Date.now(); - for (const [key, entry] of modelLockouts) { - if (now > entry.until) modelLockouts.delete(key); - } + for (const key of modelLockouts.keys()) cleanupModelLockKey(key, now); + for (const key of modelFailureState.keys()) cleanupModelLockKey(key, now); }, 15_000); if (typeof _cleanupTimer === "object" && "unref" in _cleanupTimer) { (_cleanupTimer as { unref?: () => void }).unref?.(); // Don't prevent process exit (Node.js only) @@ -114,35 +236,110 @@ function ensureCleanupTimer() { * @param {string} reason - from RateLimitReason * @param {number} cooldownMs */ -export function lockModel(provider, connectionId, model, reason, cooldownMs) { +export function lockModel( + provider, + connectionId, + model, + reason, + cooldownMs, + metadata: Partial = {} +) { if (!model) return; // No model → skip model-level locking ensureCleanupTimer(); - const key = `${provider}:${connectionId}:${model}`; + const key = getModelLockKey(provider, connectionId, model); + cleanupModelLockKey(key); const newUntil = Date.now() + cooldownMs; // Preserve the longer cooldown if an existing lock has more time remaining. // Safe without a mutex: no await between get/set, so this runs atomically // within Node.js's single-threaded event loop. const existing = modelLockouts.get(key); - if (existing && existing.until > newUntil) return; + if (existing && existing.until > newUntil) { + if (metadata.failureCount && metadata.failureCount > existing.failureCount) { + existing.failureCount = metadata.failureCount; + existing.lastFailureAt = metadata.lastFailureAt ?? existing.lastFailureAt; + existing.resetAfterMs = metadata.resetAfterMs ?? existing.resetAfterMs; + modelLockouts.set(key, existing); + } + return; + } + const now = Date.now(); modelLockouts.set(key, { reason, until: newUntil, - lockedAt: Date.now(), + lockedAt: now, + failureCount: metadata.failureCount ?? existing?.failureCount ?? 1, + lastFailureAt: metadata.lastFailureAt ?? now, + resetAfterMs: metadata.resetAfterMs ?? existing?.resetAfterMs ?? 0, }); } +export function recordModelLockoutFailure( + provider: string, + connectionId: string, + model: string, + reason: string, + status: number, + fallbackCooldownMs: number, + profile: ProviderProfile | null = null +) { + ensureCleanupTimer(); + const key = getModelLockKey(provider, connectionId, model); + const now = Date.now(); + cleanupModelLockKey(key, now); + + const resetAfterMs = getFailureWindowMs(profile); + const previous = modelFailureState.get(key); + const withinWindow = previous && now - previous.lastFailureAt <= previous.resetAfterMs; + const failureCount = withinWindow ? previous.failureCount + 1 : 1; + modelFailureState.set(key, { + failureCount, + lastFailureAt: now, + resetAfterMs, + }); + + const baseCooldownMs = getModelLockBaseCooldown(status, fallbackCooldownMs, profile); + const cooldownMs = getScaledCooldown( + baseCooldownMs, + failureCount, + profile?.maxBackoffLevel ?? BACKOFF_CONFIG.maxLevel + ); + + lockModel(provider, connectionId, model, reason, cooldownMs, { + failureCount, + lastFailureAt: now, + resetAfterMs, + }); + + return { + cooldownMs, + failureCount, + resetAfterMs, + }; +} + +export function clearModelLock(provider, connectionId, model) { + if (!model) return false; + const key = getModelLockKey(provider, connectionId, model); + const hadLock = modelLockouts.delete(key); + const hadFailureState = modelFailureState.delete(key); + return hadLock || hadFailureState; +} + /** * Whether a provider should use per-model lockouts instead of connection-wide cooldowns. - * Gemini AI Studio has per-model quotas; passthrough providers have independent model limits. + * Compatible and passthrough providers multiplex multiple upstream models behind one + * connection, so transient 404/429 responses should stay model-scoped instead of + * poisoning the whole connection. */ -export function hasPerModelQuota(provider: string): boolean { +export function hasPerModelQuota( + provider: string | null | undefined, + _model: string | null | undefined = null +): boolean { + if (!provider) return false; if (provider === "gemini") return true; - try { - const { getPassthroughProviders } = require("../config/providerRegistry.ts"); - return getPassthroughProviders().has(provider); - } catch { - return false; - } + if (getPassthroughProviders().has(provider)) return true; + if (isCompatibleProvider(provider)) return true; + return false; } /** @@ -156,25 +353,28 @@ export function lockModelIfPerModelQuota( reason: string, cooldownMs: number ): boolean { - if (!hasPerModelQuota(provider) || !model) return false; + if (!hasPerModelQuota(provider, model) || !model) return false; lockModel(provider, connectionId, model, reason, cooldownMs); return true; } +export function shouldMarkAccountExhaustedFrom429( + provider: string | null | undefined, + model: string | null | undefined = null +): boolean { + return !hasPerModelQuota(provider, model); +} + /** * Check if a specific model on a specific account is locked * @returns {boolean} */ export function isModelLocked(provider, connectionId, model) { if (!model) return false; - const key = `${provider}:${connectionId}:${model}`; + const key = getModelLockKey(provider, connectionId, model); + cleanupModelLockKey(key); const entry = modelLockouts.get(key); - if (!entry) return false; - if (Date.now() > entry.until) { - modelLockouts.delete(key); - return false; - } - return true; + return Boolean(entry); } /** @@ -182,13 +382,15 @@ export function isModelLocked(provider, connectionId, model) { */ export function getModelLockoutInfo(provider, connectionId, model) { if (!model) return null; - const key = `${provider}:${connectionId}:${model}`; + const key = getModelLockKey(provider, connectionId, model); + cleanupModelLockKey(key); const entry = modelLockouts.get(key); - if (!entry || Date.now() > entry.until) return null; + if (!entry) return null; return { reason: entry.reason, remainingMs: entry.until - Date.now(), lockedAt: new Date(entry.lockedAt).toISOString(), + failureCount: entry.failureCount, }; } @@ -198,17 +400,19 @@ export function getModelLockoutInfo(provider, connectionId, model) { export function getAllModelLockouts() { const now = Date.now(); const active = []; + for (const key of modelLockouts.keys()) { + cleanupModelLockKey(key, now); + } for (const [key, entry] of modelLockouts) { - if (now <= entry.until) { - const [provider, connectionId, model] = key.split(":"); - active.push({ - provider, - connectionId, - model, - reason: entry.reason, - remainingMs: entry.until - now, - }); - } + const [provider, connectionId, model] = key.split(":"); + active.push({ + provider, + connectionId, + model, + reason: entry.reason, + remainingMs: entry.until - now, + failureCount: entry.failureCount, + }); } return active; } @@ -421,6 +625,16 @@ export function getQuotaCooldown(backoffLevel = 0) { return Math.min(cooldown, BACKOFF_CONFIG.max); } +function getRateLimitCooldown(backoffLevel = 0, profile: ProviderProfile | null = null) { + const maxLevel = profile?.maxBackoffLevel ?? BACKOFF_CONFIG.maxLevel; + const cappedLevel = Math.min(Math.max(0, backoffLevel), maxLevel); + const configuredBase = profile?.rateLimitCooldown; + if (typeof configuredBase === "number" && configuredBase > 0) { + return configuredBase * Math.pow(2, cappedLevel); + } + return getQuotaCooldown(cappedLevel); +} + /** * Check if error should trigger account fallback (switch to next account) * @param {number} status - HTTP status code @@ -436,9 +650,11 @@ export function checkFallbackError( backoffLevel = 0, model = null, provider = null, - headers = null + headers = null, + profileOverride: ProviderProfile | null = null ) { const errorStr = (errorText || "").toString(); + const profile = profileOverride ?? (provider ? getProviderProfile(provider) : null); function parseResetFromHeaders(headers, errorStr = "") { if (!headers) return null; @@ -544,11 +760,14 @@ export function checkFallbackError( reason: RateLimitReason.RATE_LIMIT_EXCEEDED, }; } - const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel); + const newLevel = Math.min( + backoffLevel + 1, + profile?.maxBackoffLevel ?? BACKOFF_CONFIG.maxLevel + ); const reason = classifyErrorText(errorStr); return { shouldFallback: true, - cooldownMs: getQuotaCooldown(backoffLevel), + cooldownMs: getRateLimitCooldown(backoffLevel, profile), newBackoffLevel: newLevel, reason, }; @@ -594,10 +813,13 @@ export function checkFallbackError( } } - const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel); + const newLevel = Math.min( + backoffLevel + 1, + profile?.maxBackoffLevel ?? BACKOFF_CONFIG.maxLevel + ); return { shouldFallback: true, - cooldownMs: getQuotaCooldown(backoffLevel), + cooldownMs: getRateLimitCooldown(backoffLevel, profile), newBackoffLevel: newLevel, reason: RateLimitReason.RATE_LIMIT_EXCEEDED, }; @@ -626,7 +848,6 @@ export function checkFallbackError( } } - const profile = provider ? getProviderProfile(provider) : null; const baseCooldown = profile?.transientCooldown ?? COOLDOWN_MS.transientInitial; const maxLevel = profile?.maxBackoffLevel ?? BACKOFF_CONFIG.maxLevel; const cooldownMs = Math.min(baseCooldown * Math.pow(2, backoffLevel), COOLDOWN_MS.transientMax); diff --git a/open-sse/services/antigravityHeaders.ts b/open-sse/services/antigravityHeaders.ts index d7adf5e44d..009e9a2d66 100644 --- a/open-sse/services/antigravityHeaders.ts +++ b/open-sse/services/antigravityHeaders.ts @@ -9,10 +9,29 @@ import os from "node:os"; * Based on CLIProxyAPI's misc/header_utils.go. */ +type AntigravityHeaderProfile = "loadCodeAssist" | "fetchAvailableModels" | "models"; + const ANTIGRAVITY_VERSION = "1.21.9"; const GEMINI_CLI_VERSION = "0.31.0"; const GEMINI_SDK_VERSION = "1.41.0"; const NODE_VERSION = "v22.19.0"; +const LOAD_CODE_ASSIST_USER_AGENT = "google-api-nodejs-client/9.15.1"; +const LOAD_CODE_ASSIST_API_CLIENT = "google-cloud-sdk vscode_cloudshelleditor/0.1"; +const LOAD_CODE_ASSIST_METADATA = Object.freeze({ + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", +}); + +function withOptionalBearerAuth( + headers: Record, + accessToken?: string | null +): Record { + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}`; + } + return headers; +} function getPlatform(): string { const p = os.platform(); @@ -52,6 +71,43 @@ export function antigravityUserAgent(): string { return `antigravity/${ANTIGRAVITY_VERSION} darwin/arm64`; } +export function getAntigravityLoadCodeAssistMetadata(): Record { + return { ...LOAD_CODE_ASSIST_METADATA }; +} + +export function getAntigravityLoadCodeAssistClientMetadata(): string { + return JSON.stringify(LOAD_CODE_ASSIST_METADATA); +} + +export function getAntigravityHeaders( + profile: AntigravityHeaderProfile, + accessToken?: string | null +): Record { + switch (profile) { + case "loadCodeAssist": + return withOptionalBearerAuth( + { + "Content-Type": "application/json", + "User-Agent": LOAD_CODE_ASSIST_USER_AGENT, + "X-Goog-Api-Client": LOAD_CODE_ASSIST_API_CLIENT, + "Client-Metadata": getAntigravityLoadCodeAssistClientMetadata(), + }, + accessToken + ); + case "fetchAvailableModels": + case "models": + return withOptionalBearerAuth( + { + "Content-Type": "application/json", + "User-Agent": antigravityUserAgent(), + }, + accessToken + ); + default: + return withOptionalBearerAuth({ "Content-Type": "application/json" }, accessToken); + } +} + /** * Gemini CLI User-Agent: "GeminiCLI/VERSION/MODEL (OS; ARCH)" * Example: "GeminiCLI/0.31.0/gemini-3-flash (darwin; arm64)" @@ -68,4 +124,10 @@ export function googApiClientHeader(): string { return `google-genai-sdk/${GEMINI_SDK_VERSION} gl-node/${NODE_VERSION}`; } -export { ANTIGRAVITY_VERSION, GEMINI_CLI_VERSION, GEMINI_SDK_VERSION }; +export { + ANTIGRAVITY_VERSION, + GEMINI_CLI_VERSION, + GEMINI_SDK_VERSION, + LOAD_CODE_ASSIST_USER_AGENT as ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT, + LOAD_CODE_ASSIST_API_CLIENT as ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT, +}; diff --git a/open-sse/services/bailianQuotaFetcher.ts b/open-sse/services/bailianQuotaFetcher.ts new file mode 100644 index 0000000000..a10c0f93f6 --- /dev/null +++ b/open-sse/services/bailianQuotaFetcher.ts @@ -0,0 +1,307 @@ +/** + * bailianQuotaFetcher.ts — Alibaba Coding Plan (Bailian) Triple-Window Quota Fetcher + * + * Implements QuotaFetcher for the bailian-coding-plan provider (quotaPreflight.ts + quotaMonitor.ts). + * This fetcher is specific to the Alibaba Coding Plan quota API. + * + * Bailian Coding Plan has THREE independent quota windows: + * - 5h: short-term rate limit, resets every 5 hours + * - weekly: weekly limit, resets every week + * - monthly: monthly billing limit + * + * We return percentUsed = max(5h%, weekly%, monthly%) so the system switches accounts when + * ANY window approaches exhaustion (95% threshold). + * + * [Oracle CONDITIONAL] consoleApiKey is bailian-coding-plan specific. Do NOT reuse for other providers. + * + * Cache: in-memory TTL (60s) to avoid hammering the usage API on every request. + * + * Registration: call registerBailianCodingPlanQuotaFetcher() once at server startup. + */ + +import { registerQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts"; +import { registerMonitorFetcher } from "./quotaMonitor.ts"; + +// Bailian quota hosts (international / china fallback) +const BAILIAN_QUOTA_HOSTS = { + international: "https://modelstudio.console.alibabacloud.com", + china: "https://bailian.console.aliyun.com", +} as const; + +const BAILIAN_QUOTA_PATH = + "/data/api.json?action=zeldaEasy.broadscope-bailian.codingPlan.queryCodingPlanInstanceInfoV2&product=broadscope-bailian&api=queryCodingPlanInstanceInfoV2"; + +// Cache TTL — short enough to be reactive, long enough to avoid rate limits +const CACHE_TTL_MS = 60_000; // 60 seconds + +// Triple-window quota info (richer than QuotaInfo — includes all 3 windows) +// [Oracle CONDITIONAL] bailian-coding-plan only — do not reuse for other providers +export interface BailianTripleWindowQuota extends QuotaInfo { + window5h: { percentUsed: number; resetAt: string | null }; + windowWeekly: { percentUsed: number; resetAt: string | null }; + windowMonthly: { percentUsed: number; resetAt: string | null }; +} + +interface CacheEntry { + quota: BailianTripleWindowQuota; + fetchedAt: number; +} + +// In-memory cache: connectionId → { quota, fetchedAt } +const quotaCache = new Map(); + +// Auto-cleanup stale entries every 5 minutes +const _cacheCleanup = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of quotaCache) { + if (now - entry.fetchedAt > CACHE_TTL_MS * 5) { + quotaCache.delete(key); + } + } +}, 5 * 60_000); + +if (typeof _cacheCleanup === "object" && "unref" in _cacheCleanup) { + (_cacheCleanup as { unref?: () => void }).unref?.(); +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function toNumber(value: unknown, fallback = 0): number { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = parseFloat(value); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +} + +function toRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function getAuthKey( + providerSpecificData: Record | undefined, + apiKey: string +): string { + // [Oracle CONDITIONAL] consoleApiKey is bailian-coding-plan specific only + const consoleKey = providerSpecificData?.consoleApiKey; + if (typeof consoleKey === "string" && consoleKey.trim().length > 0) { + return consoleKey; + } + return apiKey; +} + +function getHost(): string { + return process.env.ALIBABA_CODING_PLAN_HOST || BAILIAN_QUOTA_HOSTS.international; +} + +function getQuotaUrl(): string { + return process.env.ALIBABA_CODING_PLAN_QUOTA_URL || `${getHost()}${BAILIAN_QUOTA_PATH}`; +} + +function buildHeaders(authKey: string): Record { + return { + Authorization: `Bearer ${authKey}`, + "x-api-key": authKey, + "X-DashScope-API-Key": authKey, + "Content-Type": "application/json", + Accept: "application/json", + }; +} + +// ─── Response Parser ───────────────────────────────────────────────────────── + +function parseBailianQuotaResponse(data: unknown): BailianTripleWindowQuota | null { + const obj = toRecord(data); + + if (obj["code"] === "ConsoleNeedLogin") { + // Caller will handle fallback — return null here to signal no usable data + return null; + } + + if (obj["code"] !== "Success" && obj["code"] !== "200") { + return null; + } + + const dataObj = toRecord(obj["data"]); + const instanceInfos = dataObj["codingPlanInstanceInfos"]; + + if (!Array.isArray(instanceInfos) || instanceInfos.length === 0) { + return null; + } + + const instance = toRecord(instanceInfos[0]); + const quotaInfo = toRecord(instance["codingPlanQuotaInfo"]); + + if (Object.keys(quotaInfo).length === 0) { + return null; + } + + // Parse 5h window + const used5h = toNumber(quotaInfo["per5HourUsedQuota"]); + const total5h = toNumber(quotaInfo["per5HourTotalQuota"]); + const resetAt5h = toNumber(quotaInfo["per5HourQuotaNextRefreshTime"]); + const pct5h = total5h > 0 ? used5h / total5h : 0; + + // Parse weekly window + const usedWeekly = toNumber(quotaInfo["perWeekUsedQuota"]); + const totalWeekly = toNumber(quotaInfo["perWeekTotalQuota"]); + const resetAtWeekly = toNumber(quotaInfo["perWeekQuotaNextRefreshTime"]); + const pctWeekly = totalWeekly > 0 ? usedWeekly / totalWeekly : 0; + + // Parse monthly window + const usedMonthly = toNumber(quotaInfo["perBillMonthUsedQuota"]); + const totalMonthly = toNumber(quotaInfo["perBillMonthTotalQuota"]); + const resetAtMonthly = toNumber(quotaInfo["perBillMonthQuotaNextRefreshTime"]); + const pctMonthly = totalMonthly > 0 ? usedMonthly / totalMonthly : 0; + + // Most restrictive window = highest percentUsed + const worstPercentUsed = Math.max(pct5h, pctWeekly, pctMonthly); + + const window5h = { + percentUsed: pct5h, + resetAt: resetAt5h > 0 ? new Date(resetAt5h * 1000).toISOString() : null, + }; + const windowWeekly = { + percentUsed: pctWeekly, + resetAt: resetAtWeekly > 0 ? new Date(resetAtWeekly * 1000).toISOString() : null, + }; + const windowMonthly = { + percentUsed: pctMonthly, + resetAt: resetAtMonthly > 0 ? new Date(resetAtMonthly * 1000).toISOString() : null, + }; + + // Dominant reset = reset time of the most restrictive window + const dominantResetAt = + worstPercentUsed === pct5h + ? window5h.resetAt + : worstPercentUsed === pctWeekly + ? windowWeekly.resetAt + : windowMonthly.resetAt; + + return { + used: Math.round(worstPercentUsed * 100), + total: 100, + percentUsed: worstPercentUsed, + resetAt: dominantResetAt, + window5h, + windowWeekly, + windowMonthly, + }; +} + +// ─── Core Fetcher ──────────────────────────────────────────────────────────── + +/** + * Fetch current quota for a Bailian Coding Plan connection. + * Returns percentUsed = max(5h%, weekly%, monthly%) — worst-case across all 3 windows. + * + * @param connectionId - Connection ID from the DB (used to look up credentials) + * @param connection - Optional connection object with apiKey / providerSpecificData + * @returns BailianTripleWindowQuota or null if fetch fails + */ +export async function fetchBailianQuota( + connectionId: string, + connection?: Record +): Promise { + // Check cache first + const cached = quotaCache.get(connectionId); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return cached.quota; + } + + // Extract credentials from connection snapshot + const providerSpecificData = + connection?.providerSpecificData && + typeof connection.providerSpecificData === "object" && + !Array.isArray(connection.providerSpecificData) + ? (connection.providerSpecificData as Record) + : undefined; + + const apiKey = + typeof connection?.apiKey === "string" && connection.apiKey.trim().length > 0 + ? connection.apiKey + : ""; + + const authKey = getAuthKey(providerSpecificData, apiKey); + + if (!authKey) { + return null; + } + + const headers = buildHeaders(authKey); + + try { + const url = getQuotaUrl(); + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({}), + signal: AbortSignal.timeout(8_000), + }); + + const rawData = await response.json(); + const obj = toRecord(rawData); + + // ConsoleNeedLogin → retry with China host exactly once + if (obj["code"] === "ConsoleNeedLogin") { + try { + const chinaUrl = process.env.ALIBABA_CODING_PLAN_QUOTA_URL + ? url + : `${BAILIAN_QUOTA_HOSTS.china}${BAILIAN_QUOTA_PATH}`; + + const retryResponse = await fetch(chinaUrl, { + method: "POST", + headers, + body: JSON.stringify({}), + signal: AbortSignal.timeout(8_000), + }); + + const retryData = await retryResponse.json(); + const quota = parseBailianQuotaResponse(retryData); + + if (quota) { + quotaCache.set(connectionId, { quota, fetchedAt: Date.now() }); + return quota; + } + + return null; + } catch { + // China host also failed — fail open + return null; + } + } + + const quota = parseBailianQuotaResponse(rawData); + + if (!quota) return null; + + quotaCache.set(connectionId, { quota, fetchedAt: Date.now() }); + return quota; + } catch { + // Network error, timeout, etc. — fail open + return null; + } +} + +// ─── Invalidation ──────────────────────────────────────────────────────────── + +/** + * Force-invalidate the cache for a connection (e.g., after receiving quota headers). + */ +export function invalidateBailianQuotaCache(connectionId: string): void { + quotaCache.delete(connectionId); +} + +// ─── Registration ───────────────────────────────────────────────────────────── + +/** + * Register the Bailian quota fetcher with the preflight and monitor systems. + * Call this once at server startup (in chat.ts or app entry point). + */ +export function registerBailianCodingPlanQuotaFetcher(): void { + registerQuotaFetcher("bailian-coding-plan", fetchBailianQuota); + registerMonitorFetcher("bailian-coding-plan", fetchBailianQuota); +} diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 1430bd4065..fa076f9de4 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -9,17 +9,26 @@ import { enforceThinkingTemperature, disableThinkingIfToolChoiceForced, enforceCacheControlLimit, - ensureCacheControlOnLastUserMessage, } from "./claudeCodeConstraints.ts"; import { obfuscateInBody } from "./claudeCodeObfuscation.ts"; +/** + * `anthropic-compatible-cc-*` targets Anthropic relay gateways that only accept + * traffic which looks like the official Claude Code client, often because those + * gateways resell the same models at materially lower prices than the direct API. + * + * This bridge is intentionally compatibility-first, not lossless. We normalize + * requests into the smallest Claude Code-shaped surface that consistently passes + * provider-side client checks, instead of trying to preserve every original + * field one-to-one. + */ export const CLAUDE_CODE_COMPATIBLE_PREFIX = "anthropic-compatible-cc-"; export const CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true"; export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH = "/models"; export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS = 8092; export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION = "2023-06-01"; export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA = - "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24,fast-mode-2025-04-01,redact-thinking-2025-06-20,token-efficient-tools-2025-02-19"; + "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24,token-efficient-tools-2025-02-19"; export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.87"; export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = `claude-cli/${CLAUDE_CODE_COMPATIBLE_VERSION} (external, cli)`; /** @@ -112,6 +121,9 @@ export function buildClaudeCodeCompatibleHeaders( stream = false, sessionId?: string | null ): Record { + // These headers intentionally mirror Claude Code's wire image closely. + // For CC-compatible relays, passing the upstream's client-gating checks is + // more important than forwarding arbitrary caller-specific header shapes. return { "Content-Type": "application/json", Accept: stream ? "text/event-stream" : "application/json", @@ -259,11 +271,10 @@ export function buildClaudeCodeCompatibleRequest({ * 2. Remap tool names to TitleCase * 3. Enforce thinking temperature constraint (temp=1) * 4. Disable thinking when tool_choice forces a specific tool - * 5. Enforce 4-block cache_control limit - * 6. Auto-inject cache_control on last user message - * 7. Obfuscate sensitive words in user messages - * 8. Serialize with CCH placeholder - * 9. Sign body with xxHash64 CCH attestation + * 5. Enforce 4-block cache_control limit when markers are already present + * 6. Obfuscate sensitive words in user messages + * 7. Serialize with CCH placeholder + * 8. Sign body with xxHash64 CCH attestation * * Returns { bodyString, headers } ready to send upstream. */ @@ -282,19 +293,18 @@ export async function buildAndSignClaudeCodeRequest( enforceThinkingTemperature(body); disableThinkingIfToolChoiceForced(body); - // Step 5-6: Cache control + // Step 5: Cache control enforceCacheControlLimit(body); - ensureCacheControlOnLastUserMessage(body); - // Step 7: Obfuscation (optional, per-provider setting) + // Step 6: Obfuscation (optional, per-provider setting) if (enableObfuscation) { obfuscateInBody(body); } - // Step 8: Serialize with CCH placeholder + // Step 7: Serialize with CCH placeholder const serialized = JSON.stringify(body); - // Step 9: Sign with xxHash64 + // Step 8: Sign with xxHash64 const bodyString = await signRequestBody(serialized); // Build headers @@ -498,7 +508,6 @@ function buildClaudeCodeCompatibleSystemBlocks({ { type: "text", text: billingHeader, - cache_control: { type: "ephemeral" }, }, { type: "text", diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 6c1e2e9bc4..d0434d494c 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -4,7 +4,11 @@ * strict-random, auto, fill-first, p2c, lkgp, context-optimized, and context-relay strategies */ -import { checkFallbackError, formatRetryAfter, getProviderProfile } from "./accountFallback.ts"; +import { + checkFallbackError, + formatRetryAfter, + getRuntimeProviderProfile, +} from "./accountFallback.ts"; import { errorResponse, unavailableResponse } from "../utils/error.ts"; import { recordComboIntent, recordComboRequest, getComboMetrics } from "./comboMetrics.ts"; import { resolveComboConfig, getDefaultComboConfig } from "./comboConfig.ts"; @@ -19,7 +23,13 @@ import { classifyWithConfig, DEFAULT_INTENT_CONFIG } from "./intentClassifier.ts import { selectProvider as selectAutoProvider } from "./autoCombo/engine.ts"; import { selectWithStrategy } from "./autoCombo/routerStrategy.ts"; import { getTaskFitness } from "./autoCombo/taskFitness.ts"; -import { calculateFactors, calculateScore, DEFAULT_WEIGHTS } from "./autoCombo/scoring.ts"; +import { + calculateFactors, + calculateScore, + DEFAULT_WEIGHTS, + type ProviderCandidate, + type ScoringWeights, +} from "./autoCombo/scoring.ts"; import { supportsToolCalling } from "./modelCapabilities.ts"; import { getSessionConnection } from "./sessionManager.ts"; import { getModelContextLimit } from "../../src/lib/modelCapabilities"; @@ -44,6 +54,7 @@ const COMBO_BAD_REQUEST_FALLBACK_PATTERNS = [ ]; const MAX_COMBO_DEPTH = 3; +const MAX_FALLBACK_WAIT_MS = 5000; function comboModelNotFoundResponse(message: string) { return errorResponse(404, message); @@ -472,7 +483,7 @@ export function resolveNestedComboModels(combo, allCombos, visited = new Set(), return resolved; } -function selectWeightedTarget(targets: Array<{ weight: number }>) { +function selectWeightedTarget(targets: T[]) { if (targets.length === 0) return null; const totalWeight = targets.reduce((sum, target) => sum + (target.weight || 0), 0); @@ -489,8 +500,8 @@ function selectWeightedTarget(targets: Array<{ weight: number }>) { return targets[targets.length - 1]; } -function orderTargetsForWeightedFallback( - targets: Array<{ executionKey: string; weight: number }>, +function orderTargetsForWeightedFallback( + targets: T[], selectedExecutionKey: string, preserveExistingOrder = false ) { @@ -499,7 +510,7 @@ function orderTargetsForWeightedFallback( if (!preserveExistingOrder) { rest.sort((a, b) => b.weight - a.weight); } - return [selected, ...rest].filter(Boolean); + return [selected, ...rest].filter(Boolean) as T[]; } // shuffleArray and getNextModelFromDeck moved to src/shared/utils/shuffleDeck.ts @@ -862,15 +873,28 @@ function resolveWeightedTargets(combo, allCombos) { }; } -function scoreAutoTargets(targets, candidates, taskType, weights) { +function scoreAutoTargets( + targets: ResolvedComboTarget[], + candidates: ProviderCandidate[], + taskType: string | null, + weights: ScoringWeights +) { const candidateByExecutionKey = new Map( - candidates.map((candidate) => [candidate.executionKey, candidate]) + candidates.map((candidate: ProviderCandidate & { executionKey: string }) => [ + candidate.executionKey, + candidate, + ]) ); return targets .map((target) => { const candidate = candidateByExecutionKey.get(target.executionKey); if (!candidate) return null; - const factors = calculateFactors(candidate, candidates, taskType, getTaskFitness); + const factors = calculateFactors( + candidate as ProviderCandidate, + candidates, + taskType, + getTaskFitness + ); return { target, score: calculateScore(factors, weights), @@ -1043,7 +1067,7 @@ export async function handleComboChat({ if (text) { if (text.includes("")) { const cleaned = text.replace( - /(?:\\n|\n)?[^<]+<\/omniModel>(?:\\n|\n)?/g, + /(?:\\n|\n|\r)*[^<]+<\/omniModel>(?:\\n|\n|\r)*/g, "" ); if (cleaned) controller.enqueue(encoder.encode(cleaned)); @@ -1057,7 +1081,7 @@ export async function handleComboChat({ if (tail) { if (tail.includes("")) { const cleaned = tail.replace( - /(?:\\n|\n)?[^<]+<\/omniModel>(?:\\n|\n)?/g, + /(?:\\n|\n|\r)*[^<]+<\/omniModel>(?:\\n|\n|\r)*/g, "" ); if (cleaned) controller.enqueue(encoder.encode(cleaned)); @@ -1265,6 +1289,11 @@ export async function handleComboChat({ "COMBO", `[LKGP] Prioritizing last known good provider ${lkgpProvider} for combo "${combo.name}"` ); + } else if (lkgpIndex === 0) { + log.debug( + "COMBO", + `[LKGP] Last known good provider ${lkgpProvider} already first for combo "${combo.name}"` + ); } } } catch (err) { @@ -1312,7 +1341,7 @@ export async function handleComboChat({ const target = orderedTargets[i]; const modelStr = target.modelStr; const provider = target.provider; - const profile = getProviderProfile(provider); + const profile = await getRuntimeProviderProfile(provider); const breakerKey = getComboBreakerKey(combo.name, target.executionKey); const breaker = getCircuitBreaker(breakerKey, { failureThreshold: profile.circuitBreakerThreshold, @@ -1489,7 +1518,8 @@ export async function handleComboChat({ 0, null, provider, - result.headers + result.headers, + profile ); const comboBadRequestFallback = shouldFallbackComboBadRequest(result.status, errorText); @@ -1538,9 +1568,13 @@ export async function handleComboChat({ if (i > 0) fallbackCount++; log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status }); - if ([502, 503, 504].includes(result.status) && cooldownMs > 0 && cooldownMs <= 5000) { - log.info("COMBO", `Waiting ${cooldownMs}ms before fallback to next model`); - await new Promise((r) => setTimeout(r, cooldownMs)); + const fallbackWaitMs = + retryDelayMs > 0 && cooldownMs > 0 && cooldownMs <= MAX_FALLBACK_WAIT_MS + ? Math.min(cooldownMs, retryDelayMs) + : 0; + if ([502, 503, 504].includes(result.status) && fallbackWaitMs > 0) { + log.info("COMBO", `Waiting ${fallbackWaitMs}ms before fallback to next model`); + await new Promise((r) => setTimeout(r, fallbackWaitMs)); } break; // Move to next model @@ -1648,7 +1682,7 @@ async function handleRoundRobinCombo({ const target = orderedTargets[modelIndex]; const modelStr = target.modelStr; const provider = target.provider; - const profile = getProviderProfile(provider); + const profile = await getRuntimeProviderProfile(provider); const breakerKey = getComboBreakerKey(combo.name, target.executionKey); const semaphoreKey = `combo:${combo.name}:${target.executionKey}`; const breaker = getCircuitBreaker(breakerKey, { @@ -1803,7 +1837,8 @@ async function handleRoundRobinCombo({ 0, null, provider, - result.headers + result.headers, + profile ); const comboBadRequestFallback = shouldFallbackComboBadRequest(result.status, errorText); @@ -1857,9 +1892,13 @@ async function handleRoundRobinCombo({ if (offset > 0) fallbackCount++; log.warn("COMBO-RR", `${modelStr} failed, trying next model`, { status: result.status }); - if ([502, 503, 504].includes(result.status) && cooldownMs > 0 && cooldownMs <= 5000) { - log.info("COMBO-RR", `Waiting ${cooldownMs}ms before fallback to next model`); - await new Promise((r) => setTimeout(r, cooldownMs)); + const fallbackWaitMs = + retryDelayMs > 0 && cooldownMs > 0 && cooldownMs <= MAX_FALLBACK_WAIT_MS + ? Math.min(cooldownMs, retryDelayMs) + : 0; + if ([502, 503, 504].includes(result.status) && fallbackWaitMs > 0) { + log.info("COMBO-RR", `Waiting ${fallbackWaitMs}ms before fallback to next model`); + await new Promise((r) => setTimeout(r, fallbackWaitMs)); } break; diff --git a/open-sse/services/comboAgentMiddleware.ts b/open-sse/services/comboAgentMiddleware.ts index 6f2a79a631..5c08ea47c5 100644 --- a/open-sse/services/comboAgentMiddleware.ts +++ b/open-sse/services/comboAgentMiddleware.ts @@ -38,7 +38,7 @@ interface Message { // by combo.ts streaming around the tag (#531). Non-global so that // .exec() and .test() stay stateless; callers that need full replacement use // String.prototype.replace() which replaces all non-overlapping matches. -const CACHE_TAG_PATTERN = /(?:\\n|\n)?([^<]+)<\/omniModel>(?:\\n|\n)?/; +const CACHE_TAG_PATTERN = /(?:\\n|\n|\r)*([^<]+)<\/omniModel>(?:\\n|\n|\r)*/; /** * Inject the model tag into the last assistant message (or append a new one). @@ -60,10 +60,7 @@ export function injectModelTag(messages: Message[], providerModel: string): Mess // #474: If no assistant message exists yet (first turn), append a synthetic one // so the tag is present when the client sends the next request with the response. if (lastAssistantIdx === -1) { - return [ - ...cleaned, - { role: "assistant", content: `${providerModel}` }, - ]; + return [...cleaned, { role: "assistant", content: `${providerModel}` }]; } const msg = cleaned[lastAssistantIdx]; @@ -73,10 +70,7 @@ export function injectModelTag(messages: Message[], providerModel: string): Mess if (typeof msg.content !== "string") { // If the message has tool_calls but no string content, append a new assistant // message with the tag rather than silently failing. - return [ - ...cleaned, - { role: "assistant", content: `${providerModel}` }, - ]; + return [...cleaned, { role: "assistant", content: `${providerModel}` }]; } const tagged = [...cleaned]; diff --git a/open-sse/services/contextHandoff.ts b/open-sse/services/contextHandoff.ts index 18b5969d86..ede5f1bb0b 100644 --- a/open-sse/services/contextHandoff.ts +++ b/open-sse/services/contextHandoff.ts @@ -18,7 +18,7 @@ const MAX_TASK_PROGRESS_LENGTH = 1200; const MAX_DECISIONS = 8; const MAX_ENTITIES = 10; const DEFAULT_TTL_MS = 5 * 60 * 60 * 1000; -const OMNI_MODEL_TAG_PATTERN = /(?:\\n|\n)?[^<]+<\/omniModel>(?:\\n|\n)?/g; +const OMNI_MODEL_TAG_PATTERN = /(?:\\n|\n|\r)*[^<]+<\/omniModel>(?:\\n|\n|\r)*/g; const inflightHandoffGenerations = new Set(); const HANDOFF_PROMPT_TEMPLATE = `You are a context summarizer. Analyze the conversation below and generate a structured handoff summary. diff --git a/open-sse/services/contextManager.ts b/open-sse/services/contextManager.ts index 190caa6c2a..7aa710b60b 100644 --- a/open-sse/services/contextManager.ts +++ b/open-sse/services/contextManager.ts @@ -40,7 +40,7 @@ const CHARS_PER_TOKEN = 4; /** * Estimate token count from text length */ -export function estimateTokens(text) { +export function estimateTokens(text: string | object | null | undefined): number { if (!text) return 0; const str = typeof text === "string" ? text : JSON.stringify(text); return Math.ceil(str.length / CHARS_PER_TOKEN); @@ -50,7 +50,7 @@ export function estimateTokens(text) { * Get token limit for a provider/model combination * Priority: Env override > models.dev DB > Registry defaultContextLength > DEFAULT_LIMITS */ -export function getTokenLimit(provider, model = null) { +export function getTokenLimit(provider: string, model: string | null = null): number { // 1. Check environment variable override first const envOverride = getEnvOverride(provider); if (envOverride) return envOverride; @@ -99,7 +99,7 @@ export function getTokenLimit(provider, model = null) { * @returns {{ body: object, compressed: boolean, stats: object }} */ export function compressContext( - body, + body: Record, options: { provider?: string; model?: string; maxTokens?: number; reserveTokens?: number } = {} ) { if (!body || !body.messages || !Array.isArray(body.messages)) { @@ -107,7 +107,8 @@ export function compressContext( } const provider = options.provider || "default"; - const maxTokens = options.maxTokens || getTokenLimit(provider, body.model || options.model); + const maxTokens = + options.maxTokens || getTokenLimit(provider, (body.model as string) || options.model || null); const reserveTokens = options.reserveTokens || 16000; // Reserve for response const targetTokens = maxTokens - reserveTokens; @@ -160,7 +161,7 @@ export function compressContext( // ─── Layer 1: Trim Tool Messages ──────────────────────────────────────────── -function trimToolMessages(messages, maxChars) { +function trimToolMessages(messages: Record[], maxChars: number) { return messages.map((msg) => { if (msg.role === "tool" && typeof msg.content === "string" && msg.content.length > maxChars) { return { @@ -190,7 +191,7 @@ function trimToolMessages(messages, maxChars) { // ─── Layer 2: Compress Thinking Blocks ────────────────────────────────────── -function compressThinking(messages) { +function compressThinking(messages: Record[]) { // Find last assistant message index let lastAssistantIdx = -1; for (let i = messages.length - 1; i >= 0; i--) { @@ -228,7 +229,7 @@ function compressThinking(messages) { // ─── Layer 3: Aggressive Purification ─────────────────────────────────────── -function purifyHistory(messages, targetTokens) { +function purifyHistory(messages: Record[], targetTokens: number) { // Keep system message(s) and the last N message pairs const system = messages.filter((m) => m.role === "system" || m.role === "developer"); const nonSystem = messages.filter((m) => m.role !== "system" && m.role !== "developer"); @@ -236,13 +237,15 @@ function purifyHistory(messages, targetTokens) { // Binary search for how many messages to keep from the end let keep = nonSystem.length; while (keep > 2) { - const candidate = [...system, ...nonSystem.slice(-keep)]; + let candidate = [...system, ...nonSystem.slice(-keep)]; + candidate = fixToolPairs(candidate); const tokens = estimateTokens(JSON.stringify(candidate)); if (tokens <= targetTokens) break; keep = Math.max(2, Math.floor(keep * 0.7)); // Drop 30% each iteration } - const result = [...system, ...nonSystem.slice(-keep)]; + let result = [...system, ...nonSystem.slice(-keep)]; + result = fixToolPairs(result); // Add summary of dropped messages if (keep < nonSystem.length) { @@ -255,3 +258,59 @@ function purifyHistory(messages, targetTokens) { return result; } + +/** + * Remove orphaned tool_result messages whose preceding tool_use was dropped. + * Also removes orphaned tool_use messages without a corresponding tool_result. + * + * When purifyHistory() drops oldest messages, it can split tool_use/tool_result + * pairs — keeping the tool_result but dropping the tool_use that initiated it. + * This causes upstream providers to reject the request with errors like: + * - Claude: "tool_result message must be preceded by a tool_use message" + * - OpenAI: "Invalid message format" + * - Gemini: "Function response without function call" + */ +function fixToolPairs(messages: Record[]) { + // Collect all tool_call IDs from assistant messages that remain + const toolCallIds = new Set(); + for (const msg of messages) { + if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) { + for (const tc of msg.tool_calls) { + if (tc.id) toolCallIds.add(tc.id); + } + } + // Claude format: content blocks with type=tool_use + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_use" && block.id) { + toolCallIds.add(block.id); + } + } + } + } + + // Remove tool_result / "tool" role messages without a matching tool_use + return messages.filter((msg) => { + // OpenAI format: role="tool" with tool_call_id + if (msg.role === "tool" && msg.tool_call_id) { + return toolCallIds.has(msg.tool_call_id); + } + // Claude format: user message with tool_result content blocks + if (msg.role === "user" && Array.isArray(msg.content)) { + const hasOrphanedResult = msg.content.some( + (block) => + block.type === "tool_result" && block.tool_use_id && !toolCallIds.has(block.tool_use_id) + ); + if (hasOrphanedResult) { + // Filter out only the orphaned blocks, keep the rest + const filtered = msg.content.filter( + (block) => + block.type !== "tool_result" || !block.tool_use_id || toolCallIds.has(block.tool_use_id) + ); + // If nothing left after filtering, drop the entire message + return filtered.length > 0; + } + } + return true; + }); +} diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index 12941c0ee9..39d513ffbe 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -96,10 +96,7 @@ export function classifyProviderError(statusCode: number, responseBody: unknown) const accountDeactivated = isAccountDeactivated(bodyStr); const oauthInvalid = isOAuthInvalidToken(bodyStr); - if ( - creditsExhausted && - (statusCode === 400 || statusCode === 402 || statusCode === 429 || statusCode === 403) - ) { + if (creditsExhausted && [400, 402, 403, 429].includes(statusCode)) { return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED; } diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index 7c12bb3b73..138281a3de 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -39,6 +39,22 @@ const PROVIDER_MODEL_ALIASES = { antigravity: {}, }; +const CROSS_PROXY_MODEL_ALIASES = { + "gpt-oss:120b": "gpt-oss-120b", + "deepseek-v3.2-chat": "deepseek-v3.2", + "deepseek-v3-2": "deepseek-v3.2", + "qwen3-coder:480b": "Qwen/Qwen3-Coder-480B-A35B-Instruct", + "claude-opus-4.5": "claude-opus-4-5-20251101", + "anthropic/claude-opus-4.5": "claude-opus-4-5-20251101", +}; + +const CROSS_PROXY_MODEL_ALIASES_LOWER = Object.fromEntries( + Object.entries(CROSS_PROXY_MODEL_ALIASES).map(([alias, canonical]) => [ + alias.toLowerCase(), + canonical, + ]) +); + // Reverse index: modelId -> providerIds that expose this model const MODEL_TO_PROVIDERS = new Map(); for (const [aliasOrId, models] of Object.entries(PROVIDER_MODELS)) { @@ -53,6 +69,7 @@ for (const [aliasOrId, models] of Object.entries(PROVIDER_MODELS)) { } } } +const KNOWN_MODEL_IDS = new Set(MODEL_TO_PROVIDERS.keys()); /** * Resolve provider alias to provider ID @@ -61,6 +78,27 @@ export function resolveProviderAlias(aliasOrId) { return ALIAS_TO_PROVIDER_ID[aliasOrId] || aliasOrId; } +function isCrossProxyModelCompatEnabled() { + const raw = process.env.MODEL_ALIAS_COMPAT_ENABLED; + return raw !== "false" && raw !== "0"; +} + +export function normalizeCrossProxyModelId(modelId) { + if (!modelId || typeof modelId !== "string" || !isCrossProxyModelCompatEnabled()) { + return { modelId, applied: false, original: null }; + } + + const normalized = + CROSS_PROXY_MODEL_ALIASES[modelId] || CROSS_PROXY_MODEL_ALIASES_LOWER[modelId.toLowerCase()]; + + if (!normalized || normalized === modelId) { + return { modelId, applied: false, original: null }; + } + + console.debug(`[MODEL] Cross-proxy alias applied: "${modelId}" → "${normalized}"`); + return { modelId: normalized, applied: true, original: modelId }; +} + /** * Resolve provider-specific legacy model alias to canonical model ID. */ @@ -71,6 +109,29 @@ function resolveProviderModelAlias(providerOrAlias, modelId) { return aliases?.[modelId] || modelId; } +function hasKnownProviderModel(providerOrAlias, modelId) { + if (!providerOrAlias || !modelId) return false; + + const providerId = resolveProviderAlias(providerOrAlias); + const providerAlias = PROVIDER_ID_TO_ALIAS[providerId] || providerId; + const models = PROVIDER_MODELS[providerAlias] || PROVIDER_MODELS[providerId] || []; + + if (models.some((entry) => entry?.id === modelId)) return true; + + const canonicalModel = resolveProviderModelAlias(providerId, modelId); + return canonicalModel !== modelId && models.some((entry) => entry?.id === canonicalModel); +} + +function shouldTreatAsExactModelId(modelStr) { + if (!modelStr || typeof modelStr !== "string" || !modelStr.includes("/")) return false; + if (!KNOWN_MODEL_IDS.has(modelStr)) return false; + + const firstSlash = modelStr.indexOf("/"); + const providerOrAlias = modelStr.slice(0, firstSlash).trim(); + const providerScopedModel = modelStr.slice(firstSlash + 1).trim(); + return !hasKnownProviderModel(providerOrAlias, providerScopedModel); +} + /** * Resolve a provider/model pair into canonical provider ID + provider-scoped model ID. * Keeps provider-specific legacy aliases out of downstream capability and budget lookups. @@ -126,6 +187,17 @@ export function parseModel(modelStr) { } cleanStr = cleanStr.trim(); + // Normalize known cross-proxy provider/model dialects before deciding whether + // the slash belongs to a provider prefix or to the model ID itself. + if (cleanStr.includes("/")) { + cleanStr = normalizeCrossProxyModelId(cleanStr).modelId; + } + + if (shouldTreatAsExactModelId(cleanStr)) { + console.debug(`[MODEL] Treating "${cleanStr}" as an exact model id`); + return { provider: null, model: cleanStr, isAlias: true, providerAlias: null, extendedContext }; + } + // Check if standard format: provider/model or alias/model if (cleanStr.includes("/")) { const firstSlash = cleanStr.indexOf("/"); @@ -144,88 +216,61 @@ export function parseModel(modelStr) { * Format: { "alias": "provider/model" } */ export function resolveModelAliasFromMap(alias, aliases) { + const resolved = resolveModelAliasTarget(alias, aliases); + if (!resolved?.provider) return null; + return { + provider: resolved.provider, + model: resolved.model, + }; +} + +function resolveModelAliasTarget(alias, aliases) { if (!aliases) return null; - // Check if alias exists const resolved = aliases[alias]; if (!resolved) return null; - // Resolved value is "provider/model" format - if (typeof resolved === "string" && resolved.includes("/")) { - const firstSlash = resolved.indexOf("/"); - const providerOrAlias = resolved.slice(0, firstSlash); - return { - provider: resolveProviderAlias(providerOrAlias), - model: resolved.slice(firstSlash + 1), - }; + if (typeof resolved === "string") { + return parseAliasTarget(resolved); } - // Or object { provider, model } if (typeof resolved === "object" && resolved.provider && resolved.model) { + const normalizedPair = normalizeCrossProxyModelId( + `${resolved.provider}/${resolved.model}` + ).modelId; + if (normalizedPair !== `${resolved.provider}/${resolved.model}`) { + return parseAliasTarget(normalizedPair); + } + return { provider: resolveProviderAlias(resolved.provider), - model: resolved.model, + model: normalizeCrossProxyModelId(resolved.model).modelId, }; } return null; } -/** - * Get full model info (parse or resolve) - * @param {string} modelStr - Model string - * @param {object|function} aliasesOrGetter - Aliases object or async function to get aliases - */ -export async function getModelInfoCore(modelStr, aliasesOrGetter) { - const parsed = parseModel(modelStr); - const { extendedContext } = parsed; +function parseAliasTarget(target) { + const normalizedTarget = normalizeCrossProxyModelId(target).modelId; + if (!normalizedTarget || typeof normalizedTarget !== "string") return null; - if (!parsed.isAlias) { - const canonicalModel = resolveProviderModelAlias(parsed.provider, parsed.model); - return { - provider: parsed.provider, - model: canonicalModel, - extendedContext, - }; - } - - // Get aliases (from object or function) - const aliases = typeof aliasesOrGetter === "function" ? await aliasesOrGetter() : aliasesOrGetter; - - // Resolve exact alias - const resolved = resolveModelAliasFromMap(parsed.model, aliases); - if (resolved) { - const canonicalModel = resolveProviderModelAlias(resolved.provider, resolved.model); - return { - provider: resolved.provider, - model: canonicalModel, - extendedContext, - }; - } - - // T13: Try wildcard alias (glob patterns like "claude-sonnet-*" → "anthropic/claude-sonnet-4-...") - if (aliases && typeof aliases === "object") { - const aliasEntries = Object.entries(aliases).map(([pattern, target]) => ({ pattern, target })); - const wildcardMatch = resolveWildcardAlias(parsed.model, aliasEntries); - if (wildcardMatch) { - const target = wildcardMatch.target as string; - if (target.includes("/")) { - const firstSlash = target.indexOf("/"); - const providerOrAlias = target.slice(0, firstSlash); - const targetModel = target.slice(firstSlash + 1); - const provider = resolveProviderAlias(providerOrAlias); - const canonicalModel = resolveProviderModelAlias(provider, targetModel); - return { - provider, - model: canonicalModel, - extendedContext, - wildcardPattern: wildcardMatch.pattern, - }; - } + if (normalizedTarget.includes("/")) { + if (shouldTreatAsExactModelId(normalizedTarget)) { + return { model: normalizedTarget }; } + + const firstSlash = normalizedTarget.indexOf("/"); + return { + provider: resolveProviderAlias(normalizedTarget.slice(0, firstSlash)), + model: normalizedTarget.slice(firstSlash + 1), + }; } - const modelId = parsed.model; + return { model: normalizedTarget }; +} + +function resolveModelByProviderInference(modelId, extendedContext) { const providers = MODEL_TO_PROVIDERS.get(modelId) || []; // Preserve historical behavior: OpenAI stays default when model exists there @@ -278,3 +323,65 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) { extendedContext, }; } + +/** + * Get full model info (parse or resolve) + * @param {string} modelStr - Model string + * @param {object|function} aliasesOrGetter - Aliases object or async function to get aliases + */ +export async function getModelInfoCore(modelStr, aliasesOrGetter) { + const parsed = parseModel(modelStr); + const { extendedContext } = parsed; + + if (!parsed.isAlias) { + const normalizedModel = normalizeCrossProxyModelId(parsed.model).modelId; + const canonicalModel = resolveProviderModelAlias(parsed.provider, normalizedModel); + return { + provider: parsed.provider, + model: canonicalModel, + extendedContext, + }; + } + + // Get aliases (from object or function) + const aliases = typeof aliasesOrGetter === "function" ? await aliasesOrGetter() : aliasesOrGetter; + + // Resolve exact alias + const resolved = resolveModelAliasTarget(parsed.model, aliases); + if (resolved?.provider) { + const canonicalModel = resolveProviderModelAlias(resolved.provider, resolved.model); + return { + provider: resolved.provider, + model: canonicalModel, + extendedContext, + }; + } + if (resolved?.model) { + return resolveModelByProviderInference(resolved.model, extendedContext); + } + + // T13: Try wildcard alias (glob patterns like "claude-sonnet-*" → "anthropic/claude-sonnet-4-...") + if (aliases && typeof aliases === "object") { + const aliasEntries = Object.entries(aliases).map(([pattern, target]) => ({ pattern, target })); + const wildcardMatch = resolveWildcardAlias(parsed.model, aliasEntries); + if (wildcardMatch) { + const target = wildcardMatch.target as string; + if (target.includes("/")) { + const firstSlash = target.indexOf("/"); + const providerOrAlias = target.slice(0, firstSlash); + const targetModel = target.slice(firstSlash + 1); + const provider = resolveProviderAlias(providerOrAlias); + const canonicalModel = resolveProviderModelAlias(provider, targetModel); + return { + provider, + model: canonicalModel, + extendedContext, + wildcardPattern: wildcardMatch.pattern, + }; + } + } + } + + const normalizedModelId = normalizeCrossProxyModelId(parsed.model).modelId; + return resolveModelByProviderInference(normalizedModelId, extendedContext); +} diff --git a/open-sse/services/provider.ts b/open-sse/services/provider.ts index f0da19cb38..79bf1e6ea4 100644 --- a/open-sse/services/provider.ts +++ b/open-sse/services/provider.ts @@ -37,15 +37,38 @@ export function getOpenAICompatibleType(provider, providerSpecificData = null) { typeof providerSpecificData.apiType === "string" ? providerSpecificData.apiType : null; - if (configuredType === "responses" || configuredType === "chat") { + if ( + configuredType === "responses" || + configuredType === "chat" || + configuredType === "embeddings" || + configuredType === "audio-transcriptions" || + configuredType === "audio-speech" || + configuredType === "images-generations" + ) { return configuredType; } - return provider.includes("responses") ? "responses" : "chat"; + if (provider.includes("responses")) return "responses"; + if (provider.includes("embeddings")) return "embeddings"; + if (provider.includes("audio-transcriptions")) return "audio-transcriptions"; + if (provider.includes("audio-speech")) return "audio-speech"; + if (provider.includes("images-generations")) return "images-generations"; + return "chat"; } function buildOpenAICompatibleUrl(baseUrl, apiType) { const normalized = baseUrl.replace(/\/$/, ""); - const path = apiType === "responses" ? "/responses" : "/chat/completions"; + let path = "/chat/completions"; + if (apiType === "responses") { + path = "/responses"; + } else if (apiType === "embeddings") { + path = "/embeddings"; + } else if (apiType === "audio-transcriptions") { + path = "/audio/transcriptions"; + } else if (apiType === "audio-speech") { + path = "/audio/speech"; + } else if (apiType === "images-generations") { + path = "/images/generations"; + } return `${normalized}${path}`; } diff --git a/open-sse/services/providerRequestDefaults.ts b/open-sse/services/providerRequestDefaults.ts new file mode 100644 index 0000000000..0659582d9b --- /dev/null +++ b/open-sse/services/providerRequestDefaults.ts @@ -0,0 +1,87 @@ +type JsonRecord = Record; + +export interface ProviderRequestDefaults { + maxTokens?: number; + temperature?: number; + thinkingBudgetTokens?: number; +} + +function asRecord(value: unknown): JsonRecord | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as JsonRecord; +} + +function toPositiveInteger(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + const normalized = Math.floor(value); + return normalized > 0 ? normalized : null; +} + +function toFiniteNumber(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + return value; +} + +function getExistingMaxTokens(body: JsonRecord): number | null { + return ( + toPositiveInteger(body.max_tokens) || + toPositiveInteger(body.max_completion_tokens) || + toPositiveInteger(body.max_output_tokens) + ); +} + +export function applyProviderRequestDefaults( + body: unknown, + defaults?: ProviderRequestDefaults | null +): unknown { + const record = asRecord(body); + if (!record || !defaults) return body; + + let changed = false; + const next: JsonRecord = { ...record }; + + const defaultTemperature = toFiniteNumber(defaults.temperature); + if (next.temperature === undefined && defaultTemperature !== null) { + next.temperature = defaultTemperature; + changed = true; + } + + const defaultMaxTokens = toPositiveInteger(defaults.maxTokens); + const explicitMaxTokens = getExistingMaxTokens(next); + let effectiveMaxTokens = explicitMaxTokens; + + if (next.max_tokens === undefined && explicitMaxTokens === null && defaultMaxTokens !== null) { + next.max_tokens = defaultMaxTokens; + effectiveMaxTokens = defaultMaxTokens; + changed = true; + } + + const defaultThinkingBudget = toPositiveInteger(defaults.thinkingBudgetTokens); + const thinking = asRecord(next.thinking); + const thinkingAlreadyEnabled = thinking?.type === "enabled"; + const thinkingBudgetSet = toPositiveInteger(thinking?.budget_tokens) !== null; + + if (defaultThinkingBudget !== null && effectiveMaxTokens !== null && effectiveMaxTokens > 1) { + const safeBudget = Math.min(defaultThinkingBudget, effectiveMaxTokens - 1); + + if (safeBudget > 0) { + if (next.thinking === undefined) { + next.thinking = { + type: "enabled", + budget_tokens: safeBudget, + }; + changed = true; + } else if (thinkingAlreadyEnabled && !thinkingBudgetSet) { + next.thinking = { + ...thinking, + budget_tokens: safeBudget, + }; + changed = true; + } + } + } + + return changed ? next : body; +} diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index 48e8e46b6a..35533938de 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -371,12 +371,10 @@ export function updateFromHeaders(provider, connectionId, headers, status, model // instead of hanging in the queue until reservoir refreshes (which can // be hours for providers like Codex with long rate limit windows). // This lets upstream callers (e.g. LiteLLM) trigger fallback to other providers. - // After stop, delete from Map so getLimiter() creates a fresh instance. - trackAsyncOperation( - limiter.stop({ dropWaitingJobs: true }).finally(() => { - limiters.delete(limiterKey); - }) - ); + // Delete from the Map first so follow-up learning from the same error body + // can materialize a fresh limiter immediately. + limiters.delete(limiterKey); + trackAsyncOperation(limiter.stop({ dropWaitingJobs: true })); return; } @@ -553,6 +551,23 @@ export async function __resetRateLimitManagerForTests() { } } +export async function __getLimiterStateForTests(provider, connectionId, model = null) { + const key = getLimiterKey(provider, connectionId, model); + const limiter = limiters.get(key); + if (!limiter) return null; + + const counts = limiter.counts(); + const reservoir = await limiter.currentReservoir(); + return { + key, + reservoir, + queued: counts.QUEUED || 0, + running: counts.RUNNING || 0, + executing: counts.EXECUTING || 0, + done: counts.DONE || 0, + }; +} + /** * Load persisted learned limits on startup. */ diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 8e7e4afdb9..bfe4fb290b 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -3,7 +3,15 @@ */ import { PROVIDERS } from "../config/constants.ts"; +import { getAntigravityFetchAvailableModelsUrls } from "../config/antigravityUpstream.ts"; +import { getGlmQuotaUrl } from "../config/glmProvider.ts"; import { safePercentage } from "@/shared/utils/formatting"; +import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuotaFetcher.ts"; +import { + antigravityUserAgent, + getAntigravityHeaders, + getAntigravityLoadCodeAssistMetadata, +} from "./antigravityHeaders.ts"; // GitHub API config const GITHUB_CONFIG = { @@ -13,7 +21,7 @@ const GITHUB_CONFIG = { // Antigravity API config (credentials from PROVIDERS via credential loader) const ANTIGRAVITY_CONFIG = { - quotaApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", + quotaApiUrls: getAntigravityFetchAvailableModelsUrls(), loadProjectApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", tokenUrl: "https://oauth2.googleapis.com/token", get clientId() { @@ -22,7 +30,9 @@ const ANTIGRAVITY_CONFIG = { get clientSecret() { return PROVIDERS.antigravity.clientSecret; }, - userAgent: "antigravity/1.11.3 Darwin/arm64", + get userAgent() { + return antigravityUserAgent(); + }, }; // Codex (OpenAI) API config @@ -108,18 +118,8 @@ function shouldDisplayGitHubQuota(quota: UsageQuota | null): quota is UsageQuota return quota.total > 0 || quota.remainingPercentage !== undefined; } -// GLM (Z.AI) quota API config -const GLM_QUOTA_URLS: Record = { - international: "https://api.z.ai/api/monitor/usage/quota/limit", - china: "https://open.bigmodel.cn/api/monitor/usage/quota/limit", -}; - async function getGlmUsage(apiKey: string, providerSpecificData?: Record) { - const region = - typeof providerSpecificData?.apiRegion === "string" - ? providerSpecificData.apiRegion - : "international"; - const quotaUrl = GLM_QUOTA_URLS[region] || GLM_QUOTA_URLS.international; + const quotaUrl = getGlmQuotaUrl(providerSpecificData); const res = await fetch(quotaUrl, { headers: { @@ -164,13 +164,51 @@ async function getGlmUsage(apiKey: string, providerSpecificData?: Record +) { + try { + const connection = { apiKey, providerSpecificData }; + const quota = await fetchBailianQuota(connectionId, connection); + + if (!quota) { + return { message: "Bailian Coding Plan connected. Unable to fetch quota." }; + } + + const bailianQuota = quota as BailianTripleWindowQuota; + const used = bailianQuota.used; + const total = bailianQuota.total; + const remaining = Math.max(0, total - used); + const remainingPercentage = Math.round(remaining); + + return { + plan: "Alibaba Coding Plan", + used, + total, + remaining, + remainingPercentage, + resetAt: bailianQuota.resetAt, + unlimited: false, + displayName: "Alibaba Coding Plan", + }; + } catch (error) { + return { message: `Bailian Coding Plan error: ${(error as Error).message}` }; + } +} + /** * Get usage data for a provider connection * @param {Object} connection - Provider connection with accessToken * @returns {Promise} Usage data with quotas */ export async function getUsageForProvider(connection) { - const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection; + const { id, provider, accessToken, apiKey, providerSpecificData, projectId } = connection; switch (provider) { case "github": @@ -192,9 +230,12 @@ export async function getUsageForProvider(connection) { case "qoder": return await getIflowUsage(accessToken); case "glm": + case "glmt": return await getGlmUsage(apiKey, providerSpecificData); case "cursor": return await getCursorUsage(accessToken); + case "bailian-coding-plan": + return await getBailianCodingPlanUsage(id, apiKey, providerSpecificData); default: return { message: `Usage API not implemented for ${provider}` }; } @@ -852,16 +893,29 @@ async function getAntigravityUsage(accessToken, providerSpecificData) { const projectId = subscriptionInfo?.cloudaicompanionProject || null; // Fetch model list with quota info from fetchAvailableModels - const response = await fetch(ANTIGRAVITY_CONFIG.quotaApiUrl, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "User-Agent": ANTIGRAVITY_CONFIG.userAgent, - "Content-Type": "application/json", - }, - body: JSON.stringify(projectId ? { project: projectId } : {}), - signal: AbortSignal.timeout(10000), - }); + let response: Response | null = null; + let lastError: Error | null = null; + + for (const quotaApiUrl of ANTIGRAVITY_CONFIG.quotaApiUrls) { + try { + response = await fetch(quotaApiUrl, { + method: "POST", + headers: getAntigravityHeaders("fetchAvailableModels", accessToken), + body: JSON.stringify(projectId ? { project: projectId } : {}), + signal: AbortSignal.timeout(10000), + }); + + if (response.ok || response.status === 401 || response.status === 403) { + break; + } + } catch (error) { + lastError = error as Error; + } + } + + if (!response) { + throw lastError || new Error("Antigravity API unavailable"); + } if (response.status === 403) { return { message: "Antigravity access forbidden. Check subscription." }; @@ -966,24 +1020,8 @@ async function getAntigravitySubscriptionInfo(accessToken) { try { const response = await fetch(ANTIGRAVITY_CONFIG.loadProjectApiUrl, { method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": "google-api-nodejs-client/9.15.1", - "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", - "Client-Metadata": JSON.stringify({ - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }), - }, - body: JSON.stringify({ - metadata: { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }, - }), + headers: getAntigravityHeaders("loadCodeAssist", accessToken), + body: JSON.stringify({ metadata: getAntigravityLoadCodeAssistMetadata() }), }); if (!response.ok) return null; diff --git a/open-sse/transformer/responsesTransformer.ts b/open-sse/transformer/responsesTransformer.ts index 6a6f8540ca..e184a021e7 100644 --- a/open-sse/transformer/responsesTransformer.ts +++ b/open-sse/transformer/responsesTransformer.ts @@ -399,6 +399,13 @@ export function createResponsesApiTransformStream(logger = null) { // Regular text content if (content) { + // Fix for #1211: Strip leading double-newlines / blank spaces from the very first text chunk + if (!state.msgTextBuf[idx]) { + content = content.trimStart(); + } + + if (!content) continue; + if (!state.msgItemAdded[idx]) { state.msgItemAdded[idx] = true; const msgId = `msg_${state.responseId}_${idx}`; diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index a0c671917b..23908e9a80 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -44,6 +44,12 @@ export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [ // Non-standard schema fields (not recognized by Gemini API) "deprecated", "optional", + // VS Code / JSON Language Service extensions injected by GitHub Copilot tools (#1175) + "enumDescriptions", + "markdownDescription", + "markdownEnumDescriptions", + "enumItemLabels", + "tags", // UI/Styling properties (from Cursor tools - NOT JSON Schema standard) "cornerRadius", "fillColor", diff --git a/open-sse/translator/helpers/geminiToolsSanitizer.ts b/open-sse/translator/helpers/geminiToolsSanitizer.ts index b8628c6e38..e35a9df7b1 100644 --- a/open-sse/translator/helpers/geminiToolsSanitizer.ts +++ b/open-sse/translator/helpers/geminiToolsSanitizer.ts @@ -1,3 +1,5 @@ +import { createHash } from "crypto"; + import { cleanJSONSchemaForAntigravity } from "./geminiHelper.ts"; type GeminiFunctionDeclaration = { @@ -11,10 +13,103 @@ type GeminiTool = { googleSearch?: Record; }; +type GeminiToolSanitizationOptions = { + stripNamespace?: boolean; + toolNameMap?: Map | null; +}; + +const MAX_GEMINI_TOOL_NAME_LENGTH = 64; +const GEMINI_TOOL_HASH_LENGTH = 8; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function normalizeGeminiToolName( + name: string, + options: GeminiToolSanitizationOptions = {} +): string { + const trimmed = name.trim(); + if (!options.stripNamespace) { + return trimmed; + } + + const namespaceIndex = trimmed.indexOf(":"); + return namespaceIndex >= 0 ? trimmed.slice(namespaceIndex + 1) : trimmed; +} + +function buildHashedGeminiToolName( + baseName: string, + originalName: string, + hashLength: number +): string { + const effectiveBase = baseName || "tool"; + const hash = createHash("sha256").update(originalName).digest("hex").slice(0, hashLength); + const prefixLength = Math.max(1, MAX_GEMINI_TOOL_NAME_LENGTH - 1 - hash.length); + return `${effectiveBase.slice(0, prefixLength)}_${hash}`; +} + +function findSanitizedNameForOriginal( + toolNameMap: Map | null | undefined, + originalName: string +): string | null { + if (!(toolNameMap instanceof Map)) return null; + for (const [sanitizedName, rawName] of toolNameMap.entries()) { + if (rawName === originalName) { + return sanitizedName; + } + } + return null; +} + +function isSanitizedNameTaken( + toolNameMap: Map | null | undefined, + sanitizedName: string, + originalName: string +): boolean { + if (!(toolNameMap instanceof Map)) return false; + const mappedOriginalName = toolNameMap.get(sanitizedName); + return typeof mappedOriginalName === "string" && mappedOriginalName !== originalName; +} + +export function sanitizeGeminiToolName( + name: string, + options: GeminiToolSanitizationOptions = {} +): string { + const normalizedName = normalizeGeminiToolName(name, options) || "tool"; + const toolNameMap = options.toolNameMap instanceof Map ? options.toolNameMap : null; + const existingSanitizedName = findSanitizedNameForOriginal(toolNameMap, name); + if (existingSanitizedName) { + return existingSanitizedName; + } + + let sanitizedName = + normalizedName.length <= MAX_GEMINI_TOOL_NAME_LENGTH + ? normalizedName + : buildHashedGeminiToolName(normalizedName, name, GEMINI_TOOL_HASH_LENGTH); + + if (isSanitizedNameTaken(toolNameMap, sanitizedName, name)) { + const conflictingOriginalName = toolNameMap?.get(sanitizedName); + sanitizedName = buildHashedGeminiToolName(normalizedName, name, GEMINI_TOOL_HASH_LENGTH); + let hashLength = GEMINI_TOOL_HASH_LENGTH + 2; + while (isSanitizedNameTaken(toolNameMap, sanitizedName, name) && hashLength <= 32) { + sanitizedName = buildHashedGeminiToolName(normalizedName, name, hashLength); + hashLength += 2; + } + + if (isSanitizedNameTaken(toolNameMap, sanitizedName, name)) { + sanitizedName = buildHashedGeminiToolName("tool", `${name}:${Date.now()}`, 12); + } + + console.warn( + `[GeminiTools] Tool name collision after sanitization: "${name}" conflicts with "${conflictingOriginalName}". Using "${sanitizedName}".` + ); + } + + toolNameMap?.set(sanitizedName, name); + return sanitizedName; +} + function toGeminiGoogleSearchTool(tool: Record): GeminiTool | null { if (isRecord(tool.googleSearch)) { return { googleSearch: tool.googleSearch }; @@ -43,7 +138,10 @@ function toGeminiGoogleSearchTool(tool: Record): GeminiTool | n return null; } -export function buildGeminiTools(tools: unknown): GeminiTool[] | undefined { +export function buildGeminiTools( + tools: unknown, + options: GeminiToolSanitizationOptions = {} +): GeminiTool[] | undefined { if (!Array.isArray(tools) || tools.length === 0) { return undefined; } @@ -69,7 +167,7 @@ export function buildGeminiTools(tools: unknown): GeminiTool[] | undefined { } functionDeclarations.push({ - name: fn.name, + name: sanitizeGeminiToolName(fn.name, options), description: typeof fn.description === "string" ? fn.description : "", parameters: cleanJSONSchemaForAntigravity( fn.parameters || { type: "object", properties: {} } @@ -81,7 +179,7 @@ export function buildGeminiTools(tools: unknown): GeminiTool[] | undefined { if (typeof rawTool.name === "string" && rawTool.name.trim()) { functionDeclarations.push({ - name: rawTool.name, + name: sanitizeGeminiToolName(rawTool.name, options), description: typeof rawTool.description === "string" ? rawTool.description : "", parameters: cleanJSONSchemaForAntigravity( rawTool.input_schema || { type: "object", properties: {} } @@ -97,7 +195,7 @@ export function buildGeminiTools(tools: unknown): GeminiTool[] | undefined { } functionDeclarations.push({ - name: fn.name, + name: sanitizeGeminiToolName(fn.name, options), description: typeof fn.description === "string" ? fn.description : "", parameters: cleanJSONSchemaForAntigravity( fn.parameters || { type: "object", properties: {} } diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index b36e064b2c..07e6538841 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -6,7 +6,7 @@ import { cleanJSONSchemaForAntigravity, } from "../helpers/geminiHelper.ts"; import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; -import { buildGeminiTools } from "../helpers/geminiToolsSanitizer.ts"; +import { buildGeminiTools, sanitizeGeminiToolName } from "../helpers/geminiToolsSanitizer.ts"; /** * Direct Claude → Gemini request translator. @@ -14,6 +14,11 @@ import { buildGeminiTools } from "../helpers/geminiToolsSanitizer.ts"; * skipping the OpenAI hub intermediate step. */ export function claudeToGeminiRequest(model, body, stream) { + const toolNameMap = new Map(); + const sanitizeToolName = (name: string) => + sanitizeGeminiToolName(name, { + toolNameMap, + }); const result: { model: string; contents: Array>; @@ -21,6 +26,7 @@ export function claudeToGeminiRequest(model, body, stream) { safetySettings: unknown; systemInstruction?: { role: string; parts: Array<{ text: string }> }; tools?: Array<{ functionDeclarations: Array> }>; + _toolNameMap?: Map; } = { model: model, contents: [], @@ -65,7 +71,7 @@ export function claudeToGeminiRequest(model, body, stream) { if (msg.role === "assistant" && Array.isArray(msg.content)) { for (const block of msg.content) { if (block.type === "tool_use" && block.id && block.name) { - toolUseNames[block.id] = block.name; + toolUseNames[block.id] = sanitizeToolName(block.name); } } } @@ -96,7 +102,7 @@ export function claudeToGeminiRequest(model, body, stream) { parts.push({ functionCall: { id: block.id, - name: block.name, + name: sanitizeToolName(block.name), args: block.input || {}, }, }); @@ -169,7 +175,9 @@ export function claudeToGeminiRequest(model, body, stream) { } // ── Convert tools ────────────────────────────────────────────── - const geminiTools = buildGeminiTools(body.tools); + const geminiTools = buildGeminiTools(body.tools, { + toolNameMap, + }); if (geminiTools) { result.tools = geminiTools; } @@ -182,6 +190,15 @@ export function claudeToGeminiRequest(model, body, stream) { }; } + const changedToolNameMap = new Map( + [...toolNameMap.entries()].filter( + ([sanitizedName, originalName]) => sanitizedName !== originalName + ) + ); + if (changedToolNameMap.size > 0) { + result._toolNameMap = changedToolNameMap; + } + return result; } diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index bbeaff2e5a..c0401e6b26 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -55,8 +55,14 @@ export function openaiResponsesToOpenAIRequest( for (const toolValue of tools) { const tool = toRecord(toolValue); const toolType = toString(tool.type); - // Allow: function tools, and tools already in Chat format (have .function property) - if (toolType && toolType !== "function" && !tool.function) { + // Allow: function tools, tools already in Chat format (have .function property), and CLI subagent tools + if ( + toolType && + toolType !== "function" && + toolType !== "custom" && + toolType !== "command" && + !tool.function + ) { throw unsupportedFeature( `Unsupported Responses API feature: ${toolType} tool type is not supported by omniroute` ); @@ -538,7 +544,14 @@ export function openaiToOpenAIResponsesRequest( } if (root.service_tier !== undefined) result.service_tier = root.service_tier; if (root.temperature !== undefined) result.temperature = root.temperature; - if (root.max_tokens !== undefined) result.max_tokens = root.max_tokens; + // Translate max_tokens / max_completion_tokens → max_output_tokens for Responses API. + // The Responses API does not accept max_tokens or max_completion_tokens; it requires + // max_output_tokens. max_completion_tokens takes priority as the newer Chat Completions field. + if (root.max_completion_tokens !== undefined) { + result.max_output_tokens = root.max_completion_tokens; + } else if (root.max_tokens !== undefined) { + result.max_output_tokens = root.max_tokens; + } if (root.top_p !== undefined) result.top_p = root.top_p; if (storeEnabled) { if (root[RESPONSES_STORE_MARKER] !== undefined) { diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index b2850de7b5..47aeac8fb8 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -495,7 +495,8 @@ function convertOpenAIToolChoice(choice) { } // Map OpenAI string types to Claude equivalents if (choice.type === "auto" || choice.type === "none") return { type: "auto" }; - if (choice.type === "required" || choice.type === "any") return { type: CLAUDE_TOOL_CHOICE_REQUIRED }; + if (choice.type === "required" || choice.type === "any") + return { type: CLAUDE_TOOL_CHOICE_REQUIRED }; // If type is "tool" already (Claude-native), pass through if (choice.type === "tool" && choice.name) return choice; // Fallback: unknown object type — default to auto to avoid 400 errors diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index bb904ed247..e375193109 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -25,7 +25,7 @@ import { generateSessionId, cleanJSONSchemaForAntigravity, } from "../helpers/geminiHelper.ts"; -import { buildGeminiTools } from "../helpers/geminiToolsSanitizer.ts"; +import { buildGeminiTools, sanitizeGeminiToolName } from "../helpers/geminiToolsSanitizer.ts"; type GeminiPart = Record; type GeminiContent = { role: string; parts: GeminiPart[] }; @@ -60,6 +60,7 @@ type GeminiRequest = { googleSearch?: Record; }>; cachedContent?: string; + _toolNameMap?: Map; }; type CloudCodeEnvelope = { @@ -82,25 +83,34 @@ type CloudCodeEnvelope = { functionCallingConfig: { mode: string }; }; }; + _toolNameMap?: Map; }; -function normalizeAntigravityToolName(name: unknown) { - if (typeof name !== "string") return name; - const trimmed = name.trim(); - if (!trimmed) return trimmed; +type GeminiToolNameOptions = { + stripNamespace?: boolean; +}; - const namespaceIndex = trimmed.indexOf(":"); - return namespaceIndex >= 0 ? trimmed.slice(namespaceIndex + 1) : trimmed; +function buildChangedToolNameMap(toolNameMap: Map): Map | null { + const changedEntries = [...toolNameMap.entries()].filter( + ([sanitizedName, originalName]) => sanitizedName !== originalName + ); + return changedEntries.length > 0 ? new Map(changedEntries) : null; } // Core: Convert OpenAI request to Gemini format (base for all variants) -function openaiToGeminiBase(model, body, stream) { +function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolNameOptions = {}) { const result: GeminiRequest = { model: model, contents: [], generationConfig: {}, safetySettings: body.safetySettings || DEFAULT_SAFETY_SETTINGS, }; + const toolNameMap = new Map(); + const sanitizeToolName = (name: string) => + sanitizeGeminiToolName(name, { + ...toolNameOptions, + toolNameMap, + }); // Preserve cachedContent if provided by client (for explicit Gemini caching) if (body.cachedContent) { @@ -223,7 +233,7 @@ function openaiToGeminiBase(model, body, stream) { ...(embeddedThoughtSignature ? { thoughtSignature: embeddedThoughtSignature } : {}), functionCall: { id: tc.id, - name: tc.function.name, + name: sanitizeToolName(tc.function.name), args: args, }, }); @@ -255,6 +265,7 @@ function openaiToGeminiBase(model, body, stream) { name = fid; } } + name = sanitizeToolName(name); let resp = toolResponses[fid]; let parsedResp = tryParseJSON(resp); @@ -284,7 +295,10 @@ function openaiToGeminiBase(model, body, stream) { } // Convert tools - const geminiTools = buildGeminiTools(body.tools); + const geminiTools = buildGeminiTools(body.tools, { + ...toolNameOptions, + toolNameMap, + }); if (geminiTools) { result.tools = geminiTools; } @@ -305,6 +319,11 @@ function openaiToGeminiBase(model, body, stream) { } } + const changedToolNameMap = buildChangedToolNameMap(toolNameMap); + if (changedToolNameMap) { + result._toolNameMap = changedToolNameMap; + } + return result; } @@ -315,8 +334,7 @@ export function openaiToGeminiRequest(model, body, stream) { // OpenAI -> Gemini CLI (Cloud Code Assist) export function openaiToGeminiCLIRequest(model, body, stream) { - const gemini = openaiToGeminiBase(model, body, stream); - const isClaude = model.toLowerCase().includes("claude"); + const gemini = openaiToGeminiBase(model, body, stream, { stripNamespace: true }); // Add thinking config for CLI if (body.reasoning_effort) { @@ -340,37 +358,6 @@ export function openaiToGeminiCLIRequest(model, body, stream) { }; } - // Clean schema for tools - if (gemini.tools?.[0]?.functionDeclarations) { - for (const fn of gemini.tools[0].functionDeclarations) { - fn.name = normalizeAntigravityToolName(fn.name); - if (fn.parameters) { - const cleanedSchema = cleanJSONSchemaForAntigravity(fn.parameters); - fn.parameters = cleanedSchema; - // if (isClaude) { - // fn.parameters = cleanedSchema; - // } else { - // fn.parametersJsonSchema = cleanedSchema; - // delete fn.parameters; - // } - } - } - } - - if (Array.isArray(gemini.contents)) { - for (const content of gemini.contents) { - if (!Array.isArray(content.parts)) continue; - for (const part of content.parts) { - if (part.functionCall?.name) { - part.functionCall.name = normalizeAntigravityToolName(part.functionCall.name); - } - if (part.functionResponse?.name) { - part.functionResponse.name = normalizeAntigravityToolName(part.functionResponse.name); - } - } - } - } - return gemini; } @@ -404,6 +391,9 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra tools: geminiCLI.tools, }, }; + if (geminiCLI._toolNameMap instanceof Map && geminiCLI._toolNameMap.size > 0) { + envelope._toolNameMap = geminiCLI._toolNameMap; + } // Antigravity specific fields if (isAntigravity) { @@ -432,6 +422,12 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra } function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = null) { + const toolNameMap = new Map(); + const sanitizeToolName = (name: string) => + sanitizeGeminiToolName(name, { + stripNamespace: true, + toolNameMap, + }); let projectId = credentials?.projectId; if (!projectId) { @@ -460,6 +456,18 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu }, }; + const toolUseNames: Record = {}; + if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) { + for (const msg of claudeRequest.messages) { + if (!Array.isArray(msg.content)) continue; + for (const block of msg.content) { + if (block.type === "tool_use" && block.id && typeof block.name === "string") { + toolUseNames[block.id] = sanitizeToolName(block.name); + } + } + } + } + // Convert Claude messages to Gemini contents if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) { for (const msg of claudeRequest.messages) { @@ -480,7 +488,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu parts.push({ functionCall: { id: block.id, - name: block.name, + name: sanitizeToolName(block.name), args: block.input || {}, }, }); @@ -494,7 +502,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu parts.push({ functionResponse: { id: block.tool_use_id, - name: "unknown", + name: toolUseNames[block.tool_use_id] || "unknown", response: { result: tryParseJSON(content) || content }, }, }); @@ -515,7 +523,10 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu // Convert Claude tools to Gemini functionDeclarations if (claudeRequest.tools && Array.isArray(claudeRequest.tools)) { - const geminiTools = buildGeminiTools(claudeRequest.tools); + const geminiTools = buildGeminiTools(claudeRequest.tools, { + stripNamespace: true, + toolNameMap, + }); if (geminiTools) { envelope.request.tools = geminiTools; envelope.request.toolConfig = { @@ -540,6 +551,11 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu envelope.request.systemInstruction = { role: "user", parts: systemParts }; + const changedToolNameMap = buildChangedToolNameMap(toolNameMap); + if (changedToolNameMap) { + envelope._toolNameMap = changedToolNameMap; + } + return envelope; } diff --git a/open-sse/translator/response/gemini-to-claude.ts b/open-sse/translator/response/gemini-to-claude.ts index a9b438ab05..280d1f85cb 100644 --- a/open-sse/translator/response/gemini-to-claude.ts +++ b/open-sse/translator/response/gemini-to-claude.ts @@ -80,6 +80,8 @@ export function geminiToClaudeResponse(chunk, state) { state.openTextBlockIdx = null; } const fc = part.functionCall; + const rawToolName = fc.name; + const restoredToolName = state.toolNameMap?.get(rawToolName) || rawToolName; const idx = state.contentBlockIndex++; const toolId = fc.id || `toolu_${Date.now()}_${idx}`; @@ -89,7 +91,7 @@ export function geminiToClaudeResponse(chunk, state) { content_block: { type: "tool_use", id: toolId, - name: fc.name, + name: restoredToolName, input: {}, }, }); diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index b13cf46695..e2b909633f 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -83,8 +83,8 @@ export function geminiToOpenAIResponse(chunk, state) { state.pendingThoughtSignature = hasThoughtSig; } - // Handle thought signature (thinking mode) - if (hasThoughtSig) { + // Handle thought signature (thinking mode) or native gemini thought flag + if (hasThoughtSig || isThought) { const hasTextContent = part.text !== undefined && part.text !== ""; const hasFunctionCall = !!part.functionCall; @@ -105,7 +105,8 @@ export function geminiToOpenAIResponse(chunk, state) { } if (hasFunctionCall) { - const fcName = part.functionCall.name; + const rawToolName = part.functionCall.name; + const fcName = state.toolNameMap?.get(rawToolName) || rawToolName; const fcArgs = part.functionCall.args || {}; const toolCallIndex = state.functionIndex++; @@ -162,7 +163,8 @@ export function geminiToOpenAIResponse(chunk, state) { // Function call if (part.functionCall) { - const fcName = part.functionCall.name; + const rawToolName = part.functionCall.name; + const fcName = state.toolNameMap?.get(rawToolName) || rawToolName; const fcArgs = part.functionCall.args || {}; const toolCallIndex = state.functionIndex++; diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index bdddc2dd87..f482ef6de8 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -30,8 +30,15 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { output_tokens, total_tokens: u.total_tokens ?? input_tokens + output_tokens, }; - if (u.prompt_tokens_details?.cached_tokens) { - state.usage.input_tokens_details = { cached_tokens: u.prompt_tokens_details.cached_tokens }; + const cachedTokens = + u.input_tokens_details?.cached_tokens ?? u.prompt_tokens_details?.cached_tokens; + if (cachedTokens) { + state.usage.input_tokens_details = { cached_tokens: cachedTokens }; + } + const reasoningTokens = + u.output_tokens_details?.reasoning_tokens ?? u.completion_tokens_details?.reasoning_tokens; + if (reasoningTokens) { + state.usage.output_tokens_details = { reasoning_tokens: reasoningTokens }; } } @@ -700,8 +707,17 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { if (responseUsage && typeof responseUsage === "object") { const inputTokens = responseUsage.input_tokens || responseUsage.prompt_tokens || 0; const outputTokens = responseUsage.output_tokens || responseUsage.completion_tokens || 0; - const cacheReadTokens = responseUsage.cache_read_input_tokens || 0; + const cacheReadTokens = + responseUsage.cache_read_input_tokens || + responseUsage.input_tokens_details?.cached_tokens || + responseUsage.prompt_tokens_details?.cached_tokens || + 0; const cacheCreationTokens = responseUsage.cache_creation_input_tokens || 0; + const reasoningTokens = + responseUsage.output_tokens_details?.reasoning_tokens || + responseUsage.completion_tokens_details?.reasoning_tokens || + responseUsage.reasoning_tokens || + 0; // prompt_tokens = input_tokens + cache_read + cache_creation (all prompt-side tokens) const promptTokens = inputTokens + cacheReadTokens + cacheCreationTokens; @@ -722,6 +738,13 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { state.usage.prompt_tokens_details.cache_creation_tokens = cacheCreationTokens; } } + + // Add completion_tokens_details if reasoning tokens exist + if (reasoningTokens > 0) { + state.usage.completion_tokens_details = { + reasoning_tokens: reasoningTokens, + }; + } } if (!state.finishReasonSent) { @@ -761,7 +784,10 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { } // Reasoning events — emit as reasoning_content in Chat format - if (eventType === "response.reasoning_summary_text.delta") { + if ( + eventType === "response.reasoning_content_text.delta" || + eventType === "response.reasoning_text.delta" + ) { const reasoningDelta = data.delta || ""; if (!reasoningDelta) return null; return { @@ -779,6 +805,25 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { }; } + // Handle true reasoning summary ("Thought for 15s") + if (eventType === "response.reasoning_summary_text.delta") { + const reasoningDelta = data.delta || ""; + if (!reasoningDelta) return null; + return { + id: state.chatId, + object: "chat.completion.chunk", + created: state.created, + model: state.model || "gpt-4", + choices: [ + { + index: 0, + delta: { reasoning: { summary: reasoningDelta } }, + finish_reason: null, + }, + ], + }; + } + // Ignore other events return null; } diff --git a/open-sse/utils/cursorChecksum.ts b/open-sse/utils/cursorChecksum.ts index 4892224416..6159afdafe 100644 --- a/open-sse/utils/cursorChecksum.ts +++ b/open-sse/utils/cursorChecksum.ts @@ -7,6 +7,7 @@ import crypto from "crypto"; import { v5 as uuidv5 } from "uuid"; +import { getCursorVersion } from "./cursorVersionDetector.ts"; const CURSOR_CLIENT_VERSION = "3.1.0"; const CURSOR_USER_AGENT = `Cursor/${CURSOR_CLIENT_VERSION}`; @@ -115,12 +116,12 @@ export function buildCursorHeaders(accessToken, machineId = null, ghostMode = tr "connect-accept-encoding": "gzip", "connect-protocol-version": "1", "Content-Type": "application/connect+proto", - "User-Agent": CURSOR_USER_AGENT, + "User-Agent": `Cursor/${getCursorVersion()}`, "x-amzn-trace-id": `Root=${crypto.randomUUID()}`, "x-client-key": clientKey, "x-cursor-checksum": checksum, - "x-cursor-client-version": CURSOR_CLIENT_VERSION, - "x-cursor-user-agent": CURSOR_USER_AGENT, + "x-cursor-client-version": getCursorVersion(), + "x-cursor-user-agent": `Cursor/${getCursorVersion()}`, "x-cursor-config-version": crypto.randomUUID(), "x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", "x-ghost-mode": ghostMode ? "true" : "false", diff --git a/open-sse/utils/cursorVersionDetector.ts b/open-sse/utils/cursorVersionDetector.ts new file mode 100644 index 0000000000..9ffdb524af --- /dev/null +++ b/open-sse/utils/cursorVersionDetector.ts @@ -0,0 +1,68 @@ +/** + * Auto-detect the installed Cursor IDE version from its local SQLite database. + * Falls back to the hardcoded default when the DB is unavailable. + * The detected version is cached in-memory for 1 hour to avoid repeated DB reads. + * + * Override the DB path with the CURSOR_STATE_DB_PATH env var for non-standard installs. + */ + +import { homedir } from "os"; +import { join } from "path"; +import { createRequire } from "module"; + +const CACHE_TTL_MS = 60 * 60 * 1000; +const DB_KEY = "cursorupdate.lastUpdatedAndShown.version"; +const FALLBACK_VERSION = "3.1.15"; + +let cachedVersion: string | null = null; +let cachedAt = 0; + +export function getCursorDbPath(): string { + if (process.env.CURSOR_STATE_DB_PATH) { + return process.env.CURSOR_STATE_DB_PATH; + } + const home = process.env.HOME || process.env.USERPROFILE || homedir(); + const platform = process.platform; + if (platform === "darwin") { + return join(home, "Library/Application Support/Cursor/User/globalStorage/state.vscdb"); + } + if (platform === "win32") { + return join(process.env.APPDATA || home, "Cursor/User/globalStorage/state.vscdb"); + } + return join(home, ".config/Cursor/User/globalStorage/state.vscdb"); +} + +export function getCursorVersion(): string { + const now = Date.now(); + if (cachedVersion && now - cachedAt < CACHE_TTL_MS) { + return cachedVersion; + } + + try { + const esmRequire = createRequire(import.meta.url); + const Database = esmRequire("better-sqlite3"); + const db = new Database(getCursorDbPath(), { readonly: true, fileMustExist: true }); + try { + const row = db.prepare("SELECT value FROM itemTable WHERE key = ?").get(DB_KEY) as + | { value: string } + | undefined; + if (row?.value) { + cachedVersion = row.value; + cachedAt = now; + return cachedVersion; + } + } finally { + db.close(); + } + } catch { + // DB missing or unreadable — fall through to default + } + + return FALLBACK_VERSION; +} + +/** Exposed for testing: reset the in-memory cache so the next call re-reads the DB. */ +export function resetCursorVersionCache(): void { + cachedVersion = null; + cachedAt = 0; +} diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index d39b3c184b..723fff1a84 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -1,6 +1,7 @@ import { getCorsOrigin } from "./cors.ts"; import { ERROR_TYPES, DEFAULT_ERROR_MESSAGES } from "../config/constants.ts"; import { normalizePayloadForLog } from "@/lib/logPayloads"; +import type { ModelCooldownErrorPayload } from "@/types"; /** * Build OpenAI-compatible error response body @@ -52,6 +53,28 @@ export async function writeStreamError(writer, statusCode, message) { await writer.write(encoder.encode(`data: ${JSON.stringify(errorBody)}\n\n`)); } +function normalizeRetryAfterSeconds(retryAfter?: string | number | Date | null): number { + if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) { + if (retryAfter > 0 && retryAfter < 1_000_000_000) { + return Math.max(Math.ceil(retryAfter), 1); + } + + const retryTimeMs = new Date(retryAfter).getTime(); + if (Number.isFinite(retryTimeMs)) { + return Math.max(Math.ceil((retryTimeMs - Date.now()) / 1000), 1); + } + } + + if (retryAfter instanceof Date || typeof retryAfter === "string") { + const retryTimeMs = new Date(retryAfter).getTime(); + if (Number.isFinite(retryTimeMs)) { + return Math.max(Math.ceil((retryTimeMs - Date.now()) / 1000), 1); + } + } + + return 1; +} + /** * Parse Antigravity error message to extract retry time * Example: "You have exhausted your capacity on this model. Your quota will reset after 2h7m23s." @@ -117,6 +140,19 @@ export async function parseUpstreamError(response, provider = null) { const messageStr = typeof message === "string" ? message : JSON.stringify(message); + const retryAfterHeader = response.headers?.get?.("retry-after"); + if (retryAfterHeader && !retryAfterMs) { + const retryAfterSec = Number.parseInt(retryAfterHeader, 10); + if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) { + retryAfterMs = retryAfterSec * 1000; + } else { + const retryAfterDate = new Date(retryAfterHeader).getTime(); + if (Number.isFinite(retryAfterDate) && retryAfterDate > Date.now()) { + retryAfterMs = retryAfterDate - Date.now(); + } + } + } + // Parse Antigravity-specific retry time from error message if (provider === "antigravity" && response.status === 429) { retryAfterMs = parseAntigravityRetryTime(messageStr); @@ -127,6 +163,14 @@ export async function parseUpstreamError(response, provider = null) { retryAfterMs = parseAntigravityRetryTime(messageStr); } + // Generic providers: "Please retry after 20s" + if (response.status === 429 && !retryAfterMs) { + const retryMatch = messageStr.match(/retry\s+after\s+(\d+)\s*s/i); + if (retryMatch) { + retryAfterMs = Number.parseInt(retryMatch[1], 10) * 1000; + } + } + // Cap maximum retry time at 24 hours to prevent infinite wait const MAX_RETRY_MS = 24 * 60 * 60 * 1000; if (retryAfterMs && retryAfterMs > MAX_RETRY_MS) { @@ -188,8 +232,7 @@ export function unavailableResponse( retryAfter?: string | number | Date | null, retryAfterHuman?: string ) { - const retryTimeMs = retryAfter ? new Date(retryAfter).getTime() : Date.now() + 1000; - const retryAfterSec = Math.max(Math.ceil((retryTimeMs - Date.now()) / 1000), 1); + const retryAfterSec = normalizeRetryAfterSeconds(retryAfter); const msg = retryAfterHuman ? `${message} (${retryAfterHuman})` : message; return new Response(JSON.stringify({ error: { message: msg } }), { status: statusCode, @@ -200,6 +243,54 @@ export function unavailableResponse( }); } +export function buildModelCooldownBody({ + model, + retryAfterSec, +}: { + model?: string | null; + retryAfterSec: number; +}): ModelCooldownErrorPayload { + const resolvedModel = typeof model === "string" && model.trim().length > 0 ? model.trim() : null; + + return { + error: { + message: resolvedModel + ? `All credentials for model ${resolvedModel} are cooling down` + : "All credentials for the requested model are cooling down", + type: "rate_limit_error", + code: "model_cooldown", + ...(resolvedModel ? { model: resolvedModel } : {}), + reset_seconds: Math.max(Math.ceil(retryAfterSec), 1), + }, + }; +} + +export function modelCooldownResponse({ + model, + retryAfter, +}: { + model?: string | null; + retryAfter?: string | number | Date | null; +}) { + const retryAfterSec = normalizeRetryAfterSeconds(retryAfter); + return new Response( + JSON.stringify( + buildModelCooldownBody({ + model, + retryAfterSec, + }) + ), + { + status: 429, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": getCorsOrigin(), + "Retry-After": String(retryAfterSec), + }, + } + ); +} + /** * Format provider error with context * @param {Error} error - Original error diff --git a/open-sse/utils/logger.ts b/open-sse/utils/logger.ts index b52f0b4128..cb2528b12e 100644 --- a/open-sse/utils/logger.ts +++ b/open-sse/utils/logger.ts @@ -23,9 +23,32 @@ */ import { getAppLogFormat, getAppLogLevel } from "../../src/lib/logEnv"; -const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; +const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const; -const currentLevel = LEVELS[getAppLogLevel("info").toLowerCase()] ?? LEVELS.info; +type LogLevel = keyof typeof LEVELS; +type LogMetadata = Record; +type ConsoleFn = (...data: unknown[]) => void; + +type TaggedLogger = { + debug: (message: string, meta?: LogMetadata | null) => void; + info: (message: string, meta?: LogMetadata | null) => void; + warn: (message: string, meta?: LogMetadata | null) => void; + error: (message: string, meta?: LogMetadata | null) => void; +}; + +type RequestScopedLogger = { + debug: (tag: string, msg: string, data?: LogMetadata | null) => void; + info: (tag: string, msg: string, data?: LogMetadata | null) => void; + warn: (tag: string, msg: string, data?: LogMetadata | null) => void; + error: (tag: string, msg: string, data?: LogMetadata | null) => void; +}; + +function isLogLevel(value: string): value is LogLevel { + return Object.prototype.hasOwnProperty.call(LEVELS, value); +} + +const configuredLevel = getAppLogLevel("info").toLowerCase(); +const currentLevel = isLogLevel(configuredLevel) ? LEVELS[configuredLevel] : LEVELS.info; const jsonFormat = getAppLogFormat("text") === "json"; @@ -46,7 +69,7 @@ export function generateRequestId() { * @param {string} key * @returns {string} */ -export function maskKey(key) { +export function maskKey(key: string | null | undefined): string { if (!key || key.length < 12) return "(redacted)"; return `${key.slice(0, 6)}...${key.slice(-4)}`; } @@ -56,7 +79,7 @@ export function maskKey(key) { * @param {string} level * @returns {Function} */ -function getConsoleFn(level) { +function getConsoleFn(level: LogLevel): ConsoleFn { switch (level) { case "debug": return console.debug; @@ -75,9 +98,9 @@ function getConsoleFn(level) { * @param {object} [meta] - Optional metadata * @returns {string} Formatted metadata string or empty string */ -function formatMeta(meta) { +function formatMeta(meta?: LogMetadata | null): string { if (!meta || typeof meta !== "object") return ""; - const cleaned = {}; + const cleaned: LogMetadata = {}; for (const [k, v] of Object.entries(meta)) { if (v !== undefined && v !== null) cleaned[k] = v; } @@ -89,8 +112,8 @@ function formatMeta(meta) { * @param {string} tag - Log category tag (e.g. "CHAT", "AUTH", "STREAM") * @returns {{ debug: Function, info: Function, warn: Function, error: Function }} */ -export function logger(tag) { - const emit = (level, message, meta) => { +export function logger(tag: string): TaggedLogger { + const emit = (level: LogLevel, message: string, meta?: LogMetadata | null): void => { if (LEVELS[level] < currentLevel) return; const consoleFn = getConsoleFn(level); @@ -125,8 +148,8 @@ export function logger(tag) { * @param {string} [requestId] - Unique request ID for correlation * @returns {{ debug, info, warn, error }} */ -export function createLogger(requestId = null) { - const emit = (level, tag, message, data) => { +export function createLogger(requestId: string | null = null): RequestScopedLogger { + const emit = (level: LogLevel, tag: string, message: string, data?: LogMetadata | null): void => { if (LEVELS[level] < currentLevel) return; const consoleFn = getConsoleFn(level); diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index ccbb986a81..36714ea614 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -78,6 +78,13 @@ function noProxyMatch(targetUrl) { if (patternPort && patternPort !== port) return false; if (!patternHost) return false; + + // Support wildcard matching (e.g. 192.168.* or *.local) + if (patternHost.includes("*")) { + const regexStr = "^" + patternHost.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$"; + if (new RegExp(regexStr).test(hostname)) return true; + } + if (patternHost.startsWith(".")) { return hostname.endsWith(patternHost) || hostname === patternHost.slice(1); } @@ -85,6 +92,15 @@ function noProxyMatch(targetUrl) { }); } +function isLocalAddress(hostname: string): boolean { + if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return true; + if (hostname.startsWith("192.168.")) return true; + if (hostname.startsWith("10.")) return true; + if (hostname.match(/^172\.(1[6-9]|2\d|3[0-1])\./)) return true; + if (hostname.endsWith(".local") || hostname.endsWith(".lan")) return true; + return false; +} + function resolveEnvProxyUrl(targetUrl) { if (noProxyMatch(targetUrl)) return null; @@ -111,6 +127,18 @@ function resolveEnvProxyUrl(targetUrl) { } function resolveProxyForRequest(targetUrl) { + let target; + try { + target = new URL(targetUrl); + } catch { + target = null; + } + + // Always bypass proxy for local/LAN addresses + if (target && isLocalAddress(target.hostname.toLowerCase())) { + return { source: "direct", proxyUrl: null }; + } + const contextProxy = proxyContext.getStore(); if (contextProxy) { return { source: "context", proxyUrl: proxyConfigToUrl(contextProxy) }; diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index ccebc722af..c6398112a9 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -85,6 +85,167 @@ type ToolCall = { type UsageTokenRecord = Record; +type ClaudeEmptyResponseLifecycle = { + hasMessageStart: boolean; + hasContentBlock: boolean; + hasMessageDelta: boolean; + hasMessageStop: boolean; + hasError: boolean; + syntheticContentInjected: boolean; + warningLogged: boolean; +}; + +const SYNTHETIC_CLAUDE_EMPTY_RESPONSE_TEXT = + "[Proxy Error] The upstream API returned an empty response. Please retry the request."; + +function createClaudeEmptyResponseLifecycle(): ClaudeEmptyResponseLifecycle { + return { + hasMessageStart: false, + hasContentBlock: false, + hasMessageDelta: false, + hasMessageStop: false, + hasError: false, + syntheticContentInjected: false, + warningLogged: false, + }; +} + +function getClaudeEventType(payload: unknown): string | null { + if (!payload || typeof payload !== "object") return null; + const type = (payload as JsonRecord).type; + return typeof type === "string" ? type : null; +} + +function isClaudeEventPayload(payload: unknown): payload is JsonRecord { + return getClaudeEventType(payload) !== null; +} + +function updateClaudeEmptyResponseLifecycle( + lifecycle: ClaudeEmptyResponseLifecycle, + payload: unknown +) { + const type = getClaudeEventType(payload); + if (!type) return; + + switch (type) { + case "message_start": + lifecycle.hasMessageStart = true; + break; + case "content_block_start": + case "content_block_delta": + case "content_block_stop": + lifecycle.hasContentBlock = true; + break; + case "message_delta": + lifecycle.hasMessageDelta = true; + break; + case "message_stop": + lifecycle.hasMessageStop = true; + break; + case "error": + lifecycle.hasError = true; + break; + default: + break; + } +} + +function hasClaudeAssistantLifecycle(lifecycle: ClaudeEmptyResponseLifecycle): boolean { + return lifecycle.hasMessageStart || lifecycle.hasMessageDelta || lifecycle.hasMessageStop; +} + +function shouldInjectClaudeEmptyResponseBeforeCurrentEvent( + lifecycle: ClaudeEmptyResponseLifecycle, + payload: unknown +): boolean { + const type = getClaudeEventType(payload); + if (!type || lifecycle.hasError || lifecycle.hasContentBlock) return false; + if (!hasClaudeAssistantLifecycle(lifecycle)) return false; + return type === "message_delta" || type === "message_stop"; +} + +function shouldInjectClaudeEmptyResponseOnFlush(lifecycle: ClaudeEmptyResponseLifecycle): boolean { + if (lifecycle.hasError || lifecycle.hasContentBlock) return false; + return hasClaudeAssistantLifecycle(lifecycle); +} + +function shouldInjectClaudeMissingFinalizersOnFlush( + lifecycle: ClaudeEmptyResponseLifecycle +): boolean { + if (lifecycle.hasError || !lifecycle.syntheticContentInjected) return false; + return !lifecycle.hasMessageDelta || !lifecycle.hasMessageStop; +} + +function buildSyntheticClaudeEmptyResponseEvents( + lifecycle: ClaudeEmptyResponseLifecycle, + model: string | null, + options: { + includeContentBlock?: boolean; + includeMessageDelta?: boolean; + includeMessageStop?: boolean; + } = {} +): JsonRecord[] { + const { + includeContentBlock = true, + includeMessageDelta = false, + includeMessageStop = false, + } = options; + const events: JsonRecord[] = []; + const resolvedModel = typeof model === "string" && model ? model : "unknown"; + + if (includeContentBlock) { + if (!lifecycle.hasMessageStart) { + events.push({ + type: "message_start", + message: { + id: `msg_synthetic_${Date.now()}`, + type: "message", + role: "assistant", + model: resolvedModel, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }); + } + + events.push( + { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { + type: "text_delta", + text: SYNTHETIC_CLAUDE_EMPTY_RESPONSE_TEXT, + }, + }, + { + type: "content_block_stop", + index: 0, + } + ); + } + + if (includeMessageDelta) { + events.push({ + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { input_tokens: 0, output_tokens: 0 }, + }); + } + + if (includeMessageStop) { + events.push({ type: "message_stop" }); + } + + return events; +} + function getOpenAIIntermediateChunks(value: unknown): unknown[] { if (!value || typeof value !== "object") return []; const candidate = (value as JsonRecord)._openaiIntermediate; @@ -194,6 +355,128 @@ export function createSSEStream(options: StreamOptions = {}) { let lastChunkTime = Date.now(); let idleTimer: ReturnType | null = null; let streamTimedOut = false; + const claudeEmptyResponseLifecycle = createClaudeEmptyResponseLifecycle(); + let pendingPassthroughEventLine: string | null = null; + let pendingPassthroughEventEmitted = false; + + const clearPendingPassthroughEvent = () => { + pendingPassthroughEventLine = null; + pendingPassthroughEventEmitted = false; + }; + + const maybePrefixPendingPassthroughEvent = (output: string, line: string) => { + if (!pendingPassthroughEventLine || !line.startsWith("data:")) { + return output; + } + if (!pendingPassthroughEventEmitted) { + pendingPassthroughEventEmitted = true; + return `${pendingPassthroughEventLine}\n${output}`; + } + return output; + }; + + const emitSyntheticClaudeEmptyResponse = ( + controller: TransformStreamDefaultController, + options: { + includeContentBlock?: boolean; + includeMessageDelta?: boolean; + includeMessageStop?: boolean; + } = {} + ) => { + const events = buildSyntheticClaudeEmptyResponseEvents( + claudeEmptyResponseLifecycle, + model, + options + ); + if (events.length === 0) return; + + if (!claudeEmptyResponseLifecycle.warningLogged) { + claudeEmptyResponseLifecycle.warningLogged = true; + console.warn( + `[STREAM] Injecting synthetic Claude SSE response for empty upstream output (${provider || "provider"}:${model || "unknown"})` + ); + } + + if (options.includeContentBlock !== false) { + claudeEmptyResponseLifecycle.syntheticContentInjected = true; + if (!passthroughAccumulatedContent.trim()) { + passthroughAccumulatedContent = SYNTHETIC_CLAUDE_EMPTY_RESPONSE_TEXT; + } + if (state?.accumulatedContent !== undefined && !state.accumulatedContent.trim()) { + state.accumulatedContent = SYNTHETIC_CLAUDE_EMPTY_RESPONSE_TEXT; + } + } + + for (const event of events) { + updateClaudeEmptyResponseLifecycle(claudeEmptyResponseLifecycle, event); + clientPayloadCollector.push(event); + const output = formatSSE(event, FORMATS.CLAUDE); + reqLogger?.appendConvertedChunk?.(output); + controller.enqueue(encoder.encode(output)); + } + }; + + const emitTranslatedClientItem = ( + controller: TransformStreamDefaultController, + item: Record + ) => { + let itemSanitized: Record = item; + const isResponsesEvent = typeof item?.event === "string" && item.event.startsWith("response."); + if (sourceFormat === FORMATS.OPENAI && !isResponsesEvent) { + itemSanitized = sanitizeStreamingChunk(itemSanitized) as Record; + + const delta = itemSanitized?.choices?.[0]?.delta; + if (delta?.content && typeof delta.content === "string") { + const { content, thinking } = extractThinkingFromContent(delta.content); + delta.content = content; + if (thinking && !delta.reasoning_content) { + delta.reasoning_content = thinking; + } + } + } + + if (!hasValuableContent(itemSanitized, sourceFormat)) { + return; + } + + const isFinishChunk = + itemSanitized.type === "message_delta" || itemSanitized.choices?.[0]?.finish_reason; + if ( + state?.finishReason && + isFinishChunk && + !hasValidUsage(itemSanitized.usage) && + totalContentLength > 0 + ) { + const estimated = estimateUsage(body, totalContentLength, sourceFormat); + itemSanitized.usage = filterUsageForFormat(estimated, sourceFormat); + state.usage = estimated; + } else if (state?.finishReason && isFinishChunk && state.usage) { + const buffered = addBufferToUsage(state.usage); + itemSanitized.usage = filterUsageForFormat(buffered, sourceFormat); + } + + if ( + sourceFormat === FORMATS.CLAUDE && + shouldInjectClaudeEmptyResponseBeforeCurrentEvent(claudeEmptyResponseLifecycle, itemSanitized) + ) { + const eventType = getClaudeEventType(itemSanitized); + emitSyntheticClaudeEmptyResponse(controller, { + includeContentBlock: true, + includeMessageDelta: + eventType === "message_stop" && !claudeEmptyResponseLifecycle.hasMessageDelta, + includeMessageStop: false, + }); + } + + if (sourceFormat === FORMATS.CLAUDE && isClaudeEventPayload(itemSanitized)) { + updateClaudeEmptyResponseLifecycle(claudeEmptyResponseLifecycle, itemSanitized); + } + + const output = formatSSE(itemSanitized, sourceFormat); + clientPayloadCollector.push(itemSanitized); + reqLogger?.appendConvertedChunk?.(output); + controller.enqueue(encoder.encode(output)); + }; return new TransformStream( { @@ -244,6 +527,7 @@ export function createSSEStream(options: StreamOptions = {}) { if (skipPassthroughEvent) { if (!trimmed) { skipPassthroughEvent = false; + clearPendingPassthroughEvent(); } continue; } @@ -252,6 +536,33 @@ export function createSSEStream(options: StreamOptions = {}) { // try to JSON.parse empty keepalive payloads and crash. if (/^event:\s*keepalive\b/i.test(trimmed)) { skipPassthroughEvent = true; + clearPendingPassthroughEvent(); + continue; + } + + if (/^event:/i.test(trimmed)) { + if (pendingPassthroughEventLine && !pendingPassthroughEventEmitted) { + const pendingOutput = `${pendingPassthroughEventLine}\n`; + reqLogger?.appendConvertedChunk?.(pendingOutput); + controller.enqueue(encoder.encode(pendingOutput)); + } + + const eventType = trimmed.replace(/^event:\s*/i, ""); + if ( + shouldInjectClaudeEmptyResponseBeforeCurrentEvent(claudeEmptyResponseLifecycle, { + type: eventType, + }) + ) { + emitSyntheticClaudeEmptyResponse(controller, { + includeContentBlock: true, + includeMessageDelta: + eventType === "message_stop" && !claudeEmptyResponseLifecycle.hasMessageDelta, + includeMessageStop: false, + }); + } + + pendingPassthroughEventLine = line; + pendingPassthroughEventEmitted = false; continue; } @@ -316,6 +627,21 @@ export function createSSEStream(options: StreamOptions = {}) { if (eu.cache_creation_input_tokens) u.cache_creation_input_tokens = eu.cache_creation_input_tokens; } + if ( + shouldInjectClaudeEmptyResponseBeforeCurrentEvent( + claudeEmptyResponseLifecycle, + parsed + ) + ) { + emitSyntheticClaudeEmptyResponse(controller, { + includeContentBlock: true, + includeMessageDelta: + parsed.type === "message_stop" && + !claudeEmptyResponseLifecycle.hasMessageDelta, + includeMessageStop: false, + }); + } + updateClaudeEmptyResponseLifecycle(claudeEmptyResponseLifecycle, parsed); const restoredToolName = restoreClaudePassthroughToolUseName(parsed, toolNameMap); // Track content length and accumulate from Claude format if (parsed.delta?.text) { @@ -478,12 +804,22 @@ export function createSSEStream(options: StreamOptions = {}) { } } + if (!trimmed && pendingPassthroughEventLine && !pendingPassthroughEventEmitted) { + output = `${pendingPassthroughEventLine}\n${output}`; + pendingPassthroughEventEmitted = true; + } + + output = maybePrefixPendingPassthroughEvent(output, line); + if (clientPayload) { clientPayloadCollector.push(clientPayload); } reqLogger?.appendConvertedChunk?.(output); controller.enqueue(encoder.encode(output)); + if (!trimmed) { + clearPendingPassthroughEvent(); + } continue; } @@ -614,59 +950,7 @@ export function createSSEStream(options: StreamOptions = {}) { if (translated?.length > 0) { for (const item of translated) { - // Content for call log is accumulated only from parsed (above) to avoid double-counting; - // do not add again from item here. - - // #723, #727: Sanitize only when the client-facing stream is OpenAI Chat format. - // When translating Responses -> Claude, `item` is already a Claude SSE event; - // sanitizing it as an OpenAI chunk strips message_start/content_block_delta/message_stop - // and causes Claude Code to drop the assistant message. - // #761: Responses API events have {event, data} structure — skip sanitization - // entirely as it strips them to {"object":"chat.completion.chunk"}, losing all content. - let itemSanitized: Record = item; - const isResponsesEvent = - typeof item?.event === "string" && item.event.startsWith("response."); - if (sourceFormat === FORMATS.OPENAI && !isResponsesEvent) { - itemSanitized = sanitizeStreamingChunk(itemSanitized) as Record; - - // Extract reasoning tags from content if translation generated them - const delta = itemSanitized?.choices?.[0]?.delta; - if (delta?.content && typeof delta.content === "string") { - const { content, thinking } = extractThinkingFromContent(delta.content); - delta.content = content; - if (thinking && !delta.reasoning_content) { - delta.reasoning_content = thinking; - } - } - } - - // Filter empty chunks - if (!hasValuableContent(itemSanitized, sourceFormat)) { - continue; // Skip this empty chunk - } - - // Inject estimated usage if finish chunk has no valid usage - const isFinishChunk = - itemSanitized.type === "message_delta" || itemSanitized.choices?.[0]?.finish_reason; - if ( - state.finishReason && - isFinishChunk && - !hasValidUsage(itemSanitized.usage) && - totalContentLength > 0 - ) { - const estimated = estimateUsage(body, totalContentLength, sourceFormat); - itemSanitized.usage = filterUsageForFormat(estimated, sourceFormat); // Filter + already has buffer - state.usage = estimated; - } else if (state.finishReason && isFinishChunk && state.usage) { - // Add buffer and filter usage for client (but keep original in state.usage for logging) - const buffered = addBufferToUsage(state.usage); - itemSanitized.usage = filterUsageForFormat(buffered, sourceFormat); - } - - const output = formatSSE(itemSanitized, sourceFormat); - clientPayloadCollector.push(itemSanitized); - reqLogger?.appendConvertedChunk?.(output); - controller.enqueue(encoder.encode(output)); + emitTranslatedClientItem(controller, item); } } } @@ -690,6 +974,7 @@ export function createSSEStream(options: StreamOptions = {}) { const bufferedLine = buffer.trim(); if (skipPassthroughEvent || /^event:\s*keepalive\b/i.test(bufferedLine)) { skipPassthroughEvent = false; + clearPendingPassthroughEvent(); } else if (buffer) { let output = buffer; if (buffer.startsWith("data:") && !buffer.startsWith("data: ")) { @@ -698,12 +983,49 @@ export function createSSEStream(options: StreamOptions = {}) { const bufferedPayload = parseSSELine(bufferedLine); if (bufferedPayload) { providerPayloadCollector.push(bufferedPayload); + if ( + shouldInjectClaudeEmptyResponseBeforeCurrentEvent( + claudeEmptyResponseLifecycle, + bufferedPayload + ) + ) { + const eventType = getClaudeEventType(bufferedPayload); + emitSyntheticClaudeEmptyResponse(controller, { + includeContentBlock: true, + includeMessageDelta: + eventType === "message_stop" && !claudeEmptyResponseLifecycle.hasMessageDelta, + includeMessageStop: false, + }); + } + if (isClaudeEventPayload(bufferedPayload)) { + updateClaudeEmptyResponseLifecycle(claudeEmptyResponseLifecycle, bufferedPayload); + } clientPayloadCollector.push(bufferedPayload); } + if (!bufferedLine && pendingPassthroughEventLine && !pendingPassthroughEventEmitted) { + output = `${pendingPassthroughEventLine}\n${output}`; + pendingPassthroughEventEmitted = true; + } + output = maybePrefixPendingPassthroughEvent(output, buffer); reqLogger?.appendConvertedChunk?.(output); controller.enqueue(encoder.encode(output)); } + if (shouldInjectClaudeEmptyResponseOnFlush(claudeEmptyResponseLifecycle)) { + emitSyntheticClaudeEmptyResponse(controller, { + includeContentBlock: true, + includeMessageDelta: !claudeEmptyResponseLifecycle.hasMessageDelta, + includeMessageStop: !claudeEmptyResponseLifecycle.hasMessageStop, + }); + } else if (shouldInjectClaudeMissingFinalizersOnFlush(claudeEmptyResponseLifecycle)) { + emitSyntheticClaudeEmptyResponse(controller, { + includeContentBlock: false, + includeMessageDelta: !claudeEmptyResponseLifecycle.hasMessageDelta, + includeMessageStop: !claudeEmptyResponseLifecycle.hasMessageStop, + }); + } + clearPendingPassthroughEvent(); + // Estimate usage if provider didn't return valid usage if (!hasValidUsage(usage) && totalContentLength > 0) { usage = estimateUsage(body, totalContentLength, sourceFormat || FORMATS.OPENAI); @@ -815,10 +1137,7 @@ export function createSSEStream(options: StreamOptions = {}) { if (translated?.length > 0) { for (const item of translated) { - const output = formatSSE(item, sourceFormat); - clientPayloadCollector.push(item); - reqLogger?.appendConvertedChunk?.(output); - controller.enqueue(encoder.encode(output)); + emitTranslatedClientItem(controller, item); } } } @@ -865,10 +1184,23 @@ export function createSSEStream(options: StreamOptions = {}) { if (flushed?.length > 0) { for (const item of flushed) { - const output = formatSSE(item, sourceFormat); - clientPayloadCollector.push(item); - reqLogger?.appendConvertedChunk?.(output); - controller.enqueue(encoder.encode(output)); + emitTranslatedClientItem(controller, item); + } + } + + if (sourceFormat === FORMATS.CLAUDE) { + if (shouldInjectClaudeEmptyResponseOnFlush(claudeEmptyResponseLifecycle)) { + emitSyntheticClaudeEmptyResponse(controller, { + includeContentBlock: true, + includeMessageDelta: !claudeEmptyResponseLifecycle.hasMessageDelta, + includeMessageStop: !claudeEmptyResponseLifecycle.hasMessageStop, + }); + } else if (shouldInjectClaudeMissingFinalizersOnFlush(claudeEmptyResponseLifecycle)) { + emitSyntheticClaudeEmptyResponse(controller, { + includeContentBlock: false, + includeMessageDelta: !claudeEmptyResponseLifecycle.hasMessageDelta, + includeMessageStop: !claudeEmptyResponseLifecycle.hasMessageStop, + }); } } diff --git a/open-sse/utils/streamPayloadCollector.ts b/open-sse/utils/streamPayloadCollector.ts index 96395bee99..0dc409aeb1 100644 --- a/open-sse/utils/streamPayloadCollector.ts +++ b/open-sse/utils/streamPayloadCollector.ts @@ -344,6 +344,10 @@ function buildClaudeSummary(events: StructuredSSEEvent[], fallbackModel?: string input: unknown; inputJson: string; }; + type ClaudeContentBlock = + | { type: "text"; text: string } + | { type: "thinking"; thinking: string; signature?: string } + | { type: "tool_use"; id: string; name: string; input: unknown }; const blocks = new Map(); const usage: JsonRecord = {}; @@ -456,7 +460,7 @@ function buildClaudeSummary(events: StructuredSSEEvent[], fallbackModel?: string const content = [...blocks.values()] .sort((a, b) => a.index - b.index) - .flatMap((block) => { + .flatMap((block) => { if (block.type === "text") { return block.text ? [ diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index 2eca04bb27..e0f88ce79d 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -341,8 +341,15 @@ export function extractUsage(chunk) { return normalizeUsage({ prompt_tokens: usage.input_tokens || usage.prompt_tokens || 0, completion_tokens: usage.output_tokens || usage.completion_tokens || 0, - cached_tokens: usage.input_tokens_details?.cached_tokens, - reasoning_tokens: usage.output_tokens_details?.reasoning_tokens, + cached_tokens: + usage.input_tokens_details?.cached_tokens ?? + usage.prompt_tokens_details?.cached_tokens ?? + usage.cache_read_input_tokens, + cache_creation_input_tokens: usage.cache_creation_input_tokens, + reasoning_tokens: + usage.output_tokens_details?.reasoning_tokens ?? + usage.completion_tokens_details?.reasoning_tokens ?? + usage.reasoning_tokens, }); } @@ -355,8 +362,12 @@ export function extractUsage(chunk) { return normalizeUsage({ prompt_tokens: chunk.usage.prompt_tokens ?? chunk.usage.input_tokens ?? 0, completion_tokens: chunk.usage.completion_tokens ?? chunk.usage.output_tokens ?? 0, - cached_tokens: chunk.usage.prompt_tokens_details?.cached_tokens, - reasoning_tokens: chunk.usage.completion_tokens_details?.reasoning_tokens, + cached_tokens: + chunk.usage.prompt_tokens_details?.cached_tokens ?? + chunk.usage.input_tokens_details?.cached_tokens, + reasoning_tokens: + chunk.usage.completion_tokens_details?.reasoning_tokens ?? + chunk.usage.output_tokens_details?.reasoning_tokens, }); } diff --git a/package-lock.json b/package-lock.json index a504791e48..5171cc4a6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.6.5", + "version": "3.6.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.6.5", + "version": "3.6.6", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -21,7 +21,7 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.6.2", "bottleneck": "^2.19.5", - "dompurify": "^3.3.2", + "dompurify": "^3.4.0", "express": "^5.2.1", "fetch-socks": "^1.3.2", "http-proxy-middleware": "^3.0.5", @@ -51,7 +51,7 @@ "zustand": "^5.0.10" }, "bin": { - "omniroute": "bin/omniroute.mjs", + "omniroute": "bin/omniroute.ts", "omniroute-reset-password": "bin/reset-password.mjs" }, "devDependencies": { @@ -84,7 +84,7 @@ "wtfnode": "^0.10.1" }, "engines": { - "node": ">=18.0.0 <24.0.0" + "node": ">=20.20.2 <21 || >=22.22.2 <23" }, "optionalDependencies": { "keytar": "^7.9.0" @@ -9671,9 +9671,9 @@ "peer": true }, "node_modules/dompurify": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", - "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", + "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -11032,9 +11032,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -11931,9 +11931,9 @@ "license": "MIT" }, "node_modules/hono": { - "version": "4.12.12", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", - "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -20962,7 +20962,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.6.5" + "version": "3.6.6" } } } diff --git a/package.json b/package.json index 1b34436009..45813fd6b8 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,29 @@ { "name": "omniroute", - "version": "3.6.5", + "version": "3.6.6", "description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", "type": "module", "bin": { - "omniroute": "bin/omniroute.mjs", + "omniroute": "bin/omniroute.ts", "omniroute-reset-password": "bin/reset-password.mjs" }, "files": [ "bin/", "app/", - "open-sse/mcp-server/", + "open-sse/mcp-server/index.ts", + "open-sse/mcp-server/server.ts", + "open-sse/mcp-server/httpTransport.ts", + "open-sse/mcp-server/audit.ts", + "open-sse/mcp-server/runtimeHeartbeat.ts", + "open-sse/mcp-server/scopeEnforcement.ts", + "open-sse/mcp-server/schemas/", + "open-sse/mcp-server/tools/", + "open-sse/mcp-server/README.md", "src/shared/contracts/", + "src/shared/utils/nodeRuntimeSupport.ts", ".env.example", "scripts/postinstall.mjs", + "scripts/check-supported-node-runtime.ts", "scripts/sync-env.mjs", "scripts/native-binary-compat.mjs", "scripts/build-next-isolated.mjs", @@ -24,7 +34,7 @@ "open-sse" ], "engines": { - "node": ">=18.0.0 <24.0.0" + "node": ">=20.20.2 <21 || >=22.22.2 <23" }, "keywords": [ "ai", @@ -51,7 +61,7 @@ "scripts": { "dev": "node scripts/run-next.mjs dev", "build": "node scripts/build-next-isolated.mjs", - "build:cli": "node scripts/prepublish.mjs", + "build:cli": "node --import tsx/esm scripts/prepublish.ts", "start": "node scripts/run-next.mjs start", "lint": "eslint .", "electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:20128 && cd electron && npm run dev\"", @@ -59,32 +69,34 @@ "electron:build:win": "npm run build && cd electron && npm run build:win", "electron:build:mac": "npm run build && cd electron && npm run build:mac", "electron:build:linux": "npm run build && cd electron && npm run build:linux", - "test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test --test-concurrency=1 tests/unit/*.test.mjs", - "test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test --test-concurrency=1 tests/unit/*.test.mjs", - "test:plan3": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/plan3-p0.test.mjs", - "test:fixes": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/fixes-p1.test.mjs", - "test:security": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/security-fase01.test.mjs", + "test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test --test-concurrency=1 tests/unit/*.test.ts", + "test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test --test-concurrency=1 tests/unit/*.test.ts", + "test:plan3": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/plan3-p0.test.ts", + "test:fixes": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/fixes-p1.test.ts", + "test:security": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/security-fase01.test.ts", "check:cycles": "node scripts/check-cycles.mjs", "check:route-validation:t06": "node scripts/check-route-validation.mjs", "check:any-budget:t11": "node scripts/check-t11-any-budget.mjs", "check:docs-sync": "node scripts/check-docs-sync.mjs", + "check:node-runtime": "node --import tsx/esm scripts/check-supported-node-runtime.ts", + "check:pack-artifact": "node --import tsx/esm scripts/validate-pack-artifact.ts", "typecheck:core": "tsc --pretty false -p tsconfig.typecheck-core.json", "typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json", "env:sync": "node scripts/sync-env.mjs", - "test:integration": "node --import tsx/esm --test tests/integration/*.test.mjs", + "test:integration": "node --import tsx/esm --test tests/integration/*.test.ts", "test:e2e": "node scripts/run-playwright-tests.mjs test tests/e2e/*.spec.ts", "test:protocols:e2e": "node scripts/run-protocol-clients-tests.mjs", "test:vitest": "vitest run --config vitest.mcp.config.ts", "test:ecosystem": "node scripts/run-ecosystem-tests.mjs", - "test:coverage": "c8 --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --import tsx/esm --test --test-concurrency=1 tests/unit/*.test.mjs", - "test:coverage:legacy": "c8 --output-dir coverage --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.mjs", + "test:coverage": "c8 --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --import tsx/esm --test --test-concurrency=1 tests/unit/*.test.ts", + "test:coverage:legacy": "c8 --output-dir coverage --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.ts", "coverage:report": "c8 report --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov", "coverage:summary": "node scripts/test-report-summary.mjs --input coverage/coverage-summary.json --output coverage/coverage-report.md --threshold 60", "check:pr-test-policy": "node scripts/check-pr-test-policy.mjs", "coverage:report:legacy": "c8 report --output-dir coverage --exclude=open-sse --reporter=text --reporter=text-summary", "test:all": "npm run test:unit && npm run test:vitest && npm run test:ecosystem && npm run test:e2e", "check": "npm run lint && npm run test", - "prepublishOnly": "npm run build:cli", + "prepublishOnly": "npm run build:cli && npm run check:pack-artifact", "postinstall": "node scripts/postinstall.mjs", "uninstall": "node scripts/uninstall.mjs", "uninstall:full": "node scripts/uninstall.mjs --full", @@ -100,7 +112,7 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.6.2", "bottleneck": "^2.19.5", - "dompurify": "^3.3.2", + "dompurify": "^3.4.0", "express": "^5.2.1", "fetch-socks": "^1.3.2", "http-proxy-middleware": "^3.0.5", @@ -182,9 +194,9 @@ }, "overrides": { "lodash-es": "^4.18.1", - "dompurify": "^3.3.2", + "dompurify": "^3.4.0", "path-to-regexp": "^8.4.0", - "hono": "^4.12.12", + "hono": "^4.12.14", "@hono/node-server": "^1.19.13", "react": "$react", "react-dom": "$react-dom" diff --git a/scripts/check-supported-node-runtime.ts b/scripts/check-supported-node-runtime.ts new file mode 100644 index 0000000000..977a84a5ab --- /dev/null +++ b/scripts/check-supported-node-runtime.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +import { + getNodeRuntimeSupport, + getNodeRuntimeWarning, +} from "../src/shared/utils/nodeRuntimeSupport.ts"; + +const support = getNodeRuntimeSupport(); + +if (!support.nodeCompatible) { + console.error(`Unsupported or insecure Node.js runtime detected: ${support.nodeVersion}`); + console.error(getNodeRuntimeWarning() || "Unsupported Node.js runtime."); + console.error(`Supported secure runtimes: ${support.supportedDisplay}`); + console.error(`Recommended version: ${support.recommendedVersion}`); + process.exit(1); +} + +console.log( + `Node.js ${support.nodeVersion} satisfies OmniRoute secure runtime policy (${support.supportedRange}).` +); diff --git a/scripts/pack-artifact-policy.ts b/scripts/pack-artifact-policy.ts new file mode 100644 index 0000000000..b2530be4da --- /dev/null +++ b/scripts/pack-artifact-policy.ts @@ -0,0 +1,134 @@ +/** + * Shared policy for OmniRoute npm publish artifact hygiene. + * + * The package currently publishes the standalone runtime under app/. + * This policy keeps local backups, QA scratch files, and development-only + * directories out of the staged app/ tree and out of the final tarball. + */ + +const STAGING_FORBIDDEN_DIRECTORIES = [ + "app.__qa_backup", + "coverage", + "electron", + "logs", + "scripts/scratch", + "tests", + "vscode-extension", + "_ideia", + "_mono_repo", + "_references", + "_tasks", +]; + +const STAGING_FORBIDDEN_FILES = ["audit-report.json", "package-lock.json"]; + +export const APP_STAGING_REMOVAL_PATHS: string[] = [ + ...STAGING_FORBIDDEN_DIRECTORIES, + ...STAGING_FORBIDDEN_FILES, +]; + +export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [ + ".env.example", + "docs/openapi.yaml", + "open-sse/mcp-server/server.js", + "package.json", + "scripts/sync-env.mjs", + "server.js", +]; + +export const APP_STAGING_ALLOWED_PATH_PREFIXES: string[] = [ + ".next/", + "data/", + "node_modules/", + "public/", + "src/lib/db/migrations/", + "src/mitm/", +]; + +export const PACK_ARTIFACT_ALLOWED_EXACT_PATHS: string[] = APP_STAGING_ALLOWED_EXACT_PATHS.map( + (filePath: string) => `app/${filePath}` +); + +export const PACK_ARTIFACT_ALLOWED_PATH_PREFIXES: string[] = APP_STAGING_ALLOWED_PATH_PREFIXES.map( + (directoryPath: string) => `app/${directoryPath}` +); + +export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [ + ".env.example", + "LICENSE", + "README.md", + "bin/mcp-server.mjs", + "bin/omniroute.ts", + "bin/reset-password.mjs", + "open-sse/mcp-server/README.md", + "open-sse/mcp-server/audit.ts", + "open-sse/mcp-server/httpTransport.ts", + "open-sse/mcp-server/index.ts", + "open-sse/mcp-server/runtimeHeartbeat.ts", + "open-sse/mcp-server/scopeEnforcement.ts", + "open-sse/mcp-server/server.ts", + "package.json", + "scripts/build-next-isolated.mjs", + "scripts/check-supported-node-runtime.ts", + "scripts/native-binary-compat.mjs", + "scripts/postinstall.mjs", + "scripts/sync-env.mjs", + "src/shared/utils/nodeRuntimeSupport.ts", +]; + +export const PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES: string[] = [ + "open-sse/mcp-server/schemas/", + "open-sse/mcp-server/tools/", + "src/shared/contracts/", +]; + +export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ + "app/server.js", + "bin/mcp-server.mjs", + "bin/omniroute.ts", + "package.json", + "scripts/native-binary-compat.mjs", + "scripts/postinstall.mjs", + "src/shared/utils/nodeRuntimeSupport.ts", +]; + +PACK_ARTIFACT_ALLOWED_EXACT_PATHS.push(...PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS); +PACK_ARTIFACT_ALLOWED_PATH_PREFIXES.push(...PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES); + +export function normalizeArtifactPath(filePath: string): string { + return String(filePath || "") + .replace(/\\/g, "/") + .replace(/^\.\//, "") + .replace(/^\/+/, "") + .replace(/\/{2,}/g, "/"); +} + +export function findUnexpectedArtifactPaths( + filePaths: string[], + { exactPaths = [], prefixPaths = [] }: { exactPaths?: string[]; prefixPaths?: string[] } = {} +): string[] { + const normalizedExact = new Set(exactPaths.map(normalizeArtifactPath)); + const normalizedPrefixes = prefixPaths.map(normalizeArtifactPath); + + return filePaths + .map(normalizeArtifactPath) + .filter(Boolean) + .filter( + (filePath) => + !normalizedExact.has(filePath) && + !normalizedPrefixes.some((prefix) => filePath.startsWith(prefix)) + ) + .sort(); +} + +export function findMissingArtifactPaths( + filePaths: string[], + requiredPaths: string[] = [] +): string[] { + const normalizedPaths = new Set(filePaths.map(normalizeArtifactPath).filter(Boolean)); + return requiredPaths + .map(normalizeArtifactPath) + .filter(Boolean) + .filter((requiredPath) => !normalizedPaths.has(requiredPath)) + .sort(); +} diff --git a/scripts/prepublish.mjs b/scripts/prepublish.ts similarity index 74% rename from scripts/prepublish.mjs rename to scripts/prepublish.ts index fe21666ace..cc5c627212 100644 --- a/scripts/prepublish.mjs +++ b/scripts/prepublish.ts @@ -23,12 +23,86 @@ import { import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { + APP_STAGING_ALLOWED_EXACT_PATHS, + APP_STAGING_ALLOWED_PATH_PREFIXES, + APP_STAGING_REMOVAL_PATHS, + findUnexpectedArtifactPaths, +} from "./pack-artifact-policy.ts"; + const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const ROOT = join(__dirname, ".."); const APP_DIR = join(ROOT, "app"); +function walkFiles(dir: string, rootDir: string = dir, files: string[] = []): string[] { + let entries: string[] = []; + try { + entries = readdirSync(dir); + } catch { + return files; + } + + for (const entry of entries) { + const fullPath = join(dir, entry); + let stat; + try { + stat = statSync(fullPath); + } catch { + continue; + } + + if (stat.isDirectory()) { + walkFiles(fullPath, rootDir, files); + continue; + } + + files.push( + fullPath + .replace(rootDir, "") + .replace(/^[/\\]/, "") + .replace(/\\/g, "/") + ); + } + + return files; +} + +function removeEmptyDirectories(dir: string): boolean { + let entries: string[] = []; + try { + entries = readdirSync(dir); + } catch { + return false; + } + + let hasFiles = false; + for (const entry of entries) { + const fullPath = join(dir, entry); + let stat; + try { + stat = statSync(fullPath); + } catch { + continue; + } + + if (stat.isDirectory()) { + const childHasFiles = removeEmptyDirectories(fullPath); + if (!childHasFiles) { + rmSync(fullPath, { recursive: true, force: true }); + } else { + hasFiles = true; + } + continue; + } + + hasFiles = true; + } + + return hasFiles; +} + console.log("🔨 OmniRoute — Building for npm publish...\n"); // ── Step 1: Clean previous app/ directory ────────────────── @@ -79,8 +153,11 @@ if (!existsSync(serverJs)) { // runtime by the externals patch in next.config.mjs, but log for visibility. { const HASH_RE = /require\(["']([\w@./-]+-[0-9a-f]{16})["']\)/; - const scanDir = (dir, hits = []) => { - let entries = []; + const scanDir = ( + dir: string, + hits: { file: string; mod: string }[] = [] + ): { file: string; mod: string }[] => { + let entries: string[] = []; try { entries = readdirSync(dir); } catch { @@ -170,8 +247,8 @@ if (sanitisedCount > 0) { const HASH_RE = /(['"\\])([a-z@][a-z0-9@./_-]+-[0-9a-f]{16})\1/g; let patchedFiles = 0; let patchedMatches = 0; - const walkDir = (dir) => { - let entries = []; + const walkDir = (dir: string) => { + let entries: string[] = []; try { entries = readdirSync(dir); } catch { @@ -250,6 +327,11 @@ if (existsSync(mitmSrc)) { resolveJsonModule: true, esModuleInterop: true, skipLibCheck: true, + types: ["node"], + baseUrl: ".", + paths: { + "@/*": ["src/*"], + }, }, include: [mitmSrc + "/**/*"], }; @@ -259,7 +341,7 @@ if (existsSync(mitmSrc)) { try { execSync("npx tsc -p tsconfig.mitm.tmp.json", { cwd: ROOT, stdio: "inherit" }); console.log(" ✅ MITM utilities compiled to app/src/mitm/"); - } catch (err) { + } catch (err: any) { console.warn(" ⚠️ MITM compile warning (non-fatal):", err.message); // Fallback: copy source files so at least they are present cpSync(mitmSrc, mitmDest, { recursive: true }); @@ -285,7 +367,7 @@ if (existsSync(mcpSrcFile)) { { cwd: ROOT, stdio: "inherit" } ); console.log(" ✅ MCP Server bundled to app/open-sse/mcp-server/server.js"); - } catch (err) { + } catch (err: any) { console.warn(" ⚠️ MCP Server bundle error:", err.message); } } @@ -299,6 +381,33 @@ if (existsSync(sharedApiKey)) { cpSync(sharedApiKey, join(sharedApiKeyDest, "apiKey.js")); } +// ── Step 9.5: Copy minimal runtime sidecars required outside .next ───────── +const envExampleSrc = join(ROOT, ".env.example"); +if (existsSync(envExampleSrc)) { + cpSync(envExampleSrc, join(APP_DIR, ".env.example")); +} + +const openapiSpecSrc = join(ROOT, "docs", "openapi.yaml"); +if (existsSync(openapiSpecSrc)) { + const docsDest = join(APP_DIR, "docs"); + mkdirSync(docsDest, { recursive: true }); + cpSync(openapiSpecSrc, join(docsDest, "openapi.yaml")); +} + +const syncEnvSrc = join(ROOT, "scripts", "sync-env.mjs"); +if (existsSync(syncEnvSrc)) { + const scriptsDest = join(APP_DIR, "scripts"); + mkdirSync(scriptsDest, { recursive: true }); + cpSync(syncEnvSrc, join(scriptsDest, "sync-env.mjs")); +} + +const migrationsSrc = join(ROOT, "src", "lib", "db", "migrations"); +if (existsSync(migrationsSrc)) { + const migrationsDest = join(APP_DIR, "src", "lib", "db", "migrations"); + mkdirSync(join(APP_DIR, "src", "lib", "db"), { recursive: true }); + cpSync(migrationsSrc, migrationsDest, { recursive: true, force: true }); +} + // ── Step 10: Ensure data/ directory exists ────────────────── mkdirSync(join(APP_DIR, "data"), { recursive: true }); @@ -314,19 +423,43 @@ if (existsSync(swcHelpersSrc) && !existsSync(swcHelpersDst)) { console.log(" ✅ @swc/helpers included in standalone build."); } -// ── Step 10.6: Remove large binaries from standalone build ── -// These directories contain platform-native binaries (.node, .asar) that -// trigger Z_DATA_ERROR during npm pack. They are not needed in the npm package. -const binaryDirsToRemove = ["vscode-extension", "electron", "logs"]; -for (const dir of binaryDirsToRemove) { - const targetDir = join(APP_DIR, dir); - if (existsSync(targetDir)) { - console.log(` 🧹 Removing app/${dir}/ (not needed in npm package)...`); - rmSync(targetDir, { recursive: true, force: true }); - console.log(` ✅ app/${dir}/ removed.`); +// ── Step 10.6: Remove development-only residue from staged app/ ───────────── +for (const relativePath of APP_STAGING_REMOVAL_PATHS) { + const targetPath = join(APP_DIR, relativePath); + if (existsSync(targetPath)) { + console.log(` 🧹 Removing app/${relativePath} (not needed in npm package)...`); + rmSync(targetPath, { recursive: true, force: true }); + console.log(` ✅ app/${relativePath} removed.`); } } +// ── Step 10.7: Prune any staged app/ file outside the allowed runtime set ─── +const stagedFiles = walkFiles(APP_DIR); +const unexpectedStagedFiles = findUnexpectedArtifactPaths(stagedFiles, { + exactPaths: APP_STAGING_ALLOWED_EXACT_PATHS, + prefixPaths: APP_STAGING_ALLOWED_PATH_PREFIXES, +}); + +if (unexpectedStagedFiles.length > 0) { + console.log(" 🧹 Pruning unexpected files from staged app/..."); + unexpectedStagedFiles.forEach((unexpectedPath: string) => { + rmSync(join(APP_DIR, unexpectedPath), { force: true }); + console.log(` ✅ Removed app/${unexpectedPath}`); + }); + removeEmptyDirectories(APP_DIR); +} + +const remainingUnexpectedFiles = findUnexpectedArtifactPaths(walkFiles(APP_DIR), { + exactPaths: APP_STAGING_ALLOWED_EXACT_PATHS, + prefixPaths: APP_STAGING_ALLOWED_PATH_PREFIXES, +}); + +if (remainingUnexpectedFiles.length > 0) { + console.error("\n ❌ Staged app/ still contains unexpected publish artifacts:"); + remainingUnexpectedFiles.forEach((violation: string) => console.error(` - app/${violation}`)); + process.exit(1); +} + // ── Done ─────────────────────────────────────────────────── const appPkg = join(APP_DIR, "package.json"); if (existsSync(appPkg)) { diff --git a/scripts/run-next-playwright.mjs b/scripts/run-next-playwright.mjs index 15b4acce03..595ae880d8 100644 --- a/scripts/run-next-playwright.mjs +++ b/scripts/run-next-playwright.mjs @@ -142,10 +142,11 @@ const testServerEnv = { ...sanitizeColorEnv(bootstrapEnvVars), ...sanitizeColorEnv(process.env), NEXT_PUBLIC_OMNIROUTE_E2E_MODE: process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE || "1", - OMNIROUTE_DISABLE_BACKGROUND_SERVICES: process.env.OMNIROUTE_DISABLE_BACKGROUND_SERVICES || "1", - OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK || "1", - OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK || "1", - OMNIROUTE_HIDE_HEALTHCHECK_LOGS: process.env.OMNIROUTE_HIDE_HEALTHCHECK_LOGS || "1", + OMNIROUTE_DISABLE_BACKGROUND_SERVICES: + process.env.OMNIROUTE_DISABLE_BACKGROUND_SERVICES || "true", + OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK || "true", + OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK || "true", + OMNIROUTE_HIDE_HEALTHCHECK_LOGS: process.env.OMNIROUTE_HIDE_HEALTHCHECK_LOGS || "true", ...(process.env.OMNIROUTE_USE_TURBOPACK ? { OMNIROUTE_USE_TURBOPACK: process.env.OMNIROUTE_USE_TURBOPACK, diff --git a/scripts/run-next.mjs b/scripts/run-next.mjs index cbf40715b7..15f31dbb0d 100644 --- a/scripts/run-next.mjs +++ b/scripts/run-next.mjs @@ -1,27 +1,101 @@ #!/usr/bin/env node -import { - resolveRuntimePorts, - withRuntimePortEnv, - spawnWithForwardedSignals, -} from "./runtime-env.mjs"; +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import next from "next"; import { bootstrapEnv } from "./bootstrap-env.mjs"; +import { resolveRuntimePorts, withRuntimePortEnv } from "./runtime-env.mjs"; +import { createOmnirouteWsBridge } from "./v1-ws-bridge.mjs"; -const mode = process.argv[2] === "start" ? "start" : "dev"; - -// Load .env / server.env first so PORT / DASHBOARD_PORT from files affect --port below. -const env = bootstrapEnv(); -const runtimePorts = resolveRuntimePorts(env); -const { dashboardPort } = runtimePorts; - -const args = ["./node_modules/next/dist/bin/next", mode, "--port", String(dashboardPort)]; -// Default: use webpack (stable). Set OMNIROUTE_USE_TURBOPACK=1 in .env for Turbopack (faster dev). -// Must read merged `env` from bootstrap — .env is not applied to process.env in the launcher. -if (mode === "dev" && env.OMNIROUTE_USE_TURBOPACK !== "1") { - args.splice(2, 0, "--webpack"); +// Add check for conflicting app/ directory (Issue #1206) +const rootAppDir = path.join(process.cwd(), "app"); +if (fs.existsSync(rootAppDir) && fs.statSync(rootAppDir).isDirectory()) { + console.error("\x1b[31m[FATAL ERROR]\x1b[0m Next.js App Router conflict detected!"); + console.error(`A root-level 'app/' directory was found at: ${rootAppDir}`); + console.error("This conflicts with the 'src/app/' directory on Windows environments."); + console.error("Next.js will serve 404s for all pages because it prefers the root 'app/' folder."); + console.error("Please rename or delete the root 'app/' directory before starting OmniRoute.\n"); + process.exit(1); } -spawnWithForwardedSignals(process.execPath, args, { - stdio: "inherit", - env: withRuntimePortEnv(env, runtimePorts), +const mode = process.argv[2] === "start" ? "start" : "dev"; +const dev = mode === "dev"; + +const bootstrappedEnv = bootstrapEnv(); +const runtimePorts = resolveRuntimePorts(bootstrappedEnv); +const mergedEnv = withRuntimePortEnv(bootstrappedEnv, runtimePorts); + +for (const [key, value] of Object.entries(mergedEnv)) { + if (value !== undefined) { + process.env[key] = value; + } +} + +const { dashboardPort } = runtimePorts; +const hostname = process.env.HOST || "0.0.0.0"; +const useTurbopack = dev && mergedEnv.OMNIROUTE_USE_TURBOPACK === "1"; + +const nextApp = next({ + dev, + dir: process.cwd(), + hostname, + port: dashboardPort, + turbopack: useTurbopack, + webpack: dev && !useTurbopack, +}); + +async function start() { + await nextApp.prepare(); + + const requestHandler = nextApp.getRequestHandler(); + const upgradeHandler = nextApp.getUpgradeHandler(); + const wsBridge = createOmnirouteWsBridge({ + baseUrl: `http://127.0.0.1:${dashboardPort}`, + }); + + const server = http.createServer((req, res) => requestHandler(req, res)); + server.on("upgrade", async (req, socket, head) => { + try { + const handled = await wsBridge.handleUpgrade(req, socket, head); + if (handled) return; + await upgradeHandler(req, socket, head); + } catch (error) { + if (!socket.destroyed) { + socket.destroy(error instanceof Error ? error : undefined); + } + console.error("[WS] Upgrade handling failed:", error); + } + }); + + server.on("error", (error) => { + console.error("[FATAL] Next custom server failed:", error); + process.exit(1); + }); + + const shutdown = async (signal) => { + try { + await new Promise((resolve) => server.close(resolve)); + await nextApp.close(); + } catch (error) { + console.error(`[SHUTDOWN] Failed during ${signal}:`, error); + } finally { + process.exit(0); + } + }; + + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); + + server.listen(dashboardPort, hostname, () => { + const bundler = dev ? (useTurbopack ? "turbopack" : "webpack") : "production"; + console.log( + `[Next] ${mode} server listening on http://${hostname}:${dashboardPort} (${bundler})` + ); + }); +} + +start().catch((error) => { + console.error("[FATAL] Failed to start Next custom server:", error); + process.exit(1); }); diff --git a/scripts/v1-ws-bridge.mjs b/scripts/v1-ws-bridge.mjs new file mode 100644 index 0000000000..3653bd159f --- /dev/null +++ b/scripts/v1-ws-bridge.mjs @@ -0,0 +1,671 @@ +import { createHash, randomUUID } from "node:crypto"; +import { STATUS_CODES } from "node:http"; + +export const WS_PUBLIC_PATHS = new Set(["/v1/ws", "/api/v1/ws"]); +export const WS_ALLOWED_ENDPOINTS = new Set([ + "/v1/chat/completions", + "/api/v1/chat/completions", + "/v1/messages", + "/api/v1/messages", + "/v1/responses", + "/api/v1/responses", + "/v1/completions", + "/api/v1/completions", +]); + +const HANDSHAKE_PATH = "/api/v1/ws"; +const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const WS_QUERY_TOKEN_KEYS = ["api_key", "token", "access_token"]; +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +function isText(value) { + return typeof value === "string" && value.length > 0; +} + +function jsonStringifySafe(value) { + try { + return JSON.stringify(value); + } catch { + return JSON.stringify({ + type: "protocol.error", + code: "serialization_failed", + message: "Failed to serialize WebSocket payload", + }); + } +} + +function encodeWsFrame(opcode, payload = Buffer.alloc(0)) { + const payloadBuffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload); + const length = payloadBuffer.length; + + let header; + if (length < 126) { + header = Buffer.allocUnsafe(2); + header[1] = length; + } else if (length <= 0xffff) { + header = Buffer.allocUnsafe(4); + header[1] = 126; + header.writeUInt16BE(length, 2); + } else { + header = Buffer.allocUnsafe(10); + header[1] = 127; + header.writeBigUInt64BE(BigInt(length), 2); + } + + header[0] = 0x80 | (opcode & 0x0f); + return Buffer.concat([header, payloadBuffer]); +} + +function decodeClientFrames(buffer) { + const frames = []; + let offset = 0; + + while (buffer.length - offset >= 2) { + const byte1 = buffer[offset]; + const byte2 = buffer[offset + 1]; + const fin = (byte1 & 0x80) !== 0; + const opcode = byte1 & 0x0f; + const masked = (byte2 & 0x80) !== 0; + let payloadLength = byte2 & 0x7f; + let headerLength = 2; + + if (!masked) { + throw new Error("Client WebSocket frames must be masked"); + } + + if (payloadLength === 126) { + if (buffer.length - offset < 4) break; + payloadLength = buffer.readUInt16BE(offset + 2); + headerLength = 4; + } else if (payloadLength === 127) { + if (buffer.length - offset < 10) break; + const bigLength = buffer.readBigUInt64BE(offset + 2); + if (bigLength > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error("WebSocket payload too large"); + } + payloadLength = Number(bigLength); + headerLength = 10; + } + + const totalLength = headerLength + 4 + payloadLength; + if (buffer.length - offset < totalLength) break; + + const mask = buffer.subarray(offset + headerLength, offset + headerLength + 4); + const payload = Buffer.from(buffer.subarray(offset + headerLength + 4, offset + totalLength)); + for (let index = 0; index < payload.length; index += 1) { + payload[index] ^= mask[index % 4]; + } + + frames.push({ fin, opcode, payload }); + offset += totalLength; + } + + return { + frames, + remaining: buffer.subarray(offset), + }; +} + +function writeHttpError(socket, status, body, headers = {}) { + if (!socket.writable || socket.destroyed) return; + + const bodyBuffer = Buffer.from(body || "", "utf8"); + const statusText = STATUS_CODES[status] || "Error"; + const responseHeaders = { + Connection: "close", + "Content-Length": String(bodyBuffer.length), + "Content-Type": "application/json; charset=utf-8", + ...headers, + }; + + const head = [ + `HTTP/1.1 ${status} ${statusText}`, + ...Object.entries(responseHeaders).map(([name, value]) => `${name}: ${value}`), + "", + "", + ].join("\r\n"); + + socket.write(head); + socket.end(bodyBuffer); +} + +function isWsPath(pathname) { + return WS_PUBLIC_PATHS.has(pathname); +} + +function normalizeEndpoint(rawEndpoint) { + const endpoint = isText(rawEndpoint) ? rawEndpoint : "/v1/chat/completions"; + + let parsed; + try { + parsed = new URL(endpoint, "http://omniroute.local"); + } catch { + return null; + } + + if (parsed.origin !== "http://omniroute.local") { + return null; + } + + if (!WS_ALLOWED_ENDPOINTS.has(parsed.pathname)) { + return null; + } + + return `${parsed.pathname}${parsed.search}`; +} + +function getForwardHeaders(requestUrl, requestHeaders) { + const headers = { + accept: "text/event-stream", + "content-type": "application/json", + }; + + const authorization = requestHeaders.authorization; + if (isText(authorization)) { + headers.authorization = authorization; + } else { + const url = new URL(requestUrl, "http://omniroute.local"); + for (const key of WS_QUERY_TOKEN_KEYS) { + const value = url.searchParams.get(key); + if (isText(value)) { + headers.authorization = `Bearer ${value.trim()}`; + break; + } + } + } + + const cookie = requestHeaders.cookie; + if (isText(cookie)) { + headers.cookie = cookie; + } + + const origin = requestHeaders.origin; + if (isText(origin)) { + headers.origin = origin; + } + + return headers; +} + +async function performHandshake(fetchImpl, baseUrl, requestUrl, requestHeaders) { + const incomingUrl = new URL(requestUrl, baseUrl); + const handshakeUrl = new URL(HANDSHAKE_PATH, baseUrl); + + for (const [key, value] of incomingUrl.searchParams.entries()) { + handshakeUrl.searchParams.set(key, value); + } + handshakeUrl.searchParams.set("handshake", "1"); + + const response = await fetchImpl(handshakeUrl, { + method: "GET", + headers: { + authorization: requestHeaders.authorization || "", + cookie: requestHeaders.cookie || "", + origin: requestHeaders.origin || "", + "x-forwarded-for": requestHeaders["x-forwarded-for"] || "", + }, + }); + + const bodyText = await response.text(); + let bodyJson = null; + try { + bodyJson = bodyText ? JSON.parse(bodyText) : null; + } catch { + bodyJson = null; + } + + return { + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + bodyText, + bodyJson, + ok: response.ok, + }; +} + +class WebSocketSession { + constructor(options) { + this.baseUrl = options.baseUrl; + this.fetchImpl = options.fetchImpl; + this.idleTimeoutMs = options.idleTimeoutMs; + this.pingIntervalMs = options.pingIntervalMs; + this.socket = options.socket; + this.requestHeaders = options.requestHeaders; + this.requestUrl = options.requestUrl; + this.sessionId = randomUUID(); + this.closed = false; + this.buffer = Buffer.alloc(0); + this.fragmentOpcode = null; + this.fragmentParts = []; + this.activeRequests = new Map(); + this.lastSeenAt = Date.now(); + + this.pingTimer = setInterval(() => { + if (this.closed) return; + const idleForMs = Date.now() - this.lastSeenAt; + if (idleForMs >= this.idleTimeoutMs) { + this.close(1001, "idle_timeout"); + return; + } + this.sendFrame(0x9); + }, this.pingIntervalMs); + + this.socket.setNoDelay(true); + this.socket.on("data", (chunk) => { + this.onData(chunk).catch((error) => { + this.sendProtocolError( + "frame_decode_failed", + error instanceof Error ? error.message : String(error) + ); + }); + }); + this.socket.on("close", () => this.dispose()); + this.socket.on("end", () => this.dispose()); + this.socket.on("error", () => this.dispose()); + } + + sendFrame(opcode, payload) { + if (this.closed || this.socket.destroyed) return; + this.socket.write(encodeWsFrame(opcode, payload)); + } + + sendJson(payload) { + this.sendFrame(0x1, Buffer.from(jsonStringifySafe(payload), "utf8")); + } + + sendProtocolError(code, message, id = null) { + this.sendJson({ + type: "protocol.error", + code, + id, + message, + }); + } + + async onData(chunk) { + this.lastSeenAt = Date.now(); + this.buffer = Buffer.concat([this.buffer, chunk]); + const parsed = decodeClientFrames(this.buffer); + this.buffer = parsed.remaining; + + for (const frame of parsed.frames) { + await this.handleFrame(frame); + } + } + + async handleFrame(frame) { + switch (frame.opcode) { + case 0x0: + if (this.fragmentOpcode === null) { + this.sendProtocolError("unexpected_continuation", "Unexpected continuation frame"); + return; + } + this.fragmentParts.push(frame.payload); + if (frame.fin) { + const payload = Buffer.concat(this.fragmentParts); + const opcode = this.fragmentOpcode; + this.fragmentOpcode = null; + this.fragmentParts = []; + await this.handleDataFrame(opcode, payload); + } + return; + case 0x1: + case 0x2: + if (!frame.fin) { + this.fragmentOpcode = frame.opcode; + this.fragmentParts = [frame.payload]; + return; + } + await this.handleDataFrame(frame.opcode, frame.payload); + return; + case 0x8: + this.close(); + return; + case 0x9: + this.sendFrame(0xa, frame.payload); + return; + case 0xa: + this.lastSeenAt = Date.now(); + return; + default: + this.sendProtocolError("unsupported_opcode", `Unsupported opcode ${frame.opcode}`); + } + } + + async handleDataFrame(opcode, payload) { + if (opcode !== 0x1) { + this.sendProtocolError("unsupported_payload", "Only UTF-8 text messages are supported"); + return; + } + + const raw = textDecoder.decode(payload); + let message; + try { + message = JSON.parse(raw); + } catch { + this.sendProtocolError("invalid_json", "WebSocket message must be valid JSON"); + return; + } + + await this.handleMessage(message); + } + + async handleMessage(message) { + if (!message || typeof message !== "object" || Array.isArray(message)) { + this.sendProtocolError("invalid_envelope", "WebSocket message must be an object"); + return; + } + + if (message.type === "ping") { + this.sendJson({ type: "pong", sessionId: this.sessionId }); + return; + } + + if (message.type === "cancel") { + const requestId = isText(message.id) ? message.id : null; + if (!requestId) { + this.sendProtocolError("invalid_cancel", "cancel envelopes require a string id"); + return; + } + const active = this.activeRequests.get(requestId); + if (!active) { + this.sendProtocolError( + "unknown_request", + "No active request matches the provided id", + requestId + ); + return; + } + active.abortController.abort(); + return; + } + + if (message.type !== "request") { + this.sendProtocolError( + "unsupported_type", + "Supported message types are request, cancel, and ping" + ); + return; + } + + const requestId = isText(message.id) ? message.id : null; + if (!requestId) { + this.sendProtocolError("invalid_request_id", "request envelopes require a non-empty id"); + return; + } + + if (this.activeRequests.has(requestId)) { + this.sendProtocolError( + "duplicate_request", + "A request with this id is already in flight", + requestId + ); + return; + } + + if (!message.payload || typeof message.payload !== "object" || Array.isArray(message.payload)) { + this.sendProtocolError( + "invalid_payload", + "request envelopes require an object payload", + requestId + ); + return; + } + + const endpoint = normalizeEndpoint(message.endpoint); + if (!endpoint) { + this.sendProtocolError( + "invalid_endpoint", + "Endpoint must target a supported /v1 chat surface", + requestId + ); + return; + } + + const requestPayload = { + ...message.payload, + stream: message.payload.stream === undefined ? true : message.payload.stream, + }; + + const abortController = new AbortController(); + this.activeRequests.set(requestId, { abortController }); + this.executeRequest(requestId, endpoint, requestPayload, abortController).catch((error) => { + this.sendJson({ + type: abortController.signal.aborted ? "response.cancelled" : "response.error", + id: requestId, + code: abortController.signal.aborted ? "client_cancelled" : "request_failed", + message: error instanceof Error ? error.message : String(error), + }); + this.activeRequests.delete(requestId); + }); + } + + async executeRequest(requestId, endpoint, payload, abortController) { + const headers = { + ...this.requestHeaders, + accept: payload.stream === false ? "application/json" : "text/event-stream", + "content-type": "application/json", + "x-omniroute-ws-session-id": this.sessionId, + "x-omniroute-ws-request-id": requestId, + }; + + const response = await this.fetchImpl(new URL(endpoint, this.baseUrl), { + method: "POST", + headers, + body: JSON.stringify(payload), + signal: abortController.signal, + }); + + const contentType = response.headers.get("content-type") || ""; + this.sendJson({ + type: "response.start", + id: requestId, + status: response.status, + ok: response.ok, + contentType, + endpoint, + }); + + if (contentType.includes("text/event-stream") && response.body) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + if (chunk) { + this.sendJson({ + type: "response.chunk", + id: requestId, + chunk, + }); + } + } + + const tail = decoder.decode(); + if (tail) { + this.sendJson({ + type: "response.chunk", + id: requestId, + chunk: tail, + }); + } + } finally { + this.activeRequests.delete(requestId); + } + + this.sendJson({ + type: response.ok ? "response.completed" : "response.error", + id: requestId, + status: response.status, + ok: response.ok, + }); + return; + } + + const bodyText = await response.text(); + let body = bodyText; + try { + body = bodyText ? JSON.parse(bodyText) : null; + } catch { + body = bodyText; + } + + this.activeRequests.delete(requestId); + this.sendJson({ + type: response.ok ? "response.output" : "response.error", + id: requestId, + status: response.status, + ok: response.ok, + body, + }); + this.sendJson({ + type: "response.completed", + id: requestId, + status: response.status, + ok: response.ok, + }); + } + + close(code = 1000, reason = "normal_closure") { + if (this.closed) return; + this.closed = true; + + clearInterval(this.pingTimer); + for (const active of this.activeRequests.values()) { + active.abortController.abort(); + } + this.activeRequests.clear(); + + const reasonBuffer = Buffer.from(reason, "utf8"); + const payload = Buffer.allocUnsafe(2 + reasonBuffer.length); + payload.writeUInt16BE(code, 0); + reasonBuffer.copy(payload, 2); + this.sendFrame(0x8, payload); + this.socket.end(); + setTimeout(() => { + if (!this.socket.destroyed) { + this.socket.destroy(); + } + }, 50).unref?.(); + } + + dispose() { + if (this.closed) return; + this.closed = true; + clearInterval(this.pingTimer); + for (const active of this.activeRequests.values()) { + active.abortController.abort(); + } + this.activeRequests.clear(); + } +} + +export function createOmnirouteWsBridge({ + baseUrl, + fetchImpl = fetch, + pingIntervalMs = 25000, + idleTimeoutMs = 90000, +} = {}) { + if (!isText(baseUrl)) { + throw new Error("createOmnirouteWsBridge requires a baseUrl"); + } + + return { + isWsPath, + async handleUpgrade(req, socket, head) { + const pathname = new URL(req.url || "/", baseUrl).pathname; + if (!isWsPath(pathname)) { + return false; + } + + const upgradeHeader = String(req.headers.upgrade || "").toLowerCase(); + if (upgradeHeader !== "websocket") { + writeHttpError( + socket, + 426, + JSON.stringify({ + error: { + message: "Upgrade Required", + code: "upgrade_required", + }, + }), + { Upgrade: "websocket" } + ); + return true; + } + + try { + const handshake = await performHandshake(fetchImpl, baseUrl, req.url || "/", req.headers); + if (!handshake.ok) { + writeHttpError(socket, handshake.status, handshake.bodyText || "{}", handshake.headers); + return true; + } + + const wsKey = req.headers["sec-websocket-key"]; + if (!isText(wsKey)) { + writeHttpError( + socket, + 400, + JSON.stringify({ + error: { + message: "Missing sec-websocket-key header", + code: "bad_websocket_handshake", + }, + }) + ); + return true; + } + + const acceptKey = createHash("sha1").update(`${wsKey}${WS_GUID}`).digest("base64"); + + const headers = [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${acceptKey}`, + "", + "", + ].join("\r\n"); + + socket.write(headers); + if (head && head.length > 0) { + socket.unshift(head); + } + + const session = new WebSocketSession({ + baseUrl, + fetchImpl, + idleTimeoutMs, + pingIntervalMs, + socket, + requestUrl: req.url || pathname, + requestHeaders: getForwardHeaders(req.url || pathname, req.headers), + }); + session.sendJson({ + type: "session.ready", + sessionId: session.sessionId, + path: handshake.bodyJson?.path || pathname, + wsAuth: handshake.bodyJson?.wsAuth === true, + authenticated: handshake.bodyJson?.authenticated === true, + authType: handshake.bodyJson?.authType || "none", + }); + return true; + } catch (error) { + writeHttpError( + socket, + 500, + JSON.stringify({ + error: { + message: error instanceof Error ? error.message : String(error), + code: "websocket_bridge_failed", + }, + }) + ); + return true; + } + }, + }; +} diff --git a/scripts/validate-pack-artifact.ts b/scripts/validate-pack-artifact.ts new file mode 100644 index 0000000000..2e6a5bf219 --- /dev/null +++ b/scripts/validate-pack-artifact.ts @@ -0,0 +1,94 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + PACK_ARTIFACT_ALLOWED_EXACT_PATHS, + PACK_ARTIFACT_ALLOWED_PATH_PREFIXES, + PACK_ARTIFACT_REQUIRED_PATHS, + findMissingArtifactPaths, + findUnexpectedArtifactPaths, +} from "./pack-artifact-policy.ts"; + +const __filename: string = fileURLToPath(import.meta.url); +const __dirname: string = dirname(__filename); +const ROOT: string = join(__dirname, ".."); +const npmCommand: string = process.platform === "win32" ? "npm.cmd" : "npm"; + +function runPackDryRun(): any { + const output = execFileSync(npmCommand, ["pack", "--dry-run", "--json", "--ignore-scripts"], { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + const parsed = JSON.parse(output); + const packReport = Array.isArray(parsed) ? parsed[0] : null; + + if (!packReport || !Array.isArray(packReport.files)) { + throw new Error("npm pack --dry-run --json did not return the expected files[] payload."); + } + + return packReport; +} + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 1024) { + return `${bytes || 0} B`; + } + + const units = ["KB", "MB", "GB"]; + let value = bytes / 1024; + let unitIndex = 0; + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex++; + } + + return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`; +} + +try { + const packReport = runPackDryRun(); + const artifactPaths: string[] = packReport.files.map((file: any) => file.path); + const unexpectedPaths: string[] = findUnexpectedArtifactPaths(artifactPaths, { + exactPaths: PACK_ARTIFACT_ALLOWED_EXACT_PATHS, + prefixPaths: PACK_ARTIFACT_ALLOWED_PATH_PREFIXES, + }); + const missingRequiredPaths: string[] = findMissingArtifactPaths( + artifactPaths, + PACK_ARTIFACT_REQUIRED_PATHS + ); + + console.log("📦 npm pack artifact summary"); + console.log(` File: ${packReport.filename}`); + console.log(` Entry count: ${packReport.entryCount}`); + console.log(` Packed size: ${formatBytes(packReport.size)}`); + console.log(` Unpacked size: ${formatBytes(packReport.unpackedSize)}`); + + if (unexpectedPaths.length > 0) { + console.error("\n❌ Unexpected files were found in the npm publish artifact:"); + for (const unexpectedPath of unexpectedPaths) { + console.error(` - ${unexpectedPath}`); + } + } + + if (missingRequiredPaths.length > 0) { + console.error("\n❌ Required runtime files are missing from the npm publish artifact:"); + for (const missingPath of missingRequiredPaths) { + console.error(` - ${missingPath}`); + } + } + + if (unexpectedPaths.length > 0 || missingRequiredPaths.length > 0) { + process.exit(1); + } + + console.log("\n✅ Pack artifact policy check passed."); +} catch (error) { + console.error(`\n❌ Pack artifact validation failed: ${error.message}`); + process.exit(1); +} diff --git a/src/app/(dashboard)/dashboard/agents/page.tsx b/src/app/(dashboard)/dashboard/agents/page.tsx index 8dda1bdc6c..85354278cf 100644 --- a/src/app/(dashboard)/dashboard/agents/page.tsx +++ b/src/app/(dashboard)/dashboard/agents/page.tsx @@ -43,7 +43,6 @@ const AGENT_ICON_MAP: Record = { droid: "droid", goose: "goose", aider: "aider", - iflow: "iflow", kiro: "kiro", nanobot: "nanobot", picoclaw: "picoclaw", diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.tsx index 3c86613ef8..b572e17162 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.tsx @@ -100,7 +100,10 @@ export default function ClaudeToolCard({ // Restore selected key from file: match token stored in file against known keys const tokenFromFile = env.ANTHROPIC_AUTH_TOKEN; if (tokenFromFile) { - const matchedKey = apiKeys?.find((k) => k.key === tokenFromFile); + // (#523) Keys from /api/keys are masked (first 8 + "****" + last 4). + // Mask the token from file to compare against the masked list. + const maskedToken = tokenFromFile.slice(0, 8) + "****" + tokenFromFile.slice(-4); + const matchedKey = apiKeys?.find((k) => k.key === maskedToken); if (matchedKey) setSelectedApiKey(matchedKey.id); } } @@ -439,13 +442,6 @@ export default function ClaudeToolCard({ arrow_forward - onModelMappingChange(model.alias, e.target.value)} - placeholder={t("providerModelPlaceholder")} - className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" - /> + onModelMappingChange(model.alias, e.target.value)} + placeholder={t("providerModelPlaceholder")} + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> {modelMappings[model.alias] && ( + setSelectedModel(e.target.value)} + placeholder="gpt-5.4" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> {selectedModel && ( )} + + {/* Reasoning Effort */} +
+ + Reasoning Effort + + + arrow_forward + + +
+ + {/* Wire API */} +
+ + Wire API + + + arrow_forward + + +
+ +
+ +
+ Model Aliases ([notice.model_migrations]) +
+ {CODEX_DEFAULT_MODELS.map((defaultModel) => ( +
+ + {defaultModel} + + + arrow_forward + + + + setModelMappings({ ...modelMappings, [defaultModel]: e.target.value }) + } + placeholder={`Route ${defaultModel} to...`} + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + {modelMappings[defaultModel] && ( + + )} +
+ ))} {message && ( diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index a94d9b7576..5cd52d6bf6 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -3195,7 +3195,6 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { ("all"); const [searchQuery, setSearchQuery] = useState(""); const [isLoading, setIsLoading] = useState(true); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [total, setTotal] = useState(0); + const [health, setHealth] = useState<{ working: boolean; latencyMs: number } | null>(null); + const [checkingHealth, setCheckingHealth] = useState(false); - useEffect(() => { - fetchMemories(); - }, []); - - const fetchMemories = async () => { + const fetchMemories = useCallback(async () => { try { - const response = await fetch("/api/memory"); + const params = new URLSearchParams({ + page: page.toString(), + limit: "20", + }); + if (filterType !== "all") params.append("type", filterType); + if (searchQuery) params.append("q", searchQuery); + + const response = await fetch(`/api/memory?${params.toString()}`); if (response.ok) { const data = await response.json(); - setMemories(data.memories || []); - setStats(data.stats || { totalEntries: 0, tokensUsed: 0, hitRate: 0 }); + setMemories(data.data || []); + setTotalPages(data.totalPages || 1); + setTotal(data.total || 0); + setStats({ + totalEntries: data.stats?.total ?? data.total ?? 0, + tokensUsed: data.stats?.tokensUsed ?? 0, + hitRate: data.stats?.hitRate ?? 0, + }); } } catch (error) { console.error("Failed to fetch memories:", error); } finally { setIsLoading(false); } - }; + }, [page, filterType, searchQuery]); + + useEffect(() => { + const timer = setTimeout(() => { + fetchMemories(); + }, 300); + return () => clearTimeout(timer); + }, [fetchMemories]); const handleDelete = async (id: string) => { try { @@ -73,14 +94,19 @@ export default function MemoryPage() { link.click(); }; - const filteredMemories = memories.filter((memory) => { - const matchesType = filterType === "all" || memory.type === filterType; - const matchesSearch = - searchQuery === "" || - memory.content.toLowerCase().includes(searchQuery.toLowerCase()) || - memory.key.toLowerCase().includes(searchQuery.toLowerCase()); - return matchesType && matchesSearch; - }); + const checkHealth = async () => { + setCheckingHealth(true); + try { + const res = await fetch("/api/memory/health"); + if (res.ok) { + setHealth(await res.json()); + } + } catch { + setHealth(null); + } finally { + setCheckingHealth(false); + } + }; const getTypeColor = (type: string) => { switch (type) { @@ -108,7 +134,26 @@ export default function MemoryPage() { return (
-

{t("title")}

+
+

{t("title")}

+
+ {health !== null && ( + + )} + {health === null && !checkingHealth && ( + + )} + +
+
+ +
+
+ Page {page} of {totalPages} ({total} total) +
+
+ + +
+
diff --git a/src/app/(dashboard)/dashboard/onboarding/page.tsx b/src/app/(dashboard)/dashboard/onboarding/page.tsx index 5cb0255115..71995da7f7 100644 --- a/src/app/(dashboard)/dashboard/onboarding/page.tsx +++ b/src/app/(dashboard)/dashboard/onboarding/page.tsx @@ -113,6 +113,16 @@ export default function OnboardingWizard() { setErrorMessage(data.error || t("failedSetPassword")); return; } + const loginRes = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + if (!loginRes.ok) { + const data = await loginRes.json().catch(() => ({})); + setErrorMessage(data.error || t("connectionError")); + return; + } handleNext(); } catch { setErrorMessage(t("connectionError")); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 55ba8d2d4b..5311a400b1 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -986,7 +986,16 @@ export default function ProviderDetailPage() { const isOAuth = providerSupportsOAuth && !providerSupportsPat; const registryModels = getModelsByProviderId(providerId); // For Gemini: always use synced API models (empty if no keys added yet) - const models = providerId === "gemini" ? syncedAvailableModels : registryModels; + // For other providers: merge registry models with custom/imported models (deduped) + const models = useMemo(() => { + if (providerId === "gemini") return syncedAvailableModels; + if (!modelMeta.customModels || modelMeta.customModels.length === 0) return registryModels; + const registryIds = new Set(registryModels.map((m) => m.id)); + const customExtras = modelMeta.customModels + .filter((cm: any) => cm.id && !registryIds.has(cm.id)) + .map((cm: any) => ({ id: cm.id, name: cm.name || cm.id })); + return [...registryModels, ...customExtras]; + }, [providerId, registryModels, syncedAvailableModels, modelMeta.customModels]); const providerAlias = getProviderAlias(providerId); const isManagedAvailableModelsProvider = isCompatible || providerId === "openrouter"; const isSearchProvider = providerId.endsWith("-search"); @@ -995,6 +1004,50 @@ export default function ProviderDetailPage() { const providerStorageAlias = isCompatible ? providerId : providerAlias; const providerDisplayAlias = isCompatible ? providerNode?.prefix || providerId : providerAlias; + const getApiLabel = () => { + if (isAnthropicProtocolCompatible) return t("messagesApi"); + const type = providerNode?.apiType; + switch (type) { + case "responses": + return t("responsesApi"); + case "embeddings": + return t("embeddings"); + case "audio-transcriptions": + return t("audioTranscriptions"); + case "audio-speech": + return t("audioSpeech"); + case "images-generations": + return t("imagesGenerations"); + default: + return t("chatCompletions"); + } + }; + + const getApiDefaultPath = () => { + if (isCcCompatible) return CC_COMPATIBLE_DEFAULT_CHAT_PATH; + if (isAnthropicCompatible) return "/messages"; + const type = providerNode?.apiType; + switch (type) { + case "responses": + return "/responses"; + case "embeddings": + return "/embeddings"; + case "audio-transcriptions": + return "/audio/transcriptions"; + case "audio-speech": + return "/audio/speech"; + case "images-generations": + return "/images/generations"; + default: + return "/chat/completions"; + } + }; + + const getApiPath = () => { + const defaultPath = getApiDefaultPath(); + return (providerNode?.chatPath || defaultPath).replace(/^\//, ""); + }; + // Define callbacks BEFORE the useEffect that uses them const fetchAliases = useCallback(async () => { try { @@ -2486,19 +2539,7 @@ export default function ProviderDetailPage() { : t("openaiCompatibleDetails")}

- {isAnthropicProtocolCompatible - ? t("messagesApi") - : providerNode.apiType === "responses" - ? t("responsesApi") - : t("chatCompletions")}{" "} - · {(providerNode.baseUrl || "").replace(/\/$/, "")}/ - {isCcCompatible - ? (providerNode.chatPath || CC_COMPATIBLE_DEFAULT_CHAT_PATH).replace(/^\//, "") - : isAnthropicCompatible - ? (providerNode.chatPath || "/messages").replace(/^\//, "") - : providerNode.apiType === "responses" - ? (providerNode.chatPath || "/responses").replace(/^\//, "") - : (providerNode.chatPath || "/chat/completions").replace(/^\//, "")} + {getApiLabel()} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/{getApiPath()}

@@ -2850,8 +2891,8 @@ export default function ProviderDetailPage() {

{t("availableModels")}

{renderModelsSection()} - {/* Custom Models — available for providers without managed available-model metadata */} - {!isManagedAvailableModelsProvider && providerId !== "gemini" && ( + {/* Custom Models — available for all providers */} + {!isManagedAvailableModelsProvider && ( setNewApiFormat(e.target.value)} className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" > - - + + + + + +
@@ -3963,8 +4008,12 @@ function CustomModelsSection({ onChange={(e) => setEditingApiFormat(e.target.value)} className="w-full px-2.5 py-2 text-xs border border-border rounded-lg bg-background text-text-main focus:outline-none focus:border-primary" > - - + + + + + +
@@ -5059,6 +5108,7 @@ ConnectionRow.propTypes = { const CONFIGURABLE_BASE_URL_PROVIDERS = new Set([ "bailian-coding-plan", + "xiaomi-mimo", "heroku", "databricks", "snowflake", @@ -5066,6 +5116,7 @@ const CONFIGURABLE_BASE_URL_PROVIDERS = new Set([ const DEFAULT_PROVIDER_BASE_URLS: Record = { "bailian-coding-plan": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1", + "xiaomi-mimo": "https://token-plan-ams.xiaomimimo.com/v1", }; function getProviderBaseUrlDefault(providerId?: string | null) { @@ -5076,6 +5127,8 @@ function getProviderBaseUrlHint(providerId?: string | null) { switch (providerId) { case "bailian-coding-plan": return "Optional: Custom base URL for bailian-coding-plan provider"; + case "xiaomi-mimo": + return "Optional: Xiaomi MiMo token-plan base URL. Examples: https://token-plan-ams.xiaomimimo.com/v1, https://token-plan-sgp.xiaomimimo.com/v1, https://token-plan-cn.xiaomimimo.com/v1. The app will append /chat/completions."; case "heroku": return "Required: paste the Heroku Inference base URL. The app will append /v1/chat/completions."; case "databricks": @@ -5090,6 +5143,7 @@ function getProviderBaseUrlHint(providerId?: string | null) { function getProviderBaseUrlPlaceholder(providerId?: string | null) { switch (providerId) { case "bailian-coding-plan": + case "xiaomi-mimo": return getProviderBaseUrlDefault(providerId); case "heroku": return "https://us.inference.heroku.com"; @@ -5117,7 +5171,7 @@ function AddApiKeyModal({ const defaultBaseUrl = getProviderBaseUrlDefault(provider); const isVertex = provider === "vertex"; const defaultRegion = "us-central1"; - const isGlm = provider === "glm"; + const isGlm = provider === "glm" || provider === "glmt"; const isQoder = provider === "qoder"; const isCloudflare = provider === "cloudflare-ai"; @@ -5131,6 +5185,7 @@ function AddApiKeyModal({ validationModelId: "", customUserAgent: "", accountId: "", + consoleApiKey: "", }); const [validating, setValidating] = useState(false); const [validationResult, setValidationResult] = useState(null); @@ -5211,6 +5266,9 @@ function AddApiKeyModal({ if (formData.customUserAgent.trim()) { providerSpecificData.customUserAgent = formData.customUserAgent.trim(); } + if (provider === "bailian-coding-plan" && formData.consoleApiKey.trim()) { + providerSpecificData.consoleApiKey = formData.consoleApiKey.trim(); + } if (usesBaseUrl) { providerSpecificData.baseUrl = validatedBaseUrl; } else if (isVertex) { @@ -5334,6 +5392,16 @@ function AddApiKeyModal({ placeholder="my-app/1.0" hint="Optional override sent upstream as the User-Agent header for this connection" /> + {provider === "bailian-coding-plan" && ( + setFormData({ ...formData, consoleApiKey: e.target.value })} + placeholder="Alibaba Console API Key" + hint="Required for quota fetching. Do not share." + type="password" + /> + )}
)} + {connection.provider === "bailian-coding-plan" && ( + setFormData({ ...formData, consoleApiKey: e.target.value })} + placeholder="Alibaba Console API Key" + hint="Required for quota fetching. Do not share." + type="password" + /> + )} )} { diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index ada2b317bb..dc9504562d 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -14,7 +14,13 @@ import { Select, Toggle, } from "@/shared/components"; -import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; +import { + OAUTH_PROVIDERS, + APIKEY_PROVIDERS, + WEB_COOKIE_PROVIDERS, + SEARCH_PROVIDERS, + AUDIO_ONLY_PROVIDERS, +} from "@/shared/constants/config"; import { FREE_PROVIDERS, isAnthropicCompatibleProvider, @@ -401,6 +407,21 @@ export default function ProvidersPage() { showConfiguredOnly ); + const webCookieProviderEntries = filterConfiguredProviderEntries( + buildProviderEntries(WEB_COOKIE_PROVIDERS, "apikey", "apikey", getProviderStats), + showConfiguredOnly + ); + + const searchProviderEntries = filterConfiguredProviderEntries( + buildProviderEntries(SEARCH_PROVIDERS, "apikey", "apikey", getProviderStats), + showConfiguredOnly + ); + + const audioProviderEntries = filterConfiguredProviderEntries( + buildProviderEntries(AUDIO_ONLY_PROVIDERS, "apikey", "apikey", getProviderStats), + showConfiguredOnly + ); + const compatibleProviderEntries = filterConfiguredProviderEntries( [ ...compatibleProviders.map((provider) => ({ @@ -594,6 +615,127 @@ export default function ProvidersPage() { + {/* Web / Cookie Providers */} + {webCookieProviderEntries.length > 0 && ( +
+
+

+ Web / Cookie Providers{" "} + +

+ +
+
+ {webCookieProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ) + )} +
+
+ )} + + {/* Search Providers */} + {searchProviderEntries.length > 0 && ( +
+
+

+ Search Providers +

+ +
+
+ {searchProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ) + )} +
+
+ )} + + {/* Audio Only Providers */} + {audioProviderEntries.length > 0 && ( +
+
+

+ Audio Providers +

+ +
+
+ {audioProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ) + )} +
+
+ )} + {/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */}
@@ -1007,6 +1149,10 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) { const apiTypeOptions = [ { value: "chat", label: t("chatCompletions") }, { value: "responses", label: t("responsesApi") }, + { value: "embeddings", label: t("embeddings") }, + { value: "audio-transcriptions", label: t("audioTranscriptions") }, + { value: "audio-speech", label: t("audioSpeech") }, + { value: "images-generations", label: t("imagesGenerations") }, ]; useEffect(() => { diff --git a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx index afb37e2986..17e57f992d 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx @@ -54,7 +54,7 @@ export default function ComboDefaultsTab() { const numericSettings = [ { key: "maxRetries", label: t("maxRetriesLabel"), min: 0, max: 5 }, { key: "retryDelayMs", label: t("retryDelayLabel"), min: 500, max: 10000, step: 500 }, - { key: "timeoutMs", label: t("timeoutLabel"), min: 5000, max: 300000, step: 5000 }, + { key: "timeoutMs", label: t("timeoutLabel"), min: 5000, step: 5000 }, { key: "maxComboDepth", label: t("maxNestingDepth"), min: 1, max: 10 }, ]; @@ -439,7 +439,6 @@ export default function ComboDefaultsTab() { diff --git a/src/app/(dashboard)/dashboard/settings/components/ModelsDevSyncTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ModelsDevSyncTab.tsx index 60d6bdfceb..117e6d168d 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ModelsDevSyncTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ModelsDevSyncTab.tsx @@ -32,6 +32,7 @@ export default function ModelsDevSyncTab() { const [saving, setSaving] = useState(false); const [enabled, setEnabled] = useState(false); const [intervalHours, setIntervalHours] = useState(24); + const [draftIntervalHours, setDraftIntervalHours] = useState(24); const [feedback, setFeedback] = useState<{ type: "success" | "error"; message: string } | null>( null ); @@ -55,7 +56,9 @@ export default function ModelsDevSyncTab() { if (settingsData) { setEnabled(settingsData.modelsDevSyncEnabled === true); const intervalMs = settingsData.modelsDevSyncInterval || 86400000; - setIntervalHours(Math.round(intervalMs / 3600000)); + const hours = Math.round(intervalMs / 3600000); + setIntervalHours(hours); + setDraftIntervalHours(hours); } }) .catch((err) => { @@ -123,6 +126,7 @@ export default function ModelsDevSyncTab() { const updateInterval = async (hours: number) => { const oldInterval = intervalHours; setIntervalHours(hours); + setDraftIntervalHours(hours); try { const res = await fetch("/api/settings", { method: "PATCH", @@ -131,12 +135,14 @@ export default function ModelsDevSyncTab() { }); if (!res.ok) { setIntervalHours(oldInterval); + setDraftIntervalHours(oldInterval); setFeedback({ type: "error", message: t("enableSyncError") || "Failed to update" }); } else { setFeedback({ type: "success", message: "Interval updated" }); } } catch { setIntervalHours(oldInterval); + setDraftIntervalHours(oldInterval); setFeedback({ type: "error", message: "Network error" }); } finally { setTimeout(() => setFeedback(null), 3000); @@ -231,15 +237,19 @@ export default function ModelsDevSyncTab() {

{t("modelsDevInterval")}

- {intervalHours}h + + {draftIntervalHours}h +
updateInterval(parseInt(e.target.value))} + value={draftIntervalHours} + onChange={(e) => setDraftIntervalHours(parseInt(e.target.value))} + onMouseUp={(e) => updateInterval(parseInt((e.target as HTMLInputElement).value))} + onBlur={(e) => updateInterval(parseInt(e.target.value))} className="w-full accent-blue-500" />
diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index 869d54603f..97af9d2166 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -165,7 +165,7 @@ export default function RoutingTab() {

Client Cache Control

- Configure how client-side cache_control headers are handled + Configure whether OmniRoute preserves client-provided cache_control markers

@@ -175,17 +175,17 @@ export default function RoutingTab() { { value: "auto", label: "Auto (Recommended)", - desc: "Preserve cache_control for native Claude-compatible flows with deterministic routing; CC-compatible bridges use OmniRoute-managed markers", + desc: "For deterministic Claude-compatible flows, preserve client-provided cache_control as-is. If the request has no cache_control, OmniRoute does not inject any bridge-owned markers for CC-compatible third-party proxy compatibility.", }, { value: "always", label: "Always Preserve", - desc: "Always forward client cache_control headers to upstream providers", + desc: "Always forward client-provided cache_control headers to upstream providers as-is.", }, { value: "never", label: "Never Preserve", - desc: "Always remove client cache_control headers, let OmniRoute manage caching", + desc: "Always remove client cache_control headers and let OmniRoute manage caching where native provider flows support it.", }, ].map((option) => (
+
+
+
+

Database backup retention

+

+ Automatic SQLite backups are stored in db_backups. Configure how many + snapshots to keep and optionally delete backups older than N days. +

+
+
+ + {storageHealth.backupCount || 0} backups + + + Max {storageHealth.backupRetention.maxFiles} + + + {storageHealth.backupRetention.days > 0 + ? `${storageHealth.backupRetention.days}d retention` + : "Age retention off"} + +
+
+
+ + + +
+ {cleanupBackupsStatus.message && ( +
+
+ + {cleanupBackupsStatus.message} +
+
+ )} +
+ {/* Export / Import */}
+ +
+
)} @@ -409,6 +466,35 @@ export default function SkillsPage() { +
+ + Page {execPage} of {execTotalPages} ({execTotal} total) + +
+ + +
+
)} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index ba2abdd5e2..5a46679c0b 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -35,6 +35,7 @@ const PROVIDER_CONFIG = { codex: { label: "OpenAI Codex", color: "#10A37F" }, claude: { label: "Claude Code", color: "#D97757" }, glm: { label: "GLM (Z.AI)", color: "#4A90D9" }, + glmt: { label: "GLM Thinking", color: "#2563EB" }, "kimi-coding": { label: "Kimi Coding", color: "#1E3A8A" }, }; @@ -290,7 +291,8 @@ export default function ProviderLimits() { claude: 5, kiro: 6, glm: 7, - "kimi-coding": 8, + glmt: 8, + "kimi-coding": 9, }; return [...filteredConnections].sort( (a, b) => (priority[a.provider] || 9) - (priority[b.provider] || 9) diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index f94d87e776..0f09d899c1 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; import { getSettings } from "@/lib/localDb"; import bcrypt from "bcryptjs"; import { SignJWT } from "jose"; @@ -10,12 +11,32 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; if (!process.env.JWT_SECRET) { console.error("[SECURITY] FATAL: JWT_SECRET is not set. Login authentication is disabled."); } -const SECRET = new TextEncoder().encode(process.env.JWT_SECRET || ""); + +function getJwtSecret(): Uint8Array { + return new TextEncoder().encode(process.env.JWT_SECRET || ""); +} + +// Test seam for cookie store injection without affecting runtime behavior. +export const authRouteInternals = { + getCookieStore: cookies, +}; export async function POST(request) { + const auditContext = getAuditRequestContext(request); + try { // Fail-fast if JWT_SECRET is not configured if (!process.env.JWT_SECRET) { + logAuditEvent({ + action: "auth.login.misconfigured", + actor: "system", + target: "dashboard-auth", + resourceType: "auth_session", + status: "failed", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { reason: "missing_jwt_secret" }, + }); return NextResponse.json( { error: "Server misconfigured: JWT_SECRET not set. Contact administrator." }, { status: 500 } @@ -43,6 +64,16 @@ export async function POST(request) { } else { // SECURITY: No default password — must be set via env or onboarding if (!process.env.INITIAL_PASSWORD) { + logAuditEvent({ + action: "auth.login.setup_required", + actor: "anonymous", + target: "dashboard-auth", + resourceType: "auth_session", + status: "failed", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { reason: "missing_initial_password" }, + }); return NextResponse.json( { error: "No password configured. Complete onboarding first.", needsSetup: true }, { status: 403 } @@ -62,9 +93,9 @@ export async function POST(request) { const token = await new SignJWT({ authenticated: true }) .setProtectedHeader({ alg: "HS256" }) .setExpirationTime("30d") - .sign(SECRET); + .sign(getJwtSecret()); - const cookieStore = await cookies(); + const cookieStore = await authRouteInternals.getCookieStore(); cookieStore.set("auth_token", token, { httpOnly: true, secure: useSecureCookie, @@ -72,12 +103,48 @@ export async function POST(request) { path: "/", }); + logAuditEvent({ + action: "auth.login.success", + actor: "admin", + target: "dashboard-auth", + resourceType: "auth_session", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + hasStoredPassword: Boolean(storedHash), + secureCookie: useSecureCookie, + }, + }); + return NextResponse.json({ success: true }); } + logAuditEvent({ + action: "auth.login.failed", + actor: "anonymous", + target: "dashboard-auth", + resourceType: "auth_session", + status: "failed", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { reason: "invalid_password" }, + }); return NextResponse.json({ error: "Invalid password" }, { status: 401 }); } catch (error) { console.error("[AUTH] Login failed:", error); + logAuditEvent({ + action: "auth.login.error", + actor: "system", + target: "dashboard-auth", + resourceType: "auth_session", + status: "failed", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + message: error instanceof Error ? error.message : "unknown_error", + }, + }); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index b09c38d55e..a4f9b7256d 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,8 +1,23 @@ import { NextResponse } from "next/server"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; import { cookies } from "next/headers"; -export async function POST() { - const cookieStore = await cookies(); +export const logoutRouteInternals = { + getCookieStore: cookies, +}; + +export async function POST(request) { + const auditContext = getAuditRequestContext(request); + const cookieStore = await logoutRouteInternals.getCookieStore(); cookieStore.delete("auth_token"); + logAuditEvent({ + action: "auth.logout.success", + actor: "admin", + target: "dashboard-auth", + resourceType: "auth_session", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + }); return NextResponse.json({ success: true }); } diff --git a/src/app/api/auth/status/route.ts b/src/app/api/auth/status/route.ts index 7e8ebea8c6..9cc4d872c6 100644 --- a/src/app/api/auth/status/route.ts +++ b/src/app/api/auth/status/route.ts @@ -2,18 +2,22 @@ import { NextResponse } from "next/server"; import { cookies } from "next/headers"; import { jwtVerify } from "jose"; -const SECRET = process.env.JWT_SECRET ? new TextEncoder().encode(process.env.JWT_SECRET) : null; +function getJwtSecret(): Uint8Array | null { + const secret = process.env.JWT_SECRET?.trim(); + return secret ? new TextEncoder().encode(secret) : null; +} export async function GET() { try { const cookieStore = await cookies(); const token = cookieStore.get("auth_token")?.value; + const secret = getJwtSecret(); - if (!token || !SECRET) { + if (!token || !secret) { return NextResponse.json({ authenticated: false }); } - await jwtVerify(token, SECRET); + await jwtVerify(token, secret); return NextResponse.json({ authenticated: true }); } catch { return NextResponse.json({ authenticated: false }); diff --git a/src/app/api/cli-tools/claude-settings/route.ts b/src/app/api/cli-tools/claude-settings/route.ts index 1c3dc32edc..63205c9e2a 100644 --- a/src/app/api/cli-tools/claude-settings/route.ts +++ b/src/app/api/cli-tools/claude-settings/route.ts @@ -95,17 +95,19 @@ export async function POST(request: Request) { return NextResponse.json({ error: writeGuard }, { status: 403 }); } + // (#523/#526) Extract keyId BEFORE validation — Zod strips unknown fields! + // The /api/keys list endpoint returns masked key strings — sending those to + // disk would save an unusable half-hidden token. Resolving by ID guarantees + // we always write the full key value to the config file. + const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; + const validation = validateBody(cliSettingsEnvSchema, rawBody); if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } const { env } = validation.data; - // (#523/#526) If a keyId was provided, resolve the real API key from DB. - // The /api/keys list endpoint returns masked key strings — sending those to - // disk would save an unusable half-hidden token. Resolving by ID guarantees - // we always write the full key value to the config file. - const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; + // Resolve the real API key from DB by ID if (keyId) { try { const keyRecord = await getApiKeyById(keyId); diff --git a/src/app/api/cli-tools/cline-settings/route.ts b/src/app/api/cli-tools/cline-settings/route.ts index 2c6fe656d3..b032babb93 100644 --- a/src/app/api/cli-tools/cline-settings/route.ts +++ b/src/app/api/cli-tools/cline-settings/route.ts @@ -122,14 +122,16 @@ export async function POST(request: Request) { return NextResponse.json({ error: writeGuard }, { status: 403 }); } + // (#526) Extract keyId BEFORE validation — Zod strips unknown fields! + const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; + const validation = validateBody(cliModelConfigSchema, rawBody); if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } let { baseUrl, apiKey, model } = validation.data; - // (#526) Resolve real key from DB if keyId was provided - const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; + // Resolve real key from DB by ID if (keyId) { try { const keyRecord = await getApiKeyById(keyId); diff --git a/src/app/api/cli-tools/codex-settings/route.ts b/src/app/api/cli-tools/codex-settings/route.ts index 6067f52326..deac929dae 100644 --- a/src/app/api/cli-tools/codex-settings/route.ts +++ b/src/app/api/cli-tools/codex-settings/route.ts @@ -72,7 +72,8 @@ const toToml = (parsed: Record) => { lines.push(""); lines.push(`[${section}]`); Object.entries(values).forEach(([key, value]) => { - lines.push(`${key} = "${value}"`); + const formattedKey = key.includes(".") ? `"${key}"` : key; + lines.push(`${formattedKey} = "${value}"`); }); }); @@ -95,6 +96,7 @@ const readConfig = async () => { const hasOmniRouteConfig = (config: string | null) => { if (!config) return false; return ( + config.includes("openai_base_url") || config.includes('model_provider = "omniroute"') || config.includes("[model_providers.omniroute]") ); @@ -163,11 +165,16 @@ export async function POST(request: Request) { return NextResponse.json({ error: writeGuard }, { status: 403 }); } + // (#549) Extract keyId BEFORE validation — Zod strips unknown fields! + // The dashboard sends masked key strings — resolving by ID guarantees + // we always write the full key value to the config file. + const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; + const validation = validateBody(cliModelConfigSchema, rawBody); if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const { baseUrl, model } = validation.data; + const { baseUrl, model, reasoningEffort, wireApi, modelMappings } = validation.data; let { apiKey } = validation.data; if (!apiKey) { return NextResponse.json( @@ -176,10 +183,7 @@ export async function POST(request: Request) { ); } - // (#549) Resolve real key from DB if keyId was provided. - // The dashboard sends masked key strings — resolving by ID guarantees - // we always write the full key value to the config file. - const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; + // Resolve real key from DB by ID if (keyId) { try { const keyRecord = await getApiKeyById(keyId); @@ -212,16 +216,38 @@ export async function POST(request: Request) { // Update only OmniRoute related fields (api_key goes to auth.json, not config.toml) parsed._root.model = model; - parsed._root.model_provider = "omniroute"; - // Update or create omniroute provider section (no api_key - Codex reads from auth.json) - // Ensure /v1 suffix is added only once - const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + if (reasoningEffort && reasoningEffort !== "none") { + // Optional: low, medium, high + parsed._root.model_reasoning_effort = reasoningEffort; + } else { + delete parsed._root.model_reasoning_effort; + } + + // Fix: Codex CLI sends /chat/completions; ensure the base resolves strictly to /api/v1 + const normalizedBaseUrl = baseUrl.replace(/\/v1\/?$/, "").replace(/\/api\/?$/, "") + "/api/v1"; + + // Always create a custom provider to reliably pass wire_api and use OMNIROUTE_API_KEY + parsed._root.model_provider = "omniroute"; parsed._sections["model_providers.omniroute"] = { name: "OmniRoute", base_url: normalizedBaseUrl, - wire_api: "responses", + wire_api: wireApi || "chat", + env_key: "OPENAI_API_KEY", }; + delete parsed._root.openai_base_url; + + // Process model aliases into notice.model_migrations + if (modelMappings && Object.keys(modelMappings).length > 0) { + if (!parsed._sections["notice.model_migrations"]) { + parsed._sections["notice.model_migrations"] = {}; + } + for (const [from, to] of Object.entries(modelMappings)) { + parsed._sections["notice.model_migrations"][from] = to; + } + } else { + delete parsed._sections["notice.model_migrations"]; + } // Write merged config const configContent = toToml(parsed); @@ -285,7 +311,9 @@ export async function DELETE() { throw error; } - // Remove OmniRoute related root fields only if they point to omniroute + // Remove OmniRoute related root fields + delete parsed._root.openai_base_url; + if (parsed._root.model_provider === "omniroute") { delete parsed._root.model; delete parsed._root.model_provider; diff --git a/src/app/api/cli-tools/droid-settings/route.ts b/src/app/api/cli-tools/droid-settings/route.ts index 3e3be5fdb4..25cd6d52cc 100644 --- a/src/app/api/cli-tools/droid-settings/route.ts +++ b/src/app/api/cli-tools/droid-settings/route.ts @@ -98,6 +98,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: writeGuard }, { status: 403 }); } + // (#549) Extract keyId BEFORE validation — Zod strips unknown fields! + const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; + const validation = validateBody(cliModelConfigSchema, rawBody); if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); @@ -105,8 +108,7 @@ export async function POST(request: Request) { const { baseUrl, model } = validation.data; let { apiKey } = validation.data; - // (#549) Resolve real key from DB if keyId was provided. - const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; + // Resolve real key from DB by ID if (keyId) { try { const keyRecord = await getApiKeyById(keyId); diff --git a/src/app/api/cli-tools/kilo-settings/route.ts b/src/app/api/cli-tools/kilo-settings/route.ts index 0974ee5455..95248e3139 100644 --- a/src/app/api/cli-tools/kilo-settings/route.ts +++ b/src/app/api/cli-tools/kilo-settings/route.ts @@ -130,6 +130,9 @@ export async function POST(request) { return NextResponse.json({ error: writeGuard }, { status: 403 }); } + // (#549) Extract keyId BEFORE validation — Zod strips unknown fields! + const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; + const validation = validateBody(cliModelConfigSchema, rawBody); if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); @@ -137,8 +140,7 @@ export async function POST(request) { const { baseUrl, model } = validation.data; let { apiKey } = validation.data; - // (#549) Resolve real key from DB if keyId was provided. - const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; + // Resolve real key from DB by ID if (keyId) { try { const keyRecord = await getApiKeyById(keyId); diff --git a/src/app/api/cli-tools/openclaw-settings/route.ts b/src/app/api/cli-tools/openclaw-settings/route.ts index e8dbebbe77..88958bb5b7 100644 --- a/src/app/api/cli-tools/openclaw-settings/route.ts +++ b/src/app/api/cli-tools/openclaw-settings/route.ts @@ -98,14 +98,16 @@ export async function POST(request: Request) { return NextResponse.json({ error: writeGuard }, { status: 403 }); } + // (#526) Extract keyId BEFORE validation — Zod strips unknown fields! + const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; + const validation = validateBody(cliModelConfigSchema, rawBody); if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } let { baseUrl, apiKey, model } = validation.data; - // (#526) Resolve real key from DB if keyId was provided - const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; + // Resolve real key from DB by ID if (keyId) { try { const keyRecord = await getApiKeyById(keyId); diff --git a/src/app/api/combos/test/route.ts b/src/app/api/combos/test/route.ts index 01a0962168..fdee2529c0 100644 --- a/src/app/api/combos/test/route.ts +++ b/src/app/api/combos/test/route.ts @@ -18,12 +18,15 @@ function buildComboTestResult(target, partial = {}) { }; } -async function testComboTarget(target, internalUrl) { +async function testComboTarget(target, baseInternalUrl) { const startTime = Date.now(); try { - // Send a minimal but real chat request through the same internal - // endpoint an external OpenAI-compatible client would use. - const testBody = buildComboTestRequestBody(target.modelStr); + const isEmbedding = + target.modelStr.toLowerCase().includes("embedding") || + target.modelStr.toLowerCase().includes("bge-") || + target.modelStr.toLowerCase().includes("text-embed"); + const internalUrl = `${baseInternalUrl}/v1/${isEmbedding ? "embeddings" : "chat/completions"}`; + const testBody = buildComboTestRequestBody(target.modelStr, isEmbedding); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 20000); @@ -137,9 +140,9 @@ export async function POST(request) { return NextResponse.json({ error: "Combo has no models" }, { status: 400 }); } - const internalUrl = `${getBaseUrl(request)}/v1/chat/completions`; + const baseInternalUrl = getBaseUrl(request); const results = await Promise.all( - targets.map((target) => testComboTarget(target, internalUrl)) + targets.map((target) => testComboTarget(target, baseInternalUrl)) ); const resolvedResult = results.find((result) => result.status === "ok") || null; const resolvedBy = resolvedResult?.model || null; diff --git a/src/app/api/compliance/audit-log/route.ts b/src/app/api/compliance/audit-log/route.ts index 2e9b419e92..80c03ece58 100644 --- a/src/app/api/compliance/audit-log/route.ts +++ b/src/app/api/compliance/audit-log/route.ts @@ -1,17 +1,42 @@ import { NextResponse } from "next/server"; -import { getAuditLog, logAuditEvent } from "@/lib/compliance/index"; +import { countAuditLog, getAuditLog } from "@/lib/compliance/index"; + +function parsePagination(value: string | null, fallback: number, min: number, max: number) { + const parsed = Number.parseInt(value || "", 10); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(max, Math.max(min, parsed)); +} export async function GET(request) { try { const { searchParams } = new URL(request.url); - const action = searchParams.get("action") || undefined; - const actor = searchParams.get("actor") || undefined; - const limit = parseInt(searchParams.get("limit") || "50", 10); - const offset = parseInt(searchParams.get("offset") || "0", 10); + const filters = { + action: searchParams.get("action") || undefined, + actor: searchParams.get("actor") || undefined, + target: searchParams.get("target") || undefined, + resourceType: + searchParams.get("resourceType") || searchParams.get("resource_type") || undefined, + status: searchParams.get("status") || undefined, + requestId: searchParams.get("requestId") || searchParams.get("request_id") || undefined, + from: searchParams.get("from") || searchParams.get("since") || undefined, + to: searchParams.get("to") || searchParams.get("until") || undefined, + limit: parsePagination(searchParams.get("limit"), 50, 1, 500), + offset: parsePagination(searchParams.get("offset"), 0, 0, 10_000), + }; - const logs = getAuditLog({ action, actor, limit, offset }); - return NextResponse.json(logs); + const logs = getAuditLog(filters); + const total = countAuditLog(filters); + return NextResponse.json(logs, { + headers: { + "x-total-count": String(total), + "x-page-limit": String(filters.limit), + "x-page-offset": String(filters.offset), + }, + }); } catch (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch audit log" }, + { status: 500 } + ); } } diff --git a/src/app/api/db-backups/exportAll/route.ts b/src/app/api/db-backups/exportAll/route.ts index 3a786ae6a9..4cb2e75144 100644 --- a/src/app/api/db-backups/exportAll/route.ts +++ b/src/app/api/db-backups/exportAll/route.ts @@ -1,8 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; import { getDbInstance, SQLITE_FILE } from "@/lib/db/core"; +import { CALL_LOGS_DIR } from "@/lib/usage/callLogArtifacts"; import fs from "fs"; import path from "path"; import os from "os"; +import { execSync } from "node:child_process"; import { isAuthenticated } from "@/shared/utils/apiAuth"; /** @@ -34,7 +36,7 @@ export async function GET(request: NextRequest) { // 1. Export database using native backup API const dbBackupPath = path.join(tempDir, "storage.sqlite"); - db.backup(dbBackupPath); + await db.backup(dbBackupPath); // 2. Export settings as JSON const settings: Record = {}; @@ -89,7 +91,12 @@ export async function GET(request: NextRequest) { } fs.writeFileSync(path.join(tempDir, "api-keys.json"), JSON.stringify(apiKeys, null, 2)); - // 6. Export metadata + // 6. Export call log artifacts directory + if (CALL_LOGS_DIR && fs.existsSync(CALL_LOGS_DIR)) { + fs.cpSync(CALL_LOGS_DIR, path.join(tempDir, "call_logs"), { recursive: true }); + } + + // 7. Export metadata const metadata = { exportedAt: new Date().toISOString(), version: process.env.npm_package_version || "unknown", @@ -100,13 +107,13 @@ export async function GET(request: NextRequest) { "combos.json - Combo configurations", "providers.json - Provider connections (no credentials)", "api-keys.json - API key metadata (masked)", + "call_logs/ - Detailed call log artifacts", ], }; fs.writeFileSync(path.join(tempDir, "metadata.json"), JSON.stringify(metadata, null, 2)); // Create ZIP using tar (available on all Linux/macOS, and the archiver npm package is not installed) // We'll use Node.js built-in zlib to create a simple tar.gz instead - const { execSync } = require("node:child_process"); const tarPath = zipPath.replace(".zip", ".tar.gz"); execSync(`tar -czf "${tarPath}" -C "${path.dirname(tempDir)}" "${path.basename(tempDir)}"`, { timeout: 30000, diff --git a/src/app/api/db-backups/route.ts b/src/app/api/db-backups/route.ts index 167a081742..fefaefd284 100644 --- a/src/app/api/db-backups/route.ts +++ b/src/app/api/db-backups/route.ts @@ -1,6 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; -import { listDbBackups, restoreDbBackup, backupDbFile } from "@/lib/localDb"; -import { dbBackupRestoreSchema } from "@/shared/validation/schemas"; +import { + listDbBackups, + restoreDbBackup, + backupDbFile, + cleanupDbBackups, + getDbBackupMaxFiles, + getDbBackupRetentionDays, +} from "@/lib/localDb"; +import { dbBackupCleanupSchema, dbBackupRestoreSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { isAuthenticated } from "@/shared/utils/apiAuth"; @@ -82,3 +89,49 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: error.message }, { status: 500 }); } } + +/** + * DELETE /api/db-backups — Cleanup old database backups. + * Body: { keepLatest?: number, retentionDays?: number } + */ +export async function DELETE(request) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let rawBody = {}; + try { + const text = await request.text(); + if (text.trim()) rawBody = JSON.parse(text); + } catch { + return NextResponse.json( + { + error: { + message: "Invalid request", + details: [{ field: "body", message: "Invalid JSON body" }], + }, + }, + { status: 400 } + ); + } + + try { + const validation = validateBody(dbBackupCleanupSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const keepLatest = validation.data.keepLatest ?? getDbBackupMaxFiles(); + const retentionDays = validation.data.retentionDays ?? getDbBackupRetentionDays(); + const result = cleanupDbBackups({ maxFiles: keepLatest, retentionDays }); + return NextResponse.json({ + cleaned: true, + keepLatest, + retentionDays, + ...result, + }); + } catch (error) { + console.error("[API] Error cleaning DB backups:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/logs/export/route.ts b/src/app/api/logs/export/route.ts index 3ee0390ff9..3e25814332 100644 --- a/src/app/api/logs/export/route.ts +++ b/src/app/api/logs/export/route.ts @@ -1,4 +1,5 @@ import { getDbInstance } from "@/lib/db/core"; +import { exportCallLogsSince } from "@/lib/usage/callLogs"; /** * GET /api/logs/export — export logs as JSON @@ -19,10 +20,7 @@ export async function GET(request: Request) { if (logType === "call-logs" || logType === "request-logs") { tableName = "call_logs"; - const stmt = db.prepare( - "SELECT * FROM call_logs WHERE timestamp >= @since ORDER BY timestamp DESC" - ); - rows = stmt.all({ since }); + rows = await exportCallLogsSince(since); } else if (logType === "proxy-logs") { tableName = "proxy_logs"; const stmt = db.prepare( diff --git a/src/app/api/memory/health/route.ts b/src/app/api/memory/health/route.ts new file mode 100644 index 0000000000..5fadd6e457 --- /dev/null +++ b/src/app/api/memory/health/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { verifyExtractionPipeline } from "@/lib/memory/verify"; + +export async function GET() { + try { + const result = await verifyExtractionPipeline("health-check"); + return NextResponse.json(result); + } catch (err: unknown) { + const error = err instanceof Error ? err.message : String(err); + return NextResponse.json({ working: false, latencyMs: 0, error }, { status: 500 }); + } +} diff --git a/src/app/api/memory/route.ts b/src/app/api/memory/route.ts index ad773098a2..785b75002f 100644 --- a/src/app/api/memory/route.ts +++ b/src/app/api/memory/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { listMemories, createMemory } from "@/lib/memory/store"; import { MemoryType } from "@/lib/memory/types"; +import { parsePaginationParams, buildPaginatedResponse } from "@/shared/types/pagination"; import { z } from "zod"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; @@ -16,31 +17,50 @@ const createMemorySchema = z.object({ export async function GET(request: Request) { try { - const { searchParams } = new URL(request.url); + const url = new URL(request.url); + const { searchParams } = url; + + const paginationParams = parsePaginationParams(searchParams); + const rawOffset = searchParams.get("offset"); + const offset = + typeof rawOffset === "string" && rawOffset.trim().length > 0 + ? Math.max(0, Number.parseInt(rawOffset, 10) || 0) + : undefined; + const query = searchParams.get("q") || undefined; + const apiKeyId = searchParams.get("apiKeyId") || undefined; const type = (searchParams.get("type") as any) || undefined; const sessionId = searchParams.get("sessionId") || undefined; - const limitParams = searchParams.get("limit"); - const offsetParams = searchParams.get("offset"); - const memories = await listMemories({ + const result = await listMemories({ apiKeyId, type, sessionId, - limit: limitParams ? parseInt(limitParams, 10) : undefined, - offset: offsetParams ? parseInt(offsetParams, 10) : undefined, + query, + limit: paginationParams.limit, + offset, + page: offset === undefined ? paginationParams.page : undefined, }); + const stats = { - total: memories.length, - byType: memories.reduce( - (acc, m) => { - acc[m.type] = (acc[m.type] || 0) + 1; - return acc; - }, - {} as Record - ), + total: result.total, + byType: result.byType ?? {}, }; - return NextResponse.json({ memories, stats }); + + const responsePagination = + offset === undefined + ? paginationParams + : { + ...paginationParams, + page: Math.floor(offset / paginationParams.limit) + 1, + }; + + const paginatedResponse = buildPaginatedResponse(result.data, result.total, responsePagination); + + return NextResponse.json({ + ...paginatedResponse, + stats, + }); } catch (err: unknown) { const error = err instanceof Error ? err.message : String(err); return NextResponse.json({ error }, { status: 500 }); diff --git a/src/app/api/oauth/cursor/auto-import/route.ts b/src/app/api/oauth/cursor/auto-import/route.ts index 07f5c3cfae..69bef3b59f 100755 --- a/src/app/api/oauth/cursor/auto-import/route.ts +++ b/src/app/api/oauth/cursor/auto-import/route.ts @@ -1,12 +1,100 @@ import { NextResponse } from "next/server"; import { homedir } from "os"; import { join } from "path"; +import { readFile } from "fs/promises"; import Database from "better-sqlite3"; import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +/** + * Try to read credentials from cursor-agent's auth.json + * (written by `cursor-agent` CLI after login). + */ +async function tryAgentAuth(): Promise<{ + found: boolean; + accessToken?: string; + source?: string; + error?: string; +}> { + try { + const authPath = join(homedir(), ".config", "cursor", "auth.json"); + const raw = await readFile(authPath, "utf-8"); + const auth = JSON.parse(raw); + if (auth.accessToken && typeof auth.accessToken === "string") { + return { found: true, accessToken: auth.accessToken, source: "cursor-agent" }; + } + return { found: false, error: "cursor-agent auth.json has no accessToken" }; + } catch { + return { found: false, error: "cursor-agent auth.json not found" }; + } +} + +/** + * Try to read credentials from Cursor IDE's state.vscdb + */ +function tryIdeAuth(): { + found: boolean; + accessToken?: string; + machineId?: string; + source?: string; + error?: string; +} { + const platform = process.platform; + let dbPath; + + if (platform === "darwin") { + dbPath = join(homedir(), "Library/Application Support/Cursor/User/globalStorage/state.vscdb"); + } else if (platform === "linux") { + dbPath = join(homedir(), ".config/Cursor/User/globalStorage/state.vscdb"); + } else if (platform === "win32") { + dbPath = join(process.env.APPDATA || "", "Cursor/User/globalStorage/state.vscdb"); + } else { + return { found: false, error: "Unsupported platform" }; + } + + let db; + try { + db = new Database(dbPath, { readonly: true, fileMustExist: true }); + } catch { + return { found: false, error: "Cursor IDE database not found" }; + } + + try { + const rows = db + .prepare("SELECT key, value FROM itemTable WHERE key IN (?, ?)") + .all("cursorAuth/accessToken", "storage.serviceMachineId") as { + key: string; + value: string; + }[]; + + const tokens: Record = {}; + for (const row of rows) { + if (row.key === "cursorAuth/accessToken") tokens.accessToken = row.value; + else if (row.key === "storage.serviceMachineId") tokens.machineId = row.value; + } + + db.close(); + + if (!tokens.accessToken) { + return { found: false, error: "Tokens not found in database" }; + } + + return { + found: true, + accessToken: tokens.accessToken, + machineId: tokens.machineId, + source: "cursor-ide", + }; + } catch (error) { + db?.close(); + return { found: false, error: `Failed to read database: ${(error as any).message}` }; + } +} + /** * GET /api/oauth/cursor/auto-import - * Auto-detect and extract Cursor tokens from local SQLite database. + * Auto-detect and extract Cursor tokens from: + * 1. Cursor IDE's local SQLite database (state.vscdb) — includes machineId + * 2. cursor-agent CLI's auth.json — fallback, no machineId * * 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-4). */ @@ -18,69 +106,31 @@ export async function GET(request: Request) { } try { - const platform = process.platform; - let dbPath; - - // Determine database path based on platform - if (platform === "darwin") { - dbPath = join(homedir(), "Library/Application Support/Cursor/User/globalStorage/state.vscdb"); - } else if (platform === "linux") { - dbPath = join(homedir(), ".config/Cursor/User/globalStorage/state.vscdb"); - } else if (platform === "win32") { - dbPath = join(process.env.APPDATA || "", "Cursor/User/globalStorage/state.vscdb"); - } else { - return NextResponse.json({ error: "Unsupported platform", found: false }, { status: 400 }); - } - - // Try to open database - let db; - try { - db = new Database(dbPath, { readonly: true, fileMustExist: true }); - } catch (error) { - return NextResponse.json({ - found: false, - error: - "Cursor database not found. Make sure Cursor IDE is installed and you are logged in.", - }); - } - - try { - // Extract tokens from database - const rows = db - .prepare("SELECT key, value FROM itemTable WHERE key IN (?, ?)") - .all("cursorAuth/accessToken", "storage.serviceMachineId"); - - const tokens: Record = {}; - for (const row of rows) { - if (row.key === "cursorAuth/accessToken") { - tokens.accessToken = row.value; - } else if (row.key === "storage.serviceMachineId") { - tokens.machineId = row.value; - } - } - - db.close(); - - // Validate tokens exist - if (!tokens.accessToken || !tokens.machineId) { - return NextResponse.json({ - found: false, - error: "Tokens not found in database. Please login to Cursor IDE first.", - }); - } - + // Try Cursor IDE first (has both accessToken and machineId) + const ideResult = tryIdeAuth(); + if (ideResult.found) { return NextResponse.json({ found: true, - accessToken: tokens.accessToken, - machineId: tokens.machineId, - }); - } catch (error) { - db?.close(); - return NextResponse.json({ - found: false, - error: `Failed to read database: ${(error as any).message}`, + accessToken: ideResult.accessToken, + machineId: ideResult.machineId, + source: ideResult.source, }); } + + // Fall back to cursor-agent CLI auth (accessToken only, no machineId) + const agentResult = await tryAgentAuth(); + if (agentResult.found) { + return NextResponse.json({ + found: true, + accessToken: agentResult.accessToken, + source: agentResult.source, + }); + } + + return NextResponse.json({ + found: false, + error: "No Cursor credentials found. Install Cursor IDE or login with cursor-agent.", + }); } catch (error) { console.log("Cursor auto-import error:", error); return NextResponse.json({ found: false, error: (error as any).message }, { status: 500 }); diff --git a/src/app/api/oauth/cursor/import/route.ts b/src/app/api/oauth/cursor/import/route.ts index 657e75741f..a0da0c4d4e 100755 --- a/src/app/api/oauth/cursor/import/route.ts +++ b/src/app/api/oauth/cursor/import/route.ts @@ -45,7 +45,7 @@ export async function POST(request: any) { // Validate token by making API call (through proxy if configured) const tokenData = await runWithProxyContext(proxy, () => - cursorService.validateImportToken(accessToken.trim(), machineId.trim()) + cursorService.validateImportToken(accessToken.trim(), machineId?.trim()) ); // Try to extract user info from token diff --git a/src/app/api/provider-nodes/[id]/route.ts b/src/app/api/provider-nodes/[id]/route.ts index ef1dc3b8e4..b11daa306e 100644 --- a/src/app/api/provider-nodes/[id]/route.ts +++ b/src/app/api/provider-nodes/[id]/route.ts @@ -62,10 +62,15 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: } // Only validate apiType for OpenAI Compatible nodes - if ( - node.type === "openai-compatible" && - (!apiType || !["chat", "responses"].includes(apiType)) - ) { + const validApiTypes = [ + "chat", + "responses", + "embeddings", + "audio-transcriptions", + "audio-speech", + "images-generations", + ]; + if (node.type === "openai-compatible" && (!apiType || !validApiTypes.includes(apiType))) { return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 }); } diff --git a/src/app/api/provider-nodes/validate/route.ts b/src/app/api/provider-nodes/validate/route.ts index 27d6b9f4e7..bc2d13c962 100644 --- a/src/app/api/provider-nodes/validate/route.ts +++ b/src/app/api/provider-nodes/validate/route.ts @@ -1,5 +1,16 @@ import { NextResponse } from "next/server"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; import { validateClaudeCodeCompatibleProvider } from "@/lib/providers/validation"; +import { + SAFE_OUTBOUND_FETCH_PRESETS, + SafeOutboundFetchError, + getSafeOutboundFetchErrorStatus, + safeOutboundFetch, +} from "@/shared/network/safeOutboundFetch"; +import { + PROVIDER_URL_BLOCKED_MESSAGE, + getProviderOutboundGuard, +} from "@/shared/network/outboundUrlGuard"; import { isCcCompatibleProviderEnabled } from "@/shared/utils/featureFlags"; import { providerNodeValidateSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; @@ -18,8 +29,19 @@ function sanitizeClaudeCodeCompatibleBaseUrl(baseUrl: string) { .replace(/\/(?:v\d+\/)?messages(?:\?[^#]*)?$/i, ""); } +function sanitizeAuditBaseUrl(baseUrl: string) { + if (!baseUrl) return null; + try { + const parsed = new URL(baseUrl); + return `${parsed.origin}${parsed.pathname}`.replace(/\/$/, "") || parsed.origin; + } catch { + return baseUrl; + } +} + // POST /api/provider-nodes/validate - Validate API key against base URL export async function POST(request) { + const auditContext = getAuditRequestContext(request); let rawBody; try { rawBody = await request.json(); @@ -74,7 +96,9 @@ export async function POST(request) { // Use /models endpoint for validation as many compatible providers support it (like OpenAI) const modelsUrl = `${normalizedBase}${modelsPath || "/models"}`; - const res = await fetch(modelsUrl, { + const res = await safeOutboundFetch(modelsUrl, { + ...SAFE_OUTBOUND_FETCH_PRESETS.validationRead, + guard: getProviderOutboundGuard(), method: "GET", headers: { "x-api-key": apiKey, @@ -88,12 +112,43 @@ export async function POST(request) { // OpenAI Compatible Validation (Default) const modelsUrl = `${baseUrl.replace(/\/$/, "")}${modelsPath || "/models"}`; - const res = await fetch(modelsUrl, { + const res = await safeOutboundFetch(modelsUrl, { + ...SAFE_OUTBOUND_FETCH_PRESETS.validationRead, + guard: getProviderOutboundGuard(), headers: { Authorization: `Bearer ${apiKey}` }, }); return NextResponse.json({ valid: res.ok, error: res.ok ? null : "Invalid API key" }); } catch (error) { + const status = getSafeOutboundFetchErrorStatus(error); + if (status) { + const message = error instanceof Error ? error.message : "Validation failed"; + if ( + error instanceof SafeOutboundFetchError && + error.code === "URL_GUARD_BLOCKED" && + message.includes(PROVIDER_URL_BLOCKED_MESSAGE) + ) { + const attemptedBaseUrl = + rawBody && typeof rawBody === "object" && "baseUrl" in rawBody + ? String((rawBody as { baseUrl?: unknown }).baseUrl || "") + : ""; + logAuditEvent({ + action: "provider.validation.ssrf_blocked", + actor: "admin", + target: "provider-node", + resourceType: "provider_validation", + status: "blocked", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + route: "/api/provider-nodes/validate", + reason: message, + baseUrl: sanitizeAuditBaseUrl(attemptedBaseUrl), + }, + }); + } + return NextResponse.json({ error: message }, { status }); + } console.log("Error validating provider node:", error); return NextResponse.json({ error: "Validation failed" }, { status: 500 }); } diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 09d05ecbc0..2e75d914d9 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -7,8 +7,16 @@ import { } from "@/shared/constants/providers"; import { PROVIDER_MODELS } from "@/shared/constants/models"; import { getModelIsHidden, resolveProxyForProvider } from "@/lib/localDb"; +import { + SAFE_OUTBOUND_FETCH_PRESETS, + getSafeOutboundFetchErrorStatus, + safeOutboundFetch, +} from "@/shared/network/safeOutboundFetch"; +import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard"; import { getStaticQoderModels } from "@omniroute/open-sse/services/qoderCli.ts"; -import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; +import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts"; +import { getAntigravityModelsDiscoveryUrls } from "@omniroute/open-sse/config/antigravityUpstream.ts"; +import { getGlmModelsUrl } from "@omniroute/open-sse/config/glmProvider.ts"; type JsonRecord = Record; @@ -16,20 +24,57 @@ function asRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } +function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + function getProviderBaseUrl(providerSpecificData: unknown): string | null { const data = asRecord(providerSpecificData); const baseUrl = data.baseUrl; return typeof baseUrl === "string" && baseUrl.trim().length > 0 ? baseUrl : null; } -const GLM_MODELS_URLS = { - international: "https://api.z.ai/api/coding/paas/v4/models", - china: "https://open.bigmodel.cn/api/coding/paas/v4/models", -} as const; +function normalizeAntigravityModelsResponse(data: unknown): Array<{ id: string; name: string }> { + const payload = asRecord(data).models; -function getGlmApiRegion(providerSpecificData: unknown): keyof typeof GLM_MODELS_URLS { - const data = asRecord(providerSpecificData); - return data.apiRegion === "china" ? "china" : "international"; + if (Array.isArray(payload)) { + return payload + .map((value) => { + const item = asRecord(value); + const id = + typeof item.id === "string" + ? item.id + : typeof item.name === "string" + ? item.name + : typeof item.model === "string" + ? item.model + : ""; + const name = + typeof item.displayName === "string" + ? item.displayName + : typeof item.name === "string" + ? item.name + : id; + return id ? { id, name } : null; + }) + .filter((value): value is { id: string; name: string } => Boolean(value)); + } + + const modelsById = asRecord(payload); + return Object.entries(modelsById) + .map(([id, value]) => { + const item = asRecord(value); + const name = + typeof item.displayName === "string" + ? item.displayName + : typeof item.name === "string" + ? item.name + : id; + return id ? { id, name } : null; + }) + .filter((value): value is { id: string; name: string } => Boolean(value)); } type ProviderModelsConfigEntry = { @@ -190,9 +235,9 @@ const PROVIDER_MODELS_CONFIG: Record = { parseResponse: (data) => data.data || [], }, antigravity: { - url: "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:models", + url: getAntigravityModelsDiscoveryUrls()[0], method: "POST", - headers: { "Content-Type": "application/json" }, + headers: getAntigravityHeaders("models"), authHeader: "Authorization", authPrefix: "Bearer ", body: {}, @@ -444,16 +489,16 @@ export async function GET( for (const modelsUrl of uniqueEndpoints) { try { - const response = await runWithProxyContext(proxy, () => - fetch(modelsUrl, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - signal: AbortSignal.timeout(5000), // Quick timeout for fallbacks - }) - ); + const response = await safeOutboundFetch(modelsUrl, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsProbe, + guard: getProviderOutboundGuard(), + proxyConfig: proxy, + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + }); if (response.ok) { const data = await response.json(); @@ -467,6 +512,10 @@ export async function GET( } } catch (err: any) { if (err.message === "auth_failed") break; // Don't try other endpoints if auth failed + const status = getSafeOutboundFetchErrorStatus(err); + if (status) { + throw err; + } } } @@ -513,20 +562,20 @@ export async function GET( }); } - if (provider === "glm") { - const region = getGlmApiRegion(connection.providerSpecificData); - const url = GLM_MODELS_URLS[region]; + if (provider === "glm" || provider === "glmt") { + const url = getGlmModelsUrl(connection.providerSpecificData); const token = apiKey || accessToken; - const response = await runWithProxyContext(proxy, () => - fetch(url, { - method: "GET", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }) - ); + const response = await safeOutboundFetch(url, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery, + guard: getProviderOutboundGuard(), + proxyConfig: proxy, + method: "GET", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); if (!response.ok) { return NextResponse.json( @@ -562,16 +611,19 @@ export async function GET( } try { - const quotaRes = await runWithProxyContext(proxy, () => - fetch("https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", { + const quotaRes = await safeOutboundFetch( + "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", + { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery, + guard: getProviderOutboundGuard(), + proxyConfig: proxy, method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ project: projectId }), - signal: AbortSignal.timeout(10000), - }) + } ); if (!quotaRes.ok) { @@ -602,6 +654,63 @@ export async function GET( } } + if (provider === "antigravity") { + const staticModels = STATIC_MODEL_PROVIDERS.antigravity(); + const discoveryUrls = getAntigravityModelsDiscoveryUrls(); + + if (!accessToken) { + return buildResponse({ + provider, + connectionId, + models: staticModels, + source: "local_catalog", + warning: "OAuth token unavailable — using cached catalog", + }); + } + + for (const discoveryUrl of discoveryUrls) { + try { + const response = await safeOutboundFetch(discoveryUrl, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery, + guard: getProviderOutboundGuard(), + proxyConfig: proxy, + method: "POST", + headers: getAntigravityHeaders("models", accessToken), + body: JSON.stringify({}), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.warn( + `[models] antigravity discovery failed at ${discoveryUrl} (${response.status}): ${errorText}` + ); + continue; + } + + const remoteModels = normalizeAntigravityModelsResponse(await response.json()); + if (remoteModels.length > 0) { + return buildResponse({ + provider, + connectionId, + models: remoteModels, + source: "api", + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[models] antigravity discovery threw for ${discoveryUrl}: ${message}`); + } + } + + return buildResponse({ + provider, + connectionId, + models: staticModels, + source: "local_catalog", + warning: "API unavailable — using cached catalog", + }); + } + if (isAnthropicCompatibleProvider(provider)) { if (isClaudeCodeCompatibleProvider(provider)) { return NextResponse.json( @@ -623,19 +732,23 @@ export async function GET( baseUrl = baseUrl.slice(0, -9); } - const url = `${baseUrl}/models`; + // Use modelsPath from provider node if available, otherwise default to /models + const psd = asRecord(connection.providerSpecificData); + const modelsPath = toNonEmptyString(psd.modelsPath) || "/models"; + const url = `${baseUrl}${modelsPath}`; const token = accessToken || apiKey; - const response = await runWithProxyContext(proxy, () => - fetch(url, { - method: "GET", - headers: { - "Content-Type": "application/json", - ...(apiKey ? { "x-api-key": apiKey } : {}), - "anthropic-version": "2023-06-01", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }) - ); + const response = await safeOutboundFetch(url, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery, + guard: getProviderOutboundGuard(), + proxyConfig: proxy, + method: "GET", + headers: { + "Content-Type": "application/json", + ...(apiKey ? { "x-api-key": apiKey } : {}), + "anthropic-version": "2023-06-01", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); if (!response.ok) { const errorText = await response.text(); @@ -751,12 +864,12 @@ export async function GET( while (pageUrl && pageCount < MAX_PAGES) { pageCount++; - const response = await runWithProxyContext(proxy, () => - fetch(pageUrl, { - ...fetchOptions, - signal: AbortSignal.timeout(15_000), - }) - ); + const response = await safeOutboundFetch(pageUrl, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsPagination, + guard: getProviderOutboundGuard(), + proxyConfig: proxy, + ...fetchOptions, + }); if (!response.ok) { const errorText = await response.text(); @@ -796,6 +909,11 @@ export async function GET( models: allModels, }); } catch (error) { + const status = getSafeOutboundFetchErrorStatus(error); + if (status) { + const message = error instanceof Error ? error.message : "Failed to fetch models"; + return NextResponse.json({ error: message }, { status }); + } console.log("Error fetching provider models:", error); return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 }); } diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index 5ade90e58b..57c236c4bd 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -1,4 +1,9 @@ import { NextResponse } from "next/server"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { + getProviderAuditTarget, + summarizeProviderConnectionForAudit, +} from "@/lib/compliance/providerAudit"; import { getProviderConnectionById, updateProviderConnection, @@ -51,6 +56,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: delete result.accessToken; delete result.refreshToken; delete result.idToken; + if (result.providerSpecificData) { + delete result.providerSpecificData.consoleApiKey; + } return NextResponse.json({ connection: result }); } catch (error) { @@ -61,6 +69,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: // PUT /api/providers/[id] - Update connection export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { + const auditContext = getAuditRequestContext(request); let rawBody; try { rawBody = await request.json(); @@ -155,10 +164,29 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: delete result.accessToken; delete result.refreshToken; delete result.idToken; + if (result.providerSpecificData) { + delete result.providerSpecificData.consoleApiKey; + } // Auto sync to Cloud if enabled await syncToCloudIfEnabled(); + logAuditEvent({ + action: "provider.credentials.updated", + actor: "admin", + target: getProviderAuditTarget(updated || existing), + resourceType: "provider_credentials", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider: existing.provider, + changedFields: Object.keys(updateData), + before: summarizeProviderConnectionForAudit(existing), + after: summarizeProviderConnectionForAudit(updated), + }, + }); + return NextResponse.json({ connection: result }); } catch (error) { console.log("Error updating connection:", error); @@ -168,6 +196,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: // DELETE /api/providers/[id] - Delete connection export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + const auditContext = getAuditRequestContext(request); + try { const { id } = await params; @@ -195,6 +225,20 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i // Auto sync to Cloud if enabled await syncToCloudIfEnabled(); + logAuditEvent({ + action: "provider.credentials.revoked", + actor: "admin", + target: getProviderAuditTarget(connection), + resourceType: "provider_credentials", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider: connection.provider, + connection: summarizeProviderConnectionForAudit(connection), + }, + }); + return NextResponse.json({ message: "Connection deleted successfully" }); } catch (error) { console.log("Error deleting connection:", error); diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index 3b3c46dd88..acb041ce54 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -52,12 +52,6 @@ const OAUTH_TEST_CONFIG = { authPrefix: "Bearer ", extraHeaders: { "User-Agent": "OmniRoute", Accept: "application/vnd.github+json" }, }, - iflow: { - // iFlow's getUserInfo endpoint returns 400 without a specific format. - // Use checkExpiry instead — actual connectivity is validated via real requests. - checkExpiry: true, - refreshable: true, - }, qwen: { // DashScope (previously portal.qwen.ai) /v1/models might return 404 or auth issues. // Use checkExpiry instead — actual connectivity is validated via real requests. diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index e09af29cb1..f3cfc76ded 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -1,4 +1,9 @@ import { NextResponse } from "next/server"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { + getProviderAuditTarget, + summarizeProviderConnectionForAudit, +} from "@/lib/compliance/providerAudit"; import { getProviderConnections, createProviderConnection, @@ -30,6 +35,12 @@ export async function GET() { accessToken: undefined, refreshToken: undefined, idToken: undefined, + providerSpecificData: c.providerSpecificData + ? { + ...c.providerSpecificData, + consoleApiKey: undefined, + } + : undefined, })); return NextResponse.json({ connections: safeConnections }); @@ -41,6 +52,8 @@ export async function GET() { // POST /api/providers - Create new connection (API Key only, OAuth via separate flow) export async function POST(request: Request) { + const auditContext = getAuditRequestContext(request); + try { const body = await request.json(); @@ -153,10 +166,27 @@ export async function POST(request: Request) { // Hide sensitive fields const result: Record = { ...newConnection }; delete result.apiKey; + if (result.providerSpecificData) { + delete result.providerSpecificData.consoleApiKey; + } // Auto sync to Cloud if enabled await syncToCloudIfEnabled(); + logAuditEvent({ + action: "provider.credentials.created", + actor: "admin", + target: getProviderAuditTarget(newConnection), + resourceType: "provider_credentials", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider: provider, + connection: summarizeProviderConnectionForAudit(newConnection), + }, + }); + return NextResponse.json({ connection: result }, { status: 201 }); } catch (error) { console.log("Error creating provider:", error); diff --git a/src/app/api/providers/validate/route.ts b/src/app/api/providers/validate/route.ts index 610c827d55..98ec8ebf77 100644 --- a/src/app/api/providers/validate/route.ts +++ b/src/app/api/providers/validate/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; import { getProviderNodeById } from "@/models"; import { isClaudeCodeCompatibleProvider, @@ -11,8 +12,19 @@ import { validateProviderApiKeySchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; +function sanitizeAuditUrl(url: string | null | undefined) { + if (!url) return null; + try { + const parsed = new URL(url); + return `${parsed.origin}${parsed.pathname}`.replace(/\/$/, "") || parsed.origin; + } catch { + return String(url); + } +} + // POST /api/providers/validate - Validate API key with provider export async function POST(request) { + const auditContext = getAuditRequestContext(request); let rawBody; try { rawBody = await request.json(); @@ -86,6 +98,30 @@ export async function POST(request) { return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 }); } + if (!result.valid && typeof result.statusCode === "number") { + if (result.securityBlocked) { + logAuditEvent({ + action: "provider.validation.ssrf_blocked", + actor: "admin", + target: provider, + resourceType: "provider_validation", + status: "blocked", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider, + route: "/api/providers/validate", + reason: result.error || "Blocked provider validation target", + baseUrl: sanitizeAuditUrl(bodyBaseUrl || providerSpecificData?.baseUrl), + }, + }); + } + return NextResponse.json( + { error: result.error || "Validation failed" }, + { status: result.statusCode } + ); + } + return NextResponse.json({ valid: !!result.valid, error: result.valid ? null : result.error || "Invalid API key", diff --git a/src/app/api/search/stats/route.ts b/src/app/api/search/stats/route.ts index a7dfc78b58..361f1f05c3 100644 --- a/src/app/api/search/stats/route.ts +++ b/src/app/api/search/stats/route.ts @@ -42,7 +42,7 @@ export async function GET(request: Request) { const recentRows = db .prepare( ` - SELECT request_body, provider, timestamp + SELECT request_summary, provider, timestamp FROM call_logs WHERE request_type = 'search' ORDER BY timestamp DESC @@ -55,12 +55,11 @@ export async function GET(request: Request) { let query = ""; let filters = {}; try { - const body = JSON.parse(row.request_body); - query = body.query || ""; - const { query: _q, provider: _p, ...rest } = body; - filters = rest; + const summary = JSON.parse(row.request_summary); + query = summary.query || ""; + filters = summary.filters || {}; } catch { - // Unparseable request_body + // Unparseable request_summary } return { query, diff --git a/src/app/api/settings/proxy/test/route.ts b/src/app/api/settings/proxy/test/route.ts index f74bd911e3..cace16576c 100644 --- a/src/app/api/settings/proxy/test/route.ts +++ b/src/app/api/settings/proxy/test/route.ts @@ -137,7 +137,7 @@ export async function POST(request: Request) { const dispatcher = createProxyDispatcher(proxyUrl); try { - const result = await undiciRequest("https://api.ipify.org?format=json", { + const result = await undiciRequest("https://api64.ipify.org?format=json", { method: "GET", dispatcher, signal: controller.signal, diff --git a/src/app/api/settings/purge-logs/route.ts b/src/app/api/settings/purge-logs/route.ts index 66cdb178ed..b752b7dbab 100644 --- a/src/app/api/settings/purge-logs/route.ts +++ b/src/app/api/settings/purge-logs/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { getDbInstance } from "@/lib/db/core"; import { getCallLogRetentionDays } from "@/lib/logEnv"; +import { deleteCallLogsBefore } from "@/lib/usage/callLogs"; import { isAuthenticated } from "@/shared/utils/apiAuth"; export async function POST(request: Request) { @@ -8,11 +8,13 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } try { - const db = getDbInstance(); const retentionMs = getCallLogRetentionDays() * 24 * 60 * 60 * 1000; const cutoff = new Date(Date.now() - retentionMs).toISOString(); - const result = db.prepare("DELETE FROM call_logs WHERE timestamp < ?").run(cutoff); - return NextResponse.json({ deleted: result.changes }); + const result = deleteCallLogsBefore(cutoff); + return NextResponse.json({ + deleted: result.deletedRows, + deletedArtifacts: result.deletedArtifacts, + }); } catch (err: unknown) { const error = err instanceof Error ? err.message : String(err); return NextResponse.json({ error }, { status: 500 }); diff --git a/src/app/api/settings/require-login/route.ts b/src/app/api/settings/require-login/route.ts index 6b33fadb21..70653e5d7a 100644 --- a/src/app/api/settings/require-login/route.ts +++ b/src/app/api/settings/require-login/route.ts @@ -1,14 +1,23 @@ import { NextResponse } from "next/server"; -import { getSettings, updateSettings } from "@/lib/localDb"; import bcrypt from "bcryptjs"; +import { getSettings, updateSettings } from "@/lib/localDb"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { getNodeRuntimeSupport } from "@/shared/utils/nodeRuntimeSupport.ts"; import { updateRequireLoginSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -// Node.js compatibility check — better-sqlite3 requires Node <24 +// Node.js compatibility check — reflect the supported secure runtime floors used by CLI/CI. function getNodeCompatibility() { - const nodeVersion = process.version; - const major = parseInt(nodeVersion.replace("v", "").split(".")[0], 10); - return { nodeVersion, nodeCompatible: major >= 18 && major < 24 }; + const { nodeVersion, nodeCompatible } = getNodeRuntimeSupport(); + return { nodeVersion, nodeCompatible }; +} + +function hasConfiguredPassword(settings: Record) { + return Boolean(settings.password) || Boolean(process.env.INITIAL_PASSWORD); +} + +function isBootstrapSecurityWindow(settings: Record) { + return settings.setupComplete !== true && !hasConfiguredPassword(settings); } export async function GET() { @@ -30,9 +39,14 @@ export async function GET() { /** * POST /api/settings/require-login — Set password and/or toggle requireLogin. - * Used by the onboarding wizard security step. + * Unauthenticated writes are only allowed during the initial bootstrap window. */ export async function POST(request: Request) { + const settings = await getSettings(); + if (!isBootstrapSecurityWindow(settings) && !(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/skills/executions/route.ts b/src/app/api/skills/executions/route.ts index 33aa6f1a6f..93d7708a3c 100644 --- a/src/app/api/skills/executions/route.ts +++ b/src/app/api/skills/executions/route.ts @@ -1,23 +1,31 @@ import { NextResponse } from "next/server"; import { skillExecutor } from "@/lib/skills/executor"; +import { parsePaginationParams, buildPaginatedResponse } from "@/shared/types/pagination"; +import { z } from "zod"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; export async function GET(request: Request) { if (!(await isAuthenticated(request))) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } try { - const executions = skillExecutor.listExecutions(); - return NextResponse.json({ executions }); + const url = new URL(request.url); + const params = parsePaginationParams(url.searchParams); + const apiKeyId = url.searchParams.get("apiKeyId") || undefined; + const total = skillExecutor.countExecutions(apiKeyId); + const executions = skillExecutor.listExecutions( + apiKeyId, + params.limit, + (params.page - 1) * params.limit + ); + return NextResponse.json(buildPaginatedResponse(executions, total, params)); } catch (err: unknown) { const error = err instanceof Error ? err.message : String(err); return NextResponse.json({ error }, { status: 500 }); } } -import { z } from "zod"; -import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; -import { isAuthenticated } from "@/shared/utils/apiAuth"; - const executionSchema = z.object({ skillName: z.string().min(1), apiKeyId: z.string().min(1), diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index ca2a8e1580..3d62075e42 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -1,11 +1,19 @@ import { NextResponse } from "next/server"; import { skillRegistry } from "@/lib/skills/registry"; +import { parsePaginationParams, buildPaginatedResponse } from "@/shared/types/pagination"; -export async function GET() { +export async function GET(request?: Request) { try { await skillRegistry.loadFromDatabase(); - const skills = skillRegistry.list(); - return NextResponse.json({ skills }); + const allSkills = skillRegistry.list(); + const url = request?.url || "http://localhost/api/skills"; + const params = parsePaginationParams(new URL(url).searchParams); + const paged = allSkills.slice((params.page - 1) * params.limit, params.page * params.limit); + const response = buildPaginatedResponse(paged, allSkills.length, params); + return NextResponse.json({ + ...response, + skills: response.data, + }); } catch (err: unknown) { const error = err instanceof Error ? err.message : String(err); return NextResponse.json({ error }, { status: 500 }); diff --git a/src/app/api/storage/health/route.ts b/src/app/api/storage/health/route.ts index b4e98b0ea3..eca65a2bc9 100644 --- a/src/app/api/storage/health/route.ts +++ b/src/app/api/storage/health/route.ts @@ -8,6 +8,7 @@ import { getCallLogsTableMaxRows, getProxyLogsTableMaxRows, } from "@/lib/logEnv"; +import { getDbBackupMaxFiles, getDbBackupRetentionDays } from "@/lib/db/backup"; /** * GET /api/storage/health — Return database storage information. @@ -70,6 +71,10 @@ export async function GET() { callLogs: getCallLogsTableMaxRows(), proxyLogs: getProxyLogsTableMaxRows(), }, + backupRetention: { + maxFiles: getDbBackupMaxFiles(), + days: getDbBackupRetentionDays(), + }, dataDir: dataDir.startsWith(homeDir) ? "~" + dataDir.slice(homeDir.length) : dataDir, }); } catch (error) { diff --git a/src/app/api/sync/bundle/route.ts b/src/app/api/sync/bundle/route.ts new file mode 100644 index 0000000000..a834cfb949 --- /dev/null +++ b/src/app/api/sync/bundle/route.ts @@ -0,0 +1,56 @@ +import { buildConfigSyncEnvelope } from "@/lib/sync/bundle"; +import { getSyncTokenFromRequest, markSyncTokenUsed, validateSyncToken } from "@/lib/sync/tokens"; +import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; + +function matchesEtag(request: Request, version: string) { + const ifNoneMatch = request.headers.get("if-none-match"); + if (!ifNoneMatch) return false; + const candidates = ifNoneMatch + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + return candidates.some((candidate) => candidate === version || candidate === `"${version}"`); +} + +function responseHeaders(version: string) { + return { + etag: `"${version}"`, + "x-config-version": version, + "cache-control": "private, no-store", + }; +} + +export async function GET(request: Request) { + try { + const rawToken = getSyncTokenFromRequest(request); + const syncToken = await validateSyncToken(rawToken); + if (!syncToken) { + return createErrorResponse({ + status: 401, + message: "Invalid sync token", + }); + } + + const { version, bundle } = await buildConfigSyncEnvelope(); + await markSyncTokenUsed(syncToken); + + if (matchesEtag(request, version)) { + return new Response(null, { + status: 304, + headers: responseHeaders(version), + }); + } + + return Response.json( + { + version, + bundle, + }, + { + headers: responseHeaders(version), + } + ); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to build sync bundle"); + } +} diff --git a/src/app/api/sync/tokens/[id]/route.ts b/src/app/api/sync/tokens/[id]/route.ts new file mode 100644 index 0000000000..cbb43b3bab --- /dev/null +++ b/src/app/api/sync/tokens/[id]/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { getSyncTokenById } from "@/lib/db/syncTokens"; +import { revokeSyncTokenById } from "@/lib/sync/tokens"; + +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const auditContext = getAuditRequestContext(request); + + try { + const { id } = await params; + const existing = await getSyncTokenById(id); + if (!existing) { + return createErrorResponse({ + status: 404, + message: "Sync token not found", + }); + } + + const revoked = await revokeSyncTokenById(id); + if (!revoked) { + return createErrorResponse({ + status: 404, + message: "Sync token not found", + }); + } + + logAuditEvent({ + action: "sync.token.revoked", + actor: "admin", + target: revoked.name, + resourceType: "sync_token", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + id: revoked.id, + name: revoked.name, + syncApiKeyId: revoked.syncApiKeyId, + revokedAt: revoked.revokedAt, + }, + }); + + return NextResponse.json({ + message: "Sync token revoked successfully", + syncToken: revoked, + }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to revoke sync token"); + } +} diff --git a/src/app/api/sync/tokens/route.ts b/src/app/api/sync/tokens/route.ts new file mode 100644 index 0000000000..4839a7feb5 --- /dev/null +++ b/src/app/api/sync/tokens/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from "next/server"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createSyncTokenSchema } from "@/shared/validation/schemas"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { + issueSyncToken, + listSyncTokenSummaries, + resolveSyncApiKeyIdFromManagementRequest, +} from "@/lib/sync/tokens"; + +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const tokens = await listSyncTokenSummaries(); + return NextResponse.json({ + tokens, + total: tokens.length, + }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to list sync tokens"); + } +} + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const auditContext = getAuditRequestContext(request); + + let rawBody; + try { + rawBody = await request.json(); + } catch { + return createErrorResponse({ + status: 400, + message: "Invalid JSON body", + }); + } + + try { + const validation = validateBody(createSyncTokenSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const syncApiKeyId = await resolveSyncApiKeyIdFromManagementRequest(request); + const issued = await issueSyncToken({ + name: validation.data.name, + syncApiKeyId, + }); + + const tokenSummary = { + id: issued.record.id, + name: issued.record.name, + syncApiKeyId: issued.record.syncApiKeyId, + revokedAt: issued.record.revokedAt, + lastUsedAt: issued.record.lastUsedAt, + createdAt: issued.record.createdAt, + updatedAt: issued.record.updatedAt, + }; + + logAuditEvent({ + action: "sync.token.created", + actor: "admin", + target: issued.record.name, + resourceType: "sync_token", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: tokenSummary, + }); + + return NextResponse.json( + { + token: issued.token, + syncToken: tokenSummary, + }, + { status: 201 } + ); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to create sync token"); + } +} diff --git a/src/app/api/v1/embeddings/route.ts b/src/app/api/v1/embeddings/route.ts index 87eb20ba20..487855a7d6 100644 --- a/src/app/api/v1/embeddings/route.ts +++ b/src/app/api/v1/embeddings/route.ts @@ -120,9 +120,10 @@ export async function POST(request) { const nodes = (await getProviderNodes()) as unknown as EmbeddingProviderNodeRow[]; dynamicProviders = (Array.isArray(nodes) ? nodes : []) .filter((n) => { - // provider_nodes apiType is "chat" or "responses" (not "embeddings") — local OpenAI-compatible + // provider_nodes apiType is "chat", "responses" or "embeddings" — local OpenAI-compatible // backends expose /embeddings under the same base URL as chat, so we build the URL as baseUrl + /embeddings. - if (n.apiType !== "chat" && n.apiType !== "responses") return false; + const validTypes = ["chat", "responses", "embeddings"]; + if (!validTypes.includes(n.apiType || "")) return false; try { const hostname = new URL(n.baseUrl).hostname; // Strictly matching 172.16.0.0/12 (Docker/local) and explicitly blocking ::1 per SSRF hardening @@ -170,7 +171,9 @@ export async function POST(request) { const allNodes = (await getProviderNodes()) as unknown as EmbeddingProviderNodeRow[]; const matchingNode = (Array.isArray(allNodes) ? allNodes : []).find( (n) => - n.prefix === provider && (n.apiType === "chat" || n.apiType === "responses") && n.baseUrl + n.prefix === provider && + (n.apiType === "chat" || n.apiType === "responses" || n.apiType === "embeddings") && + n.baseUrl ); if (matchingNode) { const baseUrl = String(matchingNode.baseUrl).replace(/\/+$/, ""); diff --git a/src/app/api/v1/messages/count_tokens/route.ts b/src/app/api/v1/messages/count_tokens/route.ts index 55316f9ece..3362654906 100644 --- a/src/app/api/v1/messages/count_tokens/route.ts +++ b/src/app/api/v1/messages/count_tokens/route.ts @@ -1,6 +1,11 @@ import { CORS_HEADERS } from "@/shared/utils/cors"; import { v1CountTokensSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { estimateTokens } from "@/shared/utils/costEstimator"; +import { getExecutor } from "@omniroute/open-sse/executors/index.ts"; +import { getModelInfo } from "@/sse/services/model"; +import { extractApiKey, getProviderCredentials, isValidApiKey } from "@/sse/services/auth"; +import * as log from "@/sse/utils/logger"; /** * Handle CORS preflight @@ -10,7 +15,8 @@ export async function OPTIONS() { } /** - * POST /v1/messages/count_tokens - Mock token count response + * POST /v1/messages/count_tokens - Hybrid token count response. + * Uses real provider-side count when supported, falling back to estimation. */ export async function POST(request) { let rawBody; @@ -32,27 +38,104 @@ export async function POST(request) { } const body = validation.data; - // Estimate token count based on content length - const messages = body.messages || []; + if (process.env.REQUIRE_API_KEY === "true") { + const apiKey = extractApiKey(request); + if (!apiKey) { + return new Response(JSON.stringify({ error: "Missing API key" }), { + status: 401, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } + const valid = await isValidApiKey(apiKey); + if (!valid) { + return new Response(JSON.stringify({ error: "Invalid API key" }), { + status: 401, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } + } + + const estimated = buildEstimatedCountResponse(body); + const requestedModel = typeof body.model === "string" ? body.model : ""; + if (!requestedModel) { + return estimated; + } + + try { + const modelInfo = await getModelInfo(requestedModel); + if (!modelInfo?.provider || !modelInfo?.model) { + return estimated; + } + + const credentials = await getProviderCredentials( + modelInfo.provider, + null, + null, + modelInfo.model + ); + if (!credentials || credentials.allRateLimited) { + return estimated; + } + + const executor = getExecutor(modelInfo.provider); + const counted = await executor?.countTokens?.({ + model: modelInfo.model, + body, + credentials, + log, + }); + + if (!counted || !Number.isFinite(counted.input_tokens)) { + return estimated; + } + + return new Response( + JSON.stringify({ + input_tokens: counted.input_tokens, + model: modelInfo.model, + provider: modelInfo.provider, + source: counted.source || "provider", + }), + { + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + } + ); + } catch (error) { + log.debug( + "COUNT_TOKENS", + `Falling back to estimate for ${requestedModel}: ${error instanceof Error ? error.message : String(error)}` + ); + return estimated; + } +} + +function buildEstimatedCountResponse(body) { + const messages = Array.isArray(body?.messages) ? body.messages : []; let totalChars = 0; + for (const msg of messages) { - if (typeof msg.content === "string") { + if (typeof msg?.content === "string") { totalChars += msg.content.length; - } else if (Array.isArray(msg.content)) { + continue; + } + + if (Array.isArray(msg?.content)) { for (const part of msg.content) { - if (part.type === "text" && part.text) { + if (part?.type === "text" && typeof part.text === "string") { totalChars += part.text.length; } } } } - // Rough estimate: ~4 chars per token - const inputTokens = Math.ceil(totalChars / 4); + if (typeof body?.system === "string") { + totalChars += body.system.length; + } return new Response( JSON.stringify({ - input_tokens: inputTokens, + input_tokens: totalChars > 0 ? Math.ceil(totalChars / 4) : estimateTokens(""), + source: "estimated", }), { headers: { "Content-Type": "application/json", ...CORS_HEADERS }, diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index c39df2f274..f2dc561399 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -29,7 +29,6 @@ const FALLBACK_ALIAS_TO_PROVIDER = { cx: "codex", gc: "gemini-cli", gh: "github", - if: "iflow", kc: "kilocode", kmc: "kimi-coding", kr: "kiro", diff --git a/src/app/api/v1/search/analytics/route.ts b/src/app/api/v1/search/analytics/route.ts index 2e11685504..f11545ef78 100644 --- a/src/app/api/v1/search/analytics/route.ts +++ b/src/app/api/v1/search/analytics/route.ts @@ -33,7 +33,7 @@ export async function GET(req: Request) { `SELECT COUNT(*) as total, COALESCE(SUM(CASE WHEN timestamp >= ? THEN 1 ELSE 0 END), 0) as today, - COALESCE(SUM(CASE WHEN status >= 400 OR error IS NOT NULL THEN 1 ELSE 0 END), 0) as errors, + COALESCE(SUM(CASE WHEN status >= 400 OR error_summary IS NOT NULL THEN 1 ELSE 0 END), 0) as errors, AVG(CASE WHEN duration > 0 THEN duration END) as avg_duration, COALESCE(SUM(CASE WHEN duration > 0 AND duration < 5 THEN 1 ELSE 0 END), 0) as cached FROM call_logs diff --git a/src/app/api/v1/ws/route.ts b/src/app/api/v1/ws/route.ts new file mode 100644 index 0000000000..6233c3ecc1 --- /dev/null +++ b/src/app/api/v1/ws/route.ts @@ -0,0 +1,88 @@ +import { CORS_HEADERS } from "@/shared/utils/cors"; +import { authorizeWebSocketHandshake } from "@/lib/ws/handshake"; + +const WS_HANDSHAKE_HEADERS = { + ...CORS_HEADERS, + "Cache-Control": "no-store", +}; + +const WS_PROTOCOL = { + request: { + type: "request", + id: "req-1", + payload: { model: "openai/gpt-4.1-mini", messages: [] }, + }, + cancel: { type: "cancel", id: "req-1" }, +}; + +export async function OPTIONS() { + return new Response(null, { + headers: { + ...WS_HANDSHAKE_HEADERS, + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +export async function GET(request: Request) { + const url = new URL(request.url); + const handshake = url.searchParams.get("handshake") === "1"; + const auth = await authorizeWebSocketHandshake(request); + + if (handshake) { + if (!auth.authorized) { + return Response.json( + { + error: { + message: auth.hasCredential + ? "Invalid WebSocket credential" + : "WebSocket auth required", + type: "invalid_request", + code: auth.hasCredential ? "ws_auth_invalid" : "ws_auth_required", + }, + wsAuth: auth.wsAuth, + path: auth.wsPath, + }, + { + status: auth.hasCredential ? 403 : 401, + headers: WS_HANDSHAKE_HEADERS, + } + ); + } + + return Response.json( + { + ok: true, + path: auth.wsPath, + wsAuth: auth.wsAuth, + authenticated: auth.authenticated, + authType: auth.authType, + protocol: WS_PROTOCOL, + }, + { + headers: WS_HANDSHAKE_HEADERS, + } + ); + } + + return Response.json( + { + error: { + message: "Upgrade Required", + type: "invalid_request", + code: "upgrade_required", + }, + path: auth.wsPath, + wsAuth: auth.wsAuth, + protocol: WS_PROTOCOL, + }, + { + status: 426, + headers: { + ...WS_HANDSHAKE_HEADERS, + Upgrade: "websocket", + }, + } + ); +} diff --git a/src/domain/modelAvailability.ts b/src/domain/modelAvailability.ts index d4cfedd2f6..9510c0ea73 100644 --- a/src/domain/modelAvailability.ts +++ b/src/domain/modelAvailability.ts @@ -21,7 +21,23 @@ /** @type {Map} */ const unavailable = new Map(); -/** @type {Map} */ +/** + * @typedef {Object} FailureState + * @property {number} failureCount + * @property {number} lastFailureAt + * @property {number} resetAfterMs + */ + +/** + * @typedef {Object} ProviderProfile + * @property {number} [transientCooldown] + * @property {number} [rateLimitCooldown] + * @property {number} [maxBackoffLevel] + * @property {number} [circuitBreakerThreshold] + * @property {number} [circuitBreakerReset] + */ + +/** @type {Map} */ const failureState = new Map(); const FAILURE_WINDOW_MS = 30 * 60 * 1000; @@ -38,6 +54,78 @@ const PROBLEMATIC_STATUS_COOLDOWNS = { const MIN_PROBLEMATIC_COOLDOWN_MS = 60 * 1000; const MAX_PROBLEMATIC_COOLDOWN_MS = 30 * 60 * 1000; +function toPositiveNumber(value) { + return Number.isFinite(value) && Number(value) > 0 ? Number(value) : null; +} + +function toNonNegativeNumber(value) { + return Number.isFinite(value) && Number(value) >= 0 ? Number(value) : null; +} + +/** + * The first layer already reacts immediately to authoritative model/account failures. + * Global provider/model quarantine is the escalation layer, so its failure window and + * threshold are only customized when a runtime provider profile is supplied. + * + * @param {ProviderProfile | null | undefined} profile + * @returns {number} + */ +function getFailureWindowMs(profile) { + return toPositiveNumber(profile?.circuitBreakerReset) ?? FAILURE_WINDOW_MS; +} + +/** + * Without a runtime profile we preserve legacy behavior: quarantine on the first failure. + * + * @param {ProviderProfile | null | undefined} profile + * @returns {number} + */ +function getFailureThreshold(profile) { + return toPositiveNumber(profile?.circuitBreakerThreshold) ?? 1; +} + +function getLegacyStatusCooldown(status) { + return status && Object.prototype.hasOwnProperty.call(PROBLEMATIC_STATUS_COOLDOWNS, status) + ? PROBLEMATIC_STATUS_COOLDOWNS[status] + : 0; +} + +/** + * @param {number | null} status + * @param {ProviderProfile | null | undefined} profile + * @returns {number} + */ +function getProfileStatusCooldown(status, profile) { + if (!profile) return 0; + if (status === 429) { + return toPositiveNumber(profile.rateLimitCooldown) ?? 0; + } + return toPositiveNumber(profile.transientCooldown) ?? 0; +} + +/** + * @param {number} baseCooldownMs + * @param {number} failureCount + * @param {ProviderProfile | null | undefined} profile + * @returns {number} + */ +function getScaledCooldown(baseCooldownMs, failureCount, profile) { + const safeBase = toPositiveNumber(baseCooldownMs) ?? 1000; + if (!profile) { + return Math.min( + Math.max(safeBase, MIN_PROBLEMATIC_COOLDOWN_MS) * Math.pow(2, Math.max(0, failureCount - 1)), + MAX_PROBLEMATIC_COOLDOWN_MS + ); + } + + const maxBackoffLevel = Math.max( + 0, + Math.trunc(toNonNegativeNumber(profile.maxBackoffLevel) ?? 0) + ); + const exponent = Math.min(Math.max(0, failureCount - 1), maxBackoffLevel); + return safeBase * Math.pow(2, exponent); +} + /** * Build a composite key for provider+model. * @param {string} provider @@ -69,6 +157,33 @@ export function isModelAvailable(provider, model) { return false; } +/** + * Get remaining cooldown information for a model, if it is currently unavailable. + * + * @param {string} provider + * @param {string} model + * @returns {{ provider: string, model: string, reason: string, remainingMs: number, unavailableSince: string } | null} + */ +export function getModelCooldownInfo(provider, model) { + const key = makeKey(provider, model); + const entry = unavailable.get(key); + if (!entry) return null; + + const elapsed = Date.now() - entry.unavailableSince; + if (elapsed >= entry.cooldownMs) { + unavailable.delete(key); + return null; + } + + return { + provider: entry.provider, + model: entry.model, + reason: entry.reason || "unknown", + remainingMs: entry.cooldownMs - elapsed, + unavailableSince: new Date(entry.unavailableSince).toISOString(), + }; +} + /** * Mark a model as temporarily unavailable. * @@ -104,35 +219,44 @@ export function setModelUnavailable(provider, model, cooldownMs = 60000, reason) * * @param {string} provider * @param {string} model - * @param {{ status?: number, baseCooldownMs?: number, reason?: string }} [options] - * @returns {{ cooldownMs: number, failureCount: number }} + * @param {{ status?: number, baseCooldownMs?: number, reason?: string, profile?: ProviderProfile | null }} [options] + * @returns {{ cooldownMs: number, failureCount: number, quarantined: boolean, threshold: number, resetAfterMs: number }} */ export function markModelAsProblematic(provider, model, options = {}) { const key = makeKey(provider, model); const now = Date.now(); const status = Number.isFinite(options.status) ? Number(options.status) : null; - const statusBaseCooldown = - status && Object.prototype.hasOwnProperty.call(PROBLEMATIC_STATUS_COOLDOWNS, status) - ? PROBLEMATIC_STATUS_COOLDOWNS[status] - : 0; - const baseCooldownMs = + const profile = options.profile || null; + const explicitBaseCooldownMs = Number.isFinite(options.baseCooldownMs) && Number(options.baseCooldownMs) > 0 ? Number(options.baseCooldownMs) : 0; + const statusBaseCooldown = profile + ? getProfileStatusCooldown(status, profile) + : getLegacyStatusCooldown(status); + const baseCooldownMs = Math.max(explicitBaseCooldownMs, statusBaseCooldown); const prev = failureState.get(key); - const withinFailureWindow = prev && now - prev.lastFailureAt <= FAILURE_WINDOW_MS; + const resetAfterMs = getFailureWindowMs(profile); + const withinFailureWindow = prev && now - prev.lastFailureAt <= prev.resetAfterMs; const failureCount = withinFailureWindow ? prev.failureCount + 1 : 1; - failureState.set(key, { failureCount, lastFailureAt: now }); + failureState.set(key, { failureCount, lastFailureAt: now, resetAfterMs }); - const cooldownBase = Math.max(baseCooldownMs, statusBaseCooldown, MIN_PROBLEMATIC_COOLDOWN_MS); - const cooldownMs = Math.min( - cooldownBase * Math.pow(2, Math.max(0, failureCount - 1)), - MAX_PROBLEMATIC_COOLDOWN_MS - ); + const threshold = getFailureThreshold(profile); + const cooldownMs = getScaledCooldown(baseCooldownMs, failureCount, profile); + const quarantined = failureCount >= threshold; - setModelUnavailable(provider, model, cooldownMs, options.reason || "problematic_model"); - return { cooldownMs, failureCount }; + if (quarantined) { + setModelUnavailable(provider, model, cooldownMs, options.reason || "problematic_model"); + } + + return { + cooldownMs, + failureCount, + quarantined, + threshold, + resetAfterMs, + }; } /** diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 6dacc1d433..2de82c5a7c 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -23,6 +23,7 @@ "active": "Active", "inactive": "Inactive", "noData": "No data available", + "nothingHere": "Nothing here yet", "configure": "Configure", "manage": "Manage", "name": "Name", @@ -136,7 +137,9 @@ "Failed to save pricing": "Failed to save pricing", "Failed to reset pricing": "Failed to reset pricing", "apikey": "API Key", - "http": "HTTP" + "http": "HTTP", + "goToDashboard": "Go to Dashboard", + "checkSystemStatus": "Check System Status" }, "sidebar": { "home": "Home", @@ -639,6 +642,7 @@ "continue": "Use when running Continue in IDEs and you need portable JSON-based provider configuration.", "opencode": "Use when you prefer terminal-native agent runs and scripted automation via OpenCode.", "kiro": "Use when integrating Kiro and controlling model routing centrally from OmniRoute.", + "windsurf": "Use when you want an AI-first IDE with Codeium/Windsurf models routed through OmniRoute.", "antigravity": "Use when Antigravity/Kiro traffic must be intercepted through MITM and routed to OmniRoute.", "copilot": "Use when you want Copilot chat style UX while enforcing OmniRoute keys and routing rules." }, @@ -655,7 +659,8 @@ "opencode": "OpenCode AI coding agent (Terminal)", "kiro": "Amazon Kiro — AI-powered IDE", "windsurf": "Windsurf AI Code Editor", - "copilot": "GitHub Copilot AI Assistant" + "copilot": "GitHub Copilot AI Assistant", + "qwen": "Alibaba Qwen Code CLI" }, "guides": { "cursor": { @@ -1062,7 +1067,42 @@ "wizardStep3Title": "Choose Strategy", "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", "wizardStep4Title": "Review & Save", - "wizardStep4Desc": "Review your configuration and activate the combo" + "wizardStep4Desc": "Review your configuration and activate the combo", + "emailVisibilityStateOn": "On", + "emailVisibilityStateOff": "Off", + "reorderHandle": "Drag to reorder", + "failedReorder": "Failed to reorder models", + "builderFlowTitle": "Combo Builder Flow", + "builderStageVisited": "Stage completed", + "builderStageCurrent": "Current stage", + "builderStagePending": "Pending", + "builderStageLocked": "Locked — complete previous stage first", + "builderTitle": "Build a Combo", + "builderBrowseCatalog": "Browse catalog", + "builderProvider": "Provider", + "builderLoadingProviders": "Loading providers...", + "builderSelectProvider": "Select provider", + "builderModel": "Model", + "builderSelectModel": "Select model", + "builderProviderFirst": "Pick provider first", + "builderAccount": "Account", + "builderPreview": "Preview", + "builderAddStep": "Add step", + "builderComboRef": "Combo Ref", + "builderAddComboRef": "Add combo reference", + "builderComboRefStep": "Add combo reference", + "builderPinnedAccount": "Pinned Account", + "builderLegacyEntry": "Legacy entry", + "reviewName": "Name", + "reviewStrategy": "Strategy", + "reviewSteps": "Steps", + "reviewAccounts": "Accounts", + "reviewProviders": "Providers", + "reviewComboRefs": "Combo References", + "reviewAdvanced": "Advanced Settings", + "reviewAgentFlags": "Agent Flags", + "reviewSequence": "Model Sequence", + "reviewNoSteps": "No steps configured" }, "costs": { "title": "Costs", @@ -1357,6 +1397,7 @@ "content": "Content", "created": "Created", "actions": "Actions", + "delete": "Delete", "factual": "Factual", "episodic": "Episodic", "procedural": "Procedural", @@ -1663,6 +1704,10 @@ "openaiCompatibleDetails": "OpenAI Compatible Details", "messagesApi": "Messages API", "responsesApi": "Responses API", + "embeddings": "Embeddings", + "audioTranscriptions": "Audio Transcriptions", + "audioSpeech": "Audio Speech", + "imagesGenerations": "Images Generations", "chatCompletions": "Chat Completions", "importingModels": "Importing...", "importFromModels": "Import from /models", @@ -2815,9 +2860,9 @@ "waitingForQoderAuthorization": "Waiting for Qoder authorization...", "exchangingCodeForTokens": "Exchanging code for tokens...", "nodeIncompatibleTitle": "Incompatible Node.js Version", - "nodeIncompatibleDesc": "You are running Node.js {version}, which is not compatible with OmniRoute. The native SQLite module (better-sqlite3) requires Node.js 18–22 LTS.", - "nodeIncompatibleFixLabel": "Fix: install Node.js 22 LTS", - "nodeIncompatibleHint": "OmniRoute requires Node.js ≥18 and <24. Node 22 LTS is recommended for stability." + "nodeIncompatibleDesc": "You are running Node.js {version}, which is outside OmniRoute's supported secure runtime policy. Use a patched Node.js 20.x or 22.x LTS release.", + "nodeIncompatibleFixLabel": "Fix: install a patched Node.js 22 LTS release", + "nodeIncompatibleHint": "OmniRoute requires Node.js 20.20.2+ (20.x LTS) or 22.22.2+ (22.x LTS). Node 22 LTS is recommended." }, "landing": { "brandName": "OmniRoute", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 92b43c8081..e1db81ebda 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -23,6 +23,7 @@ "active": "启用中", "inactive": "不活跃", "noData": "无可用数据", + "nothingHere": "暂无内容", "configure": "配置", "manage": "管理", "name": "名称", @@ -136,7 +137,9 @@ "Failed to save pricing": "保存定价失败", "Failed to reset pricing": "重置定价失败", "apikey": "API 密钥", - "http": "HTTP" + "http": "HTTP", + "goToDashboard": "前往仪表板", + "checkSystemStatus": "查看系统状态" }, "sidebar": { "home": "首页", @@ -639,6 +642,7 @@ "continue": "在 IDE 中运行“Continue”并且需要可移植的基于 JSON 的提供程序配置时使用。", "opencode": "当您更喜欢通过 OpenCode 进行终端本机代理运行和脚本自动化时使用。", "kiro": "在集成 Kiro 并从 OmniRoute 集中控制模型路由时使用。", + "windsurf": "当您需要 Windsurf AI IDE 并通过 OmniRoute 路由模型时使用。", "antigravity": "当必须通过 MITM 拦截 Antigravity/Kiro 流量并将其路由到 OmniRoute 时使用。", "copilot": "当您想要 Copilot 聊天风格的 UX 同时强制执行 OmniRoute 键和路由规则时使用。" }, @@ -950,6 +954,50 @@ "applyRecommendations": "应用推荐", "recommendationsUpdated": "已为 {strategy} 更新推荐配置。", "recommendationsApplied": "推荐配置已应用到当前组合。", + "filterAll": "全部", + "filterIntelligent": "智能路由", + "filterDeterministic": "确定性", + "filterEmptyTitle": "没有组合匹配此策略筛选。", + "filterEmptyIntelligentDescription": "创建自动或 LKGP 组合以填充智能路由仪表盘。", + "filterEmptyDeterministicDescription": "当前仅存在自动和 LKGP 组合。切换回\"全部\"或创建确定性组合。", + "intelligentPanelTitle": "智能路由仪表盘", + "intelligentPanelDesc": "此自动路由组合的实时评分和健康状态。", + "statusOverview": "状态概览", + "normalOperation": "正常运行", + "allProvidersHealthy": "供应商报告路由状况良好。", + "incidentMode": "事件模式", + "highCircuitBreakerRate": "检测到熔断器频繁触发。", + "activeModePack": "当前模式包", + "modePackUpdated": "模式包已更新为 {pack}。", + "modePackHint": "切换预设以调整路由引擎偏向,无需重建组合。", + "providerScores": "供应商评分", + "allProvidersEvaluated": "未配置候选池。运行时评估所有活跃供应商。", + "excludedProviders": "已排除的供应商", + "excludedProvidersHint": "熔断器处于 OPEN 状态的供应商将被临时排除在路由之外。", + "noExcludedProviders": "当前没有供应商被排除。", + "cooldownMinutes": "冷却:{minutes} 分钟", + "builderIntelligentTitle": "智能路由配置", + "builderIntelligentDesc": "为此自动路由组合配置多因子评分引擎。", + "candidatePoolLabel": "候选池", + "candidatePoolHint": "选择引擎应评估的供应商。留空则使用所有活跃供应商。", + "candidatePoolEmpty": "暂无可用活跃供应商。", + "candidatePoolAllProviders": "所有供应商", + "modePackLabel": "模式包", + "routerStrategyLabel": "路由策略", + "strategyRules": "规则(6 因子评分)", + "explorationRateLabel": "探索率", + "explorationRateHint": "{percent}% 的请求可以探索非最优供应商。", + "budgetCapLabel": "预算上限(美元/请求)", + "budgetCapPlaceholder": "无限制", + "advancedWeightsTitle": "高级:评分权重", + "weightQuota": "配额", + "weightHealth": "健康度", + "weightCostInv": "成本", + "weightLatencyInv": "延迟", + "weightTaskFit": "任务适配", + "weightStability": "稳定性", + "weightTierPriority": "层级", + "reviewIntelligentTitle": "智能路由配置", "strategyRecommendations": { "priority": { "title": "稳妥基线", @@ -1007,18 +1055,53 @@ "autoDesc": "自愈型智能路由池(性能优化)", "lkgp": "LKGP 模式", "lkgpDesc": "最后已知良好提供商(可预测的弹性)", - "wizardGuideTitle": "Getting Started with Combos", - "wizardGuideDesc": "Create model combos to route AI traffic intelligently", - "wizardGuideHint": "or click + Create Combo above", - "createFirstCombo": "Create Your First Combo", - "wizardStep1Title": "Name Your Combo", - "wizardStep1Desc": "Give your combo a unique name to identify it in routing rules", - "wizardStep2Title": "Add Models", - "wizardStep2Desc": "Select AI models and arrange their fallback priority order", - "wizardStep3Title": "Choose Strategy", - "wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available", - "wizardStep4Title": "Review & Save", - "wizardStep4Desc": "Review your configuration and activate the combo" + "wizardGuideTitle": "组合入门指南", + "wizardGuideDesc": "创建模型组合以智能路由 AI 流量", + "wizardGuideHint": "或点击上方「+ 创建组合」", + "createFirstCombo": "创建您的第一个组合", + "wizardStep1Title": "命名您的组合", + "wizardStep1Desc": "为您的组合指定唯一名称,以便在路由规则中识别", + "wizardStep2Title": "添加模型", + "wizardStep2Desc": "选择 AI 模型并排列其故障转移优先级顺序", + "wizardStep3Title": "选择策略", + "wizardStep3Desc": "选择请求在模型之间的分发方式 — 提供 13 种策略", + "wizardStep4Title": "审查并保存", + "wizardStep4Desc": "审查您的配置并激活组合", + "emailVisibilityStateOn": "开启", + "emailVisibilityStateOff": "关闭", + "reorderHandle": "拖拽排序", + "failedReorder": "模型重排序失败", + "builderFlowTitle": "组合构建流程", + "builderStageVisited": "阶段已完成", + "builderStageCurrent": "当前阶段", + "builderStagePending": "待处理", + "builderStageLocked": "已锁定 — 请先完成上一步", + "builderTitle": "构建组合", + "builderBrowseCatalog": "浏览目录", + "builderProvider": "提供商", + "builderLoadingProviders": "加载提供商中...", + "builderSelectProvider": "选择提供商", + "builderModel": "模型", + "builderSelectModel": "选择模型", + "builderProviderFirst": "请先选择提供商", + "builderAccount": "账户", + "builderPreview": "预览", + "builderAddStep": "添加步骤", + "builderComboRef": "组合引用", + "builderAddComboRef": "添加组合引用", + "builderComboRefStep": "添加组合引用步骤", + "builderPinnedAccount": "固定账户", + "builderLegacyEntry": "旧版条目", + "reviewName": "名称", + "reviewStrategy": "策略", + "reviewSteps": "步骤", + "reviewAccounts": "账户", + "reviewProviders": "提供商", + "reviewComboRefs": "组合引用", + "reviewAdvanced": "高级设置", + "reviewAgentFlags": "Agent 标志", + "reviewSequence": "模型序列", + "reviewNoSteps": "未配置任何步骤" }, "costs": { "title": "成本", @@ -1765,7 +1848,9 @@ "modelsPathHint": "为验证流程自定义模型路径(例如:/v4/models)", "statusDeactivated": "已停用(手动)", "statusBanned": "已封禁 / 沙箱违规", - "statusCreditsExhausted": "余额不足 / 配额已耗尽" + "statusCreditsExhausted": "余额不足 / 配额已耗尽", + "showEmails": "显示所有邮箱", + "hideEmails": "隐藏所有邮箱" }, "settings": { "title": "设置", @@ -2714,9 +2799,9 @@ "waitingForQoderAuthorization": "正在等待 Qoder 授权...", "exchangingCodeForTokens": "正在用授权码换取 Tokens...", "nodeIncompatibleTitle": "不兼容的 Node.js 版本", - "nodeIncompatibleDesc": "你当前运行的是 Node.js {version},它与 OmniRoute 不兼容。原生 SQLite 模块(better-sqlite3)要求使用 Node.js 18–22 LTS。", - "nodeIncompatibleFixLabel": "修复:安装 Node.js 22 LTS", - "nodeIncompatibleHint": "OmniRoute 要求 Node.js ≥18 且 <24。为保证稳定性,建议使用 Node 22 LTS。" + "nodeIncompatibleDesc": "你当前运行的是 Node.js {version},它不在 OmniRoute 支持的安全运行时策略内。请使用已打补丁的 Node.js 20.x 或 22.x LTS 版本。", + "nodeIncompatibleFixLabel": "修复:安装已打补丁的 Node.js 22 LTS 版本", + "nodeIncompatibleHint": "OmniRoute 要求 Node.js 20.20.2+(20.x LTS)或 22.22.2+(22.x LTS)。为保证稳定性,建议使用 Node 22 LTS。" }, "landing": { "brandName": "OmniRoute", @@ -3172,6 +3257,7 @@ "content": "内容", "created": "创建时间", "actions": "操作", + "delete": "删除", "factual": "事实型", "episodic": "情景型", "procedural": "程序型", diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 88bab3ae68..4b945d1d51 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -74,6 +74,8 @@ export async function registerNodejs(): Promise { console.log("[STARTUP] Global fetch proxy patch initialized"); await ensureSecrets(); + const { enforceWebRuntimeEnv } = await import("@/lib/env/runtimeEnv"); + enforceWebRuntimeEnv(); // Trigger request-log layout migration during startup, before any request hits usageDb. await import("@/lib/usage/migrations"); @@ -105,11 +107,15 @@ export async function registerNodejs(): Promise { } try { - const [{ setCustomAliases }, { migrateCodexConnectionDefaultsFromLegacySettings }] = - await Promise.all([ - import("@omniroute/open-sse/services/modelDeprecation.ts"), - import("@/lib/providers/codexConnectionDefaults"), - ]); + const [ + { setCustomAliases }, + { migrateCodexConnectionDefaultsFromLegacySettings }, + { seedDefaultModelAliases }, + ] = await Promise.all([ + import("@omniroute/open-sse/services/modelDeprecation.ts"), + import("@/lib/providers/codexConnectionDefaults"), + import("@/lib/modelAliasSeed"), + ]); const settings = await getSettings(); if (settings.modelAliases) { @@ -125,6 +131,11 @@ export async function registerNodejs(): Promise { } } + const seededModelAliases = await seedDefaultModelAliases(); + console.log( + `[STARTUP] Model alias seed: applied=${seededModelAliases.applied.length}, skipped=${seededModelAliases.skipped.length}, failed=${seededModelAliases.failed.length}` + ); + if (settings.backgroundDegradation) { try { const bgSettings = diff --git a/src/lib/apiBridgeServer.ts b/src/lib/apiBridgeServer.ts index ce521355c0..d289d6f752 100644 --- a/src/lib/apiBridgeServer.ts +++ b/src/lib/apiBridgeServer.ts @@ -1,5 +1,6 @@ import http from "http"; import type { IncomingMessage, ServerResponse } from "http"; +import net from "net"; import { getRuntimePorts } from "@/lib/runtime/ports"; import { getApiBridgeTimeoutConfig } from "@/shared/utils/runtimeTimeouts"; @@ -70,6 +71,79 @@ function proxyRequest(req: IncomingMessage, res: ServerResponse, dashboardPort: req.pipe(targetReq); } +function writeUpgradeProxyError(socket: net.Socket, status: number, body: string): void { + if (!socket.writable || socket.destroyed) return; + const buffer = Buffer.from(body, "utf8"); + const response = [ + `HTTP/1.1 ${status} ${http.STATUS_CODES[status] || "Error"}`, + "Connection: close", + "Content-Type: application/json; charset=utf-8", + `Content-Length: ${buffer.length}`, + "", + "", + ].join("\r\n"); + + socket.write(response); + socket.end(buffer); +} + +function proxyUpgrade( + req: IncomingMessage, + socket: net.Socket, + head: Buffer, + dashboardPort: number +) { + const upstream = net.connect(dashboardPort, "127.0.0.1"); + + upstream.on("connect", () => { + const requestLine = `${req.method || "GET"} ${req.url || "/"} HTTP/${req.httpVersion || "1.1"}`; + const headerLines: string[] = [requestLine]; + let wroteHost = false; + + for (let index = 0; index < req.rawHeaders.length; index += 2) { + const name = req.rawHeaders[index]; + const rawValue = req.rawHeaders[index + 1] || ""; + if (name.toLowerCase() === "host") { + headerLines.push(`Host: 127.0.0.1:${dashboardPort}`); + wroteHost = true; + } else { + headerLines.push(`${name}: ${rawValue}`); + } + } + + if (!wroteHost) { + headerLines.push(`Host: 127.0.0.1:${dashboardPort}`); + } + + upstream.write(`${headerLines.join("\r\n")}\r\n\r\n`); + if (head.length > 0) { + upstream.write(head); + } + + socket.pipe(upstream); + upstream.pipe(socket); + }); + + upstream.on("error", (error) => { + writeUpgradeProxyError( + socket, + 502, + JSON.stringify({ + error: "api_bridge_upgrade_failed", + detail: String(error.message || error), + }) + ); + }); + + socket.on("error", () => { + upstream.destroy(); + }); + + socket.on("close", () => { + upstream.destroy(); + }); +} + declare global { var __omnirouteApiBridgeStarted: boolean | undefined; } @@ -103,6 +177,24 @@ export function initApiBridgeServer(): void { server.headersTimeout = API_BRIDGE_TIMEOUTS.serverHeadersTimeoutMs; server.keepAliveTimeout = API_BRIDGE_TIMEOUTS.serverKeepAliveTimeoutMs; server.setTimeout(API_BRIDGE_TIMEOUTS.serverSocketTimeoutMs); + server.on("upgrade", (req, socket, head) => { + const rawUrl = req.url || "/"; + const pathname = rawUrl.split("?")[0] || "/"; + + if (!isOpenAiCompatiblePath(pathname)) { + writeUpgradeProxyError( + socket, + 404, + JSON.stringify({ + error: "not_found", + message: "API port only serves OpenAI-compatible routes.", + }) + ); + return; + } + + proxyUpgrade(req, socket, head, dashboardPort); + }); server.on("error", (error: NodeJS.ErrnoException) => { if (error?.code === "EADDRINUSE") { diff --git a/src/lib/cloudSync.ts b/src/lib/cloudSync.ts index 581409ca9f..02ecd20a18 100644 --- a/src/lib/cloudSync.ts +++ b/src/lib/cloudSync.ts @@ -1,10 +1,5 @@ -import { - getProviderConnections, - getModelAliases, - getCombos, - getApiKeys, - updateProviderConnection, -} from "@/lib/localDb"; +import { getProviderConnections, updateProviderConnection } from "@/lib/localDb"; +import { buildConfigSyncEnvelope, toLegacyCloudSyncPayload } from "@/lib/sync/bundle"; const CLOUD_URL = process.env.CLOUD_URL || process.env.NEXT_PUBLIC_CLOUD_URL; const CLOUD_SYNC_TIMEOUT_MS = Number(process.env.CLOUD_SYNC_TIMEOUT_MS || 12000); @@ -47,11 +42,10 @@ export async function syncToCloud(machineId, createdKey = null) { return { error: "NEXT_PUBLIC_CLOUD_URL is not configured" }; } - // Get current data from db - const providers = await getProviderConnections(); - const modelAliases = await getModelAliases(); - const combos = await getCombos(); - const apiKeys = await getApiKeys(); + // Keep legacy field names for upstream compatibility, but derive them + // from a canonical sync bundle with deterministic version hashing. + const { version, bundle } = await buildConfigSyncEnvelope(); + const legacyPayload = toLegacyCloudSyncPayload(bundle); let response; try { @@ -60,10 +54,8 @@ export async function syncToCloud(machineId, createdKey = null) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - providers, - modelAliases, - combos, - apiKeys, + ...legacyPayload, + version, }), }); } catch (error) { @@ -89,6 +81,7 @@ export async function syncToCloud(machineId, createdKey = null) { success: true, message: "Synced successfully", changes: result.changes, + version, }; if (createdKey) { diff --git a/src/lib/combos/testHealth.ts b/src/lib/combos/testHealth.ts index b733692145..34d8f0661b 100644 --- a/src/lib/combos/testHealth.ts +++ b/src/lib/combos/testHealth.ts @@ -118,7 +118,14 @@ function buildComboTestPrompt() { return `Calculate ${left}+${right}, and reply with the result only.`; } -export function buildComboTestRequestBody(modelStr: string) { +export function buildComboTestRequestBody(modelStr: string, isEmbedding: boolean = false) { + if (isEmbedding) { + return { + model: modelStr, + input: "Hello World", + }; + } + return { model: modelStr, // Randomize the arithmetic prompt so upstream providers are less likely to @@ -138,6 +145,10 @@ export function extractComboTestResponseText(responseBody: unknown): string { return body.output_text.trim(); } + if (Array.isArray(body.data) && body.data[0]?.embedding) { + return "[Embedding generated successfully]"; + } + if (Array.isArray(body.choices)) { for (const choice of body.choices) { const choiceRecord = asRecord(choice); diff --git a/src/lib/compliance/index.ts b/src/lib/compliance/index.ts index b143ac42d7..28a248e41b 100644 --- a/src/lib/compliance/index.ts +++ b/src/lib/compliance/index.ts @@ -10,12 +10,15 @@ */ import { getDbInstance } from "../db/core"; +import { getClientIpFromRequest } from "../ipUtils"; import { getAppLogRetentionDays, getCallLogRetentionDays, getCallLogsTableMaxRows, getProxyLogsTableMaxRows, } from "../logEnv"; +import { generateRequestId, getRequestId } from "@/shared/utils/requestId"; +import { deleteCallLogsBefore, trimCallLogsToMaxRows } from "../usage/callLogs"; /** @returns {import("better-sqlite3").Database | null} */ function getDb() { @@ -26,13 +29,123 @@ function getDb() { } } -/** - * Initialize the audit_log table. - */ -export function initAuditLog() { - const db = getDb(); - if (!db) return; +type AuditLogWriteEntry = { + action: string; + actor?: string; + target?: string; + details?: unknown; + metadata?: unknown; + ipAddress?: string; + resourceType?: string; + status?: string; + requestId?: string; + createdAt?: string; +}; +type AuditLogFilter = { + action?: string; + actor?: string; + target?: string; + resourceType?: string; + status?: string; + requestId?: string; + from?: string; + to?: string; + limit?: number; + offset?: number; +}; + +type AuditLogRow = Record & { + details?: string | null; + metadata?: string | null; + ip_address?: string | null; + resource_type?: string | null; + request_id?: string | null; + timestamp?: string | null; +}; + +const AUDIT_LOG_REQUIRED_COLUMNS: Record = { + resource_type: "TEXT", + status: "TEXT", + request_id: "TEXT", + metadata: "TEXT", +}; + +const SENSITIVE_AUDIT_KEYS = new Set([ + "apikey", + "accesstoken", + "refreshtoken", + "idtoken", + "authtoken", + "jwttoken", + "token", + "secret", + "password", + "authorization", + "cookie", + "setcookie", + "consoleapikey", + "clientsecret", +]); + +function normalizeAuditKey(key: string) { + return key.replace(/[^a-z0-9]/gi, "").toLowerCase(); +} + +function isSensitiveAuditKey(key: string) { + const normalized = normalizeAuditKey(key); + if (!normalized) return false; + if (SENSITIVE_AUDIT_KEYS.has(normalized)) return true; + return ( + normalized.endsWith("apikey") || + normalized.endsWith("token") || + normalized.endsWith("secret") || + normalized.endsWith("password") + ); +} + +function sanitizeAuditValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => sanitizeAuditValue(item)); + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, nestedValue]) => [ + key, + isSensitiveAuditKey(key) ? "[redacted]" : sanitizeAuditValue(nestedValue), + ]) + ); + } + + return value; +} + +function serializeAuditValue(value: unknown): string | null { + if (value === undefined || value === null || value === "") return null; + const sanitizedValue = sanitizeAuditValue(value); + if (typeof sanitizedValue === "string") { + return sanitizedValue; + } + try { + return JSON.stringify(sanitizedValue); + } catch { + return String(sanitizedValue); + } +} + +function parseAuditValue(value: unknown): unknown { + if (value === undefined || value === null || value === "") return null; + if (typeof value !== "string") return value; + + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function ensureAuditLogSchema(db: import("better-sqlite3").Database) { db.exec(` CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -41,13 +154,123 @@ export function initAuditLog() { actor TEXT NOT NULL DEFAULT 'system', target TEXT, details TEXT, - ip_address TEXT + ip_address TEXT, + resource_type TEXT, + status TEXT, + request_id TEXT, + metadata TEXT ); + `); + + let columns: Array<{ name: string }> = []; + try { + columns = db.prepare("PRAGMA table_info(audit_log)").all() as Array<{ name: string }>; + } catch { + columns = []; + } + + const existingColumns = new Set(columns.map((column) => column.name)); + for (const [columnName, columnType] of Object.entries(AUDIT_LOG_REQUIRED_COLUMNS)) { + if (existingColumns.has(columnName)) continue; + try { + db.exec(`ALTER TABLE audit_log ADD COLUMN ${columnName} ${columnType}`); + } catch { + // Another worker may have upgraded the schema first. Ignore. + } + } + + db.exec(` CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp); CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action); + CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor); + CREATE INDEX IF NOT EXISTS idx_audit_resource_type ON audit_log(resource_type); + CREATE INDEX IF NOT EXISTS idx_audit_status ON audit_log(status); + CREATE INDEX IF NOT EXISTS idx_audit_request_id ON audit_log(request_id); `); } +type AuditLogQuery = { + where: string; + params: string[]; +}; + +function buildAuditLogQuery(filter: AuditLogFilter = {}): AuditLogQuery { + const conditions: string[] = []; + const params: string[] = []; + + const addLikeFilter = (column: string, value?: string) => { + if (!value) return; + conditions.push(`${column} LIKE ?`); + params.push(`%${value}%`); + }; + + addLikeFilter("action", filter.action); + addLikeFilter("actor", filter.actor); + addLikeFilter("target", filter.target); + addLikeFilter("resource_type", filter.resourceType); + addLikeFilter("status", filter.status); + addLikeFilter("request_id", filter.requestId); + + if (filter.from) { + conditions.push("datetime(timestamp) >= datetime(?)"); + params.push(filter.from); + } + if (filter.to) { + conditions.push("datetime(timestamp) <= datetime(?)"); + params.push(filter.to); + } + + return { + where: conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "", + params, + }; +} + +function normalizeAuditLogRow(row: AuditLogRow) { + const details = parseAuditValue(row.details); + const metadata = parseAuditValue(row.metadata); + const resourceType = typeof row.resource_type === "string" ? row.resource_type : null; + const requestId = typeof row.request_id === "string" ? row.request_id : null; + const ip = typeof row.ip_address === "string" ? row.ip_address : null; + const timestamp = typeof row.timestamp === "string" ? row.timestamp : new Date().toISOString(); + + return { + ...(row as Record), + timestamp, + createdAt: timestamp, + details, + metadata: metadata ?? (details && typeof details === "object" ? details : null), + ip_address: ip, + ip, + resource_type: resourceType, + resourceType, + request_id: requestId, + requestId, + status: typeof row.status === "string" ? row.status : null, + }; +} + +export function getAuditRequestContext(request?: { + headers?: Headers | { get?: (name: string) => string | null }; + socket?: { remoteAddress?: string }; + ip?: string; +}) { + return { + ipAddress: request ? getClientIpFromRequest(request) : null, + requestId: getRequestId() || request?.headers?.get?.("x-request-id") || generateRequestId(), + }; +} + +/** + * Initialize the audit_log table. + */ +export function initAuditLog() { + const db = getDb(); + if (!db) return; + + ensureAuditLogSchema(db); +} + /** * Log an administrative action. * @@ -63,22 +286,52 @@ export function logAuditEvent(entry: { actor?: string; target?: string; details?: unknown; + metadata?: unknown; ipAddress?: string; + resourceType?: string; + status?: string; + requestId?: string; + createdAt?: string; }) { const db = getDb(); if (!db) return; try { + ensureAuditLogSchema(db); + const createdAt = entry.createdAt || new Date().toISOString(); + const serializedDetails = serializeAuditValue(entry.details ?? entry.metadata); + const metadataSource = + entry.metadata !== undefined + ? entry.metadata + : entry.details && typeof entry.details === "object" + ? entry.details + : null; const stmt = db.prepare(` - INSERT INTO audit_log (action, actor, target, details, ip_address) - VALUES (?, ?, ?, ?, ?) + INSERT INTO audit_log ( + timestamp, + action, + actor, + target, + details, + ip_address, + resource_type, + status, + request_id, + metadata + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( + createdAt, entry.action, entry.actor || "system", entry.target || null, - typeof entry.details === "object" ? JSON.stringify(entry.details) : entry.details || null, - entry.ipAddress || null + serializedDetails, + entry.ipAddress || null, + entry.resourceType || null, + entry.status || null, + entry.requestId || null, + serializeAuditValue(metadataSource) ); } catch { // Silently fail — audit logging should never break the main flow @@ -95,36 +348,35 @@ export function logAuditEvent(entry: { * @param {number} [filter.offset=0] - Pagination offset * @returns {Array<{ id: number, timestamp: string, action: string, actor: string, target: string, details: any, ip_address: string }>} */ -export function getAuditLog( - filter: { action?: string; actor?: string; limit?: number; offset?: number } = {} -) { +export function getAuditLog(filter: AuditLogFilter = {}) { const db = getDb(); if (!db) return []; - const conditions: string[] = []; - const params: (string | number)[] = []; + ensureAuditLogSchema(db); - if (filter.action) { - conditions.push("action = ?"); - params.push(filter.action); - } - if (filter.actor) { - conditions.push("actor = ?"); - params.push(filter.actor); - } - - const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; - const limit = filter.limit || 100; - const offset = filter.offset || 0; + const { where, params } = buildAuditLogQuery(filter); + const limit = Number.isFinite(filter.limit) + ? Math.max(1, Math.min(500, filter.limit || 100)) + : 100; + const offset = Number.isFinite(filter.offset) ? Math.max(0, filter.offset || 0) : 0; const rows = db - .prepare(`SELECT * FROM audit_log ${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`) - .all(...params, limit, offset) as Array & { details?: string | null }>; + .prepare(`SELECT * FROM audit_log ${where} ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?`) + .all(...params, limit, offset) as AuditLogRow[]; - return rows.map((row) => ({ - ...(row as Record), - details: row.details ? JSON.parse(String(row.details)) : null, - })); + return rows.map((row) => normalizeAuditLogRow(row)); +} + +export function countAuditLog(filter: AuditLogFilter = {}) { + const db = getDb(); + if (!db) return 0; + + ensureAuditLogSchema(db); + const { where, params } = buildAuditLogQuery(filter); + const row = db.prepare(`SELECT COUNT(*) as count FROM audit_log ${where}`).get(...params) as + | { count?: number } + | undefined; + return Number(row?.count || 0); } // ─── No-Log Opt-Out ──────────────── @@ -288,8 +540,8 @@ export function cleanupExpiredLogs() { } try { - const r2 = db.prepare("DELETE FROM call_logs WHERE timestamp < ?").run(callCutoff); - deletedCallLogs = r2.changes; + const r2 = deleteCallLogsBefore(callCutoff); + deletedCallLogs = r2.deletedRows; } catch { /* table may not exist */ } @@ -326,22 +578,8 @@ export function cleanupExpiredLogs() { const BATCH_SIZE = 5000; if (callLogsMaxRows > 0) { try { - let currentCount = db.prepare("SELECT COUNT(*) as cnt FROM call_logs").get() as { - cnt: number; - }; - while (currentCount.cnt > callLogsMaxRows) { - const toDelete = Math.min(currentCount.cnt - callLogsMaxRows, BATCH_SIZE); - const trimmed = db - .prepare( - `DELETE FROM call_logs WHERE id IN ( - SELECT id FROM call_logs ORDER BY timestamp ASC LIMIT ? - )` - ) - .run(toDelete); - trimmedCallLogs += trimmed.changes; - currentCount.cnt -= trimmed.changes; - if (trimmed.changes === 0) break; - } + const trimmed = trimCallLogsToMaxRows(callLogsMaxRows); + trimmedCallLogs = trimmed.deletedRows; } catch { /* best effort */ } @@ -372,6 +610,10 @@ export function cleanupExpiredLogs() { logAuditEvent({ action: "compliance.cleanup", + actor: "system", + target: "log-retention", + resourceType: "maintenance", + status: "success", details: { deletedUsage, deletedCallLogs, diff --git a/src/lib/compliance/providerAudit.ts b/src/lib/compliance/providerAudit.ts new file mode 100644 index 0000000000..b5e0d1cc7a --- /dev/null +++ b/src/lib/compliance/providerAudit.ts @@ -0,0 +1,34 @@ +type JsonRecord = Record; + +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +export function summarizeProviderConnectionForAudit(connection: unknown) { + const record = toRecord(connection); + if (Object.keys(record).length === 0) return null; + + const sanitized: JsonRecord = { ...record }; + delete sanitized.apiKey; + delete sanitized.accessToken; + delete sanitized.refreshToken; + delete sanitized.idToken; + + const providerSpecificData = toRecord(record.providerSpecificData); + if (Object.keys(providerSpecificData).length > 0) { + const sanitizedProviderSpecificData = { ...providerSpecificData }; + delete sanitizedProviderSpecificData.consoleApiKey; + sanitized.providerSpecificData = sanitizedProviderSpecificData; + } + + return sanitized; +} + +export function getProviderAuditTarget(connection: unknown) { + const record = toRecord(connection); + const provider = typeof record.provider === "string" ? record.provider : null; + const name = typeof record.name === "string" ? record.name : null; + const id = typeof record.id === "string" ? record.id : null; + + return [provider, name || id].filter(Boolean).join(":") || "provider-connection"; +} diff --git a/src/lib/db/backup.ts b/src/lib/db/backup.ts index 6827000842..3ca0f16d53 100644 --- a/src/lib/db/backup.ts +++ b/src/lib/db/backup.ts @@ -23,8 +23,140 @@ type CountRow = { cnt?: number }; let _lastBackupAt = 0; const BACKUP_THROTTLE_MS = 60 * 60 * 1000; // 60 minutes const MAX_DB_BACKUPS = 20; +const DEFAULT_DB_BACKUP_RETENTION_DAYS = 0; const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]); +function parsePositiveInt(value: string | undefined, fallback: number) { + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function parseNonNegativeInt(value: string | undefined, fallback: number) { + if (value === undefined) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback; +} + +export function getDbBackupMaxFiles() { + return parsePositiveInt(process.env.DB_BACKUP_MAX_FILES, MAX_DB_BACKUPS); +} + +export function getDbBackupRetentionDays() { + return parseNonNegativeInt( + process.env.DB_BACKUP_RETENTION_DAYS, + DEFAULT_DB_BACKUP_RETENTION_DAYS + ); +} + +function getBackupDir() { + return DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups"); +} + +function getBackupFamilyBase(filename: string) { + if (filename.endsWith("-wal") || filename.endsWith("-shm")) return filename.slice(0, -4); + if (filename.endsWith("-journal")) return filename.slice(0, -8); + return filename; +} + +function collectBackupFamilies(backupDir: string) { + if (!fs.existsSync(backupDir)) return []; + + const families = new Map< + string, + { + base: string; + hasPrimary: boolean; + primaryMtimeMs: number; + latestMtimeMs: number; + files: string[]; + } + >(); + + for (const name of fs.readdirSync(backupDir)) { + if (!name.startsWith("db_")) continue; + const base = getBackupFamilyBase(name); + const filePath = path.join(backupDir, name); + + let stat; + try { + stat = fs.statSync(filePath); + } catch { + continue; + } + + const family = families.get(base) || { + base, + hasPrimary: false, + primaryMtimeMs: 0, + latestMtimeMs: 0, + files: [], + }; + + family.files.push(name); + family.latestMtimeMs = Math.max(family.latestMtimeMs, stat.mtimeMs); + if (name === base && name.endsWith(".sqlite")) { + family.hasPrimary = true; + family.primaryMtimeMs = stat.mtimeMs; + } + + families.set(base, family); + } + + return [...families.values()]; +} + +export function cleanupDbBackups(options?: { maxFiles?: number; retentionDays?: number }) { + const backupDir = getBackupDir(); + if (!fs.existsSync(backupDir)) { + return { + deletedBackupFamilies: 0, + deletedFiles: 0, + keptBackupFamilies: 0, + maxFiles: options?.maxFiles ?? getDbBackupMaxFiles(), + retentionDays: options?.retentionDays ?? getDbBackupRetentionDays(), + }; + } + + const maxFiles = Math.max(1, options?.maxFiles ?? getDbBackupMaxFiles()); + const retentionDays = Math.max(0, options?.retentionDays ?? getDbBackupRetentionDays()); + const cutoffMs = retentionDays > 0 ? Date.now() - retentionDays * 24 * 60 * 60 * 1000 : 0; + const families = collectBackupFamilies(backupDir); + const primaryFamilies = families + .filter((family) => family.hasPrimary) + .sort((a, b) => b.primaryMtimeMs - a.primaryMtimeMs); + const keepPrimaryBases = new Set(primaryFamilies.slice(0, maxFiles).map((family) => family.base)); + + let deletedBackupFamilies = 0; + let deletedFiles = 0; + + for (const family of families) { + const isOverflowPrimary = family.hasPrimary && !keepPrimaryBases.has(family.base); + const isExpired = retentionDays > 0 && family.latestMtimeMs < cutoffMs; + const isOrphan = !family.hasPrimary; + if (!isOverflowPrimary && !isExpired && !isOrphan) continue; + + deletedBackupFamilies += 1; + for (const name of family.files) { + try { + fs.unlinkSync(path.join(backupDir, name)); + deletedFiles += 1; + } catch { + /* ignore */ + } + } + } + + return { + deletedBackupFamilies, + deletedFiles, + keptBackupFamilies: collectBackupFamilies(backupDir).filter((family) => family.hasPrimary) + .length, + maxFiles, + retentionDays, + }; +} + function isSqliteAutoBackupDisabled() { const isTest = typeof process !== "undefined" && @@ -87,7 +219,7 @@ export function backupDbFile(reason = "auto") { return null; _lastBackupAt = now; - const backupDir = DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups"); + const backupDir = getBackupDir(); if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true }); // Shrink check vs latest backup @@ -112,40 +244,13 @@ export function backupDbFile(reason = "auto") { db.backup(backupFile) .then(() => { console.log(`[DB] Backup created: ${backupFile} (${stat.size} bytes)`); + cleanupDbBackups(); }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); console.error("[DB] Backup failed:", message); }); - // Rotation — keep only last N, delete smallest first - const files = fs - .readdirSync(backupDir) - .filter((f) => f.startsWith("db_") && f.endsWith(".sqlite")) - .sort(); - while (files.length > MAX_DB_BACKUPS) { - let smallestIdx = 0; - let smallestSize = Infinity; - for (let i = 0; i < files.length - 1; i++) { - try { - const fStat = fs.statSync(path.join(backupDir, files[i])); - if (fStat.size < smallestSize) { - smallestSize = fStat.size; - smallestIdx = i; - } - } catch { - smallestIdx = i; - break; - } - } - try { - fs.unlinkSync(path.join(backupDir, files[smallestIdx])); - } catch { - /* gone */ - } - files.splice(smallestIdx, 1); - } - return { filename: path.basename(backupFile), size: stat.size }; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); @@ -157,7 +262,7 @@ export function backupDbFile(reason = "auto") { // ──────────────── List Backups ──────────────── export async function listDbBackups() { - const backupDir = DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups"); + const backupDir = getBackupDir(); try { if (!fs.existsSync(backupDir)) return []; @@ -202,7 +307,7 @@ export async function listDbBackups() { // ──────────────── Restore Backup ──────────────── export async function restoreDbBackup(backupId: string) { - const backupDir = DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups"); + const backupDir = getBackupDir(); // Validate format: must be db__.sqlite, no path separators if ( @@ -244,7 +349,7 @@ export async function restoreDbBackup(backupId: string) { // Force pre-restore backup (bypass throttle) and await so the DB is not closed while backup runs if (!isSqliteAutoBackupDisabled()) { _lastBackupAt = 0; - const backupDirForPre = DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups"); + const backupDirForPre = getBackupDir(); if (SQLITE_FILE && fs.existsSync(SQLITE_FILE)) { const stat = fs.statSync(SQLITE_FILE); if (stat.size >= 4096) { diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index ee090c6141..3e07a1619d 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -10,6 +10,12 @@ import fs from "fs"; import { resolveDataDir, getLegacyDotDataDir } from "../dataPaths"; import { runMigrations } from "./migrationRunner"; import { runDbHealthCheck } from "./healthCheck"; +import { parseStoredPayload } from "../logPayloads"; +import { + buildArtifactRelativePath, + writeCallArtifact, + type CallLogArtifact, +} from "../usage/callLogArtifacts"; type SqliteDatabase = import("better-sqlite3").Database; type JsonRecord = Record; @@ -173,6 +179,7 @@ const SCHEMA_SQL = ` tokens_cache_read INTEGER DEFAULT NULL, tokens_cache_creation INTEGER DEFAULT NULL, tokens_reasoning INTEGER DEFAULT NULL, + cache_source TEXT DEFAULT "upstream", request_type TEXT, source_format TEXT, target_format TEXT, @@ -181,11 +188,15 @@ const SCHEMA_SQL = ` combo_name TEXT, combo_step_id TEXT, combo_execution_key TEXT, - request_body TEXT, - response_body TEXT, - error TEXT, + error_summary TEXT, + detail_state TEXT DEFAULT 'none', artifact_relpath TEXT, - has_pipeline_details INTEGER DEFAULT 0 + artifact_size_bytes INTEGER DEFAULT NULL, + artifact_sha256 TEXT DEFAULT NULL, + has_request_body INTEGER DEFAULT 0, + has_response_body INTEGER DEFAULT 0, + has_pipeline_details INTEGER DEFAULT 0, + request_summary TEXT ); CREATE INDEX IF NOT EXISTS idx_cl_timestamp ON call_logs(timestamp); CREATE INDEX IF NOT EXISTS idx_cl_status ON call_logs(status); @@ -429,6 +440,10 @@ function ensureCallLogsColumns(db: SqliteDatabase) { db.exec("ALTER TABLE call_logs ADD COLUMN tokens_reasoning INTEGER DEFAULT NULL"); console.log("[DB] Added call_logs.tokens_reasoning column"); } + if (!columnNames.has("cache_source")) { + db.exec("ALTER TABLE call_logs ADD COLUMN cache_source TEXT DEFAULT 'upstream'"); + console.log("[DB] Added call_logs.cache_source column"); + } if (!columnNames.has("combo_step_id")) { db.exec("ALTER TABLE call_logs ADD COLUMN combo_step_id TEXT DEFAULT NULL"); console.log("[DB] Added call_logs.combo_step_id column"); @@ -437,6 +452,34 @@ function ensureCallLogsColumns(db: SqliteDatabase) { db.exec("ALTER TABLE call_logs ADD COLUMN combo_execution_key TEXT DEFAULT NULL"); console.log("[DB] Added call_logs.combo_execution_key column"); } + if (!columnNames.has("error_summary")) { + db.exec("ALTER TABLE call_logs ADD COLUMN error_summary TEXT DEFAULT NULL"); + console.log("[DB] Added call_logs.error_summary column"); + } + if (!columnNames.has("detail_state")) { + db.exec("ALTER TABLE call_logs ADD COLUMN detail_state TEXT DEFAULT 'none'"); + console.log("[DB] Added call_logs.detail_state column"); + } + if (!columnNames.has("artifact_size_bytes")) { + db.exec("ALTER TABLE call_logs ADD COLUMN artifact_size_bytes INTEGER DEFAULT NULL"); + console.log("[DB] Added call_logs.artifact_size_bytes column"); + } + if (!columnNames.has("artifact_sha256")) { + db.exec("ALTER TABLE call_logs ADD COLUMN artifact_sha256 TEXT DEFAULT NULL"); + console.log("[DB] Added call_logs.artifact_sha256 column"); + } + if (!columnNames.has("has_request_body")) { + db.exec("ALTER TABLE call_logs ADD COLUMN has_request_body INTEGER DEFAULT 0"); + console.log("[DB] Added call_logs.has_request_body column"); + } + if (!columnNames.has("has_response_body")) { + db.exec("ALTER TABLE call_logs ADD COLUMN has_response_body INTEGER DEFAULT 0"); + console.log("[DB] Added call_logs.has_response_body column"); + } + if (!columnNames.has("request_summary")) { + db.exec("ALTER TABLE call_logs ADD COLUMN request_summary TEXT DEFAULT NULL"); + console.log("[DB] Added call_logs.request_summary column"); + } db.exec( "CREATE INDEX IF NOT EXISTS idx_call_logs_requested_model ON call_logs(requested_model)" @@ -456,6 +499,165 @@ function hasColumn(db: SqliteDatabase, tableName: string, columnName: string): b return rows.some((row) => row.name === columnName); } +function hasTable(db: SqliteDatabase, tableName: string): boolean { + return Boolean( + db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName) + ); +} + +function parseLegacyError(value: unknown): unknown { + if (typeof value !== "string" || value.trim().length === 0) return null; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function offloadLegacyCallLogDetails(db: SqliteDatabase) { + if (!hasTable(db, "call_logs_v1_legacy")) return; + + type LegacyCallLogRow = { + id: string; + timestamp: string | null; + method: string | null; + path: string | null; + status: number | null; + model: string | null; + requested_model: string | null; + provider: string | null; + account: string | null; + connection_id: string | null; + duration: number | null; + tokens_in: number | null; + tokens_out: number | null; + tokens_cache_read: number | null; + tokens_cache_creation: number | null; + tokens_reasoning: number | null; + request_type: string | null; + source_format: string | null; + target_format: string | null; + api_key_id: string | null; + api_key_name: string | null; + combo_name: string | null; + combo_step_id: string | null; + combo_execution_key: string | null; + request_body: string | null; + response_body: string | null; + error: string | null; + }; + + const pendingRows = db + .prepare( + ` + SELECT legacy.* + FROM call_logs_v1_legacy AS legacy + JOIN call_logs AS current ON current.id = legacy.id + WHERE current.detail_state = 'legacy-inline' + ORDER BY legacy.timestamp ASC + ` + ) + .all() as LegacyCallLogRow[]; + + if (pendingRows.length === 0) { + db.exec("DROP TABLE IF EXISTS call_logs_v1_legacy"); + return; + } + + const updateStmt = db.prepare(` + UPDATE call_logs + SET artifact_relpath = @artifactRelPath, + artifact_size_bytes = @artifactSizeBytes, + artifact_sha256 = @artifactSha256, + detail_state = 'ready' + WHERE id = @id + `); + const markMissingStmt = db.prepare(` + UPDATE call_logs + SET detail_state = 'missing', + artifact_relpath = NULL, + artifact_size_bytes = NULL, + artifact_sha256 = NULL + WHERE id = ? + `); + + let failed = 0; + const tx = db.transaction(() => { + for (const row of pendingRows) { + const artifact: CallLogArtifact = { + schemaVersion: 4, + summary: { + id: row.id, + timestamp: row.timestamp || new Date().toISOString(), + method: row.method || "POST", + path: row.path || "/v1/chat/completions", + status: row.status || 0, + model: row.model || "-", + requestedModel: row.requested_model || null, + provider: row.provider || "-", + account: row.account || "-", + connectionId: row.connection_id || null, + duration: row.duration || 0, + tokens: { + in: row.tokens_in || 0, + out: row.tokens_out || 0, + cacheRead: row.tokens_cache_read ?? null, + cacheWrite: row.tokens_cache_creation ?? null, + reasoning: row.tokens_reasoning ?? null, + }, + requestType: row.request_type || null, + sourceFormat: row.source_format || null, + targetFormat: row.target_format || null, + apiKeyId: row.api_key_id || null, + apiKeyName: row.api_key_name || null, + comboName: row.combo_name || null, + comboStepId: row.combo_step_id || null, + comboExecutionKey: row.combo_execution_key || null, + }, + requestBody: parseStoredPayload(row.request_body), + responseBody: parseStoredPayload(row.response_body), + error: parseLegacyError(row.error), + }; + + const artifactResult = writeCallArtifact( + artifact, + buildArtifactRelativePath(artifact.summary.timestamp, artifact.summary.id) + ); + if (!artifactResult) { + failed++; + markMissingStmt.run(row.id); + continue; + } + + updateStmt.run({ + id: row.id, + artifactRelPath: artifactResult.relPath, + artifactSizeBytes: artifactResult.sizeBytes, + artifactSha256: artifactResult.sha256, + }); + } + }); + + tx(); + + if (failed > 0) { + console.warn( + `[DB] Kept call_logs_v1_legacy after partial call log offload (${failed} failed row(s)).` + ); + return; + } + + db.exec("DROP TABLE IF EXISTS call_logs_v1_legacy"); + try { + db.pragma("wal_checkpoint(TRUNCATE)"); + db.exec("VACUUM"); + console.log(`[DB] Offloaded ${pendingRows.length} legacy call log detail row(s) to artifacts.`); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.warn("[DB] Legacy call log compaction finished without VACUUM:", message); + } +} + function isAutomatedTestProcess(): boolean { return ( typeof process !== "undefined" && @@ -694,7 +896,47 @@ export function getDbInstance(): SqliteDatabase { "combo_call_log_targets" ); } + const hasCacheSource = hasColumn(db, "call_logs", "cache_source"); + if (hasCacheSource) { + const cacheSourceLegacy = db + .prepare("SELECT version FROM _omniroute_migrations WHERE version = ? AND name = ?") + .get("022", "call_logs_cache_source") as { version?: string } | undefined; + if (cacheSourceLegacy) { + const cacheSourceCurrent = db + .prepare("SELECT version FROM _omniroute_migrations WHERE version = ?") + .get("026") as { version?: string } | undefined; + if (cacheSourceCurrent) { + db.prepare("DELETE FROM _omniroute_migrations WHERE version = ? AND name = ?").run( + "022", + "call_logs_cache_source" + ); + } else { + db.prepare( + "UPDATE _omniroute_migrations SET version = ?, name = ? WHERE version = ? AND name = ?" + ).run("026", "call_logs_cache_source", "022", "call_logs_cache_source"); + } + } + db.prepare("INSERT OR IGNORE INTO _omniroute_migrations (version, name) VALUES (?, ?)").run( + "026", + "call_logs_cache_source" + ); + } + if ( + hasColumn(db, "call_logs", "detail_state") && + hasColumn(db, "call_logs", "request_summary") && + hasColumn(db, "call_logs", "has_request_body") && + hasColumn(db, "call_logs", "has_response_body") && + !hasColumn(db, "call_logs", "request_body") && + !hasColumn(db, "call_logs", "response_body") && + !hasColumn(db, "call_logs", "error") + ) { + db.prepare("INSERT OR IGNORE INTO _omniroute_migrations (version, name) VALUES (?, ?)").run( + "025", + "call_logs_summary_storage" + ); + } runMigrations(db); + offloadLegacyCallLogDetails(db); // Auto-migrate from db.json if exists if (jsonDbFile && fs.existsSync(jsonDbFile)) { diff --git a/src/lib/db/encryption.ts b/src/lib/db/encryption.ts index 1cfcbda3f0..c26cd10624 100644 --- a/src/lib/db/encryption.ts +++ b/src/lib/db/encryption.ts @@ -36,9 +36,26 @@ function getKey(): Buffer | null { const secret = process.env.STORAGE_ENCRYPTION_KEY; if (!secret) return null; + if (typeof secret !== "string" || secret.trim().length === 0) { + console.error( + "[Encryption] STORAGE_ENCRYPTION_KEY is set but empty or invalid. " + + "Generate a valid key with: openssl rand -base64 32" + ); + return null; + } + // Fixed salt derived from app name — deterministic so same key always produces same derived key const salt = "omniroute-field-encryption-v1"; - _derivedKey = scryptSync(secret, salt, KEY_LENGTH); + try { + _derivedKey = scryptSync(secret, salt, KEY_LENGTH); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error( + `[Encryption] Failed to derive key from STORAGE_ENCRYPTION_KEY: ${message}. ` + + `Generate a valid key with: openssl rand -base64 32` + ); + return null; + } return _derivedKey; } @@ -60,14 +77,23 @@ export function encrypt(plaintext: string | null | undefined): string | null | u // Already encrypted — don't double-encrypt if (plaintext.startsWith(PREFIX)) return plaintext; - const iv = randomBytes(IV_LENGTH); - const cipher = createCipheriv(ALGORITHM, key, iv); + try { + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); - let encrypted = cipher.update(plaintext, "utf8", "hex"); - encrypted += cipher.final("hex"); - const authTag = cipher.getAuthTag().toString("hex"); + let encrypted = cipher.update(plaintext, "utf8", "hex"); + encrypted += cipher.final("hex"); + const authTag = cipher.getAuthTag().toString("hex"); - return `${PREFIX}${iv.toString("hex")}:${encrypted}:${authTag}`; + return `${PREFIX}${iv.toString("hex")}:${encrypted}:${authTag}`; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error( + `[Encryption] Encryption failed: ${message}. ` + + `Check your STORAGE_ENCRYPTION_KEY — generate one with: openssl rand -base64 32` + ); + return plaintext; // fallback to plaintext rather than crashing + } } /** @@ -141,3 +167,37 @@ export function decryptConnectionFields { return new Set(rows.map((r) => r.version)); } +/** + * Get applied migration records (version + name) for mismatch detection. + */ +function getAppliedRecords(db: Database.Database): Array<{ version: string; name: string }> { + return db + .prepare("SELECT version, name FROM _omniroute_migrations ORDER BY version") + .all() as Array<{ + version: string; + name: string; + }>; +} + +/** + * Detect migration name mismatches — when a migration version number + * has been reused/renumbered with a different name. This is a strong signal + * that the migration tracking is corrupted or migrations were renumbered. + */ +function detectNameMismatches( + appliedRecords: Array<{ version: string; name: string }>, + files: Array<{ version: string; name: string; path: string }> +): Array<{ version: string; appliedName: string; diskName: string }> { + const appliedByName = new Map(appliedRecords.map((r) => [r.version, r.name])); + const mismatches: Array<{ version: string; appliedName: string; diskName: string }> = []; + + for (const file of files) { + const appliedName = appliedByName.get(file.version); + if (appliedName && appliedName !== file.name) { + mismatches.push({ + version: file.version, + appliedName, + diskName: file.name, + }); + } + } + + return mismatches; +} + +function reconcileRenumberedMigrations( + db: Database.Database, + files: Array<{ version: string; name: string; path: string }> +): boolean { + let repaired = false; + + for (const compatibility of RENAMED_MIGRATION_COMPATIBILITY) { + const hasTargetFile = files.some( + (file) => + file.version === compatibility.toVersion && file.name === compatibility.toName + ); + const hasSourceFile = files.some( + (file) => + file.version === compatibility.fromVersion && file.name !== compatibility.fromName + ); + + if (!hasTargetFile || !hasSourceFile) { + continue; + } + + const legacyRow = db + .prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ? AND name = ?") + .get(compatibility.fromVersion, compatibility.fromName) as + | { version: string; name: string } + | undefined; + if (!legacyRow) { + continue; + } + + const targetRow = db + .prepare("SELECT version FROM _omniroute_migrations WHERE version = ?") + .get(compatibility.toVersion) as { version: string } | undefined; + + const applyRepair = db.transaction(() => { + if (targetRow) { + db.prepare("DELETE FROM _omniroute_migrations WHERE version = ? AND name = ?").run( + compatibility.fromVersion, + compatibility.fromName + ); + } else { + db.prepare( + "UPDATE _omniroute_migrations SET version = ?, name = ? WHERE version = ? AND name = ?" + ).run( + compatibility.toVersion, + compatibility.toName, + compatibility.fromVersion, + compatibility.fromName + ); + } + }); + + applyRepair(); + repaired = true; + console.warn( + `[Migration] Reconciled renamed migration ${compatibility.fromVersion}_${compatibility.fromName} ` + + `to ${compatibility.toVersion}_${compatibility.toName} to preserve pending migrations.` + ); + } + + return repaired; +} + +/** + * Create a pre-migration backup of the SQLite database using VACUUM INTO. + * Returns the backup path on success, null on failure. + */ +function createPreMigrationBackup(db: Database.Database): string | null { + try { + const sqliteFile = db.name; + if (!sqliteFile || sqliteFile === ":memory:") return null; + + const backupDir = path.join(path.dirname(sqliteFile), "db_backups"); + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backupPath = path.join(backupDir, `db_${timestamp}_pre-migration.sqlite`); + const escapedBackupPath = backupPath.replace(/'/g, "''"); + + db.exec(`VACUUM INTO '${escapedBackupPath}'`); + console.log(`[Migration] Pre-migration backup created: ${backupPath}`); + return backupPath; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.warn(`[Migration] Failed to create pre-migration backup: ${message}`); + return null; + } +} + /** * Run all pending migrations in order. * Returns the number of migrations applied. + * + * Includes safety checks: + * 1. Detects migration name mismatches (renumbering) and warns + * 2. Aborts if too many pending migrations on an existing DB (likely wipe) + * 3. Creates automatic backup before running any migrations */ export function runMigrations(db: Database.Database): number { ensureMigrationsTable(db); const files = getMigrationFiles(); + reconcileRenumberedMigrations(db, files); const applied = getAppliedVersions(db); + const appliedRecords = getAppliedRecords(db); + + // ── Safety Check 1: Detect migration name mismatches (renumbering) ── + const mismatches = detectNameMismatches(appliedRecords, files); + if (mismatches.length > 0) { + console.error( + `[Migration] ⚠️ CRITICAL: ${mismatches.length} migration version(s) have been renumbered!` + ); + for (const m of mismatches) { + console.error( + ` Version ${m.version}: applied as "${m.appliedName}" but disk has "${m.diskName}"` + ); + } + console.error( + `[Migration] This indicates migrations were renumbered between releases, ` + + `which can cause the migration runner to skip or re-run migrations incorrectly.` + ); + console.error( + `[Migration] The version-only tracking will skip these (version already applied), ` + + `but please report this to the OmniRoute maintainers.` + ); + } + + const pending = files.filter((f) => !applied.has(f.version)); + if (pending.length === 0) { + return 0; // Nothing to do + } + + // ── Safety Check 2: Mass-migration detection (abort if existing DB + many migrations) ── + // Skip in test environments where fresh DBs legitimately have many pending migrations. + const isTestEnvironment = + process.env.NODE_ENV === "test" || + process.env.VITEST !== undefined || + (typeof process.argv !== "undefined" && process.argv.some((arg) => arg.includes("test"))); + + if ( + !isTestEnvironment && + process.env.DISABLE_SQLITE_AUTO_BACKUP !== "true" && + MAX_PENDING_MIGRATIONS_ON_EXISTING_DB > 0 && + applied.size > 0 && + pending.length > MAX_PENDING_MIGRATIONS_ON_EXISTING_DB + ) { + const msg = + `[Migration] 🛑 ABORT: Detected ${pending.length} pending migrations on an existing database ` + + `(threshold is ${MAX_PENDING_MIGRATIONS_ON_EXISTING_DB}). ` + + `This usually means the migration tracking table was accidentally wiped. ` + + `Running all migrations from scratch will cause data loss or schema errors.`; + console.error(msg); + throw new Error(msg); + } + + // ── Safety Check 3: Pre-migration backup ── + // Skip backup if it's a completely fresh database (0 applied and all pending) + // or if running in tests (where AUTO_BACKUP might be disabled) + if (applied.size > 0 && process.env.DISABLE_SQLITE_AUTO_BACKUP !== "true") { + createPreMigrationBackup(db); + } + let count = 0; - for (const migration of files) { - if (applied.has(migration.version)) continue; - + for (const migration of pending) { const sql = fs.readFileSync(migration.path, "utf-8"); const applyMigration = db.transaction(() => { diff --git a/src/lib/db/migrations/001_initial_schema.sql b/src/lib/db/migrations/001_initial_schema.sql index 42d3fe5aba..2cfc72eabe 100644 --- a/src/lib/db/migrations/001_initial_schema.sql +++ b/src/lib/db/migrations/001_initial_schema.sql @@ -109,8 +109,8 @@ CREATE INDEX IF NOT EXISTS idx_uh_provider ON usage_history(provider); CREATE INDEX IF NOT EXISTS idx_uh_model ON usage_history(model); CREATE TABLE IF NOT EXISTS call_logs ( - id TEXT PRIMARY KEY, - timestamp TEXT NOT NULL, + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, method TEXT, path TEXT, status INTEGER, @@ -125,19 +125,24 @@ CREATE TABLE IF NOT EXISTS call_logs ( tokens_cache_read INTEGER DEFAULT NULL, tokens_cache_creation INTEGER DEFAULT NULL, tokens_reasoning INTEGER DEFAULT NULL, + cache_source TEXT DEFAULT 'upstream', request_type TEXT, source_format TEXT, target_format TEXT, api_key_id TEXT, - api_key_name TEXT, - combo_name TEXT, - combo_step_id TEXT, - combo_execution_key TEXT, - request_body TEXT, - response_body TEXT, - error TEXT, - artifact_relpath TEXT, - has_pipeline_details INTEGER DEFAULT 0 + api_key_name TEXT, + combo_name TEXT, + combo_step_id TEXT, + combo_execution_key TEXT, + error_summary TEXT, + detail_state TEXT DEFAULT 'none', + artifact_relpath TEXT, + artifact_size_bytes INTEGER DEFAULT NULL, + artifact_sha256 TEXT DEFAULT NULL, + has_request_body INTEGER DEFAULT 0, + has_response_body INTEGER DEFAULT 0, + has_pipeline_details INTEGER DEFAULT 0, + request_summary TEXT ); CREATE INDEX IF NOT EXISTS idx_cl_timestamp ON call_logs(timestamp); CREATE INDEX IF NOT EXISTS idx_cl_status ON call_logs(status); diff --git a/src/lib/db/migrations/022_add_memory_fts5.sql b/src/lib/db/migrations/022_add_memory_fts5.sql new file mode 100644 index 0000000000..8042a13080 --- /dev/null +++ b/src/lib/db/migrations/022_add_memory_fts5.sql @@ -0,0 +1,33 @@ +-- 022_add_memory_fts5.sql +-- Full-Text Search (FTS5) virtual table for memory fast searching. +-- Provides efficient semantic and exact-match searching on memory content and keys. + +-- Some legacy/test databases may have version 015 marked as applied but still be missing the +-- base memories table. Recreate the table defensively here so FTS setup does not fail. +CREATE TABLE IF NOT EXISTS memories ( + id TEXT PRIMARY KEY, + api_key_id TEXT NOT NULL, + session_id TEXT, + type TEXT NOT NULL CHECK(type IN ('factual', 'episodic', 'procedural', 'semantic')), + key TEXT, + content TEXT NOT NULL, + metadata TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_memories_api_key ON memories(api_key_id); +CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id); +CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type); +CREATE INDEX IF NOT EXISTS idx_memories_expires ON memories(expires_at); + +-- Transitional setup only: create the FTS table shell without backfilling data or +-- attaching UUID-based triggers. The follow-up migration 023_fix_memory_fts_uuid.sql +-- converts memories to a stable INTEGER-backed join key, recreates the FTS table, +-- and performs the real backfill safely. +CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5( + content, + key, + content='memories' +); diff --git a/src/lib/db/migrations/023_fix_memory_fts_uuid.sql b/src/lib/db/migrations/023_fix_memory_fts_uuid.sql new file mode 100644 index 0000000000..c114036657 --- /dev/null +++ b/src/lib/db/migrations/023_fix_memory_fts_uuid.sql @@ -0,0 +1,55 @@ +-- 023_fix_memory_fts_uuid.sql +-- Fix FTS5 UUID/INTEGER mismatch that caused semantic search to always return 0 results. +-- +-- Problem: memories.id is TEXT (UUID) but memory_fts.rowid is INTEGER. +-- The JOIN `JOIN memory_fts f ON m.id = f.rowid` always failed silently (UUID ≠ integer), +-- returning 0 results for all FTS5 searches. +-- +-- Solution: +-- 1. Add INTEGER memory_id column that maps to SQLite's internal rowid +-- 2. Backfill memory_id = CAST(rowid AS INTEGER) for all existing rows +-- 3. Recreate memory_fts triggers to use memory_id (not UUID id) as rowid +-- 4. Repopulate FTS5 so JOIN on memory_id works correctly + + + +-- Step 1: Add memory_id column (will hold SQLite rowid as INTEGER) +ALTER TABLE memories ADD COLUMN memory_id INTEGER; + +-- Step 2: Backfill memory_id from SQLite's internal rowid for all existing rows +UPDATE memories SET memory_id = CAST(rowid AS INTEGER); + +-- Step 3: Make memory_id NOT NULL and UNIQUE after backfill +CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_memory_id ON memories(memory_id); + +-- Step 4: Drop old broken triggers that used UUID as rowid +DROP TRIGGER IF EXISTS memory_fts_ai; +DROP TRIGGER IF EXISTS memory_fts_ad; +DROP TRIGGER IF EXISTS memory_fts_au; + +-- Step 5: Drop and recreate memory_fts (without content_rowid, so FTS5 uses its own INTEGER rowid) +DROP TABLE IF EXISTS memory_fts; +CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5( + content, + key, + content='memories' +); + +-- Step 6: Recreate triggers using memory_id (INTEGER rowid) instead of id (UUID TEXT) +CREATE TRIGGER IF NOT EXISTS memory_fts_ai AFTER INSERT ON memories BEGIN + INSERT INTO memory_fts(rowid, content, key) VALUES (new.memory_id, new.content, new.key); +END; + +CREATE TRIGGER IF NOT EXISTS memory_fts_ad AFTER DELETE ON memories BEGIN + INSERT INTO memory_fts(memory_fts, rowid, content, key) VALUES('delete', old.memory_id, old.content, old.key); +END; + +CREATE TRIGGER IF NOT EXISTS memory_fts_au AFTER UPDATE ON memories BEGIN + INSERT INTO memory_fts(memory_fts, rowid, content, key) VALUES('delete', old.memory_id, old.content, old.key); + INSERT INTO memory_fts(rowid, content, key) VALUES (new.memory_id, new.content, new.key); +END; + +-- Step 7: Repopulate FTS5 with correct memory_id values +INSERT INTO memory_fts(rowid, content, key) SELECT memory_id, content, key FROM memories; + + diff --git a/src/lib/db/migrations/024_create_sync_tokens.sql b/src/lib/db/migrations/024_create_sync_tokens.sql new file mode 100644 index 0000000000..afc5745319 --- /dev/null +++ b/src/lib/db/migrations/024_create_sync_tokens.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS sync_tokens ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + sync_api_key_id TEXT, + revoked_at TEXT, + last_used_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_sync_tokens_created_at ON sync_tokens(created_at); +CREATE INDEX IF NOT EXISTS idx_sync_tokens_last_used_at ON sync_tokens(last_used_at); +CREATE INDEX IF NOT EXISTS idx_sync_tokens_revoked_at ON sync_tokens(revoked_at); +CREATE INDEX IF NOT EXISTS idx_sync_tokens_sync_api_key_id ON sync_tokens(sync_api_key_id); diff --git a/src/lib/db/migrations/025_call_logs_summary_storage.sql b/src/lib/db/migrations/025_call_logs_summary_storage.sql new file mode 100644 index 0000000000..194b106e94 --- /dev/null +++ b/src/lib/db/migrations/025_call_logs_summary_storage.sql @@ -0,0 +1,145 @@ +-- 025_call_logs_summary_storage.sql +-- Rebuild call_logs so SQLite stores summary metadata only. + +DROP INDEX IF EXISTS idx_cl_combo_target; +DROP INDEX IF EXISTS idx_call_logs_request_type; +DROP INDEX IF EXISTS idx_call_logs_requested_model; +DROP INDEX IF EXISTS idx_cl_status; +DROP INDEX IF EXISTS idx_cl_timestamp; + +ALTER TABLE call_logs RENAME TO call_logs_v1_legacy; + +CREATE TABLE call_logs ( + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + method TEXT, + path TEXT, + status INTEGER, + model TEXT, + requested_model TEXT, + provider TEXT, + account TEXT, + connection_id TEXT, + duration INTEGER DEFAULT 0, + tokens_in INTEGER DEFAULT 0, + tokens_out INTEGER DEFAULT 0, + tokens_cache_read INTEGER DEFAULT NULL, + tokens_cache_creation INTEGER DEFAULT NULL, + tokens_reasoning INTEGER DEFAULT NULL, + cache_source TEXT DEFAULT 'upstream', + request_type TEXT, + source_format TEXT, + target_format TEXT, + api_key_id TEXT, + api_key_name TEXT, + combo_name TEXT, + combo_step_id TEXT, + combo_execution_key TEXT, + error_summary TEXT, + detail_state TEXT DEFAULT 'none', + artifact_relpath TEXT, + artifact_size_bytes INTEGER DEFAULT NULL, + artifact_sha256 TEXT DEFAULT NULL, + has_request_body INTEGER DEFAULT 0, + has_response_body INTEGER DEFAULT 0, + has_pipeline_details INTEGER DEFAULT 0, + request_summary TEXT +); + +INSERT OR REPLACE INTO call_logs ( + id, + timestamp, + method, + path, + status, + model, + requested_model, + provider, + account, + connection_id, + duration, + tokens_in, + tokens_out, + tokens_cache_read, + tokens_cache_creation, + tokens_reasoning, + cache_source, + request_type, + source_format, + target_format, + api_key_id, + api_key_name, + combo_name, + combo_step_id, + combo_execution_key, + error_summary, + detail_state, + artifact_relpath, + artifact_size_bytes, + artifact_sha256, + has_request_body, + has_response_body, + has_pipeline_details, + request_summary +) +SELECT + id, + timestamp, + method, + path, + status, + model, + requested_model, + provider, + account, + connection_id, + COALESCE(duration, 0), + COALESCE(tokens_in, 0), + COALESCE(tokens_out, 0), + tokens_cache_read, + tokens_cache_creation, + tokens_reasoning, + COALESCE(cache_source, 'upstream') AS cache_source, + request_type, + source_format, + target_format, + api_key_id, + api_key_name, + combo_name, + combo_step_id, + combo_execution_key, + CASE + WHEN error IS NULL OR TRIM(CAST(error AS TEXT)) = '' THEN NULL + WHEN LENGTH(CAST(error AS TEXT)) > 4000 THEN SUBSTR(CAST(error AS TEXT), 1, 4000) + ELSE CAST(error AS TEXT) + END AS error_summary, + CASE + WHEN artifact_relpath IS NOT NULL AND TRIM(artifact_relpath) != '' THEN 'ready' + WHEN COALESCE(request_body, '') != '' OR COALESCE(response_body, '') != '' OR COALESCE(error, '') != '' + THEN 'legacy-inline' + ELSE 'none' + END AS detail_state, + NULLIF(TRIM(artifact_relpath), '') AS artifact_relpath, + NULL AS artifact_size_bytes, + NULL AS artifact_sha256, + CASE WHEN request_body IS NOT NULL AND TRIM(request_body) != '' THEN 1 ELSE 0 END AS has_request_body, + CASE WHEN response_body IS NOT NULL AND TRIM(response_body) != '' THEN 1 ELSE 0 END AS has_response_body, + COALESCE(has_pipeline_details, 0) AS has_pipeline_details, + CASE + WHEN request_type = 'search' AND request_body IS NOT NULL AND json_valid(request_body) + THEN json_object( + 'query', + COALESCE(json_extract(request_body, '$.query'), ''), + 'filters', + json(COALESCE(json_remove(request_body, '$.query', '$.provider'), '{}')) + ) + ELSE NULL + END AS request_summary +FROM call_logs_v1_legacy; + +CREATE INDEX idx_cl_timestamp ON call_logs(timestamp); +CREATE INDEX idx_cl_status ON call_logs(status); +CREATE INDEX idx_call_logs_requested_model ON call_logs(requested_model); +CREATE INDEX idx_call_logs_request_type ON call_logs(request_type); +CREATE INDEX idx_cl_combo_target + ON call_logs(combo_name, combo_execution_key, timestamp); diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 162794129d..89382ef98f 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -343,7 +343,13 @@ export async function addCustomModel( modelId: string, modelName?: string, source = "manual", - apiFormat: "chat-completions" | "responses" = "chat-completions", + apiFormat: + | "chat-completions" + | "responses" + | "embeddings" + | "audio-transcriptions" + | "audio-speech" + | "images-generations" = "chat-completions", supportedEndpoints: string[] = ["chat"] ) { const db = getDbInstance(); diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 646e24dcc9..4123386b2b 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -45,10 +45,13 @@ export async function getSettings() { const settings: Record = { cloudEnabled: false, stickyRoundRobinLimit: 3, + requestRetry: 3, + maxRetryIntervalSec: 30, requireLogin: true, hiddenSidebarItems: [], alwaysPreserveClientCache: "auto", idempotencyWindowMs: 5000, + wsAuth: false, }; for (const row of rows) { const record = toRecord(row); diff --git a/src/lib/db/syncTokens.ts b/src/lib/db/syncTokens.ts new file mode 100644 index 0000000000..ffa9c5149d --- /dev/null +++ b/src/lib/db/syncTokens.ts @@ -0,0 +1,163 @@ +import { v4 as uuidv4 } from "uuid"; +import { getDbInstance, rowToCamel } from "./core"; +import { backupDbFile } from "./backup"; + +type JsonRecord = Record; + +export interface SyncTokenRecord { + id: string; + name: string; + tokenHash: string; + syncApiKeyId: string | null; + revokedAt: string | null; + lastUsedAt: string | null; + createdAt: string; + updatedAt: string; +} + +interface StatementLike { + all: (...params: unknown[]) => TRow[]; + get: (...params: unknown[]) => TRow | undefined; + run: (...params: unknown[]) => { changes?: number }; +} + +interface DbLike { + prepare: (sql: string) => StatementLike; + exec: (sql: string) => void; +} + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toSyncTokenRecord(value: unknown): SyncTokenRecord | null { + const record = asRecord(rowToCamel(value)); + if (typeof record.id !== "string" || typeof record.name !== "string") { + return null; + } + + return { + id: record.id, + name: record.name, + tokenHash: typeof record.tokenHash === "string" ? record.tokenHash : "", + syncApiKeyId: typeof record.syncApiKeyId === "string" ? record.syncApiKeyId : null, + revokedAt: typeof record.revokedAt === "string" ? record.revokedAt : null, + lastUsedAt: typeof record.lastUsedAt === "string" ? record.lastUsedAt : null, + createdAt: typeof record.createdAt === "string" ? record.createdAt : new Date().toISOString(), + updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : new Date().toISOString(), + }; +} + +function ensureSyncTokensTable(db: DbLike) { + db.exec(` + CREATE TABLE IF NOT EXISTS sync_tokens ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + sync_api_key_id TEXT, + revoked_at TEXT, + last_used_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_sync_tokens_created_at ON sync_tokens(created_at); + CREATE INDEX IF NOT EXISTS idx_sync_tokens_last_used_at ON sync_tokens(last_used_at); + CREATE INDEX IF NOT EXISTS idx_sync_tokens_revoked_at ON sync_tokens(revoked_at); + CREATE INDEX IF NOT EXISTS idx_sync_tokens_sync_api_key_id ON sync_tokens(sync_api_key_id); + `); +} + +export async function listSyncTokens() { + const db = getDbInstance() as unknown as DbLike; + ensureSyncTokensTable(db); + const rows = db + .prepare( + "SELECT * FROM sync_tokens ORDER BY datetime(created_at) DESC, name COLLATE NOCASE ASC" + ) + .all(); + + return rows + .map((row) => toSyncTokenRecord(row)) + .filter((row): row is SyncTokenRecord => row !== null); +} + +export async function getSyncTokenById(id: string) { + const db = getDbInstance() as unknown as DbLike; + ensureSyncTokensTable(db); + const row = db.prepare("SELECT * FROM sync_tokens WHERE id = ?").get(id); + return toSyncTokenRecord(row); +} + +export async function getSyncTokenByHash(tokenHash: string) { + const db = getDbInstance() as unknown as DbLike; + ensureSyncTokensTable(db); + const row = db.prepare("SELECT * FROM sync_tokens WHERE token_hash = ?").get(tokenHash); + return toSyncTokenRecord(row); +} + +export async function createSyncTokenRecord(data: { + name: string; + tokenHash: string; + syncApiKeyId?: string | null; +}) { + const db = getDbInstance() as unknown as DbLike; + ensureSyncTokensTable(db); + + const now = new Date().toISOString(); + const record: SyncTokenRecord = { + id: uuidv4(), + name: data.name, + tokenHash: data.tokenHash, + syncApiKeyId: data.syncApiKeyId || null, + revokedAt: null, + lastUsedAt: null, + createdAt: now, + updatedAt: now, + }; + + db.prepare( + `INSERT INTO sync_tokens ( + id, name, token_hash, sync_api_key_id, revoked_at, last_used_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + record.id, + record.name, + record.tokenHash, + record.syncApiKeyId, + record.revokedAt, + record.lastUsedAt, + record.createdAt, + record.updatedAt + ); + + backupDbFile("pre-write"); + return record; +} + +export async function revokeSyncToken(id: string) { + const db = getDbInstance() as unknown as DbLike; + ensureSyncTokensTable(db); + + const existing = await getSyncTokenById(id); + if (!existing) return null; + if (existing.revokedAt) return existing; + + const now = new Date().toISOString(); + db.prepare("UPDATE sync_tokens SET revoked_at = ?, updated_at = ? WHERE id = ?").run( + now, + now, + id + ); + backupDbFile("pre-write"); + return await getSyncTokenById(id); +} + +export async function touchSyncTokenLastUsed(id: string, usedAt = new Date().toISOString()) { + const db = getDbInstance() as unknown as DbLike; + ensureSyncTokensTable(db); + const result = db + .prepare("UPDATE sync_tokens SET last_used_at = ?, updated_at = ? WHERE id = ?") + .run(usedAt, usedAt, id); + return Number(result.changes || 0) > 0; +} diff --git a/src/lib/env/runtimeEnv.ts b/src/lib/env/runtimeEnv.ts new file mode 100644 index 0000000000..7d3c9505c7 --- /dev/null +++ b/src/lib/env/runtimeEnv.ts @@ -0,0 +1,166 @@ +import { z } from "zod"; + +import { validateSecrets } from "@/shared/utils/secretsValidator"; + +const NODE_ENV_VALUES = ["development", "production", "test"] as const; +const BOOLEAN_ENV_VALUES = ["true", "false"] as const; + +type RuntimeEnvIssue = { + name: string; + issue: string; + hint?: string; +}; + +export type RuntimeEnvValidationResult = { + valid: boolean; + errors: RuntimeEnvIssue[]; + warnings: RuntimeEnvIssue[]; + data?: WebRuntimeEnv; +}; + +function normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed === "" ? undefined : trimmed; +} + +const optionalTrimmedString = z.preprocess(normalizeOptionalString, z.string().min(1).optional()); + +const optionalBooleanEnv = z.preprocess( + normalizeOptionalString, + z.enum(BOOLEAN_ENV_VALUES).optional() +); + +const optionalHttpUrl = z.preprocess( + normalizeOptionalString, + z + .string() + .url() + .refine((value) => value.startsWith("http://") || value.startsWith("https://"), { + message: "must start with http:// or https://", + }) + .optional() +); + +const optionalPortEnv = z.preprocess( + normalizeOptionalString, + z + .string() + .regex(/^\d+$/, "must be an integer between 1 and 65535") + .refine((value) => { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed >= 1 && parsed <= 65535; + }, "must be an integer between 1 and 65535") + .optional() +); + +export const webRuntimeEnvSchema = z.object({ + NODE_ENV: z.preprocess(normalizeOptionalString, z.enum(NODE_ENV_VALUES).optional()), + DATA_DIR: optionalTrimmedString, + JWT_SECRET: optionalTrimmedString, + API_KEY_SECRET: optionalTrimmedString, + INITIAL_PASSWORD: optionalTrimmedString, + AUTH_COOKIE_SECURE: optionalBooleanEnv, + REQUIRE_API_KEY: optionalBooleanEnv, + PRICING_SYNC_ENABLED: optionalBooleanEnv, + OMNIROUTE_DISABLE_BACKGROUND_SERVICES: optionalBooleanEnv, + CLOUD_URL: optionalHttpUrl, + NEXT_PUBLIC_CLOUD_URL: optionalHttpUrl, + OMNIROUTE_BASE_URL: optionalHttpUrl, + BASE_URL: optionalHttpUrl, + NEXT_PUBLIC_BASE_URL: optionalHttpUrl, + OMNIROUTE_PORT: optionalPortEnv, + API_PORT: optionalPortEnv, + DASHBOARD_PORT: optionalPortEnv, +}); + +export type WebRuntimeEnv = z.infer; + +function formatZodPath(path: Array): string { + return path.length > 0 ? String(path[0]) : "env"; +} + +function getSchemaIssues(error: z.ZodError): RuntimeEnvIssue[] { + return error.issues.map((issue) => ({ + name: formatZodPath(issue.path), + issue: `Invalid environment variable "${formatZodPath(issue.path)}": ${issue.message}.`, + })); +} + +export function validateWebRuntimeEnv( + env: NodeJS.ProcessEnv = process.env +): RuntimeEnvValidationResult { + const secretValidation = validateSecrets(env); + const schemaValidation = webRuntimeEnvSchema.safeParse(env); + const errors = [...secretValidation.errors]; + const warnings = [...secretValidation.warnings]; + + if (!schemaValidation.success) { + errors.push(...getSchemaIssues(schemaValidation.error)); + } + + return { + valid: errors.length === 0, + errors, + warnings, + data: schemaValidation.success ? schemaValidation.data : undefined, + }; +} + +export function formatRuntimeEnvValidationErrors( + errors: RuntimeEnvIssue[], + warnings: RuntimeEnvIssue[] = [] +): string { + const lines = ["Invalid web runtime environment configuration:"]; + + for (const error of errors) { + lines.push(`- ${error.issue}`); + if (error.hint) { + lines.push(` hint: ${error.hint}`); + } + } + + for (const warning of warnings) { + lines.push(`- Warning: ${warning.issue}`); + } + + return lines.join("\n"); +} + +export function getWebRuntimeEnv(env: NodeJS.ProcessEnv = process.env): WebRuntimeEnv { + const result = validateWebRuntimeEnv(env); + if (!result.valid || !result.data) { + throw new Error(formatRuntimeEnvValidationErrors(result.errors, result.warnings)); + } + return result.data; +} + +export function enforceWebRuntimeEnv( + env: NodeJS.ProcessEnv = process.env, + logger: Pick = console +): void { + const result = validateWebRuntimeEnv(env); + + for (const warning of result.warnings) { + logger.warn(`[STARTUP] ${warning.issue}`); + } + + if (result.valid) return; + + logger.error(""); + logger.error("═══════════════════════════════════════════════════"); + logger.error(" ❌ STARTUP: Invalid web runtime environment"); + logger.error("═══════════════════════════════════════════════════"); + for (const error of result.errors) { + logger.error(` • ${error.issue}`); + if (error.hint) { + logger.error(` → ${error.hint}`); + } + } + logger.error(""); + logger.error(" Fix the environment and restart the server."); + logger.error(" Secrets are intentionally not printed."); + logger.error("═══════════════════════════════════════════════════"); + logger.error(""); + process.exit(1); +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index d85ac54ae7..8917f8ba8b 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -151,6 +151,9 @@ export { export { // Backup Management backupDbFile, + cleanupDbBackups, + getDbBackupMaxFiles, + getDbBackupRetentionDays, listDbBackups, restoreDbBackup, } from "./db/backup"; @@ -235,6 +238,15 @@ export { setToolStatus, } from "./db/versionManager"; +export { + listSyncTokens, + getSyncTokenById, + getSyncTokenByHash, + createSyncTokenRecord, + revokeSyncToken, + touchSyncTokenLastUsed, +} from "./db/syncTokens"; + export { getUpstreamProxyConfigs, getUpstreamProxyConfig, diff --git a/src/lib/memory/__tests__/retrieval.test.ts b/src/lib/memory/__tests__/retrieval.test.ts index 98ac6fbe44..2688694d4c 100644 --- a/src/lib/memory/__tests__/retrieval.test.ts +++ b/src/lib/memory/__tests__/retrieval.test.ts @@ -1,4 +1,10 @@ -import { describe, test, expect } from "vitest"; +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import Database from "better-sqlite3"; +import { retrieveMemories, estimateTokens } from "../retrieval"; + +// ──────────────────────────────────────────────────────────── +// Existing tests (pure-logic, no DB required) +// ──────────────────────────────────────────────────────────── /** * Test that corrupt metadata in retrieval doesn't throw (returns {} instead). @@ -54,3 +60,288 @@ describe("Memory API - response shape", () => { expect(stats.byType).toEqual({ factual: 2, procedural: 1 }); }); }); + +// ──────────────────────────────────────────────────────────── +// FTS5-specific tests (real in-memory SQLite DB) +// ──────────────────────────────────────────────────────────── + +const API_KEY_ID = "test-api-key-fts5"; + +/** + * Helper: create the `memories` table + `memory_fts` FTS5 virtual table + * with the same DDL used in the real migrations (015 + 022). + */ +function setupSchema(db: InstanceType) { + db.exec(` + CREATE TABLE IF NOT EXISTS memories ( + id INTEGER PRIMARY KEY, + api_key_id TEXT NOT NULL, + session_id TEXT, + type TEXT NOT NULL CHECK(type IN ('factual','episodic','procedural','semantic')), + key TEXT, + content TEXT NOT NULL, + metadata TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_memories_api_key ON memories(api_key_id); + CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id); + `); +} + +function setupFts(db: InstanceType) { + db.exec(` + CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5( + content, + key, + content='memories', + content_rowid='id' + ); + + CREATE TRIGGER IF NOT EXISTS memory_fts_ai AFTER INSERT ON memories BEGIN + INSERT INTO memory_fts(rowid, content, key) VALUES (new.id, new.content, new.key); + END; + + CREATE TRIGGER IF NOT EXISTS memory_fts_ad AFTER DELETE ON memories BEGIN + INSERT INTO memory_fts(memory_fts, rowid, content, key) VALUES('delete', old.id, old.content, old.key); + END; + + CREATE TRIGGER IF NOT EXISTS memory_fts_au AFTER UPDATE ON memories BEGIN + INSERT INTO memory_fts(memory_fts, rowid, content, key) VALUES('delete', old.id, old.content, old.key); + INSERT INTO memory_fts(rowid, content, key) VALUES (new.id, new.content, new.key); + END; + `); +} + +/** Insert a memory row with an auto-incremented INTEGER id (FTS5-compatible). */ +function insertMemory( + db: InstanceType, + opts: { + apiKeyId?: string; + sessionId?: string; + type?: string; + key?: string; + content: string; + metadata?: string; + createdAt?: string; + } +) { + const now = opts.createdAt ?? new Date().toISOString(); + db.prepare( + `INSERT INTO memories (api_key_id, session_id, type, key, content, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + opts.apiKeyId ?? API_KEY_ID, + opts.sessionId ?? null, + opts.type ?? "factual", + opts.key ?? "", + opts.content, + opts.metadata ?? "{}", + now, + now + ); +} + +describe("Memory Retrieval — FTS5 integration", () => { + let db: InstanceType; + let savedDb: unknown; + + beforeEach(() => { + // Capture whatever DB singleton existed before the test + savedDb = (globalThis as any).__omnirouteDb; + + // Stand up an in-memory SQLite DB and inject it as the singleton + db = new Database(":memory:"); + db.pragma("journal_mode = WAL"); + setupSchema(db); + setupFts(db); + (globalThis as any).__omnirouteDb = db; + }); + + afterEach(() => { + // Restore the previous singleton (or remove it) + if (savedDb) { + (globalThis as any).__omnirouteDb = savedDb; + } else { + delete (globalThis as any).__omnirouteDb; + } + try { + db.close(); + } catch { + // already closed + } + }); + + // ── 1. Ranked results — FTS5 returns results ordered by relevance ── + + test("semantic strategy returns FTS5-ranked results with most relevant first", async () => { + // Insert memories with varying relevance to the query "TypeScript" + insertMemory(db, { + content: "Python is a popular programming language for data science.", + key: "python-info", + }); + insertMemory(db, { + content: "TypeScript is a typed superset of JavaScript that compiles to plain JavaScript.", + key: "typescript-overview", + }); + insertMemory(db, { + content: + "The TypeScript compiler (tsc) performs type checking and emits JavaScript. The TypeScript compiler is fast and TypeScript is great.", + key: "typescript-compiler", + }); + + // Use single-token query so FTS5 MATCH finds all memories containing "TypeScript" + const results = await retrieveMemories(API_KEY_ID, { + query: "TypeScript", + retrievalStrategy: "semantic", + maxTokens: 8000, + }); + + // Should return at least the two TypeScript memories (Python one won't match) + expect(results.length).toBeGreaterThanOrEqual(2); + + // The memory mentioning "TypeScript" more times should rank higher via BM25 + const topContent = results[0].content; + expect(topContent).toContain("TypeScript compiler"); + }); + + // ── 2. Hybrid strategy — combines FTS5 + recency/keyword signals ── + + test("hybrid strategy merges FTS5 results with keyword results without duplicates", async () => { + // Memory that matches FTS5 query + insertMemory(db, { + content: "Kubernetes orchestrates containerized applications in a cluster.", + key: "kubernetes", + }); + // Memory that won't match FTS5 but contains the keyword in metadata/key + insertMemory(db, { + content: "Container deployment best practices for production systems.", + key: "container-deploy", + }); + // Unrelated memory + insertMemory(db, { + content: "Baking a sourdough loaf requires patience and a good starter.", + key: "baking", + }); + + const results = await retrieveMemories(API_KEY_ID, { + query: "Kubernetes", + retrievalStrategy: "hybrid", + maxTokens: 8000, + }); + + // Should return the Kubernetes memory (FTS5 match) + expect(results.some((m) => m.content.includes("Kubernetes"))).toBe(true); + + // Verify no duplicates (unique ids) + const ids = results.map((m) => m.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + // ── 3. Graceful fallback — FTS5 table missing → falls back to LIKE / chronological ── + + test("semantic strategy does not throw when memory_fts table is missing", async () => { + // Seed data first (while FTS5 table still exists) + insertMemory(db, { + content: "React hooks simplify stateful logic in function components.", + key: "react-hooks", + }); + insertMemory(db, { + content: "Vue 3 composition API provides flexible component composition.", + key: "vue-composition", + }); + + // Now drop the FTS5 virtual table to simulate it being absent + db.exec("DROP TABLE IF EXISTS memory_fts"); + + // Should NOT throw — falls back to chronological retrieval + const results = await retrieveMemories(API_KEY_ID, { + query: "React", + retrievalStrategy: "semantic", + maxTokens: 8000, + }); + + // With FTS5 gone, the code falls back to chronological ORDER BY. + // The query filter still applies via getRelevanceScore post-scoring, + // so we should still get the React memory back. + expect(results.some((m) => m.content.includes("React"))).toBe(true); + }); + + // ── 4. Special characters in FTS5 queries ── + + test("queries with special characters do not throw and return results gracefully", async () => { + insertMemory(db, { + content: "C++ is a powerful systems programming language with operator overloading.", + key: "cpp-info", + }); + insertMemory(db, { + content: "Johnson & Johnson is a healthcare company.", + key: "company-info", + }); + + const specialQueries = [ + '"quoted phrase"', + "C++ language", + "Johnson & Johnson", + "dash-separated-query", + "parens(test)", + "asterisk*wildcard", + "single'quote", + ]; + + for (const q of specialQueries) { + // Must not throw regardless of strategy + const semanticResults = await retrieveMemories(API_KEY_ID, { + query: q, + retrievalStrategy: "semantic", + maxTokens: 8000, + }); + expect(Array.isArray(semanticResults)).toBe(true); + + const hybridResults = await retrieveMemories(API_KEY_ID, { + query: q, + retrievalStrategy: "hybrid", + maxTokens: 8000, + }); + expect(Array.isArray(hybridResults)).toBe(true); + } + }); + + // ── 5. Token budget enforcement ── + + test("results are trimmed when token budget is exceeded", async () => { + // Each memory is ~100 chars = ~25 tokens + const longContent = "A".repeat(400); // ~100 tokens per memory + for (let i = 0; i < 10; i++) { + insertMemory(db, { + content: `Memory ${i}: ${longContent}`, + key: `bulk-${i}`, + }); + } + + // With maxTokens=50, we should get at most 1 memory (~100+ tokens each, + // but the first one is always included even if it exceeds the budget) + const results = await retrieveMemories(API_KEY_ID, { + retrievalStrategy: "exact", + maxTokens: 50, + }); + + // At least 1 memory (the "always include at least 1" rule) + expect(results.length).toBeGreaterThanOrEqual(1); + // But far fewer than all 10 + expect(results.length).toBeLessThan(10); + }); + + // ── 6. estimateTokens utility ── + + test("estimateTokens returns correct approximation", () => { + expect(estimateTokens("")).toBe(0); + expect(estimateTokens("abcd")).toBe(1); // 4 chars / 4 = 1 + expect(estimateTokens("abcde")).toBe(2); // ceil(5/4) = 2 + expect(estimateTokens("a".repeat(100))).toBe(25); + // edge cases + expect(estimateTokens(null as unknown as string)).toBe(0); + expect(estimateTokens(undefined as unknown as string)).toBe(0); + }); +}); diff --git a/src/lib/memory/extraction.ts b/src/lib/memory/extraction.ts index b73de6af33..496f5dd8f1 100644 --- a/src/lib/memory/extraction.ts +++ b/src/lib/memory/extraction.ts @@ -4,9 +4,12 @@ * Stores extracted facts asynchronously (non-blocking). */ +import { logger } from "../../../open-sse/utils/logger.ts"; import { createMemory } from "./store"; import { MemoryType } from "./types"; +const log = logger("MEMORY_EXTRACTION"); + // ─── Pattern Definitions ──────────────────────────────────────────────────── /** Patterns indicating user preferences */ @@ -150,13 +153,16 @@ export function extractFactsFromText(text: string): ExtractedFact[] { export function extractFacts(response: string, apiKeyId: string, sessionId: string): void { if (!response || !apiKeyId || !sessionId) return; + log.info("memory.extraction.start", { apiKeyId }); + // Non-blocking: schedule after current event loop tick setImmediate(() => { const facts = extractFactsFromText(response); if (facts.length === 0) return; - // Store each fact, swallow errors to never block the response pipeline for (const fact of facts) { + log.debug("memory.extraction.fact_found", { key: fact.key, category: fact.category }); + createMemory({ apiKeyId, sessionId, @@ -170,11 +176,10 @@ export function extractFacts(response: string, apiKeyId: string, sessionId: stri }, expiresAt: null, }).catch((err) => { - // Silent: extraction must never affect response delivery - if (process.env.NODE_ENV !== "test") { - console.warn("[memory:extraction] Failed to store fact:", err?.message); - } + log.error("memory.extraction.background.failed", { err: err?.message, apiKeyId }); }); } + + log.info("memory.extraction.complete", { apiKeyId, factCount: facts.length }); }); } diff --git a/src/lib/memory/injection.ts b/src/lib/memory/injection.ts index ff5f40798f..8d1704e93c 100644 --- a/src/lib/memory/injection.ts +++ b/src/lib/memory/injection.ts @@ -11,6 +11,9 @@ */ import { Memory } from "./types"; +import { logger } from "../../../open-sse/utils/logger.ts"; + +const log = logger("MEMORY_INJECTION"); export interface ChatMessage { role: "system" | "user" | "assistant"; @@ -73,11 +76,15 @@ export function injectMemory( provider: string | null | undefined ): ChatRequest { if (!memories || memories.length === 0) { + log.info("memory.injection.skipped", { reason: "no_memories", model: request.model }); return request; } const memoryText = formatMemoryContext(memories); - if (!memoryText) return request; + if (!memoryText) { + log.info("memory.injection.skipped", { reason: "empty_context", model: request.model }); + return request; + } const messages: ChatMessage[] = Array.isArray(request.messages) ? [...request.messages] : []; @@ -86,11 +93,21 @@ export function injectMemory( // Prepending before any existing system messages keeps memory context // accessible without overriding the caller's own system instructions. const memorySystemMessage: ChatMessage = { role: "system", content: memoryText }; + log.info("memory.injection.injected", { + count: memories.length, + strategy: "system", + model: request.model, + }); return { ...request, messages: [memorySystemMessage, ...messages] }; } else { // Strategy 2 (fallback): inject as the first user message. // Used for providers like o1-mini that reject the system role. const memoryUserMessage: ChatMessage = { role: "user", content: memoryText }; + log.info("memory.injection.injected", { + count: memories.length, + strategy: "user", + model: request.model, + }); return { ...request, messages: [memoryUserMessage, ...messages] }; } } diff --git a/src/lib/memory/retrieval.ts b/src/lib/memory/retrieval.ts index c06d164c36..4d39b44bca 100644 --- a/src/lib/memory/retrieval.ts +++ b/src/lib/memory/retrieval.ts @@ -1,6 +1,9 @@ import { getDbInstance } from "../db/core"; import { Memory, MemoryConfig, MemoryType } from "./types"; import { MemoryConfigSchema } from "./schemas"; +import { logger } from "../../../open-sse/utils/logger.ts"; + +const log = logger("MEMORY_RETRIEVAL"); interface MemoryRow { id: string; @@ -110,6 +113,8 @@ export async function retrieveMemories( apiKeyId: string, config: RetrievalOptions = {} ): Promise { + log.info("memory.retrieval.start", { apiKeyId, strategy: config.retrievalStrategy }); + // Validate and normalize config const normalizedConfig = MemoryConfigSchema.parse({ enabled: true, @@ -168,28 +173,104 @@ export async function retrieveMemories( params.push(cutoff); } - // Add ordering based on strategy + // Execute query based on strategy + let rows: MemoryRow[]; + const ftsAvailable = useModernTable && hasTable("memory_fts"); + switch (strategy) { - case "semantic": - // For now, semantic search is same as exact (FTS5 not implemented yet) - query += ` ORDER BY ${columns.createdAt} DESC`; + case "semantic": { + if (config.query && ftsAvailable) { + const ftsQuery = + `SELECT m.* FROM ${tableName} m ` + + `JOIN memory_fts f ON m.memory_id = f.rowid ` + + `WHERE f.memory_fts MATCH ? AND m.${columns.apiKeyId} = ? ` + + `AND (m.${columns.expiresAt} IS NULL OR datetime(m.${columns.expiresAt}) > datetime('now'))` + + (normalizedConfig.scope === "session" && config.sessionId + ? ` AND m.${columns.sessionId} = ?` + : "") + + (normalizedConfig.retentionDays > 0 + ? ` AND datetime(m.${columns.createdAt}) >= datetime(?)` + : "") + + ` ORDER BY f.rank LIMIT 100`; + const ftsParams: any[] = [config.query, apiKeyId]; + if (normalizedConfig.scope === "session" && config.sessionId) { + ftsParams.push(config.sessionId); + } + if (normalizedConfig.retentionDays > 0) { + const cutoff = new Date( + Date.now() - normalizedConfig.retentionDays * 24 * 60 * 60 * 1000 + ).toISOString(); + ftsParams.push(cutoff); + } + try { + rows = db.prepare(ftsQuery).all(...ftsParams) as MemoryRow[]; + } catch { + rows = []; + } + if (rows.length === 0) { + query += ` ORDER BY ${columns.createdAt} DESC LIMIT 100`; + rows = db.prepare(query).all(...params) as MemoryRow[]; + } + } else { + query += ` ORDER BY ${columns.createdAt} DESC LIMIT 100`; + rows = db.prepare(query).all(...params) as MemoryRow[]; + } break; - case "hybrid": - // Hybrid is same as exact for now - query += ` ORDER BY ${columns.createdAt} DESC`; + } + case "hybrid": { + let ftsRows: MemoryRow[] = []; + if (config.query && ftsAvailable) { + const ftsQuery = + `SELECT m.* FROM ${tableName} m ` + + `JOIN memory_fts f ON m.memory_id = f.rowid ` + + `WHERE f.memory_fts MATCH ? AND m.${columns.apiKeyId} = ? ` + + `AND (m.${columns.expiresAt} IS NULL OR datetime(m.${columns.expiresAt}) > datetime('now'))` + + (normalizedConfig.scope === "session" && config.sessionId + ? ` AND m.${columns.sessionId} = ?` + : "") + + (normalizedConfig.retentionDays > 0 + ? ` AND datetime(m.${columns.createdAt}) >= datetime(?)` + : "") + + ` ORDER BY f.rank LIMIT 100`; + const ftsParams: any[] = [config.query, apiKeyId]; + if (normalizedConfig.scope === "session" && config.sessionId) { + ftsParams.push(config.sessionId); + } + if (normalizedConfig.retentionDays > 0) { + const cutoff = new Date( + Date.now() - normalizedConfig.retentionDays * 24 * 60 * 60 * 1000 + ).toISOString(); + ftsParams.push(cutoff); + } + try { + ftsRows = db.prepare(ftsQuery).all(...ftsParams) as MemoryRow[]; + } catch { + ftsRows = []; + } + } + // Get chronological results for keyword scoring + query += ` ORDER BY ${columns.createdAt} DESC LIMIT 100`; + const keywordRows = db.prepare(query).all(...params) as MemoryRow[]; + + // Union: FTS5 results first (higher relevance), then keyword results, dedup by id + const seen = new Set(); + rows = []; + for (const row of [...ftsRows, ...keywordRows]) { + const rowId = String(row.id); + if (!seen.has(rowId)) { + seen.add(rowId); + rows.push(row); + } + } break; + } case "exact": - default: - query += ` ORDER BY ${columns.createdAt} DESC`; + default: { + query += ` ORDER BY ${columns.createdAt} DESC LIMIT 100`; + rows = db.prepare(query).all(...params) as MemoryRow[]; + } } - // Add limit for performance - query += " LIMIT 100"; - - // Execute query - const stmt = db.prepare(query); - const rows = stmt.all(...params) as MemoryRow[]; - const rankedRows = rows .map((row) => { const memory = rowToMemory(row); @@ -223,5 +304,8 @@ export async function retrieveMemories( totalTokens += memoryTokens; } - return memories.map((entry) => entry.memory); + const result = memories.map((entry) => entry.memory); + log.info("memory.retrieval.complete", { apiKeyId, count: result.length }); + log.debug("memory.retrieval.selected", { ids: result.map((m) => m.id) }); + return result; } diff --git a/src/lib/memory/store.ts b/src/lib/memory/store.ts index d8ffc0cbc3..df9f554f55 100644 --- a/src/lib/memory/store.ts +++ b/src/lib/memory/store.ts @@ -4,6 +4,9 @@ import { getDbInstance } from "../db/core"; import { Memory, MemoryType } from "./types"; +import { logger } from "../../../open-sse/utils/logger.ts"; + +const log = logger("MEMORY_STORE"); interface CacheEntry { value: T; @@ -119,6 +122,8 @@ export async function createMemory( evictIfNeeded(_memoryCache); _memoryCache.set(id, { value: createdMemory, timestamp: Date.now() }); + log.info("memory.stored", { apiKeyId: memory.apiKeyId, type: memory.type, id }); + return createdMemory; } @@ -228,63 +233,96 @@ export async function deleteMemory(id: string): Promise { // Invalidate cache for this memory invalidateMemoryCache(id); + log.info("memory.deleted", { id }); + return true; } /** - * List memories with optional filtering + * List memories with optional filtering and pagination */ export async function listMemories(filters: { apiKeyId?: string; type?: MemoryType; sessionId?: string; + query?: string; limit?: number; offset?: number; -}): Promise { + page?: number; +}): Promise<{ data: Memory[]; total: number; byType: Record }> { const db = getDbInstance(); - // Build dynamic query - let query = "SELECT * FROM memories"; - const params: unknown[] = []; + // Build dynamic query conditions const whereClauses: string[] = []; + const whereParams: unknown[] = []; if (filters.apiKeyId) { whereClauses.push("api_key_id = ?"); - params.push(filters.apiKeyId); + whereParams.push(filters.apiKeyId); } if (filters.type) { whereClauses.push("type = ?"); - params.push(filters.type); + whereParams.push(filters.type); } if (filters.sessionId) { whereClauses.push("session_id = ?"); - params.push(filters.sessionId); + whereParams.push(filters.sessionId); } + if (typeof filters.query === "string" && filters.query.trim().length > 0) { + const likeQuery = `%${filters.query.trim().toLowerCase()}%`; + whereClauses.push("(LOWER(content) LIKE ? OR LOWER(key) LIKE ?)"); + whereParams.push(likeQuery, likeQuery); + } + + // Run COUNT query + byType aggregation in a single query + let countQuery = "SELECT COUNT(*) as total FROM memories"; + if (whereClauses.length > 0) { + countQuery += " WHERE " + whereClauses.join(" AND "); + } + const countStmt = db.prepare(countQuery); + const countRow = countStmt.get(...whereParams) as { total: number }; + const total = countRow.total; + + // Build byType aggregation (counts ALL matching rows, not just the page) + let byTypeQuery = "SELECT type, COUNT(*) as count FROM memories"; + const byTypeParams: unknown[] = [...whereParams]; + if (whereClauses.length > 0) { + byTypeQuery += " WHERE " + whereClauses.join(" AND "); + } + byTypeQuery += " GROUP BY type"; + const byTypeStmt = db.prepare(byTypeQuery); + const byTypeRows = byTypeStmt.all(...byTypeParams) as { type: string; count: number }[]; + const byType = Object.fromEntries(byTypeRows.map((r) => [r.type, r.count])) as Record< + string, + number + >; + + // Calculate effective limit and offset + const effectiveLimit = filters.limit ?? 50; + const effectivePage = filters.page ?? 1; + const effectiveOffset = filters.offset ?? (effectivePage - 1) * effectiveLimit; + + // Build SELECT query with pagination + let query = "SELECT * FROM memories"; if (whereClauses.length > 0) { query += " WHERE " + whereClauses.join(" AND "); } // Add ordering and pagination - query += " ORDER BY created_at DESC"; + query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"; - if (filters.limit !== undefined) { - query += " LIMIT ?"; - params.push(filters.limit); - } - - if (filters.offset !== undefined) { - if (filters.limit === undefined) { - query += " LIMIT -1"; - } - query += " OFFSET ?"; - params.push(filters.offset); - } + // Build params for SELECT query (WHERE params + pagination params) + const params = [...whereParams, effectiveLimit, effectiveOffset]; const stmt = db.prepare(query); const rows = stmt.all(...params); - return (rows as MemoryRow[]).map(rowToMemory); + return { + data: (rows as MemoryRow[]).map(rowToMemory), + total, + byType, + }; } diff --git a/src/lib/memory/verify.ts b/src/lib/memory/verify.ts new file mode 100644 index 0000000000..ade9e7d20c --- /dev/null +++ b/src/lib/memory/verify.ts @@ -0,0 +1,56 @@ +/** + * Memory extraction pipeline verification + * Creates a test memory, verifies it can be listed, and cleans up. + */ + +import { createMemory, listMemories, deleteMemory } from "./store"; +import { MemoryType } from "./types"; +import { logger } from "../../../open-sse/utils/logger.ts"; + +const log = logger("MEMORY_VERIFY"); + +export async function verifyExtractionPipeline( + apiKeyId: string +): Promise<{ working: boolean; latencyMs: number; error?: string }> { + const start = Date.now(); + log.info("memory.verify.start", { apiKeyId }); + + let createdMemory: { id: string } | null = null; + + try { + createdMemory = await createMemory({ + key: "__extraction_test__", + content: "pipeline verification test", + type: MemoryType.FACTUAL, + apiKeyId, + sessionId: "", + metadata: {}, + expiresAt: null, + }); + + const result = await listMemories({ apiKeyId, page: 1, limit: 100 }); + const found = result.data.some((m) => m.key === "__extraction_test__"); + + const latencyMs = Date.now() - start; + const working = found; + + log.info("memory.verify.complete", { working, latencyMs }); + + return { working, latencyMs }; + } catch (err: unknown) { + const latencyMs = Date.now() - start; + const error = String(err); + + log.info("memory.verify.complete", { working: false, latencyMs }); + + return { working: false, latencyMs, error }; + } finally { + if (createdMemory) { + try { + await deleteMemory(createdMemory.id); + } catch { + // Cleanup best-effort — don't mask original error + } + } + } +} diff --git a/src/lib/modelAliasSeed.ts b/src/lib/modelAliasSeed.ts new file mode 100644 index 0000000000..0759e80f07 --- /dev/null +++ b/src/lib/modelAliasSeed.ts @@ -0,0 +1,81 @@ +import { getModelAliases, setModelAlias } from "@/lib/db/models"; + +export const DEFAULT_MODEL_ALIAS_SEED = Object.freeze({ + "gemini-3-pro-high": "antigravity/gemini-3.1-pro-high", + "gemini-3-pro-low": "antigravity/gemini-3.1-pro-low", + "gemini-3.1-pro-preview": "antigravity/gemini-3.1-pro-high", + "gemini-3.1-pro-preview-customtools": "antigravity/gemini-3.1-pro-high", + "gemini-3-flash-preview": "antigravity/gemini-3-flash", +}); + +type SeedLogger = { + warn?: (message: string, ...args: unknown[]) => void; +}; + +type SeedOptions = { + getAliases?: typeof getModelAliases; + logger?: SeedLogger; + seedMap?: Record; + setAlias?: typeof setModelAlias; +}; + +type SeedResult = { + applied: string[]; + failed: string[]; + skipped: string[]; +}; + +function isValidAliasTarget(value: unknown): boolean { + if (typeof value === "string" && value.trim().length > 0) return true; + return Boolean( + value && + typeof value === "object" && + !Array.isArray(value) && + typeof (value as { provider?: unknown }).provider === "string" && + typeof (value as { model?: unknown }).model === "string" + ); +} + +export async function seedDefaultModelAliases(options: SeedOptions = {}): Promise { + const getAliases = options.getAliases || getModelAliases; + const setAlias = options.setAlias || setModelAlias; + const seedMap = options.seedMap || DEFAULT_MODEL_ALIAS_SEED; + const logger = options.logger || console; + + let existing: Record = {}; + try { + const loaded = await getAliases(); + existing = loaded && typeof loaded === "object" ? loaded : {}; + } catch (error) { + logger.warn?.("[STARTUP] Failed to load model aliases before seed:", error); + return { applied: [], skipped: [], failed: Object.keys(seedMap) }; + } + + const applied: string[] = []; + const skipped: string[] = []; + const failed: string[] = []; + + for (const [alias, target] of Object.entries(seedMap)) { + if (!alias || !isValidAliasTarget(target)) { + failed.push(alias || ""); + logger.warn?.(`[STARTUP] Skipping invalid model alias seed for "${alias || ""}"`); + continue; + } + + if (Object.prototype.hasOwnProperty.call(existing, alias)) { + skipped.push(alias); + continue; + } + + try { + await setAlias(alias, target); + existing[alias] = target; + applied.push(alias); + } catch (error) { + failed.push(alias); + logger.warn?.(`[STARTUP] Failed to persist model alias seed "${alias}":`, error); + } + } + + return { applied, skipped, failed }; +} diff --git a/src/lib/modelsDevSync.ts b/src/lib/modelsDevSync.ts index 6e681aab5a..8623100d7b 100644 --- a/src/lib/modelsDevSync.ts +++ b/src/lib/modelsDevSync.ts @@ -598,55 +598,72 @@ export function clearModelsDevCapabilities(): void { export async function syncModelsDev(opts?: { dryRun?: boolean; syncCapabilities?: boolean; + maxRetries?: number; }): Promise { const dryRun = opts?.dryRun ?? false; const syncCapabilities = opts?.syncCapabilities ?? true; + const maxRetries = opts?.maxRetries ?? 3; - try { - const raw = await fetchModelsDev(); - const pricing = transformModelsDevToPricing(raw); - const capabilities = syncCapabilities ? transformModelsDevToCapabilities(raw) : {}; + let lastError: Error | null = null; - const modelCount = Object.values(pricing).reduce( - (sum, models) => sum + Object.keys(models).length, - 0 - ); - const providerCount = Object.keys(pricing).length; - const capabilityCount = syncCapabilities - ? Object.values(capabilities).reduce((sum, models) => sum + Object.keys(models).length, 0) - : 0; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const raw = await fetchModelsDev(); + const pricing = transformModelsDevToPricing(raw); + const capabilities = syncCapabilities ? transformModelsDevToCapabilities(raw) : {}; - if (!dryRun) { - saveModelsDevPricing(pricing); - if (syncCapabilities) { - ensureCapabilitiesTable(); - saveModelsDevCapabilities(capabilities); + const modelCount = Object.values(pricing).reduce( + (sum, models) => sum + Object.keys(models).length, + 0 + ); + const providerCount = Object.keys(pricing).length; + const capabilityCount = syncCapabilities + ? Object.values(capabilities).reduce((sum, models) => sum + Object.keys(models).length, 0) + : 0; + + if (!dryRun) { + saveModelsDevPricing(pricing); + if (syncCapabilities) { + ensureCapabilitiesTable(); + saveModelsDevCapabilities(capabilities); + } + lastSyncTime = new Date().toISOString(); + lastSyncModelCount = modelCount; + lastSyncCapabilityCount = capabilityCount; } - lastSyncTime = new Date().toISOString(); - lastSyncModelCount = modelCount; - lastSyncCapabilityCount = capabilityCount; - } - return { - success: true, - modelCount, - providerCount, - capabilityCount, - dryRun, - ...(dryRun ? { data: { pricing, capabilities } } : {}), - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.warn("[MODELS_DEV] Sync failed:", message); - return { - success: false, - modelCount: 0, - providerCount: 0, - capabilityCount: 0, - dryRun, - error: message, - }; + return { + success: true, + modelCount, + providerCount, + capabilityCount, + dryRun, + ...(dryRun ? { data: { pricing, capabilities } } : {}), + }; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + + if (attempt < maxRetries) { + const delayMs = Math.pow(2, attempt) * 1000; // Exponential backoff: 1s, 2s, 4s + console.warn( + `[MODELS_DEV] Sync attempt ${attempt + 1} failed, retrying in ${delayMs}ms:`, + lastError.message + ); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } } + + const message = lastError?.message || "Unknown error"; + console.warn(`[MODELS_DEV] Sync failed after ${maxRetries + 1} attempts:`, message); + return { + success: false, + modelCount: 0, + providerCount: 0, + capabilityCount: 0, + dryRun, + error: message, + }; } // ─── Periodic sync ─────────────────────────────────────── diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 7b7200146a..e0d8f5937d 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -1,15 +1,26 @@ +import { + ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT, + ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT, + getAntigravityLoadCodeAssistClientMetadata, +} from "@omniroute/open-sse/services/antigravityHeaders.ts"; + /** * OAuth Configuration Constants * * All credentials are read exclusively from environment variables. - * Default values are provided via .env.example and auto-populated by - * scripts/sync-env.mjs on install. See .env.example for the built-in - * credentials used for localhost setups. + * Default values match the public CLI client IDs from .env.example + * (auto-populated by scripts/sync-env.mjs on install). + * + * These are public OAuth client credentials for desktop/CLI applications + * that rely on PKCE for security (RFC 8252), not on secret confidentiality. + * The same values appear in providerRegistry.ts for the legacy provider + * bridge; they are intentionally co-located with their respective config + * objects here for readability and to avoid a cross-layer import. */ // Claude OAuth Configuration (Authorization Code Flow with PKCE) export const CLAUDE_CONFIG = { - clientId: process.env.CLAUDE_OAUTH_CLIENT_ID || "", + clientId: process.env.CLAUDE_OAUTH_CLIENT_ID || "9d1c250a-e61b-44d9-88ed-5944d1962f5e", authorizeUrl: "https://claude.ai/oauth/authorize", tokenUrl: "https://console.anthropic.com/v1/oauth/token", redirectUri: @@ -26,7 +37,7 @@ export const CLAUDE_CONFIG = { // Codex (OpenAI) OAuth Configuration (Authorization Code Flow with PKCE) export const CODEX_CONFIG = { - clientId: process.env.CODEX_OAUTH_CLIENT_ID || "", + clientId: process.env.CODEX_OAUTH_CLIENT_ID || "app_EMoamEEZ73f0CkXaXp7hrann", authorizeUrl: "https://auth.openai.com/oauth/authorize", tokenUrl: "https://auth.openai.com/oauth/token", scope: "openid profile email offline_access", @@ -41,7 +52,10 @@ export const CODEX_CONFIG = { // Gemini (Google) OAuth Configuration (Standard OAuth2) export const GEMINI_CONFIG = { - clientId: process.env.GEMINI_CLI_OAUTH_CLIENT_ID || process.env.GEMINI_OAUTH_CLIENT_ID || "", + clientId: + process.env.GEMINI_CLI_OAUTH_CLIENT_ID || + process.env.GEMINI_OAUTH_CLIENT_ID || + "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", clientSecret: process.env.GEMINI_CLI_OAUTH_CLIENT_SECRET || process.env.GEMINI_OAUTH_CLIENT_SECRET || "", authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", @@ -56,7 +70,7 @@ export const GEMINI_CONFIG = { // Qwen OAuth Configuration (Device Code Flow with PKCE) export const QWEN_CONFIG = { - clientId: process.env.QWEN_OAUTH_CLIENT_ID || "", + clientId: process.env.QWEN_OAUTH_CLIENT_ID || "f0304373b74a44d2b584a3fb70ca9e56", deviceCodeUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code", tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token", scope: "openid profile email model.completion", @@ -91,7 +105,7 @@ export const QODER_CONFIG = { // Kimi Coding OAuth Configuration (Device Code Flow) export const KIMI_CODING_CONFIG = { - clientId: process.env.KIMI_CODING_OAUTH_CLIENT_ID || "", + clientId: process.env.KIMI_CODING_OAUTH_CLIENT_ID || "17e5f671-d194-4dfb-9706-5516cb48c098", deviceCodeUrl: "https://auth.kimi.com/api/oauth/device_authorization", tokenUrl: "https://auth.kimi.com/api/oauth/token", }; @@ -114,8 +128,11 @@ export const CLINE_CONFIG = { // Antigravity OAuth Configuration (Standard OAuth2 with Google) export const ANTIGRAVITY_CONFIG = { - clientId: process.env.ANTIGRAVITY_OAUTH_CLIENT_ID || "", - clientSecret: process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET || "", + clientId: + process.env.ANTIGRAVITY_OAUTH_CLIENT_ID || + "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com", + clientSecret: + process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET || "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", authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", tokenUrl: "https://oauth2.googleapis.com/token", userInfoUrl: "https://www.googleapis.com/oauth2/v1/userinfo", @@ -133,14 +150,15 @@ export const ANTIGRAVITY_CONFIG = { onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", fetchAvailableModelsEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", - loadCodeAssistUserAgent: "google-api-nodejs-client/9.15.1", - loadCodeAssistApiClient: "google-cloud-sdk vscode_cloudshelleditor/0.1", - loadCodeAssistClientMetadata: `{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}`, + loadCodeAssistUserAgent: ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT, + loadCodeAssistApiClient: ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT, + loadCodeAssistClientMetadata: getAntigravityLoadCodeAssistClientMetadata(), }; // OpenAI OAuth Configuration (Authorization Code Flow with PKCE) +// Re-uses CODEX_CONFIG.clientId to avoid duplication — same provider, different originator. export const OPENAI_CONFIG = { - clientId: process.env.CODEX_OAUTH_CLIENT_ID || "", + clientId: CODEX_CONFIG.clientId, authorizeUrl: "https://auth.openai.com/oauth/authorize", tokenUrl: "https://auth.openai.com/oauth/token", scope: "openid profile email offline_access", @@ -153,7 +171,7 @@ export const OPENAI_CONFIG = { // GitHub Copilot OAuth Configuration (Device Code Flow) export const GITHUB_CONFIG = { - clientId: process.env.GITHUB_OAUTH_CLIENT_ID || "", + clientId: process.env.GITHUB_OAUTH_CLIENT_ID || "Iv1.b507a08c87ecfe98", deviceCodeUrl: "https://github.com/login/device/code", tokenUrl: "https://github.com/login/oauth/access_token", userInfoUrl: "https://api.github.com/user", @@ -207,7 +225,7 @@ export const CURSOR_CONFIG = { agentEndpoint: "https://agent.api5.cursor.sh", // Privacy mode agentNonPrivacyEndpoint: "https://agentn.api5.cursor.sh", // Non-privacy mode // Client metadata - clientVersion: "3.1.0", + clientVersion: "3.1.15", clientType: "ide", // Token storage locations (for user reference) tokenStoragePaths: { diff --git a/src/lib/oauth/providers/antigravity.ts b/src/lib/oauth/providers/antigravity.ts index 0349679799..be053ee464 100644 --- a/src/lib/oauth/providers/antigravity.ts +++ b/src/lib/oauth/providers/antigravity.ts @@ -1,4 +1,8 @@ import { ANTIGRAVITY_CONFIG } from "../constants/oauth"; +import { + getAntigravityHeaders, + getAntigravityLoadCodeAssistMetadata, +} from "@omniroute/open-sse/services/antigravityHeaders.ts"; export const antigravity = { config: ANTIGRAVITY_CONFIG, @@ -44,18 +48,8 @@ export const antigravity = { return await response.json(); }, postExchange: async (tokens) => { - const headers = { - Authorization: `Bearer ${tokens.access_token}`, - "Content-Type": "application/json", - "User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent, - "X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient, - "Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata, - }; - const metadata = { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }; + const headers = getAntigravityHeaders("loadCodeAssist", tokens.access_token); + const metadata = getAntigravityLoadCodeAssistMetadata(); const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, { headers: { Authorization: `Bearer ${tokens.access_token}` }, diff --git a/src/lib/oauth/services/antigravity.ts b/src/lib/oauth/services/antigravity.ts index fc4e377f19..4db27b5e21 100644 --- a/src/lib/oauth/services/antigravity.ts +++ b/src/lib/oauth/services/antigravity.ts @@ -1,6 +1,10 @@ import crypto from "crypto"; import open from "open"; import { ANTIGRAVITY_CONFIG } from "../constants/oauth"; +import { + getAntigravityHeaders, + getAntigravityLoadCodeAssistMetadata, +} from "@omniroute/open-sse/services/antigravityHeaders.ts"; import { getServerCredentials } from "../config/index"; import { startLocalServer } from "../utils/server"; import { spinner as createSpinner } from "../utils/ui"; @@ -83,24 +87,14 @@ export class AntigravityService { * Get common headers for Antigravity API calls */ getApiHeaders(accessToken: string) { - return { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": this.config.loadCodeAssistUserAgent, - "X-Goog-Api-Client": this.config.loadCodeAssistApiClient, - "Client-Metadata": this.config.loadCodeAssistClientMetadata, - }; + return getAntigravityHeaders("loadCodeAssist", accessToken); } /** * Get metadata object for API calls */ getMetadata() { - return { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }; + return getAntigravityLoadCodeAssistMetadata(); } /** diff --git a/src/lib/oauth/services/cursor.ts b/src/lib/oauth/services/cursor.ts index c2e95fb560..e772c27714 100644 --- a/src/lib/oauth/services/cursor.ts +++ b/src/lib/oauth/services/cursor.ts @@ -90,31 +90,29 @@ export class CursorService { } /** - * Validate and import token from Cursor IDE + * Validate and import token from Cursor IDE or cursor-agent CLI. * Note: We skip API validation because Cursor API uses complex protobuf format. * Token will be validated when actually used for requests. - * @param {string} accessToken - Access token from state.vscdb - * @param {string} machineId - Machine ID from state.vscdb + * @param {string} accessToken - Access token from state.vscdb or auth.json + * @param {string} [machineId] - Machine ID from state.vscdb (optional for cursor-agent imports) */ - async validateImportToken(accessToken: string, machineId: string) { + async validateImportToken(accessToken: string, machineId?: string) { // Basic validation if (!accessToken || typeof accessToken !== "string") { throw new Error("Access token is required"); } - if (!machineId || typeof machineId !== "string") { - throw new Error("Machine ID is required"); - } - // Token format validation (Cursor tokens are typically long strings) if (accessToken.length < 50) { throw new Error("Invalid token format. Token appears too short."); } - // Machine ID format validation (should be UUID-like) - const uuidRegex = /^[a-f0-9-]{32,}$/i; - if (!uuidRegex.test(machineId.replace(/-/g, ""))) { - throw new Error("Invalid machine ID format. Expected UUID format."); + // Machine ID format validation (only if provided — cursor-agent imports don't have one) + if (machineId) { + const uuidRegex = /^[a-f0-9-]{32,}$/i; + if (!uuidRegex.test(machineId.replace(/-/g, ""))) { + throw new Error("Invalid machine ID format. Expected UUID format."); + } } // Note: We don't validate against API because Cursor uses complex protobuf. @@ -122,9 +120,9 @@ export class CursorService { return { accessToken, - machineId, + machineId: machineId || null, expiresIn: 86400, // Cursor tokens typically last 24 hours - authMethod: "imported", + authMethod: machineId ? "imported" : "cursor-agent", }; } diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 2fafe8580e..975a2886d4 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -14,6 +14,13 @@ import { isAnthropicCompatibleProvider, isOpenAICompatibleProvider, } from "@/shared/constants/providers"; +import { + SAFE_OUTBOUND_FETCH_PRESETS, + SafeOutboundFetchError, + getSafeOutboundFetchErrorStatus, + safeOutboundFetch, +} from "@/shared/network/safeOutboundFetch"; +import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard"; import { getGigachatAccessToken } from "@omniroute/open-sse/services/gigachatAuth.ts"; import { validateQoderCliPat } from "@omniroute/open-sse/services/qoderCli.ts"; @@ -145,6 +152,38 @@ function buildBearerHeaders(apiKey: string, providerSpecificData: any = {}) { ); } +async function validationRead(url: string, init: RequestInit) { + return safeOutboundFetch(url, { + ...SAFE_OUTBOUND_FETCH_PRESETS.validationRead, + guard: getProviderOutboundGuard(), + ...init, + }); +} + +async function validationWrite(url: string, init: RequestInit) { + return safeOutboundFetch(url, { + ...SAFE_OUTBOUND_FETCH_PRESETS.validationWrite, + guard: getProviderOutboundGuard(), + ...init, + }); +} + +function toValidationErrorResult(error: unknown) { + const message = error instanceof Error ? error.message : String(error || "Validation failed"); + const statusCode = getSafeOutboundFetchErrorStatus(error); + + return { + valid: false, + error: message || "Validation failed", + unsupported: false, + ...(statusCode ? { statusCode } : {}), + ...(error instanceof SafeOutboundFetchError && error.code === "TIMEOUT" + ? { timeout: true } + : {}), + ...(statusCode === 400 ? { securityBlocked: true } : {}), + }; +} + async function validateOpenAILikeProvider({ provider, apiKey, @@ -162,7 +201,7 @@ async function validateOpenAILikeProvider({ return { valid: false, error: "Invalid models endpoint" }; } - const modelsRes = await fetch(modelsUrl, { + const modelsRes = await validationRead(modelsUrl, { method: "GET", headers: buildBearerHeaders(apiKey, providerSpecificData), }); @@ -188,7 +227,7 @@ async function validateOpenAILikeProvider({ max_tokens: 1, }; - const chatRes = await fetch(chatUrl, { + const chatRes = await validationWrite(chatUrl, { method: "POST", headers: buildBearerHeaders(apiKey, providerSpecificData), body: JSON.stringify(testBody), @@ -216,7 +255,7 @@ async function validateOpenAILikeProvider({ async function validateDirectChatProvider({ url, headers, body, providerSpecificData = {} }: any) { try { - const response = await fetch(url, { + const response = await validationWrite(url, { method: "POST", headers: applyCustomUserAgent(headers, providerSpecificData), body: JSON.stringify(body), @@ -241,7 +280,7 @@ async function validateDirectChatProvider({ url, headers, body, providerSpecific return { valid: false, error: `Validation failed: ${response.status}` }; } catch (error: any) { - return { valid: false, error: error.message || "Validation failed" }; + return toValidationErrorResult(error); } } @@ -275,7 +314,7 @@ async function validateAnthropicLikeProvider({ const testModelId = providerSpecificData?.validationModelId || modelId || "claude-3-5-sonnet-20241022"; - const response = await fetch(baseUrl, { + const response = await validationWrite(baseUrl, { method: "POST", headers: requestHeaders, body: JSON.stringify({ @@ -313,7 +352,7 @@ async function validateGeminiLikeProvider({ } applyCustomUserAgent(headers, providerSpecificData); - const response = await fetch(baseUrl, { method: "GET", headers }); + const response = await validationRead(baseUrl, { method: "GET", headers }); if (response.ok) { return { valid: true, error: null }; @@ -371,7 +410,7 @@ async function validateGeminiLikeProvider({ async function validateDeepgramProvider({ apiKey, providerSpecificData = {} }: any) { try { - const response = await fetch("https://api.deepgram.com/v1/auth/token", { + const response = await validationRead("https://api.deepgram.com/v1/auth/token", { method: "GET", headers: applyCustomUserAgent({ Authorization: `Token ${apiKey}` }, providerSpecificData), }); @@ -381,13 +420,13 @@ async function validateDeepgramProvider({ apiKey, providerSpecificData = {} }: a } return { valid: false, error: `Validation failed: ${response.status}` }; } catch (error: any) { - return { valid: false, error: error.message || "Validation failed" }; + return toValidationErrorResult(error); } } async function validateAssemblyAIProvider({ apiKey, providerSpecificData = {} }: any) { try { - const response = await fetch("https://api.assemblyai.com/v2/transcript?limit=1", { + const response = await validationRead("https://api.assemblyai.com/v2/transcript?limit=1", { method: "GET", headers: applyCustomUserAgent( { @@ -403,7 +442,7 @@ async function validateAssemblyAIProvider({ apiKey, providerSpecificData = {} }: } return { valid: false, error: `Validation failed: ${response.status}` }; } catch (error: any) { - return { valid: false, error: error.message || "Validation failed" }; + return toValidationErrorResult(error); } } @@ -411,34 +450,37 @@ async function validateNanoBananaProvider({ apiKey, providerSpecificData = {} }: try { // NanoBanana doesn't expose a lightweight validation endpoint, // so we send a minimal generate request that will succeed or fail on auth. - const response = await fetch("https://api.nanobananaapi.ai/api/v1/nanobanana/generate", { - method: "POST", - headers: applyCustomUserAgent( - { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - providerSpecificData - ), - body: JSON.stringify({ - prompt: "test", - model: "nanobanana-flash", - }), - }); + const response = await validationWrite( + "https://api.nanobananaapi.ai/api/v1/nanobanana/generate", + { + method: "POST", + headers: applyCustomUserAgent( + { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + providerSpecificData + ), + body: JSON.stringify({ + prompt: "test", + model: "nanobanana-flash", + }), + } + ); // Auth errors → 401/403; anything else (even 400 bad request) means auth passed if (response.status === 401 || response.status === 403) { return { valid: false, error: "Invalid API key" }; } return { valid: true, error: null }; } catch (error: any) { - return { valid: false, error: error.message || "Validation failed" }; + return toValidationErrorResult(error); } } async function validateElevenLabsProvider({ apiKey, providerSpecificData = {} }: any) { try { // Lightweight auth check endpoint - const response = await fetch("https://api.elevenlabs.io/v1/voices", { + const response = await validationRead("https://api.elevenlabs.io/v1/voices", { method: "GET", headers: applyCustomUserAgent( { @@ -456,7 +498,7 @@ async function validateElevenLabsProvider({ apiKey, providerSpecificData = {} }: return { valid: false, error: `Validation failed: ${response.status}` }; } catch (error: any) { - return { valid: false, error: error.message || "Validation failed" }; + return toValidationErrorResult(error); } } @@ -464,7 +506,7 @@ async function validateInworldProvider({ apiKey, providerSpecificData = {} }: an try { // Inworld TTS lacks a simple key-introspection endpoint. // Send a minimal synth request and treat non-auth 4xx as auth-pass. - const response = await fetch("https://api.inworld.ai/tts/v1/voice", { + const response = await validationWrite("https://api.inworld.ai/tts/v1/voice", { method: "POST", headers: applyCustomUserAgent( { @@ -487,7 +529,7 @@ async function validateInworldProvider({ apiKey, providerSpecificData = {} }: an // Any other response indicates auth is accepted (payload/model may still be wrong) return { valid: true, error: null }; } catch (error: any) { - return { valid: false, error: error.message || "Validation failed" }; + return toValidationErrorResult(error); } } @@ -503,7 +545,7 @@ async function validateBailianCodingPlanProvider({ apiKey, providerSpecificData // It does NOT expose /v1/models — use messages probe directly const messagesUrl = `${baseUrl}/messages`; - const response = await fetch(messagesUrl, { + const response = await validationWrite(messagesUrl, { method: "POST", headers: applyCustomUserAgent( { @@ -536,7 +578,7 @@ async function validateBailianCodingPlanProvider({ apiKey, providerSpecificData return { valid: false, error: `Validation failed: ${response.status}` }; } catch (error: any) { - return { valid: false, error: error.message || "Validation failed" }; + return toValidationErrorResult(error); } } @@ -612,7 +654,7 @@ async function validateGigachatProvider({ apiKey, providerSpecificData = {} }: a if (String(error?.message || "").match(/\b(401|403)\b/)) { return { valid: false, error: "Invalid API key" }; } - return { valid: false, error: error.message || "Validation failed" }; + return toValidationErrorResult(error); } return validateDirectChatProvider({ @@ -645,7 +687,7 @@ async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData = // Step 1: Try GET /models let modelsReachable = false; try { - const modelsRes = await fetch(`${baseUrl}/models`, { + const modelsRes = await validationRead(`${baseUrl}/models`, { method: "GET", headers: buildBearerHeaders(apiKey, providerSpecificData), }); @@ -690,7 +732,7 @@ async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData = const testModelId = validationModelId; try { - const chatRes = await fetch(chatUrl, { + const chatRes = await validationWrite(chatUrl, { method: "POST", headers: buildBearerHeaders(apiKey, providerSpecificData), body: JSON.stringify({ @@ -752,10 +794,9 @@ async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData = } try { - const pingRes = await fetch(baseUrl, { + const pingRes = await validationRead(baseUrl, { method: "GET", headers: buildBearerHeaders(apiKey, providerSpecificData), - signal: AbortSignal.timeout(5000), }); // If the server responds at all (even with an error page), it's reachable @@ -765,7 +806,7 @@ async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData = return { valid: false, error: `Provider unavailable (${pingRes.status})` }; } catch (error: any) { - return { valid: false, error: error.message || "Connection failed" }; + return toValidationErrorResult(error); } } @@ -787,7 +828,7 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat // Step 1: Try GET /models try { - const modelsRes = await fetch( + const modelsRes = await validationRead( joinBaseUrlAndPath(baseUrl, providerSpecificData?.modelsPath || "/models"), { method: "GET", @@ -809,7 +850,7 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat // Step 2: Fallback — try a minimal messages request const testModelId = providerSpecificData?.validationModelId || "claude-3-5-sonnet-20241022"; try { - const messagesRes = await fetch( + const messagesRes = await validationWrite( joinBaseUrlAndPath(baseUrl, providerSpecificData?.chatPath || "/messages"), { method: "POST", @@ -829,7 +870,7 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat // Any other response (200, 400, 422, etc.) means auth passed return { valid: true, error: null }; } catch (error: any) { - return { valid: false, error: error.message || "Connection failed" }; + return toValidationErrorResult(error); } } @@ -850,7 +891,7 @@ export async function validateClaudeCodeCompatibleProvider({ ); try { - const modelsRes = await fetch(joinClaudeCodeCompatibleUrl(baseUrl, modelsPath), { + const modelsRes = await validationRead(joinClaudeCodeCompatibleUrl(baseUrl, modelsPath), { method: "GET", headers: defaultHeaders, }); @@ -872,7 +913,7 @@ export async function validateClaudeCodeCompatibleProvider({ const sessionId = JSON.parse(payload.metadata.user_id).session_id; try { - const messagesRes = await fetch(joinClaudeCodeCompatibleUrl(baseUrl, chatPath), { + const messagesRes = await validationWrite(joinClaudeCodeCompatibleUrl(baseUrl, chatPath), { method: "POST", headers: applyCustomUserAgent( buildClaudeCodeCompatibleHeaders(apiKey, true, sessionId), @@ -909,7 +950,7 @@ export async function validateClaudeCodeCompatibleProvider({ method: "cc_bridge_request", }; } catch (error: any) { - return { valid: false, error: error.message || "Connection failed" }; + return toValidationErrorResult(error); } } @@ -921,7 +962,11 @@ async function validateSearchProvider( providerSpecificData: any = {} ): Promise<{ valid: boolean; error: string | null; unsupported: false }> { try { - const response = await fetch(url, withCustomUserAgent(init, providerSpecificData)); + const response = await safeOutboundFetch(url, { + ...SAFE_OUTBOUND_FETCH_PRESETS.validationWrite, + guard: getProviderOutboundGuard(), + ...withCustomUserAgent(init, providerSpecificData), + }); if (response.ok) return { valid: true, error: null, unsupported: false }; if (response.status === 401 || response.status === 403) { return { valid: false, error: "Invalid API key", unsupported: false }; @@ -934,7 +979,7 @@ async function validateSearchProvider( } return { valid: false, error: `Validation failed: ${response.status}`, unsupported: false }; } catch (error: any) { - return { valid: false, error: error.message || "Validation failed", unsupported: false }; + return toValidationErrorResult(error); } } @@ -992,7 +1037,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi try { return await validateOpenAICompatibleProvider({ apiKey, providerSpecificData }); } catch (error: any) { - return { valid: false, error: error.message || "Validation failed", unsupported: false }; + return toValidationErrorResult(error); } } @@ -1003,7 +1048,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi } return await validateAnthropicCompatibleProvider({ apiKey, providerSpecificData }); } catch (error: any) { - return { valid: false, error: error.message || "Validation failed", unsupported: false }; + return toValidationErrorResult(error); } } @@ -1036,7 +1081,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi // LongCat AI — does not expose /v1/models; validate via chat completions directly (#592) longcat: async ({ apiKey, providerSpecificData }: any) => { try { - const res = await fetch("https://api.longcat.chat/openai/v1/chat/completions", { + const res = await validationWrite("https://api.longcat.chat/openai/v1/chat/completions", { method: "POST", headers: buildBearerHeaders(apiKey, providerSpecificData), body: JSON.stringify({ @@ -1051,7 +1096,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi // Any non-auth response (200, 400, 422) means auth passed return { valid: true, error: null }; } catch (error: any) { - return { valid: false, error: error.message || "Connection failed" }; + return toValidationErrorResult(error); } }, // Search providers — use factored validator @@ -1070,7 +1115,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi try { return await SPECIALTY_VALIDATORS[provider]({ apiKey, providerSpecificData }); } catch (error: any) { - return { valid: false, error: error.message || "Validation failed", unsupported: false }; + return toValidationErrorResult(error); } } @@ -1131,6 +1176,6 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi return { valid: false, error: "Provider validation not supported", unsupported: true }; } catch (error: any) { - return { valid: false, error: error.message || "Validation failed", unsupported: false }; + return toValidationErrorResult(error); } } diff --git a/src/lib/semanticCache.ts b/src/lib/semanticCache.ts index 8802bd2bc5..3e12b9f3a5 100644 --- a/src/lib/semanticCache.ts +++ b/src/lib/semanticCache.ts @@ -1,7 +1,9 @@ /** * Semantic Cache — Phase 9.1 * - * Caches non-streaming LLM responses (temperature=0) to reduce cost and latency. + * Caches LLM responses (temperature=0) to reduce cost and latency. + * Supports both streaming and non-streaming requests: streaming responses + * are cached after assembly; cache hits always return JSON. * Two-tier: in-memory LRU (fast) + SQLite (persistent across restarts). * * Cache key = SHA-256(model + normalized messages + temperature + top_p) @@ -113,25 +115,38 @@ function getMemoryCache() { * @param {number} topP * @returns {string} hex signature */ -export function generateSignature(model, messages, temperature = 0, topP = 1) { +export function generateSignature(model, conversation, temperature = 0, topP = 1) { const payload = JSON.stringify({ model, - messages: normalizeMessages(messages), + messages: normalizeConversation(conversation), temperature, top_p: topP, }); return crypto.createHash("sha256").update(payload).digest("hex"); } +function stringifyForSignature(value: unknown): string { + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + /** - * Normalize messages for consistent hashing. - * Strips metadata, keeps only role + content. + * Normalize conversation items for consistent hashing. + * Supports both Chat Completions `messages[]` and Responses API `input[]`. */ -function normalizeMessages(messages) { - if (!Array.isArray(messages)) return []; - return messages.map((m) => ({ - role: m.role || "user", - content: typeof m.content === "string" ? m.content : JSON.stringify(m.content), +function normalizeConversation(conversation: unknown) { + if (typeof conversation === "string") { + return [{ role: "user", content: conversation }]; + } + if (!Array.isArray(conversation)) return []; + + return conversation.map((item: Record) => ({ + role: typeof item?.role === "string" && item.role.trim().length > 0 ? item.role : "user", + content: stringifyForSignature(item?.content), })); } @@ -379,8 +394,9 @@ export function getCacheStats() { } /** - * Check if a request is cacheable. + * Check if a request is cacheable for read (pre-request lookup). * Only non-streaming, deterministic (temperature=0) requests. + * @deprecated Use isCacheableForRead instead. */ export function isCacheable(body, headers) { if ((getHeaderValue(headers, "x-omniroute-no-cache") || "").toLowerCase() === "true") { @@ -390,3 +406,30 @@ export function isCacheable(body, headers) { if ((body.temperature ?? 0) !== 0) return false; return true; } + +/** + * Check if a cached response can be served for this request. + * Works for both streaming and non-streaming requests (cache hit returns JSON). + * Omitted temperature defaults to 0 for read (matching existing cache entries). + */ +export function isCacheableForRead(body, headers) { + if ((getHeaderValue(headers, "x-omniroute-no-cache") || "").toLowerCase() === "true") { + return false; + } + if ((body.temperature ?? 0) !== 0) return false; + return true; +} + +/** + * Check if a response should be stored in cache after completion. + * Works for both streaming and non-streaming responses. + * Requires explicit `temperature: 0` — omitted temperature is NOT cacheable + * because the provider default may be non-deterministic. + */ +export function isCacheableForWrite(body, headers) { + if ((getHeaderValue(headers, "x-omniroute-no-cache") || "").toLowerCase() === "true") { + return false; + } + if (body.temperature !== 0) return false; + return true; +} diff --git a/src/lib/skills/executor.ts b/src/lib/skills/executor.ts index cf258564b6..8d7db16810 100644 --- a/src/lib/skills/executor.ts +++ b/src/lib/skills/executor.ts @@ -3,6 +3,9 @@ import { SkillExecution, SkillStatus, SkillHandler } from "./types"; import { getDbInstance } from "../db/core"; import { getSettings } from "../db/settings"; import { randomUUID } from "crypto"; +import { logger } from "../../../open-sse/utils/logger.ts"; + +const log = logger("SKILLS_EXECUTOR"); class SkillExecutor { private static instance: SkillExecutor; @@ -54,6 +57,8 @@ class SkillExecutor { const executionId = randomUUID(); const startTime = Date.now(); + log.info("skills.executor.start", { skillId: skill.id, skillName, apiKeyId: context.apiKeyId }); + try { db.prepare( `INSERT INTO skill_executions (id, skill_id, api_key_id, session_id, input, status, created_at) @@ -93,6 +98,12 @@ class SkillExecutor { `UPDATE skill_executions SET output = ?, status = ?, error_message = ?, duration_ms = ? WHERE id = ?` ).run(output ? JSON.stringify(output) : null, status, errorMessage, durationMs, executionId); + log.info("skills.executor.complete", { + skillId: skill.id, + success: status === SkillStatus.SUCCESS, + durationMs, + }); + return { id: executionId, skillId: skill.id, @@ -145,15 +156,17 @@ class SkillExecutor { }; } - listExecutions(apiKeyId?: string, limit: number = 50): SkillExecution[] { + listExecutions(apiKeyId?: string, limit: number = 50, offset: number = 0): SkillExecution[] { const db = getDbInstance(); const rows = apiKeyId ? db .prepare( - "SELECT * FROM skill_executions WHERE api_key_id = ? ORDER BY created_at DESC LIMIT ?" + "SELECT * FROM skill_executions WHERE api_key_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?" ) - .all(apiKeyId, limit) - : db.prepare("SELECT * FROM skill_executions ORDER BY created_at DESC LIMIT ?").all(limit); + .all(apiKeyId, limit, offset) + : db + .prepare("SELECT * FROM skill_executions ORDER BY created_at DESC LIMIT ? OFFSET ?") + .all(limit, offset); return (rows as any[]).map((row) => ({ id: row.id, @@ -168,6 +181,16 @@ class SkillExecutor { createdAt: new Date(row.created_at), })); } + + countExecutions(apiKeyId?: string): number { + const db = getDbInstance(); + const row = apiKeyId + ? (db + .prepare("SELECT COUNT(*) as count FROM skill_executions WHERE api_key_id = ?") + .get(apiKeyId) as any) + : (db.prepare("SELECT COUNT(*) as count FROM skill_executions").get() as any); + return row?.count ?? 0; + } } export const skillExecutor = SkillExecutor.getInstance(); diff --git a/src/lib/skills/injection.ts b/src/lib/skills/injection.ts index 134a1079b9..809c65f00b 100644 --- a/src/lib/skills/injection.ts +++ b/src/lib/skills/injection.ts @@ -1,5 +1,8 @@ import { skillRegistry } from "./registry"; import { Skill } from "./types"; +import { logger } from "../../../open-sse/utils/logger.ts"; + +const log = logger("SKILLS_INJECTION"); interface OpenAITool { type: string; @@ -59,9 +62,19 @@ export function injectSkills(options: InjectionOptions): unknown[] { const skills = skillRegistry.list(options.apiKeyId).filter((s) => s.enabled); if (skills.length === 0) { + log.info("skills.injection.skipped", { + apiKeyId: options.apiKeyId, + reason: "no_enabled_skills", + }); return options.existingTools || []; } + log.info("skills.injection.injected", { + apiKeyId: options.apiKeyId, + provider: options.provider, + skillCount: skills.length, + }); + const injectedTools = skills.map((skill) => { switch (options.provider) { case "openai": diff --git a/src/lib/skills/interception.ts b/src/lib/skills/interception.ts index 13ab0d89cc..afdb5de478 100644 --- a/src/lib/skills/interception.ts +++ b/src/lib/skills/interception.ts @@ -1,5 +1,8 @@ import { skillExecutor } from "./executor"; import { detectProvider } from "./injection"; +import { logger } from "../../../open-sse/utils/logger.ts"; + +const log = logger("SKILLS_INTERCEPTION"); interface ToolCall { id: string; @@ -26,6 +29,11 @@ export async function interceptToolCalls( const skillName = version === "latest" ? name : `${name}@${version}`; + log.info("skills.interception.tool_call_detected", { + toolName: call.name, + callId: call.id, + }); + const execution = await skillExecutor.execute(skillName, call.arguments, { apiKeyId: context.apiKeyId, sessionId: context.sessionId, @@ -37,11 +45,21 @@ export async function interceptToolCalls( ? { error: execution.errorMessage } : { error: "Skill execution returned no output" }); + log.info("skills.interception.execution_complete", { + toolName: call.name, + callId: call.id, + }); + return { id: call.id, result, }; } catch (err) { + log.error("skills.interception.execution_failed", { + toolName: call.name, + callId: call.id, + err: err instanceof Error ? err.message : String(err), + }); return { id: call.id, result: { error: err instanceof Error ? err.message : String(err) }, diff --git a/src/lib/skills/registry.ts b/src/lib/skills/registry.ts index dd309e911b..17f0b4b9f0 100644 --- a/src/lib/skills/registry.ts +++ b/src/lib/skills/registry.ts @@ -2,11 +2,17 @@ import { Skill, SkillSchema } from "./types"; import { SkillCreateInputSchema } from "./schemas"; import { getDbInstance } from "../db/core"; import { randomUUID } from "crypto"; +import { logger } from "../../../open-sse/utils/logger.ts"; + +const log = logger("SKILLS"); class SkillRegistry { private static instance: SkillRegistry; private registeredSkills: Map = new Map(); private versionCache: Map> = new Map(); + private lastLoaded: number = 0; + private readonly cacheTTL: number = 60_000; // 60 seconds + private pendingLoad: Promise | null = null; // dedupes concurrent cache fills private constructor() {} @@ -17,6 +23,14 @@ class SkillRegistry { return SkillRegistry.instance; } + private isCacheStale(): boolean { + return Date.now() - this.lastLoaded > this.cacheTTL; + } + + invalidateCache(): void { + this.lastLoaded = 0; + } + async register(skillData: { name: string; version?: string; @@ -63,6 +77,7 @@ class SkillRegistry { this.registeredSkills.set(`${parsed.name}@${parsed.version}`, skill); this.updateVersionCache(skill); + this.invalidateCache(); return skill; } @@ -77,6 +92,7 @@ class SkillRegistry { db.prepare("DELETE FROM skills WHERE id = ?").run(skill.id); this.registeredSkills.delete(key); this.rebuildVersionCache(name); + this.invalidateCache(); return true; } } else { @@ -90,6 +106,7 @@ class SkillRegistry { .map(([key]) => key); keysToDelete.forEach((k) => this.registeredSkills.delete(k)); this.rebuildVersionCache(name); + this.invalidateCache(); return true; } } @@ -110,12 +127,14 @@ class SkillRegistry { }); keysToDelete.forEach((k) => this.registeredSkills.delete(k)); affectedNames.forEach((name) => this.rebuildVersionCache(name)); + this.invalidateCache(); return true; } return false; } list(apiKeyId?: string): Skill[] { + log.debug("skills.registry.list", { apiKeyId, cached: !this.isCacheStale() }); if (apiKeyId) { return Array.from(this.registeredSkills.values()).filter((s) => s.apiKeyId === apiKeyId); } @@ -212,26 +231,48 @@ class SkillRegistry { } async loadFromDatabase(apiKeyId?: string): Promise { - const db = getDbInstance(); - const rows = apiKeyId - ? db.prepare("SELECT * FROM skills WHERE api_key_id = ?").all(apiKeyId) - : db.prepare("SELECT * FROM skills").all(); + if (this.pendingLoad) { + await this.pendingLoad; + return; + } + if (!this.isCacheStale()) return; - for (const row of rows as any[]) { - const skill: Skill = { - id: row.id, - apiKeyId: row.api_key_id, - name: row.name, - version: row.version, - description: row.description || "", - schema: JSON.parse(row.schema), - handler: row.handler, - enabled: row.enabled === 1, - createdAt: new Date(row.created_at), - updatedAt: new Date(row.updated_at), - }; - this.registeredSkills.set(`${skill.name}@${skill.version}`, skill); - this.updateVersionCache(skill); + this.pendingLoad = (async () => { + try { + log.debug("skills.registry.loadFromDatabase", { cached: false }); + const db = getDbInstance(); + const rows = apiKeyId + ? db.prepare("SELECT * FROM skills WHERE api_key_id = ?").all(apiKeyId) + : db.prepare("SELECT * FROM skills").all(); + + for (const row of rows as any[]) { + const skill: Skill = { + id: row.id, + apiKeyId: row.api_key_id, + name: row.name, + version: row.version, + description: row.description || "", + schema: JSON.parse(row.schema), + handler: row.handler, + enabled: row.enabled === 1, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + }; + this.registeredSkills.set(`${skill.name}@${skill.version}`, skill); + this.updateVersionCache(skill); + } + this.lastLoaded = Date.now(); + } catch (err: any) { + log.error("loadFromDatabase error:", err); + throw err; + } finally { + this.pendingLoad = null; + } + })(); + try { + await this.pendingLoad; + } finally { + this.pendingLoad = null; } } } diff --git a/src/lib/sync/bundle.ts b/src/lib/sync/bundle.ts new file mode 100644 index 0000000000..2c9ff803d8 --- /dev/null +++ b/src/lib/sync/bundle.ts @@ -0,0 +1,206 @@ +import { createHash } from "crypto"; +import { + getApiKeys, + getCombos, + getModelAliases, + getProviderConnections, + getProviderNodes, + getSettings, +} from "@/lib/localDb"; + +type JsonRecord = Record; + +export interface ConfigSyncBundle { + settings: JsonRecord; + providerConnections: JsonRecord[]; + providerNodes: JsonRecord[]; + modelAliases: JsonRecord; + combos: JsonRecord[]; + apiKeys: JsonRecord[]; +} + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function sanitizeSettingsForSync(settings: unknown): JsonRecord { + const record = asRecord(settings); + const { + password: _password, + requireLogin: _requireLogin, + cloudEnabled: _cloudEnabled, + ...safeSettings + } = record; + return safeSettings; +} + +function sortByStringKeys(items: T[], keys: string[]) { + return [...items].sort((a, b) => { + for (const key of keys) { + const leftRaw = a[key]; + const rightRaw = b[key]; + + if (typeof leftRaw === "number" || typeof rightRaw === "number") { + const left = typeof leftRaw === "number" ? leftRaw : Number.MAX_SAFE_INTEGER; + const right = typeof rightRaw === "number" ? rightRaw : Number.MAX_SAFE_INTEGER; + if (left !== right) return left - right; + continue; + } + + const left = typeof leftRaw === "string" ? String(leftRaw) : ""; + const right = typeof rightRaw === "string" ? String(rightRaw) : ""; + const comparison = left.localeCompare(right, undefined, { numeric: true }); + if (comparison !== 0) return comparison; + } + return 0; + }); +} + +function pickDefined(record: JsonRecord, keys: string[]) { + return Object.fromEntries( + keys.filter((key) => record[key] !== undefined).map((key) => [key, record[key]]) + ); +} + +function sanitizeProviderConnectionForSync(connection: unknown): JsonRecord { + const record = asRecord(connection); + return pickDefined(record, [ + "id", + "provider", + "authType", + "name", + "displayName", + "email", + "priority", + "globalPriority", + "defaultModel", + "isActive", + "accessToken", + "refreshToken", + "expiresAt", + "expiresIn", + "tokenType", + "scope", + "idToken", + "projectId", + "apiKey", + "providerSpecificData", + "group", + ]); +} + +function sanitizeProviderNodeForSync(node: unknown): JsonRecord { + const record = asRecord(node); + return pickDefined(record, [ + "id", + "type", + "name", + "prefix", + "apiType", + "baseUrl", + "chatPath", + "modelsPath", + ]); +} + +function sanitizeComboForSync(combo: unknown): JsonRecord { + const record = asRecord(combo); + const { createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = record; + return rest; +} + +function sanitizeApiKeyForSync(apiKey: unknown): JsonRecord { + const record = asRecord(apiKey); + return pickDefined(record, [ + "id", + "name", + "key", + "machineId", + "allowedModels", + "allowedConnections", + "noLog", + "autoResolve", + "isActive", + "accessSchedule", + "maxRequestsPerDay", + "maxRequestsPerMinute", + "maxSessions", + ]); +} + +function canonicalizeJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => canonicalizeJson(entry)); + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.keys(value as JsonRecord) + .sort((a, b) => a.localeCompare(b)) + .map((key) => [key, canonicalizeJson((value as JsonRecord)[key])]) + ); + } + + return value; +} + +export function serializeStableJson(value: unknown) { + return JSON.stringify(canonicalizeJson(value)); +} + +export function computeConfigSyncVersion(bundle: ConfigSyncBundle) { + return createHash("sha256").update(serializeStableJson(bundle)).digest("hex"); +} + +export async function buildConfigSyncBundle(): Promise { + const [settings, providerConnections, providerNodes, modelAliases, combos, apiKeys] = + await Promise.all([ + getSettings(), + getProviderConnections(), + getProviderNodes(), + getModelAliases(), + getCombos(), + getApiKeys(), + ]); + + return { + settings: sanitizeSettingsForSync(settings), + providerConnections: sortByStringKeys( + providerConnections.map((connection) => sanitizeProviderConnectionForSync(connection)), + ["provider", "name", "id"] + ), + providerNodes: sortByStringKeys( + providerNodes.map((node) => sanitizeProviderNodeForSync(node)), + ["type", "name", "id"] + ), + modelAliases: asRecord(modelAliases), + combos: sortByStringKeys( + combos.map((combo) => sanitizeComboForSync(combo)), + ["sortOrder", "name", "id"] + ), + apiKeys: sortByStringKeys( + apiKeys.map((apiKey) => sanitizeApiKeyForSync(apiKey)), + ["name", "id"] + ), + }; +} + +export async function buildConfigSyncEnvelope() { + const bundle = await buildConfigSyncBundle(); + const version = computeConfigSyncVersion(bundle); + return { + version, + bundle, + }; +} + +export function toLegacyCloudSyncPayload(bundle: ConfigSyncBundle) { + return { + providers: bundle.providerConnections, + providerNodes: bundle.providerNodes, + modelAliases: bundle.modelAliases, + combos: bundle.combos, + apiKeys: bundle.apiKeys, + settings: bundle.settings, + }; +} diff --git a/src/lib/sync/tokens.ts b/src/lib/sync/tokens.ts new file mode 100644 index 0000000000..4b39db7ccd --- /dev/null +++ b/src/lib/sync/tokens.ts @@ -0,0 +1,104 @@ +import { createHash, randomBytes } from "crypto"; +import { + createSyncTokenRecord, + getSyncTokenByHash, + listSyncTokens, + revokeSyncToken, + touchSyncTokenLastUsed, + type SyncTokenRecord, +} from "@/lib/db/syncTokens"; +import { getApiKeyMetadata } from "@/lib/db/apiKeys"; + +function normalizeToken(rawToken: string | null | undefined) { + if (typeof rawToken !== "string") return null; + const token = rawToken.trim(); + return token.length > 0 ? token : null; +} + +export function hashSyncToken(rawToken: string) { + return createHash("sha256").update(rawToken).digest("hex"); +} + +export function generatePlaintextSyncToken() { + return `osync_${randomBytes(32).toString("base64url")}`; +} + +export async function issueSyncToken(params: { name: string; syncApiKeyId?: string | null }) { + const plaintextToken = generatePlaintextSyncToken(); + const record = await createSyncTokenRecord({ + name: params.name, + tokenHash: hashSyncToken(plaintextToken), + syncApiKeyId: params.syncApiKeyId || null, + }); + + return { + token: plaintextToken, + record, + }; +} + +export async function validateSyncToken(rawToken: string | null | undefined) { + const token = normalizeToken(rawToken); + if (!token) return null; + + const record = await getSyncTokenByHash(hashSyncToken(token)); + if (!record || record.revokedAt) return null; + return record; +} + +export async function markSyncTokenUsed(record: SyncTokenRecord) { + await touchSyncTokenLastUsed(record.id); +} + +export async function listSyncTokenSummaries() { + const records = await listSyncTokens(); + return records.map((record) => ({ + id: record.id, + name: record.name, + syncApiKeyId: record.syncApiKeyId, + revokedAt: record.revokedAt, + lastUsedAt: record.lastUsedAt, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + })); +} + +export async function revokeSyncTokenById(id: string) { + const revoked = await revokeSyncToken(id); + if (!revoked) return null; + return { + id: revoked.id, + name: revoked.name, + syncApiKeyId: revoked.syncApiKeyId, + revokedAt: revoked.revokedAt, + lastUsedAt: revoked.lastUsedAt, + createdAt: revoked.createdAt, + updatedAt: revoked.updatedAt, + }; +} + +export async function resolveSyncApiKeyIdFromManagementRequest(request: Request) { + const authHeader = request.headers.get("authorization") || request.headers.get("Authorization"); + if (typeof authHeader !== "string") return null; + const trimmedHeader = authHeader.trim(); + if (!trimmedHeader.toLowerCase().startsWith("bearer ")) return null; + + const apiKey = trimmedHeader.slice(7).trim(); + if (!apiKey) return null; + + const metadata = await getApiKeyMetadata(apiKey); + return metadata?.id || null; +} + +export function getSyncTokenFromRequest(request: Request) { + const explicitHeader = request.headers.get("x-sync-token"); + if (typeof explicitHeader === "string" && explicitHeader.trim().length > 0) { + return explicitHeader.trim(); + } + + const authHeader = request.headers.get("authorization") || request.headers.get("Authorization"); + if (typeof authHeader !== "string") return null; + const trimmedHeader = authHeader.trim(); + if (!trimmedHeader.toLowerCase().startsWith("bearer ")) return null; + return trimmedHeader.slice(7).trim() || null; +} diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts index 8f3d944109..4522aa38a8 100644 --- a/src/lib/tokenHealthCheck.ts +++ b/src/lib/tokenHealthCheck.ts @@ -35,6 +35,19 @@ function getConnectionLogLabel(conn: { name?: string; email?: string; id?: strin return pickMaskedDisplayValue([conn.name, conn.email], conn.id || "-"); } +export function extractResolvedProxyConfig(resolvedProxy: unknown) { + if ( + resolvedProxy && + typeof resolvedProxy === "object" && + !Array.isArray(resolvedProxy) && + "proxy" in resolvedProxy + ) { + return (resolvedProxy as { proxy?: unknown }).proxy ?? null; + } + + return resolvedProxy ?? null; +} + export function buildRefreshFailureUpdate(conn: any, now: string) { const wasExpired = conn.testStatus === "expired"; const retryCount = (conn.expiredRetryCount ?? 0) + (wasExpired ? 1 : 0); @@ -186,13 +199,21 @@ async function sweep() { if (!connections || connections.length === 0) return; - for (const conn of connections) { + const staggerMs = parseInt(process.env.HEALTHCHECK_STAGGER_MS || "3000", 10); + + for (let i = 0; i < connections.length; i++) { + const conn = connections[i]; try { await checkConnection(conn); } catch (err) { // Per-connection isolation: one failure never blocks others logError(`${LOG_PREFIX} Error checking ${conn.name || conn.id}:`, err.message); } + + // Stagger delay between checks to prevent bursting (Issue #1220) + if (staggerMs > 0 && i < connections.length - 1) { + await new Promise((resolve) => setTimeout(resolve, staggerMs)); + } } } catch (err) { logError(`${LOG_PREFIX} Sweep error:`, err.message); @@ -202,7 +223,7 @@ async function sweep() { /** * Check a single connection and refresh if due. */ -async function checkConnection(conn) { +export async function checkConnection(conn) { // Determine interval (0 = disabled) const intervalMin = conn.healthCheckInterval ?? DEFAULT_HEALTH_CHECK_INTERVAL_MIN; if (intervalMin <= 0) return; @@ -255,7 +276,8 @@ async function checkConnection(conn) { }; const hideLogs = await shouldHideLogs(); - const proxyConfig = await resolveProxyForConnection(conn.id); + const proxyResolution = await resolveProxyForConnection(conn.id); + const proxyConfig = extractResolvedProxyConfig(proxyResolution); const result = await getAccessToken( conn.provider, credentials, diff --git a/src/lib/usage/callLogArtifacts.ts b/src/lib/usage/callLogArtifacts.ts new file mode 100644 index 0000000000..daa2d6c8b6 --- /dev/null +++ b/src/lib/usage/callLogArtifacts.ts @@ -0,0 +1,181 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts"; +import { resolveDataDir } from "../dataPaths"; + +const isCloud = typeof globalThis.caches === "object" && globalThis.caches !== null; +const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build"; +const DATA_DIR = resolveDataDir({ isCloud }); + +export const CALL_LOGS_DIR = isCloud ? null : path.join(DATA_DIR, "call_logs"); + +export type CallLogDetailState = "none" | "ready" | "missing" | "corrupt" | "legacy-inline"; + +export type CallLogArtifact = { + schemaVersion: 4; + summary: { + id: string; + timestamp: string; + method: string; + path: string; + status: number; + model: string; + requestedModel: string | null; + provider: string; + account: string; + connectionId: string | null; + duration: number; + tokens: { + in: number; + out: number; + cacheRead: number | null; + cacheWrite: number | null; + reasoning: number | null; + }; + requestType: string | null; + sourceFormat: string | null; + targetFormat: string | null; + apiKeyId: string | null; + apiKeyName: string | null; + comboName: string | null; + comboStepId: string | null; + comboExecutionKey: string | null; + }; + requestBody: unknown; + responseBody: unknown; + error: unknown; + pipeline?: RequestPipelinePayloads; +}; + +export type CallLogArtifactWriteResult = { + relPath: string; + sizeBytes: number; + sha256: string; +}; + +export function buildArtifactRelativePath(timestamp: string, id: string) { + const parsed = new Date(timestamp); + const safeTimestamp = ( + Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString() + ).replace(/[:]/g, "-"); + const dateFolder = safeTimestamp.slice(0, 10); + return path.posix.join(dateFolder, `${safeTimestamp}_${id}.json`); +} + +export function writeCallArtifact( + artifact: CallLogArtifact, + relativePath = buildArtifactRelativePath(artifact.summary.timestamp, artifact.summary.id) +): CallLogArtifactWriteResult | null { + if (!CALL_LOGS_DIR || isBuildPhase) return null; + + const absPath = path.join(CALL_LOGS_DIR, relativePath); + const tmpPath = `${absPath}.${process.pid}.${Date.now()}.tmp`; + + try { + const serialized = JSON.stringify(artifact, null, 2); + const sizeBytes = Buffer.byteLength(serialized); + const sha256 = crypto.createHash("sha256").update(serialized).digest("hex"); + + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(tmpPath, serialized); + fs.renameSync(tmpPath, absPath); + + return { + relPath: relativePath, + sizeBytes, + sha256, + }; + } catch (error) { + try { + fs.rmSync(tmpPath, { force: true }); + } catch { + // Best effort cleanup only. + } + console.error("[callLogs] Failed to write request artifact:", (error as Error).message); + return null; + } +} + +export function readCallArtifact(relativePath: string | null): { + artifact: CallLogArtifact | null; + state: "ready" | "missing" | "corrupt"; +} { + if (!CALL_LOGS_DIR || !relativePath) { + return { artifact: null, state: "missing" }; + } + + try { + const absPath = path.join(CALL_LOGS_DIR, relativePath); + if (!fs.existsSync(absPath)) { + return { artifact: null, state: "missing" }; + } + return { + artifact: JSON.parse(fs.readFileSync(absPath, "utf8")) as CallLogArtifact, + state: "ready", + }; + } catch (error) { + console.error("[callLogs] Failed to read request artifact:", (error as Error).message); + return { artifact: null, state: "corrupt" }; + } +} + +export function deleteCallArtifact(relativePath: string | null): boolean { + if (!CALL_LOGS_DIR || !relativePath) return false; + + try { + const absPath = path.join(CALL_LOGS_DIR, relativePath); + if (!fs.existsSync(absPath)) return false; + fs.rmSync(absPath, { force: true }); + return true; + } catch { + return false; + } +} + +export function cleanupEmptyCallLogDirs(baseDir = CALL_LOGS_DIR) { + if (!baseDir || !fs.existsSync(baseDir)) return; + + try { + for (const entry of fs.readdirSync(baseDir)) { + const entryPath = path.join(baseDir, entry); + const stat = fs.statSync(entryPath); + if (!stat.isDirectory()) continue; + if (fs.readdirSync(entryPath).length === 0) { + fs.rmSync(entryPath, { recursive: true, force: true }); + } + } + } catch { + // Best effort only. + } +} + +export function listCallLogArtifactFiles(baseDir = CALL_LOGS_DIR) { + if (!baseDir || !fs.existsSync(baseDir)) return []; + + return fs + .readdirSync(baseDir) + .flatMap((entry) => { + const entryPath = path.join(baseDir, entry); + try { + const stat = fs.statSync(entryPath); + if (!stat.isDirectory()) return []; + + return fs + .readdirSync(entryPath) + .filter((file) => file.endsWith(".json")) + .map((file) => { + const absPath = path.join(entryPath, file); + const fileStat = fs.statSync(absPath); + return { + relativePath: path.posix.join(entry, file), + absPath, + mtimeMs: fileStat.mtimeMs, + }; + }); + } catch { + return []; + } + }) + .sort((a, b) => b.mtimeMs - a.mtimeMs); +} diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index fa57df9a53..30977f21fa 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -1,10 +1,8 @@ /** - * Call Logs — extracted from usageDb.js (T-15) + * Structured call log management. * - * Structured call log management: save, query, rotate, and - * unified single-artifact disk storage for the Logger UI. - * - * @module lib/usage/callLogs + * SQLite stores only summary metadata. Detailed request/response payloads live in + * filesystem artifacts and are loaded only for explicit detail/export flows. */ import fs from "fs"; @@ -12,7 +10,7 @@ import path from "path"; import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts"; import { getDbInstance } from "../db/core"; import { getRequestDetailLogByCallLogId } from "../db/detailedLogs"; -import { shouldPersistToDisk, CALL_LOGS_DIR } from "./migrations"; +import { shouldPersistToDisk } from "./migrations"; import { getLoggedInputTokens, getLoggedOutputTokens, @@ -22,53 +20,72 @@ import { } from "./tokenAccounting"; import { isNoLog } from "../compliance"; import { sanitizePII } from "../piiSanitizer"; -import { - protectPayloadForLog, - parseStoredPayload, - serializePayloadForStorage, -} from "../logPayloads"; -import { getCallLogMaxEntries, getCallLogRetentionDays } from "../logEnv"; +import { protectPayloadForLog, parseStoredPayload } from "../logPayloads"; +import { getCallLogMaxEntries, getCallLogRetentionDays, getCallLogsTableMaxRows } from "../logEnv"; import { pickMaskedDisplayValue } from "@/shared/utils/maskEmail"; +import { + CALL_LOGS_DIR, + cleanupEmptyCallLogDirs, + deleteCallArtifact, + listCallLogArtifactFiles, + readCallArtifact, + writeCallArtifact, + type CallLogArtifact, + type CallLogDetailState, +} from "./callLogArtifacts"; type JsonRecord = Record; -type CallLogArtifact = { - schemaVersion: 3; - summary: { - id: string; - timestamp: string; - method: string; - path: string; - status: number; - model: string; - requestedModel: string | null; - provider: string; - account: string; - connectionId: string | null; - duration: number; - tokens: { - in: number; - out: number; - cacheRead: number | null; - cacheWrite: number | null; - reasoning: number | null; - }; - requestType: string | null; - sourceFormat: string | null; - targetFormat: string | null; - apiKeyId: string | null; - apiKeyName: string | null; - comboName: string | null; - comboStepId: string | null; - comboExecutionKey: string | null; - }; - requestBody: unknown; - responseBody: unknown; - error: unknown; - pipeline?: RequestPipelinePayloads; +type CallLogSummaryRow = { + id: string; + timestamp: string | null; + method: string | null; + path: string | null; + status: number | null; + model: string | null; + requested_model: string | null; + provider: string | null; + account: string | null; + connection_id: string | null; + duration: number | null; + tokens_in: number | null; + tokens_out: number | null; + tokens_cache_read: number | null; + tokens_cache_creation: number | null; + tokens_reasoning: number | null; + cache_source: string | null; + request_type: string | null; + source_format: string | null; + target_format: string | null; + api_key_id: string | null; + api_key_name: string | null; + combo_name: string | null; + combo_step_id: string | null; + combo_execution_key: string | null; + error_summary: string | null; + detail_state: string | null; + artifact_relpath: string | null; + artifact_size_bytes: number | null; + artifact_sha256: string | null; + has_request_body: number | null; + has_response_body: number | null; + has_pipeline_details: number | null; + request_summary: string | null; + provider_node_prefix?: string | null; }; -const CALL_LOG_INLINE_BODY_LIMIT = 256 * 1024; +type LegacyInlineRow = { + request_body: string | null; + response_body: string | null; + error: string | null; +}; + +type DeleteResult = { + deletedRows: number; + deletedArtifacts: number; +}; + +let logIdCounter = 0; function asRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; @@ -87,9 +104,29 @@ function toStringOrNull(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } -function hasTruncatedFlag(value: unknown): boolean { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - return (value as Record)._truncated === true; +function truncateText(value: string, maxLength: number) { + return value.length > maxLength ? value.slice(0, maxLength) : value; +} + +function parseInlineError(value: unknown): unknown { + if (typeof value !== "string" || value.trim().length === 0) return null; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function normalizeDetailState(value: unknown): CallLogDetailState { + if ( + value === "ready" || + value === "missing" || + value === "corrupt" || + value === "legacy-inline" + ) { + return value; + } + return "none"; } function sanitizeErrorForLog(error: unknown): unknown { @@ -105,14 +142,18 @@ function sanitizeErrorForLog(error: unknown): unknown { return protectPayloadForLog(error); } -function toStoredErrorString(error: unknown): string | null { +function toStoredErrorSummary(error: unknown): string | null { const sanitized = sanitizeErrorForLog(error); if (sanitized === null || sanitized === undefined) return null; - if (typeof sanitized === "string") return sanitized; + + if (typeof sanitized === "string") { + return truncateText(sanitized, 4000); + } + try { - return JSON.stringify(sanitized); + return truncateText(JSON.stringify(sanitized), 4000); } catch { - return String(sanitized); + return truncateText(String(sanitized), 4000); } } @@ -121,9 +162,7 @@ function protectPipelinePayloads(payloads: unknown): RequestPipelinePayloads | n const protectedPayloads: RequestPipelinePayloads = {}; for (const [key, value] of Object.entries(payloads as JsonRecord)) { - if (value === null || value === undefined) { - continue; - } + if (value === null || value === undefined) continue; if (key === "streamChunks" && value && typeof value === "object") { const chunks = value as Record; @@ -146,7 +185,28 @@ function protectPipelinePayloads(payloads: unknown): RequestPipelinePayloads | n return Object.keys(protectedPayloads).length > 0 ? protectedPayloads : null; } -let logIdCounter = 0; +function buildRequestSummary(requestType: string | null, requestBody: unknown): string | null { + if (requestType !== "search") return null; + + const body = asRecord(requestBody); + if (Object.keys(body).length === 0) return null; + + const summary: JsonRecord = {}; + if (typeof body.query === "string" && body.query.trim().length > 0) { + summary.query = sanitizePII(body.query).text; + } + + const filters = Object.fromEntries( + Object.entries(body).filter(([key]) => key !== "query" && key !== "provider") + ); + if (Object.keys(filters).length > 0) { + summary.filters = filters; + } + + if (Object.keys(summary).length === 0) return null; + return JSON.stringify(summary); +} + function generateLogId() { logIdCounter++; return `${Date.now()}-${logIdCounter}`; @@ -164,7 +224,10 @@ async function resolveAccountName(connectionId: string | null | undefined) { const connections = await getProviderConnections(); const conn = connections.find((item) => item.id === connectionId); if (conn) { - account = pickMaskedDisplayValue([conn.name, conn.email], account); + account = pickMaskedDisplayValue( + [toStringOrNull(conn.name), toStringOrNull(conn.email)], + account + ); } } catch { // Best-effort lookup only. @@ -173,15 +236,38 @@ async function resolveAccountName(connectionId: string | null | undefined) { return account; } -function buildArtifactRelativePath(timestamp: string, id: string) { - const parsed = new Date(timestamp); - const safeTimestamp = ( - Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString() - ).replace(/[:]/g, "-"); - const dateFolder = safeTimestamp.slice(0, 10); - return path.posix.join(dateFolder, `${safeTimestamp}_${id}.json`); +async function resolveProviderPrefix(providerId: string): Promise { + if (!providerId) return null; + try { + const { getProviderNodeById } = await import("@/lib/localDb"); + const node = await getProviderNodeById(providerId); + if (node && typeof node.prefix === "string" && node.prefix.trim().length > 0) { + return node.prefix.trim(); + } + } catch { + // Best-effort lookup only. + } + return null; } +function isCompatibleProviderId(providerId: string | null): boolean { + if (!providerId) return false; + return ( + providerId.startsWith("openai-compatible-") || providerId.startsWith("anthropic-compatible-") + ); +} + +function applyNodePrefix( + requestedModel: string | null, + provider: string | null, + nodePrefix: string | null +): string | null { + if (!requestedModel || !provider || !nodePrefix) return requestedModel; + if (requestedModel.startsWith(provider + "/")) { + return nodePrefix + "/" + requestedModel.slice(provider.length + 1); + } + return requestedModel; +} function buildArtifact( logEntry: { id: string; @@ -215,7 +301,7 @@ function buildArtifact( pipelinePayloads: RequestPipelinePayloads | null ): CallLogArtifact { return { - schemaVersion: 3, + schemaVersion: 4, summary: { id: logEntry.id, timestamp: logEntry.timestamp, @@ -251,34 +337,11 @@ function buildArtifact( }; } -function writeCallArtifact(artifact: CallLogArtifact): string | null { - if (!CALL_LOGS_DIR) return null; - - const relPath = buildArtifactRelativePath(artifact.summary.timestamp, artifact.summary.id); - const absPath = path.join(CALL_LOGS_DIR, relPath); - - try { - fs.mkdirSync(path.dirname(absPath), { recursive: true }); - fs.writeFileSync(absPath, JSON.stringify(artifact, null, 2)); - rotateCallLogs(); - return relPath; - } catch (error) { - console.error("[callLogs] Failed to write request artifact:", (error as Error).message); - return null; - } -} - -function readArtifactFromDisk(relativePath: string | null) { - if (!CALL_LOGS_DIR || !relativePath) return null; - - try { - const absPath = path.join(CALL_LOGS_DIR, relativePath); - if (!fs.existsSync(absPath)) return null; - return JSON.parse(fs.readFileSync(absPath, "utf8")) as CallLogArtifact; - } catch (error) { - console.error("[callLogs] Failed to read request artifact:", (error as Error).message); - return null; - } +function hasTable(tableName: string): boolean { + const db = getDbInstance(); + return Boolean( + db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName) + ); } function readLegacyLogFromDisk(entry: { @@ -324,69 +387,215 @@ function readLegacyLogFromDisk(entry: { return null; } -function cleanupEmptyCallLogDirs() { - if (!CALL_LOGS_DIR || !fs.existsSync(CALL_LOGS_DIR)) return; +function clearArtifactReference(relativePath: string, nextState: CallLogDetailState) { + const db = getDbInstance(); + db.prepare( + ` + UPDATE call_logs + SET detail_state = ?, + artifact_relpath = NULL, + artifact_size_bytes = NULL, + artifact_sha256 = NULL + WHERE artifact_relpath = ? + ` + ).run(nextState, relativePath); +} + +function listReferencedArtifacts() { + const db = getDbInstance(); + const rows = db + .prepare("SELECT artifact_relpath FROM call_logs WHERE artifact_relpath IS NOT NULL") + .all() as Array<{ artifact_relpath: string | null }>; + + return new Set( + rows.map((row) => row.artifact_relpath).filter((value): value is string => Boolean(value)) + ); +} + +function deleteCallLogRowsByIds(ids: string[]): DeleteResult { + if (ids.length === 0) { + return { deletedRows: 0, deletedArtifacts: 0 }; + } + + const db = getDbInstance(); + const placeholders = ids.map(() => "?").join(", "); + const rows = db + .prepare(`SELECT artifact_relpath FROM call_logs WHERE id IN (${placeholders})`) + .all(...ids) as Array<{ artifact_relpath: string | null }>; + + const result = db.prepare(`DELETE FROM call_logs WHERE id IN (${placeholders})`).run(...ids); + let deletedArtifacts = 0; + for (const row of rows) { + if (deleteCallArtifact(row.artifact_relpath)) { + deletedArtifacts++; + } + } + cleanupEmptyCallLogDirs(); + + return { + deletedRows: result.changes, + deletedArtifacts, + }; +} + +export function cleanupOrphanCallLogFiles(baseDir = CALL_LOGS_DIR) { + if (!baseDir || !fs.existsSync(baseDir)) return 0; try { - for (const entry of fs.readdirSync(CALL_LOGS_DIR)) { - const entryPath = path.join(CALL_LOGS_DIR, entry); - const stat = fs.statSync(entryPath); - if (!stat.isDirectory()) continue; - if (fs.readdirSync(entryPath).length === 0) { - fs.rmSync(entryPath, { recursive: true, force: true }); + const referenced = listReferencedArtifacts(); + let deleted = 0; + for (const file of listCallLogArtifactFiles(baseDir)) { + if (referenced.has(file.relativePath)) continue; + if (deleteCallArtifact(file.relativePath)) { + deleted++; } } - } catch { - // Best effort only. + cleanupEmptyCallLogDirs(baseDir); + return deleted; + } catch (error) { + console.error("[callLogs] Failed to prune orphan request artifacts:", (error as Error).message); + return 0; } } export function cleanupOverflowCallLogFiles(baseDir = CALL_LOGS_DIR, maxEntries?: number) { - if (!baseDir || !fs.existsSync(baseDir)) return; + if (!baseDir || !fs.existsSync(baseDir)) return 0; const limit = maxEntries ?? getCallLogMaxEntries(); - if (!Number.isInteger(limit) || limit < 1) return; + if (!Number.isInteger(limit) || limit < 1) return 0; try { - const files = fs - .readdirSync(baseDir) - .flatMap((entry) => { - const entryPath = path.join(baseDir, entry); - try { - const stat = fs.statSync(entryPath); - if (!stat.isDirectory()) return []; - - return fs - .readdirSync(entryPath) - .filter((file) => file.endsWith(".json")) - .map((file) => { - const filePath = path.join(entryPath, file); - const fileStat = fs.statSync(filePath); - return { filePath, mtimeMs: fileStat.mtimeMs }; - }); - } catch { - return []; - } - }) - .sort((a, b) => b.mtimeMs - a.mtimeMs); - + let deleted = 0; + const files = listCallLogArtifactFiles(baseDir); for (const file of files.slice(limit)) { - try { - fs.rmSync(file.filePath, { force: true }); - } catch { - // Best effort only. + if (deleteCallArtifact(file.relativePath)) { + clearArtifactReference(file.relativePath, "missing"); + deleted++; } } - - cleanupEmptyCallLogDirs(); + cleanupEmptyCallLogDirs(baseDir); + return deleted; } catch (error) { console.error( "[callLogs] Failed to prune overflow request artifacts:", (error as Error).message ); + return 0; } } +export function deleteCallLogsBefore(cutoff: string): DeleteResult { + const db = getDbInstance(); + const ids = db + .prepare("SELECT id FROM call_logs WHERE timestamp < ? ORDER BY timestamp ASC") + .all(cutoff) + .map((row) => String((row as { id: string }).id)); + + return deleteCallLogRowsByIds(ids); +} + +export function trimCallLogsToMaxRows(maxRows = getCallLogsTableMaxRows()) { + if (!Number.isInteger(maxRows) || maxRows < 1) { + return { deletedRows: 0, deletedArtifacts: 0 }; + } + + const db = getDbInstance(); + let deletedRows = 0; + let deletedArtifacts = 0; + const batchSize = 5000; + + while (true) { + const currentCount = db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get() as { + cnt: number; + }; + if (currentCount.cnt <= maxRows) break; + + const toDelete = Math.min(currentCount.cnt - maxRows, batchSize); + const ids = db + .prepare("SELECT id FROM call_logs ORDER BY timestamp ASC LIMIT ?") + .all(toDelete) + .map((row) => String((row as { id: string }).id)); + const result = deleteCallLogRowsByIds(ids); + deletedRows += result.deletedRows; + deletedArtifacts += result.deletedArtifacts; + if (result.deletedRows === 0) break; + } + + return { deletedRows, deletedArtifacts }; +} + +function mapSummaryRow(row: CallLogSummaryRow) { + const detailState = normalizeDetailState(row.detail_state); + const provider = row.provider; + const nodePrefix = row.provider_node_prefix ?? null; + return { + id: row.id, + timestamp: row.timestamp, + method: row.method, + path: row.path, + status: toNumber(row.status), + model: row.model, + requestedModel: applyNodePrefix(row.requested_model, provider, nodePrefix), + provider, + account: row.account, + connectionId: row.connection_id, + duration: toNumber(row.duration), + tokens: { + in: toNumber(row.tokens_in), + out: toNumber(row.tokens_out), + cacheRead: row.tokens_cache_read != null ? toNumber(row.tokens_cache_read) : null, + cacheWrite: row.tokens_cache_creation != null ? toNumber(row.tokens_cache_creation) : null, + reasoning: row.tokens_reasoning != null ? toNumber(row.tokens_reasoning) : null, + }, + cacheSource: row.cache_source || "upstream", + requestType: row.request_type, + sourceFormat: row.source_format, + targetFormat: row.target_format, + apiKeyId: row.api_key_id, + apiKeyName: row.api_key_name, + comboName: row.combo_name, + comboStepId: row.combo_step_id, + comboExecutionKey: row.combo_execution_key, + error: row.error_summary, + detailState, + artifactRelPath: row.artifact_relpath, + artifactSizeBytes: row.artifact_size_bytes, + artifactSha256: row.artifact_sha256, + requestSummary: row.request_summary ? parseStoredPayload(row.request_summary) : null, + hasRequestBody: toNumber(row.has_request_body) === 1, + hasResponseBody: toNumber(row.has_response_body) === 1, + hasPipelineDetails: toNumber(row.has_pipeline_details) === 1, + }; +} + +function buildLegacyPipelinePayloads(id: string) { + const detailed = getRequestDetailLogByCallLogId(id); + if (!detailed) return null; + + return { + clientRequest: detailed.client_request ?? null, + providerRequest: detailed.translated_request ?? null, + providerResponse: detailed.provider_response ?? null, + clientResponse: detailed.client_response ?? null, + }; +} + +function getLegacyInlineDetail(id: string) { + if (!hasTable("call_logs_v1_legacy")) return null; + + const db = getDbInstance(); + const row = db + .prepare("SELECT request_body, response_body, error FROM call_logs_v1_legacy WHERE id = ?") + .get(id) as LegacyInlineRow | undefined; + if (!row) return null; + + return { + requestBody: parseStoredPayload(row.request_body), + responseBody: parseStoredPayload(row.response_body), + error: parseInlineError(row.error), + }; +} + export async function saveCallLog(entry: any) { if (!shouldPersistToDisk) return; @@ -402,7 +611,13 @@ export async function saveCallLog(entry: any) { const protectedError = sanitizeErrorForLog(entry.error); const account = await resolveAccountName(entry.connectionId || null); - + const rawProvider: string = entry.provider || "-"; + const rawRequestedModel: string | null = entry.requestedModel || null; + let resolvedRequestedModel = rawRequestedModel; + if (rawRequestedModel && isCompatibleProviderId(rawProvider)) { + const nodePrefix = await resolveProviderPrefix(rawProvider); + resolvedRequestedModel = applyNodePrefix(rawRequestedModel, rawProvider, nodePrefix); + } const logEntry = { id: typeof entry.id === "string" && entry.id.length > 0 ? entry.id : generateLogId(), timestamp: typeof entry.timestamp === "string" ? entry.timestamp : new Date().toISOString(), @@ -410,8 +625,8 @@ export async function saveCallLog(entry: any) { path: entry.path || "/v1/chat/completions", status: entry.status || 0, model: entry.model || "-", - requestedModel: entry.requestedModel || null, - provider: entry.provider || "-", + requestedModel: resolvedRequestedModel, + provider: rawProvider, account, connectionId: entry.connectionId || null, duration: entry.duration || 0, @@ -420,6 +635,7 @@ export async function saveCallLog(entry: any) { tokensCacheRead: getPromptCacheReadTokensOrNull(entry.tokens), tokensCacheCreation: getPromptCacheCreationTokensOrNull(entry.tokens), tokensReasoning: getReasoningTokensOrNull(entry.tokens), + cacheSource: entry.cacheSource === "semantic" ? "semantic" : "upstream", requestType: entry.requestType || null, sourceFormat: entry.sourceFormat || null, targetFormat: entry.targetFormat || null, @@ -429,11 +645,42 @@ export async function saveCallLog(entry: any) { comboStepId: toStringOrNull(entry.comboStepId), comboExecutionKey: toStringOrNull(entry.comboExecutionKey) || toStringOrNull(entry.comboStepId), - requestBody: serializePayloadForStorage(protectedRequestBody, CALL_LOG_INLINE_BODY_LIMIT), - responseBody: serializePayloadForStorage(protectedResponseBody, CALL_LOG_INLINE_BODY_LIMIT), - error: toStoredErrorString(protectedError), }; + const requestSummary = noLogEnabled + ? null + : buildRequestSummary(logEntry.requestType, protectedRequestBody); + const detailExpected = + !noLogEnabled && + (protectedRequestBody !== null || + protectedResponseBody !== null || + protectedError !== null || + protectedPipelinePayloads !== null); + + let detailState: CallLogDetailState = "none"; + let artifactRelPath: string | null = null; + let artifactSizeBytes: number | null = null; + let artifactSha256: string | null = null; + + if (detailExpected) { + const artifact = buildArtifact( + logEntry, + protectedRequestBody, + protectedResponseBody, + protectedError, + protectedPipelinePayloads + ); + const artifactResult = writeCallArtifact(artifact); + if (artifactResult) { + detailState = "ready"; + artifactRelPath = artifactResult.relPath; + artifactSizeBytes = artifactResult.sizeBytes; + artifactSha256 = artifactResult.sha256; + } else { + detailState = "missing"; + } + } + const db = getDbInstance(); db.prepare( ` @@ -441,42 +688,35 @@ export async function saveCallLog(entry: any) { id, timestamp, method, path, status, model, requested_model, provider, account, connection_id, duration, tokens_in, tokens_out, tokens_cache_read, tokens_cache_creation, tokens_reasoning, - request_type, source_format, - target_format, api_key_id, api_key_name, combo_name, combo_step_id, - combo_execution_key, request_body, response_body, error, artifact_relpath, - has_pipeline_details + cache_source, request_type, source_format, target_format, api_key_id, api_key_name, + combo_name, combo_step_id, combo_execution_key, error_summary, detail_state, + artifact_relpath, artifact_size_bytes, artifact_sha256, + has_request_body, has_response_body, has_pipeline_details, request_summary ) VALUES ( @id, @timestamp, @method, @path, @status, @model, @requestedModel, @provider, @account, @connectionId, @duration, @tokensIn, @tokensOut, @tokensCacheRead, @tokensCacheCreation, @tokensReasoning, - @requestType, @sourceFormat, - @targetFormat, @apiKeyId, @apiKeyName, @comboName, @comboStepId, - @comboExecutionKey, @requestBody, @responseBody, @error, NULL, 0 + @cacheSource, @requestType, @sourceFormat, @targetFormat, @apiKeyId, @apiKeyName, + @comboName, @comboStepId, @comboExecutionKey, @errorSummary, @detailState, + @artifactRelPath, @artifactSizeBytes, @artifactSha256, + @hasRequestBody, @hasResponseBody, @hasPipelineDetails, @requestSummary ) ` - ).run(logEntry); + ).run({ + ...logEntry, + errorSummary: toStoredErrorSummary(protectedError), + detailState, + artifactRelPath, + artifactSizeBytes, + artifactSha256, + hasRequestBody: protectedRequestBody !== null ? 1 : 0, + hasResponseBody: protectedResponseBody !== null ? 1 : 0, + hasPipelineDetails: protectedPipelinePayloads ? 1 : 0, + requestSummary, + }); - if (!noLogEnabled) { - const artifact = buildArtifact( - logEntry, - protectedRequestBody, - protectedResponseBody, - protectedError, - protectedPipelinePayloads - ); - const artifactRelPath = writeCallArtifact(artifact); - - if (artifactRelPath) { - db.prepare( - ` - UPDATE call_logs - SET artifact_relpath = ?, has_pipeline_details = ? - WHERE id = ? - ` - ).run(artifactRelPath, protectedPipelinePayloads ? 1 : 0, logEntry.id); - } - } + rotateCallLogs(); } catch (error) { console.error("[callLogs] Failed to save call log:", (error as Error).message); } @@ -486,18 +726,13 @@ export function rotateCallLogs() { if (!CALL_LOGS_DIR || !fs.existsSync(CALL_LOGS_DIR)) return; try { - const entries = fs.readdirSync(CALL_LOGS_DIR); - const now = Date.now(); const retentionMs = getCallLogRetentionDays() * 24 * 60 * 60 * 1000; + const cutoff = new Date(Date.now() - retentionMs).toISOString(); - for (const entry of entries) { - const entryPath = path.join(CALL_LOGS_DIR, entry); - const stat = fs.statSync(entryPath); - if (stat.isDirectory() && now - stat.mtimeMs > retentionMs) { - fs.rmSync(entryPath, { recursive: true, force: true }); - } - } + deleteCallLogsBefore(cutoff); + trimCallLogsToMaxRows(getCallLogsTableMaxRows()); cleanupOverflowCallLogFiles(CALL_LOGS_DIR, getCallLogMaxEntries()); + cleanupOrphanCallLogFiles(CALL_LOGS_DIR); } catch (error) { console.error("[callLogs] Failed to rotate request artifacts:", (error as Error).message); } @@ -513,50 +748,56 @@ if (shouldPersistToDisk) { export async function getCallLogs(filter: any = {}) { const db = getDbInstance(); - let sql = "SELECT * FROM call_logs"; + let sql = ` + SELECT cl.*, + pn.prefix AS provider_node_prefix + FROM call_logs cl + LEFT JOIN provider_nodes pn ON pn.id = cl.provider + `; const conditions: string[] = []; const params: Record = {}; if (filter.status) { if (filter.status === "error") { - conditions.push("(status >= 400 OR error IS NOT NULL)"); + conditions.push("(cl.status >= 400 OR cl.error_summary IS NOT NULL)"); } else if (filter.status === "ok") { - conditions.push("status >= 200 AND status < 300"); + conditions.push("cl.status >= 200 AND cl.status < 300"); } else { const statusCode = parseInt(filter.status, 10); if (!Number.isNaN(statusCode)) { - conditions.push("status = @statusCode"); + conditions.push("cl.status = @statusCode"); params.statusCode = statusCode; } } } if (filter.model) { - conditions.push("(model LIKE @modelQ OR requested_model LIKE @modelQ)"); + conditions.push("(cl.model LIKE @modelQ OR cl.requested_model LIKE @modelQ)"); params.modelQ = `%${filter.model}%`; } if (filter.provider) { - conditions.push("provider LIKE @providerQ"); + conditions.push("cl.provider LIKE @providerQ"); params.providerQ = `%${filter.provider}%`; } if (filter.account) { - conditions.push("account LIKE @accountQ"); + conditions.push("cl.account LIKE @accountQ"); params.accountQ = `%${filter.account}%`; } if (filter.apiKey) { - conditions.push("(api_key_name LIKE @apiKeyQ OR api_key_id LIKE @apiKeyQ)"); + conditions.push("(cl.api_key_name LIKE @apiKeyQ OR cl.api_key_id LIKE @apiKeyQ)"); params.apiKeyQ = `%${filter.apiKey}%`; } if (filter.combo) { - conditions.push("combo_name IS NOT NULL"); + conditions.push("cl.combo_name IS NOT NULL"); } if (filter.search) { conditions.push(`( - model LIKE @searchQ OR path LIKE @searchQ OR account LIKE @searchQ OR - requested_model LIKE @searchQ OR provider LIKE @searchQ OR - api_key_name LIKE @searchQ OR api_key_id LIKE @searchQ OR - combo_name LIKE @searchQ OR CAST(status AS TEXT) LIKE @searchQ - OR combo_step_id LIKE @searchQ OR combo_execution_key LIKE @searchQ + cl.model LIKE @searchQ OR cl.path LIKE @searchQ OR cl.account LIKE @searchQ OR + cl.requested_model LIKE @searchQ OR cl.provider LIKE @searchQ OR + cl.api_key_name LIKE @searchQ OR cl.api_key_id LIKE @searchQ OR + cl.combo_name LIKE @searchQ OR CAST(cl.status AS TEXT) LIKE @searchQ + OR cl.combo_step_id LIKE @searchQ OR cl.combo_execution_key LIKE @searchQ + OR cl.error_summary LIKE @searchQ )`); params.searchQ = `%${filter.search}%`; } @@ -566,135 +807,108 @@ export async function getCallLogs(filter: any = {}) { } const limit = filter.limit || 200; - sql += ` ORDER BY timestamp DESC LIMIT ${limit}`; + sql += ` ORDER BY cl.timestamp DESC LIMIT ${limit}`; - const rows = db.prepare(sql).all(params); - - return rows.map((row) => { - const l = asRecord(row); - return { - id: toStringOrNull(l.id), - timestamp: toStringOrNull(l.timestamp), - method: toStringOrNull(l.method), - path: toStringOrNull(l.path), - status: toNumber(l.status), - model: toStringOrNull(l.model), - requestedModel: toStringOrNull(l.requested_model), - provider: toStringOrNull(l.provider), - account: toStringOrNull(l.account), - duration: toNumber(l.duration), - tokens: { - in: toNumber(l.tokens_in), - out: toNumber(l.tokens_out), - cacheRead: l.tokens_cache_read != null ? toNumber(l.tokens_cache_read) : null, - cacheWrite: l.tokens_cache_creation != null ? toNumber(l.tokens_cache_creation) : null, - reasoning: l.tokens_reasoning != null ? toNumber(l.tokens_reasoning) : null, - }, - sourceFormat: toStringOrNull(l.source_format), - targetFormat: toStringOrNull(l.target_format), - error: toStringOrNull(l.error), - comboName: toStringOrNull(l.combo_name), - comboStepId: toStringOrNull(l.combo_step_id), - comboExecutionKey: toStringOrNull(l.combo_execution_key), - apiKeyId: toStringOrNull(l.api_key_id), - apiKeyName: toStringOrNull(l.api_key_name), - hasRequestBody: typeof l.request_body === "string" && l.request_body.length > 0, - hasResponseBody: typeof l.response_body === "string" && l.response_body.length > 0, - hasPipelineDetails: toNumber(l.has_pipeline_details) === 1, - }; - }); -} - -function buildLegacyPipelinePayloads(id: string) { - const detailed = getRequestDetailLogByCallLogId(id); - if (!detailed) return null; - - return { - clientRequest: detailed.client_request ?? null, - providerRequest: detailed.translated_request ?? null, - providerResponse: detailed.provider_response ?? null, - clientResponse: detailed.client_response ?? null, - }; + const rows = db.prepare(sql).all(params) as CallLogSummaryRow[]; + return rows.map(mapSummaryRow); } export async function getCallLogById(id: string) { const db = getDbInstance(); - const row = db.prepare("SELECT * FROM call_logs WHERE id = ?").get(id); + const row = db + .prepare( + `SELECT cl.*, + pn.prefix AS provider_node_prefix + FROM call_logs cl + LEFT JOIN provider_nodes pn ON pn.id = cl.provider + WHERE cl.id = ?` + ) + .get(id) as + | CallLogSummaryRow + | undefined; if (!row) return null; - const entryRow = asRecord(row); - const artifactRelPath = toStringOrNull(entryRow.artifact_relpath); - const entry = { - id: toStringOrNull(entryRow.id), - timestamp: toStringOrNull(entryRow.timestamp), - method: toStringOrNull(entryRow.method), - path: toStringOrNull(entryRow.path), - status: toNumber(entryRow.status), - model: toStringOrNull(entryRow.model), - requestedModel: toStringOrNull(entryRow.requested_model), - provider: toStringOrNull(entryRow.provider), - account: toStringOrNull(entryRow.account), - connectionId: toStringOrNull(entryRow.connection_id), - duration: toNumber(entryRow.duration), - tokens: { - in: toNumber(entryRow.tokens_in), - out: toNumber(entryRow.tokens_out), - cacheRead: entryRow.tokens_cache_read != null ? toNumber(entryRow.tokens_cache_read) : null, - cacheWrite: - entryRow.tokens_cache_creation != null ? toNumber(entryRow.tokens_cache_creation) : null, - reasoning: entryRow.tokens_reasoning != null ? toNumber(entryRow.tokens_reasoning) : null, - }, - sourceFormat: toStringOrNull(entryRow.source_format), - targetFormat: toStringOrNull(entryRow.target_format), - apiKeyId: toStringOrNull(entryRow.api_key_id), - apiKeyName: toStringOrNull(entryRow.api_key_name), - comboName: toStringOrNull(entryRow.combo_name), - comboStepId: toStringOrNull(entryRow.combo_step_id), - comboExecutionKey: toStringOrNull(entryRow.combo_execution_key), - requestBody: parseStoredPayload(entryRow.request_body), - responseBody: parseStoredPayload(entryRow.response_body), - error: toStringOrNull(entryRow.error), - artifactRelPath, - hasPipelineDetails: toNumber(entryRow.has_pipeline_details) === 1, - }; + const entry = mapSummaryRow(row); + let detailState = entry.detailState; + let artifactRelPath = entry.artifactRelPath; - const artifact = readArtifactFromDisk(artifactRelPath); - if (artifact) { - return { - ...entry, - requestBody: artifact.requestBody ?? entry.requestBody, - responseBody: artifact.responseBody ?? entry.responseBody, - error: artifact.error ?? entry.error, - pipelinePayloads: artifact.pipeline ?? null, - hasPipelineDetails: Boolean(artifact.pipeline) || entry.hasPipelineDetails, - }; + if (artifactRelPath) { + const artifactResult = readCallArtifact(artifactRelPath); + if (artifactResult.state === "ready" && artifactResult.artifact) { + return { + ...entry, + detailState: "ready" as const, + requestBody: artifactResult.artifact.requestBody ?? null, + responseBody: artifactResult.artifact.responseBody ?? null, + error: artifactResult.artifact.error ?? entry.error, + pipelinePayloads: artifactResult.artifact.pipeline ?? buildLegacyPipelinePayloads(id), + hasPipelineDetails: Boolean(artifactResult.artifact.pipeline) || entry.hasPipelineDetails, + }; + } + + detailState = artifactResult.state; + if (artifactResult.state === "missing") { + clearArtifactReference(artifactRelPath, "missing"); + artifactRelPath = null; + } else { + db.prepare("UPDATE call_logs SET detail_state = ? WHERE id = ?").run("corrupt", id); + } } - const needsLegacyDisk = - hasTruncatedFlag(entry.requestBody) || hasTruncatedFlag(entry.responseBody) || !artifactRelPath; - if (needsLegacyDisk) { - const legacyEntry = readLegacyLogFromDisk(entry); - if (legacyEntry) { + if (detailState === "legacy-inline") { + const legacyInline = getLegacyInlineDetail(id); + if (legacyInline) { const legacyPipeline = buildLegacyPipelinePayloads(id); return { ...entry, - requestBody: legacyEntry.requestBody ?? entry.requestBody, - responseBody: legacyEntry.responseBody ?? entry.responseBody, - error: legacyEntry.error ?? entry.error, + detailState, + artifactRelPath, + ...legacyInline, pipelinePayloads: legacyPipeline, - hasPipelineDetails: Boolean(legacyPipeline), + hasPipelineDetails: Boolean(legacyPipeline) || entry.hasPipelineDetails, }; } } - const legacyPipeline = buildLegacyPipelinePayloads(id); - if (legacyPipeline) { + const legacyDisk = readLegacyLogFromDisk(entry); + if (legacyDisk) { + const legacyPipeline = buildLegacyPipelinePayloads(id); return { ...entry, + detailState, + artifactRelPath, + requestBody: legacyDisk.requestBody ?? null, + responseBody: legacyDisk.responseBody ?? null, + error: legacyDisk.error ?? entry.error, pipelinePayloads: legacyPipeline, - hasPipelineDetails: true, + hasPipelineDetails: Boolean(legacyPipeline) || entry.hasPipelineDetails, }; } - return entry; + const legacyPipeline = buildLegacyPipelinePayloads(id); + return { + ...entry, + detailState, + artifactRelPath, + requestBody: null, + responseBody: null, + error: entry.error, + pipelinePayloads: legacyPipeline, + hasPipelineDetails: Boolean(legacyPipeline) || entry.hasPipelineDetails, + }; +} + +export async function exportCallLogsSince(since: string) { + const db = getDbInstance(); + const ids = db + .prepare("SELECT id FROM call_logs WHERE timestamp >= ? ORDER BY timestamp DESC") + .all(since) + .map((row) => String((row as { id: string }).id)); + + const logs: unknown[] = []; + for (const id of ids) { + const log = await getCallLogById(id); + if (log) logs.push(log); + } + return logs; } diff --git a/src/lib/usage/fetcher.ts b/src/lib/usage/fetcher.ts index b3723cd817..bf8c1c41bc 100644 --- a/src/lib/usage/fetcher.ts +++ b/src/lib/usage/fetcher.ts @@ -2,7 +2,9 @@ * Usage Fetcher - Get usage data from provider APIs */ -import { GITHUB_CONFIG, GEMINI_CONFIG, ANTIGRAVITY_CONFIG } from "@/lib/oauth/constants/oauth"; +import { GITHUB_CONFIG, GEMINI_CONFIG } from "@/lib/oauth/constants/oauth"; +import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts"; +import { getAntigravityFetchAvailableModelsUrls } from "@omniroute/open-sse/config/antigravityUpstream.ts"; import { getAntigravityRemainingCredits } from "@omniroute/open-sse/executors/antigravity.ts"; /** @@ -151,28 +153,42 @@ async function getGeminiUsage(accessToken) { * Credit balance (GOOGLE_ONE_AI) is read from the executor's in-memory cache, * which is populated automatically after each successful credit-injected SSE call. */ -async function getAntigravityUsage(accessToken: string, providerSpecificData: Record = {}) { +async function getAntigravityUsage( + accessToken: string, + providerSpecificData: Record = {} +) { try { // Derive accountId (same key used in AntigravityExecutor.execute) const accountId: string = - (providerSpecificData?.email as string) || - (providerSpecificData?.sub as string) || - "unknown"; + (providerSpecificData?.email as string) || (providerSpecificData?.sub as string) || "unknown"; // Read cached credit balance from executor module (populated from SSE remainingCredits) const creditBalance = getAntigravityRemainingCredits(accountId); // fetchAvailableModels — resolves project from token, no projectId needed - const res = await fetch(ANTIGRAVITY_CONFIG.fetchAvailableModelsEndpoint, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": "antigravity/1.11.3 Darwin/arm64", - }, - body: JSON.stringify({}), - signal: AbortSignal.timeout(15_000), - }); + let res: Response | null = null; + let lastError: Error | null = null; + + for (const endpoint of getAntigravityFetchAvailableModelsUrls()) { + try { + res = await fetch(endpoint, { + method: "POST", + headers: getAntigravityHeaders("fetchAvailableModels", accessToken), + body: JSON.stringify({}), + signal: AbortSignal.timeout(15_000), + }); + + if (res.ok || res.status === 401 || res.status === 403) { + break; + } + } catch (error) { + lastError = error as Error; + } + } + + if (!res) { + throw lastError || new Error("Antigravity API unavailable"); + } if (!res.ok) { return { @@ -198,7 +214,10 @@ async function getAntigravityUsage(accessToken: string, providerSpecificData: Re // Walk quota-based models (those with remainingFraction in quotaInfo) let quotaModelsTotal = 0; let quotaModelsAvailable = 0; - const modelQuotas: Record = {}; + const modelQuotas: Record< + string, + { remaining: number; resetAt: string | null; limited: boolean } + > = {}; for (const [modelId, rawInfo] of Object.entries(models)) { const info = rawInfo as Record; @@ -206,7 +225,8 @@ async function getAntigravityUsage(accessToken: string, providerSpecificData: Re const quotaInfo = (info.quotaInfo as Record) ?? {}; if ("remainingFraction" in quotaInfo) { - const fraction = typeof quotaInfo.remainingFraction === "number" ? quotaInfo.remainingFraction : 1; + const fraction = + typeof quotaInfo.remainingFraction === "number" ? quotaInfo.remainingFraction : 1; const resetTime = typeof quotaInfo.resetTime === "string" ? quotaInfo.resetTime : null; modelQuotas[modelId] = { remaining: Math.round(fraction * 100), diff --git a/src/lib/usage/migrations.ts b/src/lib/usage/migrations.ts index 2cd0275aef..0780ff9381 100644 --- a/src/lib/usage/migrations.ts +++ b/src/lib/usage/migrations.ts @@ -12,6 +12,9 @@ import path from "path"; import { ZipFile } from "yazl"; import { getDbInstance, isCloud, isBuildPhase, DATA_DIR } from "../db/core"; import { getLegacyDotDataDir, isSamePath } from "../dataPaths"; +import { protectPayloadForLog } from "../logPayloads"; +import { sanitizePII } from "../piiSanitizer"; +import { writeCallArtifact, type CallLogArtifact } from "./callLogArtifacts"; export const shouldPersistToDisk = !isCloud && !isBuildPhase; @@ -41,6 +44,25 @@ type ArchiveTarget = { deleteAfterArchive: boolean; }; +function buildLegacyRequestSummary(requestType: unknown, requestBody: unknown) { + if (requestType !== "search" || !requestBody || typeof requestBody !== "object") return null; + + const record = requestBody as Record; + const summary: Record = {}; + if (typeof record.query === "string" && record.query.trim().length > 0) { + summary.query = sanitizePII(record.query).text; + } + + const filters = Object.fromEntries( + Object.entries(record).filter(([key]) => key !== "query" && key !== "provider") + ); + if (Object.keys(filters).length > 0) { + summary.filters = filters; + } + + return Object.keys(summary).length > 0 ? JSON.stringify(summary) : null; +} + function copyIfMissing(fromPath: string | null, toPath: string | null, label: string) { if (!fromPath || !toPath) return; if (!fs.existsSync(fromPath) || fs.existsSync(toPath)) return; @@ -297,25 +319,96 @@ export function migrateUsageJsonToSqlite() { console.log(`[usageDb] Migrating ${logs.length} call log entries from JSON → SQLite...`); const insert = db.prepare(` - INSERT OR IGNORE INTO call_logs (id, timestamp, method, path, status, model, provider, + INSERT OR IGNORE INTO call_logs (id, timestamp, method, path, status, model, requested_model, provider, account, connection_id, duration, tokens_in, tokens_out, source_format, target_format, - api_key_id, api_key_name, combo_name, request_body, response_body, error, - artifact_relpath, has_pipeline_details) - VALUES (@id, @timestamp, @method, @path, @status, @model, @provider, + api_key_id, api_key_name, combo_name, combo_step_id, combo_execution_key, error_summary, + detail_state, artifact_relpath, artifact_size_bytes, artifact_sha256, + has_request_body, has_response_body, has_pipeline_details, request_summary) + VALUES (@id, @timestamp, @method, @path, @status, @model, @requestedModel, @provider, @account, @connectionId, @duration, @tokensIn, @tokensOut, @sourceFormat, @targetFormat, - @apiKeyId, @apiKeyName, @comboName, @requestBody, @responseBody, @error, - NULL, 0) + @apiKeyId, @apiKeyName, @comboName, @comboStepId, @comboExecutionKey, @errorSummary, + @detailState, @artifactRelPath, @artifactSizeBytes, @artifactSha256, + @hasRequestBody, @hasResponseBody, @hasPipelineDetails, @requestSummary) `); const tx = db.transaction(() => { for (const log of logs) { + const id = log.id || `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const timestamp = log.timestamp || new Date().toISOString(); + const protectedRequestBody = log.requestBody + ? protectPayloadForLog(log.requestBody) + : null; + const protectedResponseBody = log.responseBody + ? protectPayloadForLog(log.responseBody) + : null; + const protectedError = + log.error && typeof log.error === "object" + ? protectPayloadForLog(log.error) + : log.error || null; + const detailExpected = + protectedRequestBody !== null || + protectedResponseBody !== null || + protectedError !== null; + + let detailState: "none" | "ready" | "missing" = "none"; + let artifactRelPath: string | null = null; + let artifactSizeBytes: number | null = null; + let artifactSha256: string | null = null; + + if (detailExpected) { + const artifact: CallLogArtifact = { + schemaVersion: 4, + summary: { + id, + timestamp, + method: log.method || "POST", + path: log.path || "/v1/chat/completions", + status: log.status || 0, + model: log.model || "-", + requestedModel: log.requestedModel || null, + provider: log.provider || "-", + account: log.account || "-", + connectionId: log.connectionId || null, + duration: log.duration || 0, + tokens: { + in: log.tokens?.in ?? 0, + out: log.tokens?.out ?? 0, + cacheRead: null, + cacheWrite: null, + reasoning: null, + }, + requestType: log.requestType || null, + sourceFormat: log.sourceFormat || null, + targetFormat: log.targetFormat || null, + apiKeyId: log.apiKeyId || null, + apiKeyName: log.apiKeyName || null, + comboName: log.comboName || null, + comboStepId: log.comboStepId || null, + comboExecutionKey: log.comboExecutionKey || null, + }, + requestBody: protectedRequestBody, + responseBody: protectedResponseBody, + error: protectedError, + }; + const artifactResult = writeCallArtifact(artifact); + if (artifactResult) { + detailState = "ready"; + artifactRelPath = artifactResult.relPath; + artifactSizeBytes = artifactResult.sizeBytes; + artifactSha256 = artifactResult.sha256; + } else { + detailState = "missing"; + } + } + insert.run({ - id: log.id || `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, - timestamp: log.timestamp || new Date().toISOString(), + id, + timestamp, method: log.method || "POST", path: log.path || null, status: log.status || 0, model: log.model || null, + requestedModel: log.requestedModel || null, provider: log.provider || null, account: log.account || null, connectionId: log.connectionId || null, @@ -327,9 +420,22 @@ export function migrateUsageJsonToSqlite() { apiKeyId: log.apiKeyId || null, apiKeyName: log.apiKeyName || null, comboName: log.comboName || null, - requestBody: log.requestBody ? JSON.stringify(log.requestBody) : null, - responseBody: log.responseBody ? JSON.stringify(log.responseBody) : null, - error: log.error || null, + comboStepId: log.comboStepId || null, + comboExecutionKey: log.comboExecutionKey || log.comboStepId || null, + errorSummary: + typeof protectedError === "string" + ? protectedError.slice(0, 4000) + : protectedError + ? JSON.stringify(protectedError).slice(0, 4000) + : null, + detailState, + artifactRelPath, + artifactSizeBytes, + artifactSha256, + hasRequestBody: protectedRequestBody ? 1 : 0, + hasResponseBody: protectedResponseBody ? 1 : 0, + hasPipelineDetails: 0, + requestSummary: buildLegacyRequestSummary(log.requestType, protectedRequestBody), }); } }); diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index c632ae4cab..8cc239e743 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -34,7 +34,7 @@ interface ProviderConnectionLike { isActive?: boolean; } -const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set(["glm"]); +const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set(["glm", "glmt"]); const DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES = 70; const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run"; diff --git a/src/lib/ws/handshake.ts b/src/lib/ws/handshake.ts new file mode 100644 index 0000000000..8fafc39b53 --- /dev/null +++ b/src/lib/ws/handshake.ts @@ -0,0 +1,129 @@ +import { jwtVerify } from "jose"; +import { getSettings } from "@/lib/localDb"; +import { validateApiKey } from "@/lib/db/apiKeys"; + +export const DEFAULT_WS_PATH = "/v1/ws"; +const WS_QUERY_TOKEN_KEYS = ["api_key", "token", "access_token"]; + +export type WsAuthType = "none" | "api_key" | "session"; + +export interface WsRuntimeConfig { + wsAuth: boolean; + wsPath: string; +} + +export interface WsHandshakeAuthResult extends WsRuntimeConfig { + authorized: boolean; + authenticated: boolean; + authType: WsAuthType; + hasCredential: boolean; +} + +function getCookieValue(cookieHeader: string | null, cookieName: string): string | null { + if (typeof cookieHeader !== "string" || cookieHeader.trim().length === 0) { + return null; + } + + for (const part of cookieHeader.split(";")) { + const [rawName, ...rest] = part.trim().split("="); + if (rawName === cookieName) { + const value = rest.join("=").trim(); + return value || null; + } + } + + return null; +} + +async function hasValidSessionCookie(request: Request): Promise { + const secretValue = process.env.JWT_SECRET; + if (typeof secretValue !== "string" || secretValue.trim().length === 0) { + return false; + } + + const token = getCookieValue(request.headers.get("cookie"), "auth_token"); + if (!token) return false; + + try { + await jwtVerify(token, new TextEncoder().encode(secretValue)); + return true; + } catch { + return false; + } +} + +export function extractWsTokenFromUrl(input: string | URL): string | null { + const url = input instanceof URL ? input : new URL(input); + for (const key of WS_QUERY_TOKEN_KEYS) { + const value = url.searchParams.get(key); + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + return null; +} + +export function extractWsTokenFromRequest(request: Request): string | null { + const authHeader = request.headers.get("authorization") || request.headers.get("Authorization"); + if (typeof authHeader === "string" && authHeader.trim().toLowerCase().startsWith("bearer ")) { + const token = authHeader.slice(7).trim(); + if (token) return token; + } + + return extractWsTokenFromUrl(request.url); +} + +export async function getWsRuntimeConfig(): Promise { + const settings = await getSettings().catch(() => ({})); + return { + wsAuth: settings.wsAuth === true, + wsPath: DEFAULT_WS_PATH, + }; +} + +export async function authorizeWebSocketHandshake( + request: Request +): Promise { + const config = await getWsRuntimeConfig(); + const token = extractWsTokenFromRequest(request); + const hasCredential = typeof token === "string" && token.length > 0; + const validApiKey = hasCredential ? await validateApiKey(token) : false; + + if (!config.wsAuth) { + return { + ...config, + authorized: true, + authenticated: validApiKey, + authType: validApiKey ? "api_key" : "none", + hasCredential, + }; + } + + if (validApiKey) { + return { + ...config, + authorized: true, + authenticated: true, + authType: "api_key", + hasCredential: true, + }; + } + + if (await hasValidSessionCookie(request)) { + return { + ...config, + authorized: true, + authenticated: true, + authType: "session", + hasCredential, + }; + } + + return { + ...config, + authorized: false, + authenticated: false, + authType: "none", + hasCredential, + }; +} diff --git a/src/proxy.ts b/src/proxy.ts index 6e3a17b04d..34fab99a1f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -3,28 +3,17 @@ import { jwtVerify, SignJWT } from "jose"; import { generateRequestId } from "./shared/utils/requestId"; import { checkBodySize, getBodySizeLimit } from "./shared/middleware/bodySizeGuard"; import { isDraining } from "./lib/gracefulShutdown"; +import { isPublicApiRoute } from "./shared/constants/publicApiRoutes"; -const SECRET = new TextEncoder().encode(process.env.JWT_SECRET || ""); const E2E_MODE = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1"; -const PUBLIC_API_ROUTES = [ - "/api/auth/login", - "/api/auth/logout", - "/api/auth/status", - "/api/settings/require-login", - "/api/init", - "/api/monitoring/health", - "/api/v1/", - "/api/cloud/", - "/api/oauth/", -]; let apiAuthModulePromise: Promise | null = null; let settingsModulePromise: Promise | null = null; let modelSyncModulePromise: Promise | null = null; -function isPublicApiRoute(pathname: string): boolean { - return PUBLIC_API_ROUTES.some((route) => pathname.startsWith(route)); +function getJwtSecret(): Uint8Array { + return new TextEncoder().encode(process.env.JWT_SECRET || ""); } async function getApiAuthModule() { @@ -88,7 +77,7 @@ export async function proxy(request: any) { // ──────────────── Protect Management API Routes ──────────────── if (pathname.startsWith("/api/") && !pathname.startsWith("/api/v1/")) { // Allow public routes (login, logout, health, etc.) - if (isPublicApiRoute(pathname)) { + if (isPublicApiRoute(pathname, request.method)) { return response; } @@ -157,7 +146,7 @@ export async function proxy(request: any) { if (token) { try { - const { payload } = await jwtVerify(token, SECRET); + const { payload } = await jwtVerify(token, getJwtSecret()); // Auto-refresh: if token expires within 7 days, issue a fresh 30-day token const exp = payload.exp as number; @@ -168,7 +157,7 @@ export async function proxy(request: any) { const freshToken = await new SignJWT({ authenticated: true }) .setProtectedHeader({ alg: "HS256" }) .setExpirationTime("30d") - .sign(SECRET); + .sign(getJwtSecret()); // Detect secure context const fwdProto = (request.headers.get("x-forwarded-proto") || "") diff --git a/src/server-init.ts b/src/server-init.ts index bf83efd0eb..42056f25e4 100644 --- a/src/server-init.ts +++ b/src/server-init.ts @@ -1,5 +1,6 @@ // Server startup script import initializeCloudSync from "./shared/services/initializeCloudSync"; +import { enforceWebRuntimeEnv } from "./lib/env/runtimeEnv"; import { enforceSecrets } from "./shared/utils/secretsValidator"; import { initAuditLog, cleanupExpiredLogs, logAuditEvent } from "./lib/compliance/index"; import { initConsoleInterceptor } from "./lib/consoleInterceptor"; @@ -13,6 +14,7 @@ async function startServer() { // FASE-01: Validate required secrets before anything else (fail-fast) enforceSecrets(); + enforceWebRuntimeEnv(); // Compliance: Initialize audit_log table try { @@ -47,7 +49,14 @@ async function startServer() { console.log("Server started with cloud sync initialized"); // Log server start event to audit log - logAuditEvent({ action: "server.start", details: { timestamp: new Date().toISOString() } }); + logAuditEvent({ + action: "server.start", + actor: "system", + target: "server-runtime", + resourceType: "maintenance", + status: "success", + details: { timestamp: new Date().toISOString() }, + }); } catch (error) { console.error("[FATAL] Error initializing cloud sync:", error); process.exit(1); diff --git a/src/shared/components/CursorAuthModal.tsx b/src/shared/components/CursorAuthModal.tsx index 381b735f00..3f05e3a34d 100644 --- a/src/shared/components/CursorAuthModal.tsx +++ b/src/shared/components/CursorAuthModal.tsx @@ -33,7 +33,7 @@ export default function CursorAuthModal({ isOpen, onSuccess, onClose }) { if (data.found) { setAccessToken(data.accessToken); - setMachineId(data.machineId); + setMachineId(data.machineId || ""); setAutoDetected(true); } else { setError(data.error || "Could not auto-detect tokens"); @@ -54,22 +54,17 @@ export default function CursorAuthModal({ isOpen, onSuccess, onClose }) { return; } - if (!machineId.trim()) { - setError("Please enter a machine ID"); - return; - } - setImporting(true); setError(null); try { + const body: Record = { accessToken: accessToken.trim() }; + if (machineId.trim()) body.machineId = machineId.trim(); + const res = await fetch("/api/oauth/cursor/import", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - accessToken: accessToken.trim(), - machineId: machineId.trim(), - }), + body: JSON.stringify(body), }); const data = await res.json(); @@ -100,7 +95,7 @@ export default function CursorAuthModal({ isOpen, onSuccess, onClose }) {

Auto-detecting tokens...

-

Reading from Cursor IDE database

+

Reading from Cursor IDE or cursor-agent

)} @@ -149,10 +144,10 @@ export default function CursorAuthModal({ isOpen, onSuccess, onClose }) { /> - {/* Machine ID Input */} + {/* Machine ID Input (optional — not needed for cursor-agent imports) */}
{importing ? "Importing..." : "Import Token"} diff --git a/src/shared/components/DataTable.tsx b/src/shared/components/DataTable.tsx index 76287b5957..91dd8d7ed2 100644 --- a/src/shared/components/DataTable.tsx +++ b/src/shared/components/DataTable.tsx @@ -1,5 +1,7 @@ "use client"; +import { useTranslations } from "next-intl"; + /** * DataTable — Shared UI primitive (T-29) * @@ -54,8 +56,11 @@ export default function DataTable({ loading = false, maxHeight = "calc(100vh - 320px)", emptyIcon = "📭", - emptyMessage = "No data found", + emptyMessage, }: DataTableProps) { + const t = useTranslations("common"); + const resolvedEmptyMessage = emptyMessage ?? t("noData"); + if (loading) { return (
- Loading... + {t("loading")}
); @@ -89,7 +94,7 @@ export default function DataTable({ }} > {emptyIcon} - {emptyMessage} + {resolvedEmptyMessage}
); } diff --git a/src/shared/components/EmptyState.tsx b/src/shared/components/EmptyState.tsx index df24b2c49c..c1b74ced95 100644 --- a/src/shared/components/EmptyState.tsx +++ b/src/shared/components/EmptyState.tsx @@ -1,5 +1,7 @@ "use client"; +import { useTranslations } from "next-intl"; + /** * EmptyState — FASE-07 UX * @@ -26,11 +28,13 @@ interface EmptyStateProps { export default function EmptyState({ icon = "📭", - title = "Nothing here yet", + title, description = "", actionLabel = "", onAction = null, }: EmptyStateProps) { + const t = useTranslations("common"); + const resolvedTitle = title ?? t("nothingHere"); return (
- {title} + {resolvedTitle} {description && (

- {primaryAction.label} + {resolvedPrimary.label} - {secondaryAction.label} + {resolvedSecondary.label}

diff --git a/src/shared/components/Loading.tsx b/src/shared/components/Loading.tsx index 4e81d79233..688d76449c 100644 --- a/src/shared/components/Loading.tsx +++ b/src/shared/components/Loading.tsx @@ -1,6 +1,7 @@ "use client"; import type { HTMLAttributes } from "react"; +import { useTranslations } from "next-intl"; import { cn } from "@/shared/utils/cn"; type SpinnerSize = "sm" | "md" | "lg" | "xl"; @@ -37,15 +38,17 @@ interface LoadingProps extends HTMLAttributes { } // Spinner loading -export function Spinner({ size = "md", className, label = "Loading" }: SpinnerProps) { +export function Spinner({ size = "md", className, label }: SpinnerProps) { + const t = useTranslations("common"); + const ariaLabel = label ?? t("loading"); return ( - {label} + {ariaLabel}