From 9e45baae58644a3001b6d1012a461e7f5726184b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 16 Apr 2026 05:26:17 -0300 Subject: [PATCH] =?UTF-8?q?chore(release):=20v3.6.6=20=E2=80=94=20Stabiliz?= =?UTF-8?q?ation=20(#1241)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(streaming): #1211 greedy strip omniModel tags to prevent literal \n\n artifacts - Changed regex quantifier from ? to * in combo.ts, comboAgentMiddleware.ts, and contextHandoff.ts to greedily strip all JSON-escaped newline sequences surrounding tags in SSE streaming chunks - Added \r to the character class for cross-platform robustness - Fixed Playwright strict-mode violation in combo-unification.spec.ts - Bumped OpenAPI version and CHANGELOG to 3.6.6 * fix: 3 bugs found during issue triage (#1175, #1187/#1218, #1202) - fix(gemini): strip VS Code JSON Schema extensions from tool schemas (#1175) Add enumDescriptions, markdownDescription, markdownEnumDescriptions, enumItemLabels and tags to UNSUPPORTED_SCHEMA_CONSTRAINTS so the Gemini sanitizer removes them before forwarding. GitHub Copilot injects these non-standard fields into tool definitions, causing Gemini to reject with 'Unknown name enumDescriptions at functionDeclarations[n].parameters'. - fix(health-check): unwrap proxy config object before passing to getAccessToken (#1187 #1218) resolveProxyForConnection() returns { proxy, level, levelId } but the health check loop was passing the full wrapper to getAccessToken(), which expects the inner config object (.host, .port etc). The proxy dispatcher validated .host on the wrapper (undefined) and threw 'Context proxy host is required', silently marking every connection as unhealthy every sweep. Fix mirrors the pattern already used in chatHelpers.ts: proxyResult?.proxy || null. - fix(ui): debounce models.dev sync interval slider to save only on release (#1202) The slider's onChange fired updateInterval() on every drag tick, sending a PATCH per pixel of movement. Rapid API responses overwrote UI state mid-drag. Introduce draftIntervalHours for smooth visual feedback; the PATCH fires on onMouseUp / onBlur once the user releases the control. * fix(providers): update Xiaomi MiMo token-plan endpoints (#1238) Integrated into release/v3.6.6 * fix(cc-compatible): trim beta flags and preserve cache passthrough (#1230) Integrated into release/v3.6.6 * feat(memory+skills): full-featured memory & skills systems with tests (#1228) Integrated into release/v3.6.6 * fix: forward client x-initiator header to GitHub Copilot upstream (#1227) Integrated into release/v3.6.6 * feat(bailian-quota): add Alibaba Coding Plan quota monitoring (#1235) * fix: resolve v3.6.6 backlog bugs (#1206, #1211, #1220, #1231) - fix(core): #1206 inject startup guard against app/ and src/app/ conflict - fix(health): #1220 add HEALTHCHECK_STAGGER_MS to prevent token refresh bursting - fix(proxy): #1231 prioritize HTTP 429 over quota body heuristics - fix(sse): #1211 strip leading double-newlines in responses API stream * fix(tests): resolve memory migration and skills route pagination bugs from PR overlaps * docs: Update CHANGELOG.md with v3.6.6 features (#1182, #1165, #1177) * chore(release): bump version to 3.6.6 Update package versions for the electron app and open-sse package. Sync llm.txt metadata and feature headings with the 3.6.6 release. * feat(core): harden outbound provider calls and add cooldown retries Add guarded outbound fetch helpers with private/local URL blocking, controlled retries, timeout normalization, and route-level status propagation for provider validation and model discovery. Introduce cooldown-aware chat retries with configurable requestRetry and maxRetryIntervalSec settings, model-scoped cooldown responses, and improved rate-limit learning from headers and error bodies so short upstream lockouts can recover automatically. Also align Antigravity and Codex header handling, require API keys for Pollinations, validate web runtime env at startup, restore sanitized Gemini tool names in translated responses, and inject a synthetic Claude text block when upstream SSE completes empty. * feat(models): add glmt preset and hybrid token counting Introduce GLM Thinking as a first-class provider preset with shared GLM model metadata, pricing, usage sync, dashboard support, and provider request defaults for higher token budgets and longer timeouts. Use provider-side /messages/count_tokens when a Claude-compatible upstream supports it, while preserving estimated fallback behavior for missing models, missing credentials, and upstream failures. Also add startup seeding for default model aliases and normalize common cross-proxy model dialects so canonical slashful model ids do not get misrouted during resolution. * feat(api): add sync tokens and v1 websocket bridge Add dedicated sync token storage, issuance, revocation, and bundle download routes backed by stable config bundle versioning and ETag support. Expose the v1 websocket handshake route and custom Next server bridge so OpenAI-compatible websocket traffic can be upgraded and proxied through the dashboard and API bridge. Expand compliance auditing with structured metadata, pagination, request context, auth and provider credential events, and SSRF-blocked validation logging. * docs: Update all documentation for v3.6.6 - CHANGELOG: Add WebSocket bridge, GLM Thinking preset, safe outbound fetch/SSRF guard, cooldown-aware retries, compliance audit v2, model alias seeding, and all Internal Improvements for the 3 new commits - README: Expand v3.6.x highlights table with 10 new features; add SafeOutboundFetch, CooldownAwareRetry, SSRF guard, TPS metric, sync tokens, WebSocket bridge to Resilience/Observability/Deployment tables - ARCHITECTURE: Bump date; add new modules to executive summary, API routes, SSE core services, Auth/Security section; add SSRF/Outbound guard failure mode (section 6); expand module mapping - ENVIRONMENT: Add OMNIROUTE_CRYPT_KEY/OMNIROUTE_API_KEY_BASE64 legacy aliases, OUTBOUND_SSRF_GUARD_ENABLED, CODEX_CLIENT_VERSION, and REQUEST_RETRY/MAX_RETRY_INTERVAL_SEC cooldown retry settings - FEATURES: Add 6 new feature sections — V1 WebSocket Bridge, Sync Tokens & Config Bundle, GLM Thinking Preset, Safe Outbound Fetch & SSRF Guard, Cooldown-Aware Retries, Compliance Audit v2 * fix: use api64 for proxy test (#1255) Integrated into release/v3.6.6 — IPv6 proxy test fix * fix(page): update custom models section to include all providers #1200 (#1256) Integrated into release/v3.6.6 — Gemini custom model picker fix * fix: provide default client_id fallbacks to prevent broken OAuth requests (#1246) Integrated into release/v3.6.6 — OAuth client_id default fallbacks * fix: translate max_tokens/max_completion_tokens → max_output_tokens in Chat→Responses translator (#1245) Integrated into release/v3.6.6 — max_tokens → max_output_tokens Responses API translation + unit tests * feat(oauth): support cursor-agent CLI as Cursor credential source (#1258) Integrated into release/v3.6.6 — cursor-agent CLI credential source support * fix(cc-compatible): restore upstream SSE and correct stream/combo timeout behavior (#1257) Integrated into release/v3.6.6 — CC-compatible upstream SSE restore + stream timeout fix + README table repair * fix(cli-tools): resolve API key resolution and model mapping bugs in CLI tools (#1263) Integrated into release/v3.6.6 * feat(cli-tools): add Qwen Code CLI integration (#1266) Integrated into release/v3.6.6 * fix(i18n): add missing zh-CN translations and fix logger imports (#1269) Integrated into release/v3.6.6 * fix(i18n): add Chinese i18n support to dashboard components (#1274) Integrated into release/v3.6.6 * feat: update Pollinations to require API key, remove free tier flag (#1177) * feat: friendly error messages for crypto/encryption failures (#1165) * feat: add TPS (tokens per second) metric column to request logs (#1182) * feat: merge custom/imported models into filter list for all providers (#1191) * feat(fallback): Fix provider-profile-driven lockouts (#1267) This integrates rdself's unify-provider-profile-locks PR manually to handle structural conflicts. * fix(claude): proper Anthropic SDK integration (#1271) * fix(healthcheck): use correct proxy wrapper format for getAccessToken (#1272) * chore(release): v3.6.6 — skills registry stability fix + final integration * fix(auth): harden bootstrap auth and memory dashboard behavior Restrict unauthenticated writes to /api/settings/require-login to the initial bootstrap window while keeping read-only checks public. This prevents post-setup config changes without blocking first-run login setup, and the onboarding flow now logs in immediately after setting the password. Restore memory API filtering and pagination behavior by supporting q searches, honoring offset-based requests, and avoiding unrelated fallback results when FTS misses. Update dashboard stats fallback to use the response totals consistently. Package the MCP server with explicit file entries and add regression tests for bootstrap auth and memory route behavior * fix(codex): remove max_output_tokens from body for compatibility * chore(release): v3.6.6 — include PR 1274 fixes in changelog * chore: exclude additional build artifacts and internal directories from npm package distribution * fix: update Gemini OAuth test to match registry defaults + codex UI improvements * fix: restore .mjs refs for scripts/ in test imports after ts migration * fix: restore next.config.mjs ref in dev-origins test * fix: implement db migration safety checks and codex config format * fix: disable mass-migration abort during unit tests based on auto-backup flag * fix: update script regex in auto-update tests to use .mjs * feat: Add Perplexity Web (Session) provider (#1289) Integrated into release/v3.6.6 * fix(cli): resolve codex routing config parsing, standardize select model button positioning, and clarify oauth documentation * docs(changelog): record recent cli, provider, and test updates Document the latest fixes for Codex routing configuration parsing and Lobehub provider icon fallback behavior. Add the note that the remaining JavaScript test files were migrated to TypeScript ES modules to reflect the completed test stack transition. * chore(release): merge #1286 minor improvements manually to avoid testing conflict * chore(test): rename perplexity-web.test.mjs to .ts to maintain 100% TS codebase * chore(docs): update CHANGELOG.md for perplexity-web provider * fix(security): resolve CodeQL incomplete URL substring sanitization via URL parsing in test mocks * fix: integrate compressContext() into chatCore.ts request pipeline Proactively compress oversized contexts before sending to upstream providers, preventing context_length_exceeded errors. Compression triggers at 85% of model's context limit using the existing 3-layer compressContext() function. - Import compressContext, estimateTokens, getTokenLimit from contextManager - Add compression check after translation, before executor dispatch - Estimate tokens and compare against 85% threshold of model's context limit - Apply 3-layer compression (trim tools, compress thinking, purify history) - Log compression events with before/after token counts and layers applied - Audit compression events for observability - Add unit tests verifying integration behavior Closes #1290 * fix(tests): align reasoning expectations with GLM thinking structure * fix: prevent orphaned tool_result messages in purifyHistory() When purifyHistory() drops oldest messages to fit context window, 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 format errors. Add fixToolPairs() that runs after each purification pass to remove: - OpenAI format: orphaned role='tool' messages without matching tool_calls ID - Claude format: orphaned tool_result content blocks without matching tool_use ID Closes #1291 * fix(tests): supply tool_use in mock so it is not dropped * chore: convert remaining test to TypeScript * fix(tests): restore compatibility with compressContext threshold test after tsx migration * docs: finalize v3.6.6 release documentation * fix(core): finalize provider removal, type issues, and codex API key config * fix(dashboard): render Web/Cookie, Search, Audio provider sections and fix TypeScript errors * fix: increase MCP web_search timeout to 60s (#1278) * fix: route combo testing properly for embedding models (#1260) * fix: accumulate excluded accounts in combo fallback loop (#1233) * fix: strip leading whitespace and newlines from first streaming chunk (#1211) * docs: clarify VPS and Docker settings for OAuth credentials (#1204) * fix: return real retry-after for pipeline gates (#1301) Integrated into release/v3.6.6 — returns real Retry-After values from pipeline gates * feat: streaming semantic cache, Cursor auto-version detection, and call-log enhancements (#1296) Integrated into release/v3.6.6 — streaming semantic cache, Cursor auto-version detection, call-log cache_source tracking * feat(api): support more OpenAI types (image, embeddings, audio-transcriptions, audio-speech) (#1297) Integrated into release/v3.6.6 — adds embeddings, audio-transcriptions, audio-speech, and images-generations support for custom OpenAI-compatible providers, plus Pollinations image registry * deps: bump hono from 4.12.12 to 4.12.14 (#1302) Integrated into release/v3.6.6 * deps: bump hono from 4.12.12 to 4.12.14 (#1306) Integrated into release/v3.6.6 * chore: stabilization fixes for v3.6.6 (#1298, #1254, #59, CI) * fix(providers): match correct endpoint for Xiaomi MiMo, strip routing prefix for custom openai endpoints (#1303, #1261) * feat(storage): add database backup cleanup controls * chore(release): v3.6.6 — Final Stabilization Push * Backport call log storage refactor to release/v3.6.6 (#1307) Integrated into release/v3.6.6 * deps: update dompurify to 3.4.0 to resolve CVE-XYZ (#60) * test: disable sqlite auto backup in CI to resolve E2E timeout (#24481475058) * chore(docs): sync CHANGELOG for v3.6.6 with missing features and fixes * chore(release): prep v3.6.6 infrastructure and type safety fixes - Migrated legacy .mjs scripts to .ts (bin, prepublish, policies) - Resolved pre-commit strict lint (t11 budget) errors in combo.ts - Explicitly typed all TS bindings in pack-artifact policies - Updated package.json commands to run Node via tsx/esm internally - Hardened CI/CD with explicit node version 22.22.2 checks - Completed stage validations for v3.6.6 final release * chore: fix TS build errors and e2e timeouts in CI - Migrate nodeRuntimeSupport to TS interfaces avoiding implicit any - Increase visibility timeouts in skills-marketplace E2E test to 15s to bypass CI flakiness - Complete migration of .mjs scripts to .ts ensuring type safety * chore(release): sync package version 3.6.6 across workspaces * test(e2e): universally increase UI component visibility timeouts from 5s to 15s to bypass CI starvation * chore(build): inject baseUrl, paths, and types:node into MITM tsconfig within prepublish hook to fix missing types in CI check --------- Co-authored-by: diegosouzapw Co-authored-by: Jack <5443152+hijak@users.noreply.github.com> Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: Samuel Cedric Co-authored-by: Max Garmash Co-authored-by: Markus Hartung Co-authored-by: Gi99lin <74502520+Gi99lin@users.noreply.github.com> Co-authored-by: Payne Co-authored-by: Benson K B Co-authored-by: clousky2020 <33016567+clousky2020@users.noreply.github.com> Co-authored-by: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Co-authored-by: oyi77 Co-authored-by: Hdsje Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: xiaoge1688 --- .env.example | 17 +- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/ISSUE_TEMPLATE/test_coverage_task.yml | 4 +- .github/workflows/ci.yml | 57 +- .github/workflows/npm-publish.yml | 10 +- .gitignore | 1 + .npmignore | 4 + AGENTS.md | 10 +- CHANGELOG.md | 60 ++ CONTRIBUTING.md | 2 +- README.md | 119 ++- audit-report.json | 48 + bin/{omniroute.mjs => omniroute.ts} | 17 +- docs/ARCHITECTURE.md | 40 +- docs/CLI-TOOLS.md | 90 +- docs/ENVIRONMENT.md | 56 +- docs/FEATURES.md | 55 ++ docs/RELEASE_CHECKLIST.md | 9 +- docs/TROUBLESHOOTING.md | 37 +- docs/USER_GUIDE.md | 23 +- docs/i18n/ar/README.md | 171 ++-- docs/i18n/bg/README.md | 171 ++-- docs/i18n/cs/README.md | 171 ++-- docs/i18n/da/README.md | 171 ++-- docs/i18n/de/README.md | 171 ++-- docs/i18n/es/README.md | 171 ++-- docs/i18n/fi/README.md | 171 ++-- docs/i18n/fr/README.md | 171 ++-- docs/i18n/he/README.md | 171 ++-- docs/i18n/hi/README.md | 171 ++-- docs/i18n/hu/README.md | 171 ++-- docs/i18n/id/README.md | 171 ++-- docs/i18n/in/README.md | 171 ++-- docs/i18n/it/README.md | 171 ++-- docs/i18n/ja/README.md | 171 ++-- docs/i18n/ko/README.md | 171 ++-- docs/i18n/ms/README.md | 171 ++-- docs/i18n/nl/README.md | 171 ++-- docs/i18n/no/README.md | 171 ++-- docs/i18n/phi/README.md | 171 ++-- docs/i18n/pl/README.md | 171 ++-- docs/i18n/pt-BR/README.md | 171 ++-- docs/i18n/pt/README.md | 171 ++-- docs/i18n/ro/README.md | 171 ++-- docs/i18n/ru/README.md | 171 ++-- docs/i18n/sk/README.md | 171 ++-- docs/i18n/sv/README.md | 171 ++-- docs/i18n/th/README.md | 171 ++-- docs/i18n/tr/README.md | 171 ++-- docs/i18n/uk-UA/README.md | 171 ++-- docs/i18n/vi/README.md | 171 ++-- docs/i18n/zh-CN/README.md | 171 ++-- docs/openapi.yaml | 2 +- electron/package.json | 2 +- eslint.config.mjs | 6 + llm.txt | 5 +- open-sse/config/antigravityUpstream.ts | 20 + open-sse/config/codexClient.ts | 41 + open-sse/config/constants.ts | 4 +- open-sse/config/glmProvider.ts | 56 ++ open-sse/config/imageRegistry.ts | 24 + open-sse/config/providerRegistry.ts | 127 ++- open-sse/executors/base.ts | 148 +++- open-sse/executors/cliproxyapi.ts | 5 +- open-sse/executors/codex.ts | 6 +- open-sse/executors/cursor.ts | 16 +- open-sse/executors/default.ts | 18 +- open-sse/executors/github.ts | 105 ++- open-sse/executors/grok-web.ts | 308 +++++-- open-sse/executors/index.ts | 4 + open-sse/executors/perplexity-web.ts | 824 ++++++++++++++++++ open-sse/executors/pollinations.ts | 21 +- open-sse/handlers/chatCore.ts | 341 ++++++-- open-sse/handlers/responseTranslator.ts | 6 +- open-sse/handlers/sseParser.ts | 16 +- open-sse/handlers/usageExtractor.ts | 13 +- .../__tests__/glmCodingProviderConfig.test.ts | 18 + open-sse/mcp-server/server.ts | 4 +- open-sse/mcp-server/tools/memoryTools.ts | 11 +- open-sse/package.json | 2 +- open-sse/services/accountFallback.ts | 307 ++++++- open-sse/services/antigravityHeaders.ts | 64 +- open-sse/services/bailianQuotaFetcher.ts | 307 +++++++ open-sse/services/claudeCodeCompatible.ts | 35 +- open-sse/services/combo.ts | 81 +- open-sse/services/comboAgentMiddleware.ts | 12 +- open-sse/services/contextHandoff.ts | 2 +- open-sse/services/contextManager.ts | 77 +- open-sse/services/errorClassifier.ts | 5 +- open-sse/services/model.ts | 231 +++-- open-sse/services/provider.ts | 29 +- open-sse/services/providerRequestDefaults.ts | 87 ++ open-sse/services/rateLimitManager.ts | 27 +- open-sse/services/usage.ts | 122 ++- open-sse/transformer/responsesTransformer.ts | 7 + open-sse/translator/helpers/geminiHelper.ts | 6 + .../helpers/geminiToolsSanitizer.ts | 106 ++- .../translator/request/claude-to-gemini.ts | 25 +- .../translator/request/openai-responses.ts | 19 +- .../translator/request/openai-to-claude.ts | 3 +- .../translator/request/openai-to-gemini.ts | 108 ++- .../translator/response/gemini-to-claude.ts | 4 +- .../translator/response/gemini-to-openai.ts | 10 +- .../translator/response/openai-responses.ts | 53 +- open-sse/utils/cursorChecksum.ts | 7 +- open-sse/utils/cursorVersionDetector.ts | 68 ++ open-sse/utils/error.ts | 95 +- open-sse/utils/logger.ts | 43 +- open-sse/utils/proxyFetch.ts | 28 + open-sse/utils/stream.ts | 454 ++++++++-- open-sse/utils/streamPayloadCollector.ts | 6 +- open-sse/utils/usageTracking.ts | 19 +- package-lock.json | 30 +- package.json | 46 +- scripts/check-supported-node-runtime.ts | 20 + scripts/pack-artifact-policy.ts | 134 +++ scripts/{prepublish.mjs => prepublish.ts} | 165 +++- scripts/run-next-playwright.mjs | 9 +- scripts/run-next.mjs | 114 ++- scripts/v1-ws-bridge.mjs | 671 ++++++++++++++ scripts/validate-pack-artifact.ts | 94 ++ src/app/(dashboard)/dashboard/agents/page.tsx | 1 - .../cli-tools/components/ClaudeToolCard.tsx | 19 +- .../cli-tools/components/CodexToolCard.tsx | 217 ++++- src/app/(dashboard)/dashboard/combos/page.tsx | 1 - src/app/(dashboard)/dashboard/memory/page.tsx | 122 ++- .../(dashboard)/dashboard/onboarding/page.tsx | 10 + .../dashboard/providers/[id]/page.tsx | 137 ++- .../(dashboard)/dashboard/providers/page.tsx | 148 +++- .../settings/components/ComboDefaultsTab.tsx | 3 +- .../settings/components/ModelsDevSyncTab.tsx | 18 +- .../settings/components/RoutingTab.tsx | 8 +- .../settings/components/SystemStorageTab.tsx | 134 +++ src/app/(dashboard)/dashboard/skills/page.tsx | 98 ++- .../usage/components/ProviderLimits/index.tsx | 4 +- src/app/api/auth/login/route.ts | 73 +- src/app/api/auth/logout/route.ts | 19 +- src/app/api/auth/status/route.ts | 10 +- .../api/cli-tools/claude-settings/route.ts | 12 +- src/app/api/cli-tools/cline-settings/route.ts | 6 +- src/app/api/cli-tools/codex-settings/route.ts | 52 +- src/app/api/cli-tools/droid-settings/route.ts | 6 +- src/app/api/cli-tools/kilo-settings/route.ts | 6 +- .../api/cli-tools/openclaw-settings/route.ts | 6 +- src/app/api/combos/test/route.ts | 15 +- src/app/api/compliance/audit-log/route.ts | 41 +- src/app/api/db-backups/exportAll/route.ts | 13 +- src/app/api/db-backups/route.ts | 57 +- src/app/api/logs/export/route.ts | 6 +- src/app/api/memory/health/route.ts | 12 + src/app/api/memory/route.ts | 50 +- src/app/api/oauth/cursor/auto-import/route.ts | 170 ++-- src/app/api/oauth/cursor/import/route.ts | 2 +- src/app/api/provider-nodes/[id]/route.ts | 13 +- src/app/api/provider-nodes/validate/route.ts | 59 +- src/app/api/providers/[id]/models/route.ts | 226 +++-- src/app/api/providers/[id]/route.ts | 44 + src/app/api/providers/[id]/test/route.ts | 6 - src/app/api/providers/route.ts | 30 + src/app/api/providers/validate/route.ts | 36 + src/app/api/search/stats/route.ts | 11 +- src/app/api/settings/proxy/test/route.ts | 2 +- src/app/api/settings/purge-logs/route.ts | 10 +- src/app/api/settings/require-login/route.ts | 26 +- src/app/api/skills/executions/route.ts | 20 +- src/app/api/skills/route.ts | 14 +- src/app/api/storage/health/route.ts | 5 + src/app/api/sync/bundle/route.ts | 56 ++ src/app/api/sync/tokens/[id]/route.ts | 55 ++ src/app/api/sync/tokens/route.ts | 87 ++ src/app/api/v1/embeddings/route.ts | 9 +- src/app/api/v1/messages/count_tokens/route.ts | 101 ++- src/app/api/v1/models/catalog.ts | 1 - src/app/api/v1/search/analytics/route.ts | 2 +- src/app/api/v1/ws/route.ts | 88 ++ src/domain/modelAvailability.ts | 158 +++- src/i18n/messages/en.json | 57 +- src/i18n/messages/zh-CN.json | 120 ++- src/instrumentation-node.ts | 21 +- src/lib/apiBridgeServer.ts | 92 ++ src/lib/cloudSync.ts | 25 +- src/lib/combos/testHealth.ts | 13 +- src/lib/compliance/index.ts | 346 ++++++-- src/lib/compliance/providerAudit.ts | 34 + src/lib/db/backup.ts | 169 +++- src/lib/db/core.ts | 250 +++++- src/lib/db/encryption.ts | 74 +- src/lib/db/migrationRunner.ts | 220 ++++- src/lib/db/migrations/001_initial_schema.sql | 27 +- src/lib/db/migrations/022_add_memory_fts5.sql | 33 + .../db/migrations/023_fix_memory_fts_uuid.sql | 55 ++ .../db/migrations/024_create_sync_tokens.sql | 15 + .../025_call_logs_summary_storage.sql | 145 +++ src/lib/db/models.ts | 8 +- src/lib/db/settings.ts | 3 + src/lib/db/syncTokens.ts | 163 ++++ src/lib/env/runtimeEnv.ts | 166 ++++ src/lib/localDb.ts | 12 + src/lib/memory/__tests__/retrieval.test.ts | 293 ++++++- src/lib/memory/extraction.ts | 15 +- src/lib/memory/injection.ts | 19 +- src/lib/memory/retrieval.ts | 118 ++- src/lib/memory/store.ts | 82 +- src/lib/memory/verify.ts | 56 ++ src/lib/modelAliasSeed.ts | 81 ++ src/lib/modelsDevSync.ts | 97 ++- src/lib/oauth/constants/oauth.ts | 50 +- src/lib/oauth/providers/antigravity.ts | 18 +- src/lib/oauth/services/antigravity.ts | 18 +- src/lib/oauth/services/cursor.ts | 26 +- src/lib/providers/validation.ts | 147 ++-- src/lib/semanticCache.ts | 65 +- src/lib/skills/executor.ts | 31 +- src/lib/skills/injection.ts | 13 + src/lib/skills/interception.ts | 18 + src/lib/skills/registry.ts | 79 +- src/lib/sync/bundle.ts | 206 +++++ src/lib/sync/tokens.ts | 104 +++ src/lib/tokenHealthCheck.ts | 28 +- src/lib/usage/callLogArtifacts.ts | 181 ++++ src/lib/usage/callLogs.ts | 812 ++++++++++------- src/lib/usage/fetcher.ts | 54 +- src/lib/usage/migrations.ts | 128 ++- src/lib/usage/providerLimits.ts | 2 +- src/lib/ws/handshake.ts | 129 +++ src/proxy.ts | 23 +- src/server-init.ts | 11 +- src/shared/components/CursorAuthModal.tsx | 23 +- src/shared/components/DataTable.tsx | 11 +- src/shared/components/EmptyState.tsx | 8 +- src/shared/components/ErrorPageScaffold.tsx | 21 +- src/shared/components/Loading.tsx | 14 +- src/shared/components/MaintenanceBanner.tsx | 2 +- src/shared/components/ManualConfigModal.tsx | 14 +- src/shared/components/ModelSelectModal.tsx | 13 +- src/shared/components/ProviderIcon.tsx | 20 +- src/shared/components/RequestLoggerDetail.tsx | 33 + src/shared/components/RequestLoggerV2.tsx | 79 ++ src/shared/constants/cliTools.ts | 42 + src/shared/constants/config.ts | 4 + src/shared/constants/pricing.ts | 147 ++-- src/shared/constants/providers.ts | 256 +++--- src/shared/constants/publicApiRoutes.ts | 29 + src/shared/network/outboundUrlGuard.ts | 131 +++ src/shared/network/safeOutboundFetch.ts | 356 ++++++++ src/shared/services/cliRuntime.ts | 12 +- src/shared/types/index.ts | 2 + src/shared/types/pagination.ts | 61 ++ src/shared/utils/apiAuth.ts | 36 +- src/shared/utils/circuitBreaker.ts | 33 +- src/shared/utils/nodeRuntimeSupport.ts | 109 +++ src/shared/utils/requestId.ts | 28 +- src/shared/utils/secretsValidator.ts | 9 +- src/shared/validation/schemas.ts | 104 ++- src/shared/validation/settingsSchemas.ts | 5 +- src/sse/handlers/chat.ts | 574 ++++++------ src/sse/handlers/chatHelpers.ts | 56 +- src/sse/services/auth.ts | 222 +++-- src/sse/services/cooldownAwareRetry.ts | 155 ++++ src/types/global.d.ts | 9 + src/types/index.ts | 2 +- src/types/provider.ts | 10 + src/types/settings.ts | 2 + test_reasoning.json | 6 + tests/e2e/analytics-tabs.spec.ts | 2 +- tests/e2e/combo-unification.spec.ts | 13 +- tests/e2e/memory-settings.spec.ts | 4 +- .../e2e/providers-bailian-coding-plan.spec.ts | 10 +- ...responsiveSpecs.mjs => responsiveSpecs.ts} | 12 +- tests/e2e/settings-toggles.spec.ts | 18 +- tests/e2e/skills-marketplace.spec.ts | 6 +- ...ineHarness.mjs => _chatPipelineHarness.ts} | 0 .../{api-keys.test.mjs => api-keys.test.ts} | 0 ...l.test.mjs => api-routes-critical.test.ts} | 0 ...ipeline.test.mjs => chat-pipeline.test.ts} | 178 ++++ ...e2e.test.mjs => combo-routing-e2e.test.ts} | 2 +- ...ng.test.mjs => integration-wiring.test.ts} | 17 + ...eline.test.mjs => memory-pipeline.test.ts} | 269 +++++- ...DevSync.test.mjs => modelsDevSync.test.ts} | 0 .../performance-regression.test.ts | 244 ++++++ ...peline.test.mjs => proxy-pipeline.test.ts} | 2 +- ...w.test.mjs => proxy-registry-flow.test.ts} | 0 ...ng.test.mjs => security-hardening.test.ts} | 9 + tests/integration/skills-pipeline.test.mjs | 362 -------- tests/integration/skills-pipeline.test.ts | 713 +++++++++++++++ ...test.mjs => v1-contracts-behavior.test.ts} | 0 tests/load/{proxy-load.js => proxy-load.ts} | 0 tests/manual/embedding.http | 11 + tests/manual/gemini-streaming.http | 85 ++ tests/manual/image-generation.http | 53 ++ .../{testFromFile.js => testFromFile.ts} | 13 +- ...t.mjs => account-fallback-service.test.ts} | 106 +++ ...ctor.test.mjs => account-selector.test.ts} | 0 tests/unit/admin-audit-events.test.ts | 202 +++++ ...09.test.mjs => advanced-fase07-09.test.ts} | 0 ...s.test.mjs => antigravity-credits.test.ts} | 0 .../{api-auth.test.mjs => api-auth.test.ts} | 1 + ...policy.test.mjs => api-key-policy.test.ts} | 0 ....test.mjs => api-key-reveal-route.test.ts} | 0 ....test.mjs => audio-speech-handler.test.ts} | 0 ...js => audio-transcription-handler.test.ts} | 0 ...t.mjs => auth-clear-account-error.test.ts} | 0 ...mjs => auth-clear-provider-routes.test.ts} | 0 ....test.mjs => auth-terminal-status.test.ts} | 0 ...ine.test.mjs => auto-combo-engine.test.ts} | 0 ...e.test.mjs => auto-update-runtime.test.ts} | 0 ...to-update.test.mjs => auto-update.test.ts} | 0 ...test.mjs => autocombo-unification.test.ts} | 0 ...t.mjs => background-task-detector.test.ts} | 0 ...s => bailian-coding-plan-provider.test.ts} | 0 tests/unit/bailian-quota-fetcher.test.ts | 577 ++++++++++++ tests/unit/bailian-schema-validation.test.ts | 120 +++ tests/unit/bailian-usage.test.ts | 173 ++++ ...domain.test.mjs => batch-a-domain.test.ts} | 65 ++ ...b-final.test.mjs => batch-b-final.test.ts} | 2 +- ...Manager.test.mjs => binaryManager.test.ts} | 0 ...rap-env.test.mjs => bootstrap-env.test.ts} | 0 ...d.test.mjs => build-next-isolated.test.ts} | 0 ...andler.test.mjs => bypass-handler.test.ts} | 0 ...=> cache-control-claude-providers.test.ts} | 0 ....test.mjs => cache-control-policy.test.ts} | 0 ...metrics.test.mjs => cache-metrics.test.ts} | 0 tests/unit/call-log-cap.test.mjs | 566 ------------ tests/unit/call-log-cap.test.ts | 506 +++++++++++ ...t.mjs => call-log-detailed-tokens.test.ts} | 0 ...est.mjs => call-log-file-rotation.test.ts} | 137 ++- ...rtup.test.mjs => call-log-startup.test.ts} | 0 tests/unit/call-logs-requested-model.test.mjs | 52 -- tests/unit/call-logs-requested-model.test.ts | 156 ++++ ...js => cc-compatible-model-catalog.test.ts} | 0 ...est.mjs => cc-compatible-provider.test.ts} | 77 +- ....test.mjs => chat-combo-live-test.test.ts} | 0 ...ay.test.mjs => chat-context-relay.test.ts} | 2 +- tests/unit/chat-cooldown-aware-retry.test.ts | 245 ++++++ ...-helpers.test.mjs => chat-helpers.test.ts} | 86 +- ...s => chat-openai-compat-providers.test.ts} | 0 tests/unit/chat-rate-limit-body-lock.test.ts | 93 ++ ...e.test.mjs => chat-route-coverage.test.ts} | 2 +- ...test.mjs => chat-route-edge-cases.test.ts} | 2 +- .../chatcore-compression-integration.test.ts | 136 +++ ...test.mjs => chatcore-sanitization.test.ts} | 10 +- ...mjs => chatcore-translation-paths.test.ts} | 261 +++++- ... claude-cache-control-passthrough.test.ts} | 0 ...=> claude-code-compatible-helpers.test.ts} | 9 + ...=> claude-code-compatible-request.test.ts} | 2 + ...ty.test.mjs => claude-code-parity.test.ts} | 0 ...js => claude-code-rendering-fixes.test.ts} | 5 +- ...> claude-native-passthrough-tools.test.ts} | 6 +- ...test.mjs => claude-oauth-provider.test.ts} | 0 ...claude-passthrough-stream-boolean.test.ts} | 0 ...cli-memory.test.mjs => cli-memory.test.ts} | 6 +- ...test.mjs => cli-runtime-detection.test.ts} | 1 + ....test.mjs => cli-runtime-extended.test.ts} | 0 ....test.mjs => cliproxyapi-executor.test.ts} | 0 ...cloud-sync.test.mjs => cloud-sync.test.ts} | 12 +- ...mjs => cloudflaredTunnel-extended.test.ts} | 0 ...nel.test.mjs => cloudflaredTunnel.test.ts} | 0 ....mjs => codex-connection-defaults.test.ts} | 0 ...r.test.mjs => codex-quota-fetcher.test.ts} | 0 ...se.test.mjs => codex-stream-false.test.ts} | 0 ...t.test.mjs => combo-builder-draft.test.ts} | 0 ...js => combo-builder-options-route.test.ts} | 0 ...test.mjs => combo-circuit-breaker.test.ts} | 20 +- ...o-config.test.mjs => combo-config.test.ts} | 16 + ...y.test.mjs => combo-context-relay.test.ts} | 0 ...te.test.mjs => combo-health-route.test.ts} | 0 tests/unit/combo-provider-cooldown.test.ts | 113 +++ ...s => combo-routes-composite-tiers.test.ts} | 0 ....test.mjs => combo-routing-engine.test.ts} | 0 ...gies.test.mjs => combo-strategies.test.ts} | 0 ...lth.test.mjs => combo-test-health.test.ts} | 0 ...oute.test.mjs => combo-test-route.test.ts} | 0 tests/unit/compliance-audit-route.test.ts | 69 ++ ...ndex.test.mjs => compliance-index.test.ts} | 78 +- ...mjs => composite-tiers-validation.test.ts} | 0 ...ls.test.mjs => console-log-levels.test.ts} | 0 ...ndoff.test.mjs => context-handoff.test.ts} | 0 tests/unit/context-manager.test.mjs | 116 --- tests/unit/context-manager.test.ts | 236 +++++ ...mjs => context-pinning-tool-calls.test.ts} | 0 ...t-usage.test.mjs => copilot-usage.test.ts} | 0 ...tobuf.test.mjs => cursor-protobuf.test.ts} | 0 tests/unit/cursor-version-detector.test.mjs | 122 +++ ...test.mjs => custom-endpoint-paths.test.ts} | 0 ...-crud.test.mjs => db-apikeys-crud.test.ts} | 0 tests/unit/db-backup-extended.test.mjs | 109 --- tests/unit/db-backup-extended.test.ts | 194 +++++ ...s-crud.test.mjs => db-combos-crud.test.ts} | 0 ...ore-init.test.mjs => db-core-init.test.ts} | 79 ++ ...ion.test.mjs => db-core-migration.test.ts} | 0 ...logs.test.mjs => db-detailed-logs.test.ts} | 0 ...ryption.test.mjs => db-encryption.test.ts} | 0 ...check.test.mjs => db-health-check.test.ts} | 0 ...route.test.mjs => db-health-route.test.ts} | 0 ...r.test.mjs => db-migration-runner.test.ts} | 191 ++++ ...s-crud.test.mjs => db-models-crud.test.ts} | 0 ...ts.test.mjs => db-provider-limits.test.ts} | 0 ...rud.test.mjs => db-providers-crud.test.ts} | 0 ...-crud.test.mjs => db-proxies-crud.test.ts} | 0 ...ts.test.mjs => db-quota-snapshots.test.ts} | 0 ...d-cache.test.mjs => db-read-cache.test.ts} | 0 ...ys.test.mjs => db-registered-keys.test.ts} | 0 ...db-secrets.test.mjs => db-secrets.test.ts} | 0 ...crud.test.mjs => db-settings-crud.test.ts} | 6 + ...roxy.test.mjs => db-upstreamProxy.test.ts} | 0 ...ger.test.mjs => db-versionManager.test.ts} | 0 ...-webhooks.test.mjs => db-webhooks.test.ts} | 0 ...ig.test.mjs => dev-origins-config.test.ts} | 0 ...st.mjs => display-and-error-utils.test.ts} | 0 ...st.mjs => domain-branch-hardening.test.ts} | 0 ...les.test.mjs => domain-cost-rules.test.ts} | 0 ...on.test.mjs => domain-degradation.test.ts} | 0 ...est.mjs => domain-fallback-policy.test.ts} | 0 ...test.mjs => domain-lockout-policy.test.ts} | 0 ...ce.test.mjs => domain-persistence.test.ts} | 0 ...on-main.test.mjs => electron-main.test.ts} | 0 ...load.test.mjs => electron-preload.test.ts} | 0 ...er.test.mjs => embeddings-handler.test.ts} | 0 ...mjs => emergency-fallback-service.test.ts} | 0 ....test.mjs => empty-tool-name-loop.test.ts} | 0 ....test.mjs => error-classification.test.ts} | 0 ...fier.test.mjs => error-classifier.test.ts} | 10 +- ....test.mjs => executor-antigravity.test.ts} | 0 ...est.mjs => executor-cloudflare-ai.test.ts} | 0 ...-codex.test.mjs => executor-codex.test.ts} | 55 ++ ...t.mjs => executor-cursor-extended.test.ts} | 57 +- ...test.mjs => executor-default-base.test.ts} | 77 +- ...i.test.mjs => executor-gemini-cli.test.ts} | 0 ...ithub.test.mjs => executor-github.test.ts} | 0 ...or-kiro.test.mjs => executor-kiro.test.ts} | 0 ...test.mjs => executor-pollinations.test.ts} | 9 +- ...-puter.test.mjs => executor-puter.test.ts} | 0 ...t.mjs => executor-vertex-extended.test.ts} | 0 ...timeout.test.mjs => fetch-timeout.test.ts} | 0 .../{fixes-p1.test.mjs => fixes-p1.test.ts} | 0 ...> glm-provider-model-import-route.test.ts} | 33 + tests/unit/grok-web.test.ts | 73 +- ...Monitor.test.mjs => healthMonitor.test.ts} | 0 ...empotency.test.mjs => idempotency.test.ts} | 0 ...t.mjs => image-generation-handler.test.ts} | 0 .../{ip-filter.test.mjs => ip-filter.test.ts} | 0 tests/unit/log-export-routes.test.mjs | 86 ++ ...tention.test.mjs => log-retention.test.ts} | 0 ...rotation.test.mjs => log-rotation.test.ts} | 0 ...test.mjs => login-bootstrap-route.test.ts} | 22 + ...machine-id.test.mjs => machine-id.test.ts} | 0 ...t.mjs => managed-available-models.test.ts} | 0 ...mask-email.test.mjs => mask-email.test.ts} | 0 ...ry-cache.test.mjs => memory-cache.test.ts} | 0 ...ion.test.mjs => memory-extraction.test.ts} | 0 tests/unit/memory-route.test.ts | 92 ++ ...tings.test.mjs => memory-settings.test.ts} | 0 ...ry-store.test.mjs => memory-store.test.ts} | 157 +++- ....test.mjs => memory-summarization.test.ts} | 0 ...ry-tools.test.mjs => memory-tools.test.ts} | 6 +- .../unit/messages-count-tokens-route.test.ts | 129 +++ tests/unit/model-alias-seed.test.ts | 74 ++ ...js => model-capabilities-registry.test.ts} | 0 ...st.mjs => model-combo-mappings-db.test.ts} | 0 ....test.mjs => model-combo-mappings.test.ts} | 0 tests/unit/model-cross-proxy-compat.test.ts | 45 + ...ion.test.mjs => model-deprecation.test.ts} | 0 ...del-parse.test.mjs => model-parse.test.ts} | 23 +- ...oute.test.mjs => model-sync-route.test.ts} | 0 ....test.mjs => model-sync-scheduler.test.ts} | 0 ....test.mjs => models-catalog-route.test.ts} | 0 ...est.mjs => modelsDevSync-extended.test.ts} | 0 ...DevSync.test.mjs => modelsDevSync.test.ts} | 0 ...r.test.mjs => moderations-handler.test.ts} | 0 ...t.mjs => music-generation-handler.test.ts} | 0 ...js => nanobanana-image-generation.test.ts} | 0 ...t.mjs => nanobanana-image-handler.test.ts} | 0 ....test.mjs => native-binary-compat.test.ts} | 0 ...xt-config.test.mjs => next-config.test.ts} | 0 tests/unit/node-runtime-support.test.ts | 58 ++ ...est.mjs => oauth-providers-config.test.ts} | 51 +- ....test.mjs => observability-fase04.test.ts} | 0 ...est.mjs => observability-payloads.test.ts} | 0 ...s => openai-to-claude-strip-empty.test.ts} | 0 ...te.test.mjs => openapi-spec-route.test.ts} | 0 ...tor.test.mjs => opencode-executor.test.ts} | 0 ...y.test.mjs => openrouter-registry.test.ts} | 5 +- ....test.mjs => orphaned-tool-filter.test.ts} | 14 +- tests/unit/pack-artifact-policy.test.ts | 57 ++ tests/unit/pagination.test.ts | 202 +++++ tests/unit/perplexity-web.test.ts | 750 ++++++++++++++++ .../{plan3-p0.test.mjs => plan3-p0.test.ts} | 0 ...-engine.test.mjs => policy-engine.test.ts} | 0 ...test.mjs => pricing-sync-extended.test.ts} | 0 ...ing-sync.test.mjs => pricing-sync.test.ts} | 0 ...anager.test.mjs => processManager.test.ts} | 0 ...cker.test.mjs => progress-tracker.test.ts} | 0 ...est.mjs => prompt-injection-guard.test.ts} | 0 ...ui.test.mjs => provider-limits-ui.test.ts} | 0 ...est.mjs => provider-models-config.test.ts} | 0 ... provider-models-management-route.test.ts} | 0 ...test.mjs => provider-models-route.test.ts} | 120 +++ ....test.mjs => provider-nodes-route.test.ts} | 0 ...vice.test.mjs => provider-service.test.ts} | 0 ... => provider-specific-data-schema.test.ts} | 0 ...s => provider-validation-branches.test.ts} | 27 + ... => provider-validation-hardening.test.ts} | 0 ... => provider-validation-specialty.test.ts} | 0 ....test.mjs => providers-page-utils.test.ts} | 0 tests/unit/providers-validate-route.test.mjs | 100 --- tests/unit/providers-validate-route.test.ts | 222 +++++ ...test.mjs => proxy-connection-test.test.ts} | 0 ...e-mode.test.mjs => proxy-e2e-mode.test.ts} | 0 ...oxy-fetch.test.mjs => proxy-fetch.test.ts} | 0 tests/unit/proxy-login-bootstrap-auth.test.ts | 92 ++ ....mjs => proxy-management-v1-route.test.ts} | 0 ...gistry.test.mjs => proxy-registry.test.ts} | 0 tests/unit/public-api-routes.test.ts | 22 + .../{qoder-cli.test.mjs => qoder-cli.test.ts} | 0 ...ecutor.test.mjs => qoder-executor.test.ts} | 0 ...ig.test.mjs => qoder-oauth-config.test.ts} | 0 ...monitor.test.mjs => quota-monitor.test.ts} | 0 ...js => quota-policy-generalization.test.ts} | 0 ...light.test.mjs => quota-preflight.test.ts} | 0 ...d.test.mjs => rate-limit-enhanced.test.ts} | 0 ...er.test.mjs => rate-limit-manager.test.ts} | 0 ...-utils.test.mjs => registry-utils.test.ts} | 0 ...hecker.test.mjs => releaseChecker.test.ts} | 0 ...tasks.test.mjs => remaining-tasks.test.ts} | 10 +- ...=> request-defaults-store-session.test.ts} | 0 ....mjs => request-log-detail-layout.test.ts} | 4 + ...test.mjs => request-log-migration.test.ts} | 0 ....test.mjs => request-log-payloads.test.ts} | 0 ...mjs => resolve-omniroute-base-url.test.ts} | 0 ...er.test.mjs => response-sanitizer.test.ts} | 0 ...ler.test.mjs => responses-handler.test.ts} | 0 ...test.mjs => responses-transformer.test.ts} | 0 ...js => responses-translation-fixes.test.ts} | 55 +- ...lizer.test.mjs => role-normalizer.test.ts} | 0 ...e.test.mjs => route-edge-coverage.test.ts} | 0 ...t.test.mjs => run-next-playwright.test.ts} | 0 ...ntime-env.test.mjs => runtime-env.test.ts} | 0 ...e-ports.test.mjs => runtime-ports.test.ts} | 0 ...outs.test.mjs => runtime-timeouts.test.ts} | 0 tests/unit/safe-outbound-fetch.test.ts | 112 +++ ...rcion.test.mjs => schema-coercion.test.ts} | 0 ...test.mjs => search-cache-ttl-zero.test.ts} | 0 ...st.mjs => search-handler-extended.test.ts} | 0 ...mjs => search-provider-validation.test.ts} | 0 ...istry.test.mjs => search-registry.test.ts} | 0 ...ase01.test.mjs => security-fase01.test.ts} | 0 ...-cache.test.mjs => semantic-cache.test.ts} | 16 + ....mjs => services-branch-hardening.test.ts} | 0 ...nager.test.mjs => session-manager.test.ts} | 0 ...ings-api.test.mjs => settings-api.test.ts} | 0 ...ys.test.mjs => settings-i18n-keys.test.ts} | 0 ...ettings-schema-routing-strategies.test.ts} | 23 + ...tils.test.mjs => shared-api-utils.test.ts} | 0 ...h.test.mjs => sidebar-route-match.test.ts} | 0 ...ty.test.mjs => sidebar-visibility.test.ts} | 0 ...cache.test.mjs => signature-cache.test.ts} | 10 +- ...st.mjs => skills-builtins-sandbox.test.ts} | 0 ...cutor.test.mjs => skills-executor.test.ts} | 0 ...tion.test.mjs => skills-injection.test.ts} | 0 ...n.test.mjs => skills-interception.test.ts} | 0 ...istry.test.mjs => skills-registry.test.ts} | 170 +++- ...-routes.test.mjs => skills-routes.test.ts} | 27 +- ...llssh.test.mjs => skills-skillssh.test.ts} | 8 +- .../{sse-auth.test.mjs => sse-auth.test.ts} | 95 +- ...artbeat.test.mjs => sse-heartbeat.test.ts} | 0 ...sse-parser.test.mjs => sse-parser.test.ts} | 29 + ...andler.test.mjs => stream-handler.test.ts} | 0 ...ties.test.mjs => stream-utilities.test.ts} | 0 ...am-utils.test.mjs => stream-utils.test.ts} | 101 +++ ...Helpers.test.mjs => streamHelpers.test.ts} | 0 ...ck.test.mjs => strict-random-deck.test.ts} | 0 tests/unit/sync-bundle.test.ts | 101 +++ .../{sync-env.test.mjs => sync-env.test.ts} | 0 tests/unit/sync-routes.test.ts | 166 ++++ ...-prompt.test.mjs => system-prompt.test.ts} | 7 +- ....test.mjs => t06-schema-hardening.test.ts} | 0 ...test.mjs => t07-no-log-key-config.test.ts} | 0 ...st.mjs => t08-allowed-connections.test.ts} | 0 ....mjs => t08-mcp-scope-enforcement.test.ts} | 0 ...cle.test.mjs => t09-a2a-lifecycle.test.ts} | 0 ...s.test.mjs => t12-pricing-updates.test.ts} | 4 + ...st.mjs => t13-stale-quota-display.test.ts} | 0 ...l.test.mjs => t14-proxy-fast-fail.test.ts} | 0 ...js => t16-gemini-enum-type-string.test.ts} | 0 ...t19-codex-responses-empty-content.test.ts} | 0 ...t.mjs => t20-t22-provider-headers.test.ts} | 7 +- ...js => t23-t24-fallback-resilience.test.ts} | 0 ...vider-validation-modelid-fallback.test.ts} | 0 ...> t26-ai-sdk-accept-header-compat.test.ts} | 0 ...27-github-copilot-response-format.test.ts} | 0 ....mjs => t28-model-catalog-updates.test.ts} | 0 ...js => t29-vertex-sa-json-executor.test.ts} | 0 ...=> t30-kiro-400-model-unavailable.test.ts} | 0 ...js => t31-t33-t34-t38-model-specs.test.ts} | 3 +- ...40-opencode-cli-tools-integration.test.ts} | 0 ...=> t42-image-size-to-aspect-ratio.test.ts} | 0 ...ni-tool-call-no-thought-signature.test.ts} | 0 ...avity-thought-signature-preserved.test.ts} | 0 ...st.mjs => telemetry-summary-route.test.ts} | 0 ...rser.test.mjs => think-tag-parser.test.ts} | 0 ...udget.test.mjs => thinking-budget.test.ts} | 0 ...-herd.test.mjs => thundering-herd.test.ts} | 0 ...mjs => token-accounting-input-fix.test.ts} | 0 tests/unit/token-health-check.test.mjs | 43 - tests/unit/token-health-check.test.ts | 279 ++++++ ...js => token-refresh-route-service.test.ts} | 0 ...test.mjs => token-refresh-service.test.ts} | 0 ....mjs => tool-request-sanitization.test.ts} | 0 ... => tools-filter-anthropic-format.test.ts} | 0 ... translator-antigravity-to-openai.test.ts} | 0 ...js => translator-claude-to-gemini.test.ts} | 48 + ...js => translator-claude-to-openai.test.ts} | 0 ...js => translator-gemini-to-openai.test.ts} | 0 ...mjs => translator-helper-branches.test.ts} | 0 ...> translator-openai-responses-req.test.ts} | 50 +- ...js => translator-openai-to-claude.test.ts} | 0 ...js => translator-openai-to-cursor.test.ts} | 0 ...js => translator-openai-to-gemini.test.ts} | 126 +++ ....mjs => translator-openai-to-kiro.test.ts} | 0 ... translator-resp-claude-to-openai.test.ts} | 0 ... translator-resp-cursor-to-openai.test.ts} | 0 ... translator-resp-gemini-to-claude.test.ts} | 23 +- ... translator-resp-gemini-to-openai.test.ts} | 55 +- ...=> translator-resp-kiro-to-openai.test.ts} | 0 ... translator-resp-openai-responses.test.ts} | 2 +- ...slator-resp-openai-to-antigravity.test.ts} | 0 ... translator-resp-openai-to-claude.test.ts} | 0 ....mjs => upstream-headers-sanitize.test.ts} | 0 ...ytics.test.mjs => usage-analytics.test.ts} | 0 ...actor.test.mjs => usage-extractor.test.ts} | 141 +++ tests/unit/usage-fetcher-antigravity.test.ts | 54 ++ ...ions.test.mjs => usage-migrations.test.ts} | 32 +- ...st.mjs => usage-service-hardening.test.ts} | 84 +- tests/unit/v1-ws-bridge.test.ts | 159 ++++ tests/unit/v1-ws-route.test.ts | 103 +++ ...nager.test.mjs => version-manager.test.ts} | 0 ...js => versionManager-orchestrator.test.ts} | 0 ...t.mjs => video-generation-handler.test.ts} | 0 tests/unit/web-runtime-env.test.ts | 89 ++ ...outer.test.mjs => wildcard-router.test.ts} | 8 +- tests/unit/xiaomi-mimo-provider.test.ts | 78 ++ 641 files changed, 27918 insertions(+), 6203 deletions(-) create mode 100644 audit-report.json rename bin/{omniroute.mjs => omniroute.ts} (96%) create mode 100644 open-sse/config/antigravityUpstream.ts create mode 100644 open-sse/config/codexClient.ts create mode 100644 open-sse/config/glmProvider.ts create mode 100644 open-sse/executors/perplexity-web.ts create mode 100644 open-sse/services/bailianQuotaFetcher.ts create mode 100644 open-sse/services/providerRequestDefaults.ts create mode 100644 open-sse/utils/cursorVersionDetector.ts create mode 100644 scripts/check-supported-node-runtime.ts create mode 100644 scripts/pack-artifact-policy.ts rename scripts/{prepublish.mjs => prepublish.ts} (74%) create mode 100644 scripts/v1-ws-bridge.mjs create mode 100644 scripts/validate-pack-artifact.ts create mode 100644 src/app/api/memory/health/route.ts create mode 100644 src/app/api/sync/bundle/route.ts create mode 100644 src/app/api/sync/tokens/[id]/route.ts create mode 100644 src/app/api/sync/tokens/route.ts create mode 100644 src/app/api/v1/ws/route.ts create mode 100644 src/lib/compliance/providerAudit.ts create mode 100644 src/lib/db/migrations/022_add_memory_fts5.sql create mode 100644 src/lib/db/migrations/023_fix_memory_fts_uuid.sql create mode 100644 src/lib/db/migrations/024_create_sync_tokens.sql create mode 100644 src/lib/db/migrations/025_call_logs_summary_storage.sql create mode 100644 src/lib/db/syncTokens.ts create mode 100644 src/lib/env/runtimeEnv.ts create mode 100644 src/lib/memory/verify.ts create mode 100644 src/lib/modelAliasSeed.ts create mode 100644 src/lib/sync/bundle.ts create mode 100644 src/lib/sync/tokens.ts create mode 100644 src/lib/usage/callLogArtifacts.ts create mode 100644 src/lib/ws/handshake.ts create mode 100644 src/shared/constants/publicApiRoutes.ts create mode 100644 src/shared/network/outboundUrlGuard.ts create mode 100644 src/shared/network/safeOutboundFetch.ts create mode 100644 src/shared/types/index.ts create mode 100644 src/shared/types/pagination.ts create mode 100644 src/shared/utils/nodeRuntimeSupport.ts create mode 100644 src/sse/services/cooldownAwareRetry.ts create mode 100644 test_reasoning.json rename tests/e2e/{responsiveSpecs.mjs => responsiveSpecs.ts} (87%) rename tests/integration/{_chatPipelineHarness.mjs => _chatPipelineHarness.ts} (100%) rename tests/integration/{api-keys.test.mjs => api-keys.test.ts} (100%) rename tests/integration/{api-routes-critical.test.mjs => api-routes-critical.test.ts} (100%) rename tests/integration/{chat-pipeline.test.mjs => chat-pipeline.test.ts} (85%) rename tests/integration/{combo-routing-e2e.test.mjs => combo-routing-e2e.test.ts} (99%) rename tests/integration/{integration-wiring.test.mjs => integration-wiring.test.ts} (95%) rename tests/integration/{memory-pipeline.test.mjs => memory-pipeline.test.ts} (50%) rename tests/integration/{modelsDevSync.test.mjs => modelsDevSync.test.ts} (100%) create mode 100644 tests/integration/performance-regression.test.ts rename tests/integration/{proxy-pipeline.test.mjs => proxy-pipeline.test.ts} (99%) rename tests/integration/{proxy-registry-flow.test.mjs => proxy-registry-flow.test.ts} (100%) rename tests/integration/{security-hardening.test.mjs => security-hardening.test.ts} (96%) delete mode 100644 tests/integration/skills-pipeline.test.mjs create mode 100644 tests/integration/skills-pipeline.test.ts rename tests/integration/{v1-contracts-behavior.test.mjs => v1-contracts-behavior.test.ts} (100%) rename tests/load/{proxy-load.js => proxy-load.ts} (100%) create mode 100644 tests/manual/embedding.http create mode 100644 tests/manual/gemini-streaming.http create mode 100644 tests/manual/image-generation.http rename tests/translator/{testFromFile.js => testFromFile.ts} (93%) rename tests/unit/{account-fallback-service.test.mjs => account-fallback-service.test.ts} (69%) rename tests/unit/{account-selector.test.mjs => account-selector.test.ts} (100%) create mode 100644 tests/unit/admin-audit-events.test.ts rename tests/unit/{advanced-fase07-09.test.mjs => advanced-fase07-09.test.ts} (100%) rename tests/unit/{antigravity-credits.test.mjs => antigravity-credits.test.ts} (100%) rename tests/unit/{api-auth.test.mjs => api-auth.test.ts} (98%) rename tests/unit/{api-key-policy.test.mjs => api-key-policy.test.ts} (100%) rename tests/unit/{api-key-reveal-route.test.mjs => api-key-reveal-route.test.ts} (100%) rename tests/unit/{audio-speech-handler.test.mjs => audio-speech-handler.test.ts} (100%) rename tests/unit/{audio-transcription-handler.test.mjs => audio-transcription-handler.test.ts} (100%) rename tests/unit/{auth-clear-account-error.test.mjs => auth-clear-account-error.test.ts} (100%) rename tests/unit/{auth-clear-provider-routes.test.mjs => auth-clear-provider-routes.test.ts} (100%) rename tests/unit/{auth-terminal-status.test.mjs => auth-terminal-status.test.ts} (100%) rename tests/unit/{auto-combo-engine.test.mjs => auto-combo-engine.test.ts} (100%) rename tests/unit/{auto-update-runtime.test.mjs => auto-update-runtime.test.ts} (100%) rename tests/unit/{auto-update.test.mjs => auto-update.test.ts} (100%) rename tests/unit/{autocombo-unification.test.mjs => autocombo-unification.test.ts} (100%) rename tests/unit/{background-task-detector.test.mjs => background-task-detector.test.ts} (100%) rename tests/unit/{bailian-coding-plan-provider.test.mjs => bailian-coding-plan-provider.test.ts} (100%) create mode 100644 tests/unit/bailian-quota-fetcher.test.ts create mode 100644 tests/unit/bailian-schema-validation.test.ts create mode 100644 tests/unit/bailian-usage.test.ts rename tests/unit/{batch-a-domain.test.mjs => batch-a-domain.test.ts} (84%) rename tests/unit/{batch-b-final.test.mjs => batch-b-final.test.ts} (99%) rename tests/unit/{binaryManager.test.mjs => binaryManager.test.ts} (100%) rename tests/unit/{bootstrap-env.test.mjs => bootstrap-env.test.ts} (100%) rename tests/unit/{build-next-isolated.test.mjs => build-next-isolated.test.ts} (100%) rename tests/unit/{bypass-handler.test.mjs => bypass-handler.test.ts} (100%) rename tests/unit/{cache-control-claude-providers.test.mjs => cache-control-claude-providers.test.ts} (100%) rename tests/unit/{cache-control-policy.test.mjs => cache-control-policy.test.ts} (100%) rename tests/unit/{cache-metrics.test.mjs => cache-metrics.test.ts} (100%) delete mode 100644 tests/unit/call-log-cap.test.mjs create mode 100644 tests/unit/call-log-cap.test.ts rename tests/unit/{call-log-detailed-tokens.test.mjs => call-log-detailed-tokens.test.ts} (100%) rename tests/unit/{call-log-file-rotation.test.mjs => call-log-file-rotation.test.ts} (60%) rename tests/unit/{call-log-startup.test.mjs => call-log-startup.test.ts} (100%) delete mode 100644 tests/unit/call-logs-requested-model.test.mjs create mode 100644 tests/unit/call-logs-requested-model.test.ts rename tests/unit/{cc-compatible-model-catalog.test.mjs => cc-compatible-model-catalog.test.ts} (100%) rename tests/unit/{cc-compatible-provider.test.mjs => cc-compatible-provider.test.ts} (92%) rename tests/unit/{chat-combo-live-test.test.mjs => chat-combo-live-test.test.ts} (100%) rename tests/unit/{chat-context-relay.test.mjs => chat-context-relay.test.ts} (99%) create mode 100644 tests/unit/chat-cooldown-aware-retry.test.ts rename tests/unit/{chat-helpers.test.mjs => chat-helpers.test.ts} (72%) rename tests/unit/{chat-openai-compat-providers.test.mjs => chat-openai-compat-providers.test.ts} (100%) create mode 100644 tests/unit/chat-rate-limit-body-lock.test.ts rename tests/unit/{chat-route-coverage.test.mjs => chat-route-coverage.test.ts} (99%) rename tests/unit/{chat-route-edge-cases.test.mjs => chat-route-edge-cases.test.ts} (99%) create mode 100644 tests/unit/chatcore-compression-integration.test.ts rename tests/unit/{chatcore-sanitization.test.mjs => chatcore-sanitization.test.ts} (97%) rename tests/unit/{chatcore-translation-paths.test.mjs => chatcore-translation-paths.test.ts} (86%) rename tests/unit/{claude-cache-control-passthrough.test.mjs => claude-cache-control-passthrough.test.ts} (100%) rename tests/unit/{claude-code-compatible-helpers.test.mjs => claude-code-compatible-helpers.test.ts} (86%) rename tests/unit/{claude-code-compatible-request.test.mjs => claude-code-compatible-request.test.ts} (98%) rename tests/unit/{claude-code-parity.test.mjs => claude-code-parity.test.ts} (100%) rename tests/unit/{claude-code-rendering-fixes.test.mjs => claude-code-rendering-fixes.test.ts} (98%) rename tests/unit/{claude-native-passthrough-tools.test.mjs => claude-native-passthrough-tools.test.ts} (95%) rename tests/unit/{claude-oauth-provider.test.mjs => claude-oauth-provider.test.ts} (100%) rename tests/unit/{claude-passthrough-stream-boolean.test.mjs => claude-passthrough-stream-boolean.test.ts} (100%) rename tests/unit/{cli-memory.test.mjs => cli-memory.test.ts} (97%) rename tests/unit/{cli-runtime-detection.test.mjs => cli-runtime-detection.test.ts} (99%) rename tests/unit/{cli-runtime-extended.test.mjs => cli-runtime-extended.test.ts} (100%) rename tests/unit/{cliproxyapi-executor.test.mjs => cliproxyapi-executor.test.ts} (100%) rename tests/unit/{cloud-sync.test.mjs => cloud-sync.test.ts} (96%) rename tests/unit/{cloudflaredTunnel-extended.test.mjs => cloudflaredTunnel-extended.test.ts} (100%) rename tests/unit/{cloudflaredTunnel.test.mjs => cloudflaredTunnel.test.ts} (100%) rename tests/unit/{codex-connection-defaults.test.mjs => codex-connection-defaults.test.ts} (100%) rename tests/unit/{codex-quota-fetcher.test.mjs => codex-quota-fetcher.test.ts} (100%) rename tests/unit/{codex-stream-false.test.mjs => codex-stream-false.test.ts} (100%) rename tests/unit/{combo-builder-draft.test.mjs => combo-builder-draft.test.ts} (100%) rename tests/unit/{combo-builder-options-route.test.mjs => combo-builder-options-route.test.ts} (100%) rename tests/unit/{combo-circuit-breaker.test.mjs => combo-circuit-breaker.test.ts} (91%) rename tests/unit/{combo-config.test.mjs => combo-config.test.ts} (93%) rename tests/unit/{combo-context-relay.test.mjs => combo-context-relay.test.ts} (100%) rename tests/unit/{combo-health-route.test.mjs => combo-health-route.test.ts} (100%) create mode 100644 tests/unit/combo-provider-cooldown.test.ts rename tests/unit/{combo-routes-composite-tiers.test.mjs => combo-routes-composite-tiers.test.ts} (100%) rename tests/unit/{combo-routing-engine.test.mjs => combo-routing-engine.test.ts} (100%) rename tests/unit/{combo-strategies.test.mjs => combo-strategies.test.ts} (100%) rename tests/unit/{combo-test-health.test.mjs => combo-test-health.test.ts} (100%) rename tests/unit/{combo-test-route.test.mjs => combo-test-route.test.ts} (100%) create mode 100644 tests/unit/compliance-audit-route.test.ts rename tests/unit/{compliance-index.test.mjs => compliance-index.test.ts} (71%) rename tests/unit/{composite-tiers-validation.test.mjs => composite-tiers-validation.test.ts} (100%) rename tests/unit/{console-log-levels.test.mjs => console-log-levels.test.ts} (100%) rename tests/unit/{context-handoff.test.mjs => context-handoff.test.ts} (100%) delete mode 100644 tests/unit/context-manager.test.mjs create mode 100644 tests/unit/context-manager.test.ts rename tests/unit/{context-pinning-tool-calls.test.mjs => context-pinning-tool-calls.test.ts} (100%) rename tests/unit/{copilot-usage.test.mjs => copilot-usage.test.ts} (100%) rename tests/unit/{cursor-protobuf.test.mjs => cursor-protobuf.test.ts} (100%) create mode 100644 tests/unit/cursor-version-detector.test.mjs rename tests/unit/{custom-endpoint-paths.test.mjs => custom-endpoint-paths.test.ts} (100%) rename tests/unit/{db-apikeys-crud.test.mjs => db-apikeys-crud.test.ts} (100%) delete mode 100644 tests/unit/db-backup-extended.test.mjs create mode 100644 tests/unit/db-backup-extended.test.ts rename tests/unit/{db-combos-crud.test.mjs => db-combos-crud.test.ts} (100%) rename tests/unit/{db-core-init.test.mjs => db-core-init.test.ts} (85%) rename tests/unit/{db-core-migration.test.mjs => db-core-migration.test.ts} (100%) rename tests/unit/{db-detailed-logs.test.mjs => db-detailed-logs.test.ts} (100%) rename tests/unit/{db-encryption.test.mjs => db-encryption.test.ts} (100%) rename tests/unit/{db-health-check.test.mjs => db-health-check.test.ts} (100%) rename tests/unit/{db-health-route.test.mjs => db-health-route.test.ts} (100%) rename tests/unit/{db-migration-runner.test.mjs => db-migration-runner.test.ts} (63%) rename tests/unit/{db-models-crud.test.mjs => db-models-crud.test.ts} (100%) rename tests/unit/{db-provider-limits.test.mjs => db-provider-limits.test.ts} (100%) rename tests/unit/{db-providers-crud.test.mjs => db-providers-crud.test.ts} (100%) rename tests/unit/{db-proxies-crud.test.mjs => db-proxies-crud.test.ts} (100%) rename tests/unit/{db-quota-snapshots.test.mjs => db-quota-snapshots.test.ts} (100%) rename tests/unit/{db-read-cache.test.mjs => db-read-cache.test.ts} (100%) rename tests/unit/{db-registered-keys.test.mjs => db-registered-keys.test.ts} (100%) rename tests/unit/{db-secrets.test.mjs => db-secrets.test.ts} (100%) rename tests/unit/{db-settings-crud.test.mjs => db-settings-crud.test.ts} (98%) rename tests/unit/{db-upstreamProxy.test.mjs => db-upstreamProxy.test.ts} (100%) rename tests/unit/{db-versionManager.test.mjs => db-versionManager.test.ts} (100%) rename tests/unit/{db-webhooks.test.mjs => db-webhooks.test.ts} (100%) rename tests/unit/{dev-origins-config.test.mjs => dev-origins-config.test.ts} (100%) rename tests/unit/{display-and-error-utils.test.mjs => display-and-error-utils.test.ts} (100%) rename tests/unit/{domain-branch-hardening.test.mjs => domain-branch-hardening.test.ts} (100%) rename tests/unit/{domain-cost-rules.test.mjs => domain-cost-rules.test.ts} (100%) rename tests/unit/{domain-degradation.test.mjs => domain-degradation.test.ts} (100%) rename tests/unit/{domain-fallback-policy.test.mjs => domain-fallback-policy.test.ts} (100%) rename tests/unit/{domain-lockout-policy.test.mjs => domain-lockout-policy.test.ts} (100%) rename tests/unit/{domain-persistence.test.mjs => domain-persistence.test.ts} (100%) rename tests/unit/{electron-main.test.mjs => electron-main.test.ts} (100%) rename tests/unit/{electron-preload.test.mjs => electron-preload.test.ts} (100%) rename tests/unit/{embeddings-handler.test.mjs => embeddings-handler.test.ts} (100%) rename tests/unit/{emergency-fallback-service.test.mjs => emergency-fallback-service.test.ts} (100%) rename tests/unit/{empty-tool-name-loop.test.mjs => empty-tool-name-loop.test.ts} (100%) rename tests/unit/{error-classification.test.mjs => error-classification.test.ts} (100%) rename tests/unit/{error-classifier.test.mjs => error-classifier.test.ts} (85%) rename tests/unit/{executor-antigravity.test.mjs => executor-antigravity.test.ts} (100%) rename tests/unit/{executor-cloudflare-ai.test.mjs => executor-cloudflare-ai.test.ts} (100%) rename tests/unit/{executor-codex.test.mjs => executor-codex.test.ts} (84%) rename tests/unit/{executor-cursor-extended.test.mjs => executor-cursor-extended.test.ts} (89%) rename tests/unit/{executor-default-base.test.mjs => executor-default-base.test.ts} (89%) rename tests/unit/{executor-gemini-cli.test.mjs => executor-gemini-cli.test.ts} (100%) rename tests/unit/{executor-github.test.mjs => executor-github.test.ts} (100%) rename tests/unit/{executor-kiro.test.mjs => executor-kiro.test.ts} (100%) rename tests/unit/{executor-pollinations.test.mjs => executor-pollinations.test.ts} (76%) rename tests/unit/{executor-puter.test.mjs => executor-puter.test.ts} (100%) rename tests/unit/{executor-vertex-extended.test.mjs => executor-vertex-extended.test.ts} (100%) rename tests/unit/{fetch-timeout.test.mjs => fetch-timeout.test.ts} (100%) rename tests/unit/{fixes-p1.test.mjs => fixes-p1.test.ts} (100%) rename tests/unit/{glm-provider-model-import-route.test.mjs => glm-provider-model-import-route.test.ts} (86%) rename tests/unit/{healthMonitor.test.mjs => healthMonitor.test.ts} (100%) rename tests/unit/{idempotency.test.mjs => idempotency.test.ts} (100%) rename tests/unit/{image-generation-handler.test.mjs => image-generation-handler.test.ts} (100%) rename tests/unit/{ip-filter.test.mjs => ip-filter.test.ts} (100%) create mode 100644 tests/unit/log-export-routes.test.mjs rename tests/unit/{log-retention.test.mjs => log-retention.test.ts} (100%) rename tests/unit/{log-rotation.test.mjs => log-rotation.test.ts} (100%) rename tests/unit/{login-bootstrap-route.test.mjs => login-bootstrap-route.test.ts} (88%) rename tests/unit/{machine-id.test.mjs => machine-id.test.ts} (100%) rename tests/unit/{managed-available-models.test.mjs => managed-available-models.test.ts} (100%) rename tests/unit/{mask-email.test.mjs => mask-email.test.ts} (100%) rename tests/unit/{memory-cache.test.mjs => memory-cache.test.ts} (100%) rename tests/unit/{memory-extraction.test.mjs => memory-extraction.test.ts} (100%) create mode 100644 tests/unit/memory-route.test.ts rename tests/unit/{memory-settings.test.mjs => memory-settings.test.ts} (100%) rename tests/unit/{memory-store.test.mjs => memory-store.test.ts} (56%) rename tests/unit/{memory-summarization.test.mjs => memory-summarization.test.ts} (100%) rename tests/unit/{memory-tools.test.mjs => memory-tools.test.ts} (92%) create mode 100644 tests/unit/messages-count-tokens-route.test.ts create mode 100644 tests/unit/model-alias-seed.test.ts rename tests/unit/{model-capabilities-registry.test.mjs => model-capabilities-registry.test.ts} (100%) rename tests/unit/{model-combo-mappings-db.test.mjs => model-combo-mappings-db.test.ts} (100%) rename tests/unit/{model-combo-mappings.test.mjs => model-combo-mappings.test.ts} (100%) create mode 100644 tests/unit/model-cross-proxy-compat.test.ts rename tests/unit/{model-deprecation.test.mjs => model-deprecation.test.ts} (100%) rename tests/unit/{model-parse.test.mjs => model-parse.test.ts} (56%) rename tests/unit/{model-sync-route.test.mjs => model-sync-route.test.ts} (100%) rename tests/unit/{model-sync-scheduler.test.mjs => model-sync-scheduler.test.ts} (100%) rename tests/unit/{models-catalog-route.test.mjs => models-catalog-route.test.ts} (100%) rename tests/unit/{modelsDevSync-extended.test.mjs => modelsDevSync-extended.test.ts} (100%) rename tests/unit/{modelsDevSync.test.mjs => modelsDevSync.test.ts} (100%) rename tests/unit/{moderations-handler.test.mjs => moderations-handler.test.ts} (100%) rename tests/unit/{music-generation-handler.test.mjs => music-generation-handler.test.ts} (100%) rename tests/unit/{nanobanana-image-generation.test.mjs => nanobanana-image-generation.test.ts} (100%) rename tests/unit/{nanobanana-image-handler.test.mjs => nanobanana-image-handler.test.ts} (100%) rename tests/unit/{native-binary-compat.test.mjs => native-binary-compat.test.ts} (100%) rename tests/unit/{next-config.test.mjs => next-config.test.ts} (100%) create mode 100644 tests/unit/node-runtime-support.test.ts rename tests/unit/{oauth-providers-config.test.mjs => oauth-providers-config.test.ts} (93%) rename tests/unit/{observability-fase04.test.mjs => observability-fase04.test.ts} (100%) rename tests/unit/{observability-payloads.test.mjs => observability-payloads.test.ts} (100%) rename tests/unit/{openai-to-claude-strip-empty.test.mjs => openai-to-claude-strip-empty.test.ts} (100%) rename tests/unit/{openapi-spec-route.test.mjs => openapi-spec-route.test.ts} (100%) rename tests/unit/{opencode-executor.test.mjs => opencode-executor.test.ts} (100%) rename tests/unit/{openrouter-registry.test.mjs => openrouter-registry.test.ts} (97%) rename tests/unit/{orphaned-tool-filter.test.mjs => orphaned-tool-filter.test.ts} (95%) create mode 100644 tests/unit/pack-artifact-policy.test.ts create mode 100644 tests/unit/pagination.test.ts create mode 100644 tests/unit/perplexity-web.test.ts rename tests/unit/{plan3-p0.test.mjs => plan3-p0.test.ts} (100%) rename tests/unit/{policy-engine.test.mjs => policy-engine.test.ts} (100%) rename tests/unit/{pricing-sync-extended.test.mjs => pricing-sync-extended.test.ts} (100%) rename tests/unit/{pricing-sync.test.mjs => pricing-sync.test.ts} (100%) rename tests/unit/{processManager.test.mjs => processManager.test.ts} (100%) rename tests/unit/{progress-tracker.test.mjs => progress-tracker.test.ts} (100%) rename tests/unit/{prompt-injection-guard.test.mjs => prompt-injection-guard.test.ts} (100%) rename tests/unit/{provider-limits-ui.test.mjs => provider-limits-ui.test.ts} (100%) rename tests/unit/{provider-models-config.test.mjs => provider-models-config.test.ts} (100%) rename tests/unit/{provider-models-management-route.test.mjs => provider-models-management-route.test.ts} (100%) rename tests/unit/{provider-models-route.test.mjs => provider-models-route.test.ts} (80%) rename tests/unit/{provider-nodes-route.test.mjs => provider-nodes-route.test.ts} (100%) rename tests/unit/{provider-service.test.mjs => provider-service.test.ts} (100%) rename tests/unit/{provider-specific-data-schema.test.mjs => provider-specific-data-schema.test.ts} (100%) rename tests/unit/{provider-validation-branches.test.mjs => provider-validation-branches.test.ts} (93%) rename tests/unit/{provider-validation-hardening.test.mjs => provider-validation-hardening.test.ts} (100%) rename tests/unit/{provider-validation-specialty.test.mjs => provider-validation-specialty.test.ts} (100%) rename tests/unit/{providers-page-utils.test.mjs => providers-page-utils.test.ts} (100%) delete mode 100644 tests/unit/providers-validate-route.test.mjs create mode 100644 tests/unit/providers-validate-route.test.ts rename tests/unit/{proxy-connection-test.test.mjs => proxy-connection-test.test.ts} (100%) rename tests/unit/{proxy-e2e-mode.test.mjs => proxy-e2e-mode.test.ts} (100%) rename tests/unit/{proxy-fetch.test.mjs => proxy-fetch.test.ts} (100%) create mode 100644 tests/unit/proxy-login-bootstrap-auth.test.ts rename tests/unit/{proxy-management-v1-route.test.mjs => proxy-management-v1-route.test.ts} (100%) rename tests/unit/{proxy-registry.test.mjs => proxy-registry.test.ts} (100%) create mode 100644 tests/unit/public-api-routes.test.ts rename tests/unit/{qoder-cli.test.mjs => qoder-cli.test.ts} (100%) rename tests/unit/{qoder-executor.test.mjs => qoder-executor.test.ts} (100%) rename tests/unit/{qoder-oauth-config.test.mjs => qoder-oauth-config.test.ts} (100%) rename tests/unit/{quota-monitor.test.mjs => quota-monitor.test.ts} (100%) rename tests/unit/{quota-policy-generalization.test.mjs => quota-policy-generalization.test.ts} (100%) rename tests/unit/{quota-preflight.test.mjs => quota-preflight.test.ts} (100%) rename tests/unit/{rate-limit-enhanced.test.mjs => rate-limit-enhanced.test.ts} (100%) rename tests/unit/{rate-limit-manager.test.mjs => rate-limit-manager.test.ts} (100%) rename tests/unit/{registry-utils.test.mjs => registry-utils.test.ts} (100%) rename tests/unit/{releaseChecker.test.mjs => releaseChecker.test.ts} (100%) rename tests/unit/{remaining-tasks.test.mjs => remaining-tasks.test.ts} (96%) rename tests/unit/{request-defaults-store-session.test.mjs => request-defaults-store-session.test.ts} (100%) rename tests/unit/{request-log-detail-layout.test.mjs => request-log-detail-layout.test.ts} (93%) rename tests/unit/{request-log-migration.test.mjs => request-log-migration.test.ts} (100%) rename tests/unit/{request-log-payloads.test.mjs => request-log-payloads.test.ts} (100%) rename tests/unit/{resolve-omniroute-base-url.test.mjs => resolve-omniroute-base-url.test.ts} (100%) rename tests/unit/{response-sanitizer.test.mjs => response-sanitizer.test.ts} (100%) rename tests/unit/{responses-handler.test.mjs => responses-handler.test.ts} (100%) rename tests/unit/{responses-transformer.test.mjs => responses-transformer.test.ts} (100%) rename tests/unit/{responses-translation-fixes.test.mjs => responses-translation-fixes.test.ts} (92%) rename tests/unit/{role-normalizer.test.mjs => role-normalizer.test.ts} (100%) rename tests/unit/{route-edge-coverage.test.mjs => route-edge-coverage.test.ts} (100%) rename tests/unit/{run-next-playwright.test.mjs => run-next-playwright.test.ts} (100%) rename tests/unit/{runtime-env.test.mjs => runtime-env.test.ts} (100%) rename tests/unit/{runtime-ports.test.mjs => runtime-ports.test.ts} (100%) rename tests/unit/{runtime-timeouts.test.mjs => runtime-timeouts.test.ts} (100%) create mode 100644 tests/unit/safe-outbound-fetch.test.ts rename tests/unit/{schema-coercion.test.mjs => schema-coercion.test.ts} (100%) rename tests/unit/{search-cache-ttl-zero.test.mjs => search-cache-ttl-zero.test.ts} (100%) rename tests/unit/{search-handler-extended.test.mjs => search-handler-extended.test.ts} (100%) rename tests/unit/{search-provider-validation.test.mjs => search-provider-validation.test.ts} (100%) rename tests/unit/{search-registry.test.mjs => search-registry.test.ts} (100%) rename tests/unit/{security-fase01.test.mjs => security-fase01.test.ts} (100%) rename tests/unit/{semantic-cache.test.mjs => semantic-cache.test.ts} (80%) rename tests/unit/{services-branch-hardening.test.mjs => services-branch-hardening.test.ts} (100%) rename tests/unit/{session-manager.test.mjs => session-manager.test.ts} (100%) rename tests/unit/{settings-api.test.mjs => settings-api.test.ts} (100%) rename tests/unit/{settings-i18n-keys.test.mjs => settings-i18n-keys.test.ts} (100%) rename tests/unit/{settings-schema-routing-strategies.test.mjs => settings-schema-routing-strategies.test.ts} (53%) rename tests/unit/{shared-api-utils.test.mjs => shared-api-utils.test.ts} (100%) rename tests/unit/{sidebar-route-match.test.mjs => sidebar-route-match.test.ts} (100%) rename tests/unit/{sidebar-visibility.test.mjs => sidebar-visibility.test.ts} (100%) rename tests/unit/{signature-cache.test.mjs => signature-cache.test.ts} (96%) rename tests/unit/{skills-builtins-sandbox.test.mjs => skills-builtins-sandbox.test.ts} (100%) rename tests/unit/{skills-executor.test.mjs => skills-executor.test.ts} (100%) rename tests/unit/{skills-injection.test.mjs => skills-injection.test.ts} (100%) rename tests/unit/{skills-interception.test.mjs => skills-interception.test.ts} (100%) rename tests/unit/{skills-registry.test.mjs => skills-registry.test.ts} (50%) rename tests/unit/{skills-routes.test.mjs => skills-routes.test.ts} (87%) rename tests/unit/{skills-skillssh.test.mjs => skills-skillssh.test.ts} (96%) rename tests/unit/{sse-auth.test.mjs => sse-auth.test.ts} (91%) rename tests/unit/{sse-heartbeat.test.mjs => sse-heartbeat.test.ts} (100%) rename tests/unit/{sse-parser.test.mjs => sse-parser.test.ts} (87%) rename tests/unit/{stream-handler.test.mjs => stream-handler.test.ts} (100%) rename tests/unit/{stream-utilities.test.mjs => stream-utilities.test.ts} (100%) rename tests/unit/{stream-utils.test.mjs => stream-utils.test.ts} (86%) rename tests/unit/{streamHelpers.test.mjs => streamHelpers.test.ts} (100%) rename tests/unit/{strict-random-deck.test.mjs => strict-random-deck.test.ts} (100%) create mode 100644 tests/unit/sync-bundle.test.ts rename tests/unit/{sync-env.test.mjs => sync-env.test.ts} (100%) create mode 100644 tests/unit/sync-routes.test.ts rename tests/unit/{system-prompt.test.mjs => system-prompt.test.ts} (96%) rename tests/unit/{t06-schema-hardening.test.mjs => t06-schema-hardening.test.ts} (100%) rename tests/unit/{t07-no-log-key-config.test.mjs => t07-no-log-key-config.test.ts} (100%) rename tests/unit/{t08-allowed-connections.test.mjs => t08-allowed-connections.test.ts} (100%) rename tests/unit/{t08-mcp-scope-enforcement.test.mjs => t08-mcp-scope-enforcement.test.ts} (100%) rename tests/unit/{t09-a2a-lifecycle.test.mjs => t09-a2a-lifecycle.test.ts} (100%) rename tests/unit/{t12-pricing-updates.test.mjs => t12-pricing-updates.test.ts} (87%) rename tests/unit/{t13-stale-quota-display.test.mjs => t13-stale-quota-display.test.ts} (100%) rename tests/unit/{t14-proxy-fast-fail.test.mjs => t14-proxy-fast-fail.test.ts} (100%) rename tests/unit/{t16-gemini-enum-type-string.test.mjs => t16-gemini-enum-type-string.test.ts} (100%) rename tests/unit/{t19-codex-responses-empty-content.test.mjs => t19-codex-responses-empty-content.test.ts} (100%) rename tests/unit/{t20-t22-provider-headers.test.mjs => t20-t22-provider-headers.test.ts} (88%) rename tests/unit/{t23-t24-fallback-resilience.test.mjs => t23-t24-fallback-resilience.test.ts} (100%) rename tests/unit/{t25-provider-validation-modelid-fallback.test.mjs => t25-provider-validation-modelid-fallback.test.ts} (100%) rename tests/unit/{t26-ai-sdk-accept-header-compat.test.mjs => t26-ai-sdk-accept-header-compat.test.ts} (100%) rename tests/unit/{t27-github-copilot-response-format.test.mjs => t27-github-copilot-response-format.test.ts} (100%) rename tests/unit/{t28-model-catalog-updates.test.mjs => t28-model-catalog-updates.test.ts} (100%) rename tests/unit/{t29-vertex-sa-json-executor.test.mjs => t29-vertex-sa-json-executor.test.ts} (100%) rename tests/unit/{t30-kiro-400-model-unavailable.test.mjs => t30-kiro-400-model-unavailable.test.ts} (100%) rename tests/unit/{t31-t33-t34-t38-model-specs.test.mjs => t31-t33-t34-t38-model-specs.test.ts} (96%) rename tests/unit/{t40-opencode-cli-tools-integration.test.mjs => t40-opencode-cli-tools-integration.test.ts} (100%) rename tests/unit/{t42-image-size-to-aspect-ratio.test.mjs => t42-image-size-to-aspect-ratio.test.ts} (100%) rename tests/unit/{t43-gemini-tool-call-no-thought-signature.test.mjs => t43-gemini-tool-call-no-thought-signature.test.ts} (100%) rename tests/unit/{t44-antigravity-thought-signature-preserved.test.mjs => t44-antigravity-thought-signature-preserved.test.ts} (100%) rename tests/unit/{telemetry-summary-route.test.mjs => telemetry-summary-route.test.ts} (100%) rename tests/unit/{think-tag-parser.test.mjs => think-tag-parser.test.ts} (100%) rename tests/unit/{thinking-budget.test.mjs => thinking-budget.test.ts} (100%) rename tests/unit/{thundering-herd.test.mjs => thundering-herd.test.ts} (100%) rename tests/unit/{token-accounting-input-fix.test.mjs => token-accounting-input-fix.test.ts} (100%) delete mode 100644 tests/unit/token-health-check.test.mjs create mode 100644 tests/unit/token-health-check.test.ts rename tests/unit/{token-refresh-route-service.test.mjs => token-refresh-route-service.test.ts} (100%) rename tests/unit/{token-refresh-service.test.mjs => token-refresh-service.test.ts} (100%) rename tests/unit/{tool-request-sanitization.test.mjs => tool-request-sanitization.test.ts} (100%) rename tests/unit/{tools-filter-anthropic-format.test.mjs => tools-filter-anthropic-format.test.ts} (100%) rename tests/unit/{translator-antigravity-to-openai.test.mjs => translator-antigravity-to-openai.test.ts} (100%) rename tests/unit/{translator-claude-to-gemini.test.mjs => translator-claude-to-gemini.test.ts} (73%) rename tests/unit/{translator-claude-to-openai.test.mjs => translator-claude-to-openai.test.ts} (100%) rename tests/unit/{translator-gemini-to-openai.test.mjs => translator-gemini-to-openai.test.ts} (100%) rename tests/unit/{translator-helper-branches.test.mjs => translator-helper-branches.test.ts} (100%) rename tests/unit/{translator-openai-responses-req.test.mjs => translator-openai-responses-req.test.ts} (86%) rename tests/unit/{translator-openai-to-claude.test.mjs => translator-openai-to-claude.test.ts} (100%) rename tests/unit/{translator-openai-to-cursor.test.mjs => translator-openai-to-cursor.test.ts} (100%) rename tests/unit/{translator-openai-to-gemini.test.mjs => translator-openai-to-gemini.test.ts} (77%) rename tests/unit/{translator-openai-to-kiro.test.mjs => translator-openai-to-kiro.test.ts} (100%) rename tests/unit/{translator-resp-claude-to-openai.test.mjs => translator-resp-claude-to-openai.test.ts} (100%) rename tests/unit/{translator-resp-cursor-to-openai.test.mjs => translator-resp-cursor-to-openai.test.ts} (100%) rename tests/unit/{translator-resp-gemini-to-claude.test.mjs => translator-resp-gemini-to-claude.test.ts} (87%) rename tests/unit/{translator-resp-gemini-to-openai.test.mjs => translator-resp-gemini-to-openai.test.ts} (82%) rename tests/unit/{translator-resp-kiro-to-openai.test.mjs => translator-resp-kiro-to-openai.test.ts} (100%) rename tests/unit/{translator-resp-openai-responses.test.mjs => translator-resp-openai-responses.test.ts} (99%) rename tests/unit/{translator-resp-openai-to-antigravity.test.mjs => translator-resp-openai-to-antigravity.test.ts} (100%) rename tests/unit/{translator-resp-openai-to-claude.test.mjs => translator-resp-openai-to-claude.test.ts} (100%) rename tests/unit/{upstream-headers-sanitize.test.mjs => upstream-headers-sanitize.test.ts} (100%) rename tests/unit/{usage-analytics.test.mjs => usage-analytics.test.ts} (100%) rename tests/unit/{usage-extractor.test.mjs => usage-extractor.test.ts} (50%) create mode 100644 tests/unit/usage-fetcher-antigravity.test.ts rename tests/unit/{usage-migrations.test.mjs => usage-migrations.test.ts} (90%) rename tests/unit/{usage-service-hardening.test.mjs => usage-service-hardening.test.ts} (92%) create mode 100644 tests/unit/v1-ws-bridge.test.ts create mode 100644 tests/unit/v1-ws-route.test.ts rename tests/unit/{version-manager.test.mjs => version-manager.test.ts} (100%) rename tests/unit/{versionManager-orchestrator.test.mjs => versionManager-orchestrator.test.ts} (100%) rename tests/unit/{video-generation-handler.test.mjs => video-generation-handler.test.ts} (100%) create mode 100644 tests/unit/web-runtime-env.test.ts rename tests/unit/{wildcard-router.test.mjs => wildcard-router.test.ts} (96%) create mode 100644 tests/unit/xiaomi-mimo-provider.test.ts 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}