diff --git a/.env.example b/.env.example index ec7a157df873..13579dd8a37d 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. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 121d5091d51b..a8a5146b66a8 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 57b03c440493..14306182065b 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/.gitignore b/.gitignore index 992eaf5f0f87..c4abb01ea6f2 100644 --- a/.gitignore +++ b/.gitignore @@ -169,3 +169,4 @@ docs/superpowers/ # GitNexus local index .gitnexus +.worktrees diff --git a/.npmignore b/.npmignore index 69b962ff519b..e0a5b8865437 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 2b3504b13f68..f80682d4f08e 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 55de93ba1f5d..266039632023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,50 @@ --- +## [3.6.6] — 2026-04-15 + +### ✨ New Features + +- **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) + +### 🐛 Bug Fixes + +- **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 153da4b24cda..86a8d21f6b0c 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 e4411783c1a7..56cd9264c3e3 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 @@ -792,20 +792,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 +822,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 +1331,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 +1433,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 +1469,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 +1489,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 diff --git a/audit-report.json b/audit-report.json new file mode 100644 index 000000000000..0d8c271630b0 --- /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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 90dee6ce94fa..40e4eb3e1b8c 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: diff --git a/docs/CLI-TOOLS.md b/docs/CLI-TOOLS.md index 0ff2eb803cee..bda30bd23165 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 f19f3ead2f31..9a9150f33963 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 c1e44fad5c5a..3dc803380e84 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/USER_GUIDE.md b/docs/USER_GUIDE.md index 15b66446e5c9..b4892aabb38d 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/openapi.yaml b/docs/openapi.yaml index 5350b10d546d..898f507efb89 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 c8a6cfdb020a..dda6a100b9f0 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 37fbd5f5c22e..a90263b07c79 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 a26b5c111828..8df769de43fa 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 000000000000..a4ca845301cb --- /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 000000000000..73c26776ea06 --- /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 52ae37916ee7..be34098c099b 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 000000000000..afef06a7e72c --- /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/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 9de66365e236..f86a9f2ddc09 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", @@ -1068,6 +1076,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", @@ -1387,17 +1414,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)" }, ], }, @@ -1804,7 +1831,7 @@ export const REGISTRY: Record = { alias: "mimo", format: "openai", executor: "default", - baseUrl: "https://api.xiaomi.com/v1/chat/completions", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", authType: "apikey", authHeader: "bearer", models: CHAT_OPENAI_COMPAT_MODELS["xiaomi-mimo"], @@ -1892,6 +1919,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 20e149f8dcf3..ee953afe10f0 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 83549c8334f8..69e2f05bb873 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 967cbc990dcd..9a631bcfda77 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 2f4f7ed2a9d5..d9ec3acf55d4 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -240,12 +240,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; diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 2e2f76b99a2e..76367a50088d 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 8d0083a495ee..88ed0a71ad08 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; - - 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; - } + this._clientHeaders = input.clientHeaders ?? null; + try { + const result = await super.execute(input); + if (!result || !result.response) 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); - }, - }); + 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; + } - const newResponse = new Response(sourceResponse.body.pipeThrough(transformStream), { - status: sourceResponse.status, - statusText: sourceResponse.statusText, - headers: new Headers(sourceResponse.headers), - }); - result.response = newResponse; - } + if (!result.response.body) return result; - 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; + } finally { + this._clientHeaders = null; + } } 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/index.ts b/open-sse/executors/index.ts index b3bbb1347d91..d76e1a5dee4b 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"; const executors = { antigravity: new AntigravityExecutor(), @@ -33,6 +34,8 @@ const executors = { vertex: new VertexExecutor(), cliproxyapi: new CliproxyapiExecutor(), cpa: new CliproxyapiExecutor(), // Alias + "perplexity-web": new PerplexityWebExecutor(), + "pplx-web": new PerplexityWebExecutor(), // Alias }; const defaultCache = new Map(); @@ -62,3 +65,4 @@ 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"; diff --git a/open-sse/executors/perplexity-web.ts b/open-sse/executors/perplexity-web.ts new file mode 100644 index 000000000000..b0232dd5c23b --- /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 55f9c6180e54..5b4e41a8b791 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 1f377892199f..63e1bb77f50b 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,6 +79,7 @@ import { sanitizeOpenAIResponse } from "./responseSanitizer.ts"; import { withRateLimit, updateFromHeaders, + updateFromResponseBody, initializeRateLimits, } from "../services/rateLimitManager.ts"; import { @@ -97,6 +98,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 +131,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 +269,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 @@ -817,17 +850,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"; + const settings = await getCachedSettings(); + const semanticCacheEnabled = settings.semanticCacheEnabled !== false; // ── Phase 9.1: Semantic cache check (non-streaming, temp=0 only) ── if (semanticCacheEnabled && isCacheable(body, clientRawRequest?.headers)) { @@ -962,13 +989,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 +1056,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 @@ -1289,6 +1304,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. @@ -1684,23 +1746,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 +1865,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 @@ -2009,7 +2075,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 +2083,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({ @@ -2052,36 +2104,27 @@ export async function handleChatCore({ 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), }); - 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); } } @@ -2221,10 +2264,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 ) diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 279224501594..603e7c64da7b 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 4ab94cfbbce1..44beadf0da91 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/mcp-server/__tests__/glmCodingProviderConfig.test.ts b/open-sse/mcp-server/__tests__/glmCodingProviderConfig.test.ts index c49a1acb052e..3cd7fcb75126 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/tools/memoryTools.ts b/open-sse/mcp-server/tools/memoryTools.ts index 4863e9d758a8..9ac7dd9ce2b6 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 46e3ce00a4b1..06de1ec9fe38 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 8e8f75236c2e..cfd621dcaa42 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 d7adf5e44d6e..009e9a2d661e 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 000000000000..a10c0f93f690 --- /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 1430bd406556..fa076f9de4a6 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 6c1e2e9bc468..3bbe99134f85 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"; @@ -44,6 +48,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); @@ -1043,7 +1048,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 +1062,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 +1270,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 +1322,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 +1499,8 @@ export async function handleComboChat({ 0, null, provider, - result.headers + result.headers, + profile ); const comboBadRequestFallback = shouldFallbackComboBadRequest(result.status, errorText); @@ -1538,9 +1549,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 +1663,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 +1818,8 @@ async function handleRoundRobinCombo({ 0, null, provider, - result.headers + result.headers, + profile ); const comboBadRequestFallback = shouldFallbackComboBadRequest(result.status, errorText); @@ -1857,9 +1873,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 6f2a79a631a7..5c08ea47c586 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 18b5969d8641..2189186d9208 100644 --- a/open-sse/services/contextHandoff.ts +++ b/open-sse/services/contextHandoff.ts @@ -18,7 +18,8 @@ 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 SUMMARIZATION_TIMEOUT_MS = 300000; +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. @@ -269,10 +270,52 @@ async function generateHandoffAsync(options: { temperature: 0.1, _omnirouteSkipContextRelay: true, _omnirouteInternalRequest: "context-handoff", + _omnirouteSummarizationTimeout: SUMMARIZATION_TIMEOUT_MS, }; - const response = await options.handleSingleModel(summaryBody, summaryModel); - if (!response.ok) return; + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error("Summarization timeout")), SUMMARIZATION_TIMEOUT_MS) + ); + + let response: Response; + try { + response = await Promise.race([ + options.handleSingleModel(summaryBody, summaryModel), + timeoutPromise, + ]); + } catch (err: any) { + console.warn( + `[context-handoff] Summarization timeout for session ${options.sessionId}: ${err?.message || "unknown"}` + ); + return; + } + + // T09: Better error logging for compaction failures + // Log the actual error to help diagnose 500 issues during OpenCode compaction + if (!response.ok) { + let errorDetail = `Status ${response.status}`; + try { + const errorText = await response.clone().text(); + if (errorText) { + try { + const errorJson = JSON.parse(errorText); + errorDetail = + errorJson?.error?.message || + errorJson?.error || + errorJson?.message || + errorText.substring(0, 200); + } catch { + errorDetail = errorText.substring(0, 200); + } + } + } catch { + /* ignore */ + } + console.warn( + `[context-handoff] Summarization failed for session ${options.sessionId}: ${errorDetail}` + ); + return; + } let content = ""; try { @@ -287,7 +330,10 @@ async function generateHandoffAsync(options: { } const parsed = parseHandoffJSON(content); - if (!parsed) return; + if (!parsed) { + console.warn(`[context-handoff] Failed to parse handoff JSON for session ${options.sessionId}`); + return; + } upsertHandoff({ sessionId: options.sessionId, diff --git a/open-sse/services/contextManager.ts b/open-sse/services/contextManager.ts index 190caa6c2a68..c7eff8d69983 100644 --- a/open-sse/services/contextManager.ts +++ b/open-sse/services/contextManager.ts @@ -236,13 +236,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 +257,63 @@ 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) { + // 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 12941c0ee937..39d513ffbe23 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 7c12bb3b73c6..138281a3defa 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; - - if (!parsed.isAlias) { - const canonicalModel = resolveProviderModelAlias(parsed.provider, parsed.model); - return { - provider: parsed.provider, - model: canonicalModel, - extendedContext, - }; - } +function parseAliasTarget(target) { + const normalizedTarget = normalizeCrossProxyModelId(target).modelId; + if (!normalizedTarget || typeof normalizedTarget !== "string") return null; - // Get aliases (from object or function) - const aliases = typeof aliasesOrGetter === "function" ? await aliasesOrGetter() : aliasesOrGetter; + if (normalizedTarget.includes("/")) { + if (shouldTreatAsExactModelId(normalizedTarget)) { + return { model: normalizedTarget }; + } - // Resolve exact alias - const resolved = resolveModelAliasFromMap(parsed.model, aliases); - if (resolved) { - const canonicalModel = resolveProviderModelAlias(resolved.provider, resolved.model); + const firstSlash = normalizedTarget.indexOf("/"); return { - provider: resolved.provider, - model: canonicalModel, - extendedContext, + provider: resolveProviderAlias(normalizedTarget.slice(0, firstSlash)), + model: normalizedTarget.slice(firstSlash + 1), }; } - // 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, - }; - } - } - } + return { model: normalizedTarget }; +} - const modelId = parsed.model; +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/providerRequestDefaults.ts b/open-sse/services/providerRequestDefaults.ts new file mode 100644 index 000000000000..0659582d9b8f --- /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 48e8e46b6a2f..35533938de41 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 8e7e4afdb9d6..bfe4fb290b8d 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 6a6f8540ca6c..7a7c719ff021 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.replace(/^\n+/, ""); + } + + 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 a0c671917bc3..23908e9a80ae 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 b8628c6e3800..e35a9df7b1d8 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 b36e064b2cfb..07e65388413b 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 bbeaff2e5a1e..c0401e6b2608 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 b2850de7b5e4..47aeac8fb84c 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 bb904ed2478d..e375193109fb 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 a9b438ab054c..280d1f85cb56 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 b13cf4669584..40d63bd45d2f 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -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 bdddc2dd8753..656bb92e6f19 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -761,7 +761,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 +782,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/error.ts b/open-sse/utils/error.ts index d39b3c184b74..723fff1a84f2 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 b52f0b41282a..cb2528b12ee6 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; + +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 currentLevel = LEVELS[getAppLogLevel("info").toLowerCase()] ?? LEVELS.info; +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/stream.ts b/open-sse/utils/stream.ts index ccebc722af17..c6398112a918 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/package-lock.json b/package-lock.json index a504791e4853..231a88821d52 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": [ @@ -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", @@ -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 1b34436009c2..1a24b25d035a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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": { @@ -10,7 +10,15 @@ "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/", ".env.example", "scripts/postinstall.mjs", @@ -59,11 +67,11 @@ "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", @@ -71,13 +79,13 @@ "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", diff --git a/scripts/prepublish.mjs b/scripts/prepublish.mjs index fe21666ace12..d8941f0aab75 100644 --- a/scripts/prepublish.mjs +++ b/scripts/prepublish.mjs @@ -314,10 +314,22 @@ if (existsSync(swcHelpersSrc) && !existsSync(swcHelpersDst)) { console.log(" ✅ @swc/helpers included in standalone build."); } -// ── Step 10.6: Remove large binaries from standalone build ── +// ── Step 10.6: Remove large binaries & unneeded traces 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"]; +// trigger Z_DATA_ERROR during npm pack, or are huge trace outputs (_references). +// They are not needed in the npm package. +const binaryDirsToRemove = [ + "vscode-extension", + "electron", + "logs", + "coverage", + "_references", + "_mono_repo", + "_tasks", + "_ideia", + "tests", + "docs", +]; for (const dir of binaryDirsToRemove) { const targetDir = join(APP_DIR, dir); if (existsSync(targetDir)) { diff --git a/scripts/run-next-playwright.mjs b/scripts/run-next-playwright.mjs index 15b4acce0388..595ae880d86c 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 cbf40715b70c..15f31dbb0df7 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"; + +// 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); +} 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; + } +} -// 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 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")); -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"); + server.listen(dashboardPort, hostname, () => { + const bundler = dev ? (useTurbopack ? "turbopack" : "webpack") : "production"; + console.log( + `[Next] ${mode} server listening on http://${hostname}:${dashboardPort} (${bundler})` + ); + }); } -spawnWithForwardedSignals(process.execPath, args, { - stdio: "inherit", - env: withRuntimePortEnv(env, runtimePorts), +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 000000000000..3653bd159f96 --- /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/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.tsx index 3c86613ef8ad..b572e17162c4 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 a94d9b7576df..5cd52d6bf66f 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 5cb0255115b7..71995da7f7bd 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 55ba8d2d4bc7..347b39518295 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"); @@ -2850,8 +2859,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 && ( = { "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 +5087,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 +5103,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 +5131,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 +5145,7 @@ function AddApiKeyModal({ validationModelId: "", customUserAgent: "", accountId: "", + consoleApiKey: "", }); const [validating, setValidating] = useState(false); const [validationResult, setValidationResult] = useState(null); @@ -5211,6 +5226,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 +5352,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/settings/components/ModelsDevSyncTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ModelsDevSyncTab.tsx index 60d6bdfceb00..117e6d168daa 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 869d54603fef..97af9d2166d2 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) => ( + +
+ )} @@ -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 ba2abdd5e21c..5a46679c0b28 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 f94d87e77626..0f09d899c118 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 b09c38d55eda..a4f9b7256d17 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 7e8ebea8c67b..9cc4d872c6eb 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 1c3dc32edcb9..63205c9e2a54 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 2c6fe656d360..b032babb9351 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 6067f5232661..401b0dae4e5d 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: "OMNIROUTE_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 3e3be5fdb413..25cd6d52cc4d 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 0974ee5455ec..95248e313978 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 e8dbebbe776e..88958bb5b798 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/compliance/audit-log/route.ts b/src/app/api/compliance/audit-log/route.ts index 2e9b419e9232..80c03ece5817 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/memory/health/route.ts b/src/app/api/memory/health/route.ts new file mode 100644 index 000000000000..5fadd6e457ec --- /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 ad773098a2ce..785b75002fbb 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 07f5c3cfae30..69bef3b59f53 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) { + // Try Cursor IDE first (has both accessToken and machineId) + const ideResult = tryIdeAuth(); + if (ideResult.found) { return NextResponse.json({ - found: false, - error: - "Cursor database not found. Make sure Cursor IDE is installed and you are logged in.", + found: true, + accessToken: ideResult.accessToken, + machineId: ideResult.machineId, + source: ideResult.source, }); } - 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.", - }); - } - + // Fall back to cursor-agent CLI auth (accessToken only, no machineId) + const agentResult = await tryAgentAuth(); + if (agentResult.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: 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 657e75741f31..a0da0c4d4e34 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/validate/route.ts b/src/app/api/provider-nodes/validate/route.ts index 27d6b9f4e7dc..bc2d13c96254 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 09d05ecbc00d..2e75d914d9f2 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; + + 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)); + } -function getGlmApiRegion(providerSpecificData: unknown): keyof typeof GLM_MODELS_URLS { - const data = asRecord(providerSpecificData); - return data.apiRegion === "china" ? "china" : "international"; + 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 5ade90e58bce..57c236c4bdcc 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/route.ts b/src/app/api/providers/route.ts index e09af29cb14b..f3cfc76ded96 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 610c827d55d3..98ec8ebf7778 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/settings/proxy/test/route.ts b/src/app/api/settings/proxy/test/route.ts index f74bd911e3cf..cace16576ceb 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/require-login/route.ts b/src/app/api/settings/require-login/route.ts index 6b33fadb21c3..6900be7f93d3 100644 --- a/src/app/api/settings/require-login/route.ts +++ b/src/app/api/settings/require-login/route.ts @@ -1,6 +1,7 @@ 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 { updateRequireLoginSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; @@ -11,6 +12,14 @@ function getNodeCompatibility() { return { nodeVersion, nodeCompatible: major >= 18 && major < 24 }; } +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() { const nodeInfo = getNodeCompatibility(); try { @@ -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 33aa6f1a6f7b..93d7708a3c32 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 ca2a8e158037..3d62075e4287 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/sync/bundle/route.ts b/src/app/api/sync/bundle/route.ts new file mode 100644 index 000000000000..a834cfb9499c --- /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 000000000000..cbb43b3bab65 --- /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 000000000000..4839a7feb519 --- /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/messages/count_tokens/route.ts b/src/app/api/v1/messages/count_tokens/route.ts index 55316f9eceda..33626549065d 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/ws/route.ts b/src/app/api/v1/ws/route.ts new file mode 100644 index 000000000000..6233c3ecc194 --- /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 d4cfedd2f621..f8a86c1a4b7e 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 @@ -104,35 +192,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; + + if (quarantined) { + setModelUnavailable(provider, model, cooldownMs, options.reason || "problematic_model"); + } - setModelUnavailable(provider, model, cooldownMs, options.reason || "problematic_model"); - return { cooldownMs, failureCount }; + return { + cooldownMs, + failureCount, + quarantined, + threshold, + resetAfterMs, + }; } /** diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 6dacc1d43302..55f9e6d815f2 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", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 92b43c80815a..fe08527d3bbb 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": "设置", @@ -3172,6 +3257,7 @@ "content": "内容", "created": "创建时间", "actions": "操作", + "delete": "删除", "factual": "事实型", "episodic": "情景型", "procedural": "程序型", diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 88bab3ae684c..4b945d1d51a2 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 ce521355c014..d289d6f7527f 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 581409ca9fbf..02ecd20a182d 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/compliance/index.ts b/src/lib/compliance/index.ts index b143ac42d783..e52685c1ed7e 100644 --- a/src/lib/compliance/index.ts +++ b/src/lib/compliance/index.ts @@ -10,12 +10,14 @@ */ import { getDbInstance } from "../db/core"; +import { getClientIpFromRequest } from "../ipUtils"; import { getAppLogRetentionDays, getCallLogRetentionDays, getCallLogsTableMaxRows, getProxyLogsTableMaxRows, } from "../logEnv"; +import { generateRequestId, getRequestId } from "@/shared/utils/requestId"; /** @returns {import("better-sqlite3").Database | null} */ function getDb() { @@ -26,13 +28,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 +153,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 +285,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 +347,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 ──────────────── @@ -372,6 +623,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 000000000000..b5e0d1cc7aef --- /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/encryption.ts b/src/lib/db/encryption.ts index 1cfcbda3f0a7..c26cd106240c 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; +} + +/** + * 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(); const applied = getAppliedVersions(db); - let count = 0; + 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 + } - for (const migration of files) { - if (applied.has(migration.version)) continue; + // ── Safety Check 2: Mass-migration detection (abort if existing DB + many migrations) ── + if ( + 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 pending) { const sql = fs.readFileSync(migration.path, "utf-8"); const applyMigration = db.transaction(() => { 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 000000000000..32887cc561b1 --- /dev/null +++ b/src/lib/db/migrations/022_add_memory_fts5.sql @@ -0,0 +1,59 @@ +-- 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); + +-- Create FTS5 virtual table for full-text search on memories +CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5( + content, + key, + content='memories', + content_rowid='id' +); + +-- KNOWN LIMITATION: content_rowid='id' requires an INTEGER rowid, but memories.id is TEXT (UUID). +-- SQLite FTS5 silently accepts the TEXT UUID as rowid during INSERT (line 15), but the internal rowid +-- won't match memories.id, causing FTS5 content-based inserts to fail lookup by actual rowid. +-- Production code (src/lib/memory/retrieval.ts) gracefully falls back to keyword scoring via getRelevanceScore() +-- when FTS5 matching fails. This is a pre-existing known limitation; fixing it requires: +-- (a) Adding an INTEGER primary key column to memories table, or +-- (b) Using external content FTS5 (content=) with proper INTEGER sync. +-- For now, keyword-based fallback is acceptable and maintains backward compatibility. + +-- Sync trigger for INSERT — keep FTS5 in sync when new memories are added +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; + +-- Sync trigger for DELETE — keep FTS5 in sync when memories are removed +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; + +-- Sync trigger for UPDATE — keep FTS5 in sync when memories are modified +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; + +-- Populate FTS5 table with existing memory data +INSERT INTO memory_fts(rowid, content, key) SELECT id, content, key FROM 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 000000000000..c11403665729 --- /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 000000000000..afc5745319ac --- /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/settings.ts b/src/lib/db/settings.ts index 646e24dcc938..4123386b2b8e 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 000000000000..ffa9c5149db5 --- /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 000000000000..7d3c9505c77e --- /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 d85ac54ae791..cc96ff74d6aa 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -235,6 +235,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 98ac6fbe4466..2688694d4c3c 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 b73de6af33ad..496f5dd8f100 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 ff5f40798f4e..8d1704e93cb2 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 c06d164c3676..4d39b44bca3d 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 d8ffc0cbc3f4..df9f554f5538 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 000000000000..ade9e7d20c73 --- /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 000000000000..0759e80f07c6 --- /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 6e681aab5a2d..8623100d7b83 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) : {}; - - 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); + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const raw = await fetchModelsDev(); + const pricing = transformModelsDevToPricing(raw); + const capabilities = syncCapabilities ? transformModelsDevToCapabilities(raw) : {}; + + 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 7b7200146ae9..fb8d499a64bc 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", diff --git a/src/lib/oauth/providers/antigravity.ts b/src/lib/oauth/providers/antigravity.ts index 034967979970..be053ee46443 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 fc4e377f1929..4db27b5e21de 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 c2e95fb56015..e772c2771431 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 2fafe8580e15..975a2886d417 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/skills/executor.ts b/src/lib/skills/executor.ts index cf258564b648..8d7db16810ae 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 134a1079b922..809c65f00b63 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 13ab0d89cc3f..afdb5de478f4 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 dd309e911bea..17f0b4b9f06f 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(); - - 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); + if (this.pendingLoad) { + await this.pendingLoad; + return; + } + if (!this.isCacheStale()) return; + + 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 000000000000..2c9ff803d823 --- /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 000000000000..4b39db7ccdbd --- /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 8f3d94410960..4522aa38a8b9 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/fetcher.ts b/src/lib/usage/fetcher.ts index b3723cd8173e..bf8c1c41bc9d 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/providerLimits.ts b/src/lib/usage/providerLimits.ts index c632ae4cab87..8cc239e74331 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 000000000000..8fafc39b53e5 --- /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 6e3a17b04d52..2837aaa8219b 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -4,27 +4,39 @@ import { generateRequestId } from "./shared/utils/requestId"; import { checkBodySize, getBodySizeLimit } from "./shared/middleware/bodySizeGuard"; import { isDraining } from "./lib/gracefulShutdown"; -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/sync/bundle", "/api/oauth/", ]; +const PUBLIC_READONLY_API_ROUTES = ["/api/settings/require-login"]; 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 isPublicApiRoute(pathname: string, method = "GET"): boolean { + if (PUBLIC_API_ROUTES.some((route) => pathname.startsWith(route))) { + return true; + } + + if (!["GET", "HEAD", "OPTIONS"].includes(method)) { + return false; + } + + return PUBLIC_READONLY_API_ROUTES.some((route) => pathname.startsWith(route)); +} + +function getJwtSecret(): Uint8Array { + return new TextEncoder().encode(process.env.JWT_SECRET || ""); } async function getApiAuthModule() { @@ -88,7 +100,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 +169,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 +180,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 bf83efd0eb0c..42056f25e49a 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 381b735f009c..3f05e3a34d80 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 76287b5957eb..91dd8d7ed2b1 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 df24b2c49ca9..c1b74ced955b 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 4e81d7923350..688d76449c2b 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}