diff --git a/.env.example b/.env.example index 844cb0659a..ec7a157df8 100644 --- a/.env.example +++ b/.env.example @@ -335,7 +335,21 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 # ── Qoder ── QODER_OAUTH_CLIENT_SECRET=4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW -# ── Qoder (URLs — set these to enable Qoder OAuth login) ── +# ── Qoder Browser OAuth (experimental) ── +# OmniRoute only enables the browser OAuth flow when ALL 5 variables below are set: +# - QODER_OAUTH_AUTHORIZE_URL +# - QODER_OAUTH_TOKEN_URL +# - QODER_OAUTH_USERINFO_URL +# - QODER_OAUTH_CLIENT_ID +# - QODER_OAUTH_CLIENT_SECRET +# +# Redirect URI to register in the Qoder OAuth app: +# - Localhost dev with PORT=20128: http://localhost:20128/callback +# - LAN access (example): http://192.168.0.15:20128/callback +# - Public domain (recommended): https://omniroute.example.com/callback +# +# Behind reverse proxy / public domain, also set NEXT_PUBLIC_BASE_URL to the same public origin. +# If these values are not available, prefer QODER_PERSONAL_ACCESS_TOKEN below. # QODER_OAUTH_AUTHORIZE_URL= # QODER_OAUTH_TOKEN_URL= # QODER_OAUTH_USERINFO_URL= @@ -449,6 +463,7 @@ GEMINI_CLI_USER_AGENT=google-api-nodejs-client/9.15.1 # ── Upstream fetch (provider calls) ── # FETCH_TIMEOUT_MS=600000 # Total request timeout (default: 600000 = 10 min) +# # Also drives anthropic-compatible-cc-* X-Stainless-Timeout. # FETCH_HEADERS_TIMEOUT_MS=600000 # Time to receive response headers # FETCH_BODY_TIMEOUT_MS=600000 # Time to receive full response body # FETCH_CONNECT_TIMEOUT_MS=30000 # TCP connection establishment (default: 30s) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c548c577fa..54357e91cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,14 +134,11 @@ jobs: build: name: Build runs-on: ubuntu-latest - strategy: - matrix: - node-version: [20, 22] steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: - node-version: ${{ matrix.node-version }} + node-version: 22 cache: npm - run: npm ci - run: npm run build @@ -151,9 +148,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 needs: build - strategy: - matrix: - node-version: [20, 22] env: JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-test-api-key-secret-long @@ -161,7 +155,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: - node-version: ${{ matrix.node-version }} + node-version: 22 cache: npm - run: npm ci - run: npm run test:unit diff --git a/AGENTS.md b/AGENTS.md index 134a0b64ae..2b3504b13f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,16 +64,11 @@ npm run test:protocols:e2e # Ecosystem compatibility tests npm run test:ecosystem -# Coverage (60% minimum for statements, lines, functions, and branches) +# Coverage (see CONTRIBUTING.md) npm run test:coverage ``` -### PR Coverage Policy - -- `npm run test:coverage` is the PR coverage gate in CI. -- The repository minimum is **60%** for statements, lines, functions, and branches. -- If a PR changes production code in `src/`, `open-sse/`, `electron/`, or `bin/`, it must include or update automated tests in the same PR. -- For agent-driven review or coding flows: if coverage is below the gate or source changes ship without tests, do not stop at reporting. Add or update tests first, rerun the gate, and only then ask for confirmation. +**For authoritative coverage requirements, test execution, and PR gates, see [`CONTRIBUTING.md`](CONTRIBUTING.md#running-tests).** --- @@ -141,9 +136,69 @@ All persistence uses SQLite through domain-specific modules: Schema migrations live in `db/migrations/` and run via `migrationRunner.ts`. `src/lib/localDb.ts` is a **re-export layer only** — never add logic there. +#### DB Internals + +- **`core.ts`**: `getDbInstance()` returns a singleton `better-sqlite3` instance with WAL + journaling. `SCHEMA_SQL` defines 15 base tables. Helpers: `rowToCamel`, `encryptConnectionFields`. +- **`migrationRunner.ts`**: Applies versioned SQL files from `db/migrations/` inside transactions. + Tracks applied migrations in `_omniroute_migrations` table. +- **Migrations**: 21 files (`001_initial_schema.sql` → `021_combo_call_log_targets.sql`). + Each migration is idempotent and runs in a transaction. +- **Domain modules** import `getDbInstance()` from `core.ts` for all CRUD operations. + Each module owns a specific table/set of tables (e.g., `providers.ts` → `provider_connections`, + `combos.ts` → `combos`). Encryption helpers protect sensitive fields at rest. +- **`localDb.ts`** re-exports all domain modules — consumers import from here for convenience. + +### API Route Layer (`src/app/api/v1/`) + +Next.js App Router routes — each follows a consistent pattern: + +``` +Route → CORS preflight → Body validation (Zod) → Optional auth (extractApiKey/isValidApiKey) + → API key policy enforcement (enforceApiKeyPolicy) → Handler delegation (open-sse) +``` + +| Route | Handler | Notes | +| ------------------------------- | ------------------------- | ----------------------------------------- | +| `chat/completions/route.ts` | `handleChat()` | + prompt injection guard (clones request) | +| `responses/route.ts` | `handleChat()` (unified) | Responses API format | +| `embeddings/route.ts` | `handleEmbedding()` | Model listing + creation | +| `images/generations/route.ts` | `handleImageGeneration()` | Model listing + creation | +| `audio/transcriptions/route.ts` | audio handler | Multipart form data | +| `audio/speech/route.ts` | TTS handler | Binary audio response | +| `videos/generations/route.ts` | video handler | ComfyUI/SD WebUI | +| `music/generations/route.ts` | music handler | ComfyUI workflows | +| `moderations/route.ts` | moderation handler | Content safety | +| `rerank/route.ts` | rerank handler | Document relevance | +| `search/route.ts` | search handler | Web search (5 providers) | + +**No global Next.js middleware file** — interception is route-specific. Auth is optional +(controlled by `REQUIRE_API_KEY` env). Prompt injection guard is unique to chat completions. + ### Request Pipeline (`open-sse/`) -`chatCore.ts` → executor → upstream provider. Translations in `open-sse/translator/`. +The `open-sse/` workspace is the core streaming engine. Full request flow: + +``` +Client Request + → src/app/api/v1/.../route.ts (Next.js route) + → open-sse/handlers/chatCore.ts::handleChatCore() + → Semantic/signature cache check + → Rate limit check (rateLimitManager) + → Combo routing? → open-sse/services/combo.ts::handleComboChat() + → resolveComboTargets() → ordered ResolvedComboTarget[] + → For each target: handleSingleModel() (wraps chatCore) + → translateRequest() (open-sse/translator/) + → Convert source format (e.g., OpenAI) → target format (e.g., Claude) + → getExecutor() → provider-specific executor instance + → executor.execute() (BaseExecutor → DefaultExecutor or provider-specific) + → buildUrl() + buildHeaders() + transformRequest() + → fetch() to upstream provider + → Retry logic with exponential backoff + → Response translation back to client format + → If Responses API: responsesTransformer.ts TransformStream + → SSE stream or JSON response to client +``` **Handlers** (`open-sse/handlers/`): `chatCore.ts`, `responsesHandler.ts`, `embeddings.ts`, `imageGeneration.ts`, `videoGeneration.ts`, `musicGeneration.ts`, `audioSpeech.ts`, @@ -180,15 +235,46 @@ Provider-specific request executors: `base.ts`, `default.ts`, `cursor.ts`, `code `antigravity.ts`, `github.ts`, `gemini-cli.ts`, `kiro.ts`, `qoder.ts`, `vertex.ts`, `cloudflare-ai.ts`, `opencode.ts`, `pollinations.ts`, `puter.ts`. +#### Executor Internals + +- **`base.ts`** (`BaseExecutor`): Abstract base with `buildUrl()`, `buildHeaders()`, + `transformRequest()`, retry logic (exponential backoff), and `execute()`. Subclasses + override URL/header/transform methods for provider-specific behavior. +- **`default.ts`** (`DefaultExecutor extends BaseExecutor`): Handles most OpenAI-compatible + providers. Reads provider config from `providerRegistry.ts` to resolve base URL, auth + header format, and request transformations. +- **`getExecutor()`** (`executors/index.ts`): Factory that returns the correct executor + instance based on provider ID. Provider-specific executors (Cursor, Codex, Vertex, etc.) + override only what differs from the default. + ### Translator (`open-sse/translator/`) Translates between API formats (OpenAI-format ↔ Anthropic, Gemini, etc.). Includes request/response translators with helpers for image handling. +#### Translator Internals + +- **`translator/index.ts`**: Exports `translateRequest()` and format constants. Called by + `chatCore.ts` before executor dispatch. +- **Flow**: `translateRequest(body, sourceFormat, targetFormat)` → detects source format + (OpenAI, Anthropic, Gemini) → applies the matching translator module → returns + transformed body ready for the target provider. +- **Response translation** runs in reverse after upstream response, converting back to + the client's expected format. + ### Transformer (`open-sse/transformer/`) `responsesTransformer.ts` — transforms Responses API format to/from Chat Completions format. +#### Transformer Internals + +- **`createResponsesApiTransformStream()`**: Returns a `TransformStream` that converts + Chat Completions SSE chunks (`data: {"choices":[...]}`) into Responses API SSE events + (`response.output_item.added`, `response.output_text.delta`, etc.). +- Used when the client sends a Responses API request: the request is internally converted + to Chat Completions format, dispatched normally, and the response is piped through this + transform stream before reaching the client. + ### Services (`open-sse/services/`) 36+ service modules including: `combo.ts` (routing engine), `usage.ts`, `tokenRefresh.ts`, @@ -198,6 +284,17 @@ Includes request/response translators with helpers for image handling. `emergencyFallback.ts`, `workflowFSM.ts`, `backgroundTaskDetector.ts`, `ipFilter.ts`, `signatureCache.ts`, `volumeDetector.ts`, `contextHandoff.ts`, and more. +#### Combo Routing Engine (`combo.ts`) + +- **`handleComboChat()`**: Entry point for combo-routed requests. Receives the combo config + and iterates through targets in order until one succeeds or all fail. +- **`resolveComboTargets()`**: Expands a combo configuration into an ordered array of + `ResolvedComboTarget[]`, each specifying provider + model + account + credentials. +- **Strategies** (13): priority, weighted, fill-first, round-robin, P2C, random, least-used, + cost-optimized, strict-random, auto, lkgp, context-optimized, context-relay. +- Each target calls **`handleSingleModel()`** which wraps `handleChatCore()` with + per-target error handling and circuit breaker checks. + ### Domain Layer (`src/domain/`) Policy engine modules: `policyEngine.ts`, `comboResolver.ts`, `costRules.ts`, @@ -217,12 +314,37 @@ best_combo_for_task, explain_route, get_session_snapshot, sync_pricing. **Skill tools** (4): skills_list, skills_enable, skills_execute, skills_executions. +#### MCP Internals + +- **Tool registration**: Each tool is an object with `{ name, description, inputSchema: ZodSchema, +handler: async (args) => {...} }`. Zod validates inputs before the handler fires. +- **`createMcpServer()`** and **`startMcpStdio()`** exported from `mcp-server/index.ts`. + `createMcpServer()` wires all tool sets; `startMcpStdio()` launches the stdio transport. +- **Transports**: stdio (CLI `omniroute --mcp`), SSE (`/api/mcp/sse`), Streamable HTTP + (`/api/mcp/stream`). All share the same tool/scope engine. +- **Scopes** (10): Control which tool categories an API key can access. Enforcement happens + before handler dispatch. +- **Audit**: Every tool invocation is logged to SQLite (`mcp_audit` table) with tool name, + args, success/failure, API key attribution, and timestamp. + ### A2A Server (`src/lib/a2a/`) -JSON-RPC 2.0, SSE streaming, Task Manager with TTL cleanup( +JSON-RPC 2.0, SSE streaming, Task Manager with TTL cleanup. Agent Card at `/.well-known/agent.json`. Skills: `quotaManagement.ts`, `smartRouting.ts`. +#### A2A Internals + +- **`taskManager.ts`**: State machine lifecycle for tasks: `submitted → working → +completed | failed | canceled`. Tasks have TTL and are cleaned up automatically. +- **JSON-RPC methods**: `message/send` (sync), `message/stream` (SSE), `tasks/get`, + `tasks/cancel`. Dispatched via `POST /a2a`. +- **Skills**: Registered in a DB-backed registry. Each skill receives task context + (messages, metadata) and returns structured results. `quotaManagement.ts` summarizes + quota; `smartRouting.ts` recommends routing decisions. +- **Agent Card**: `/.well-known/agent.json` exposes capabilities, skills, and metadata + for client auto-discovery. + ### ACP Module (`src/lib/acp/`) Agent Communication Protocol registry and manager. @@ -237,6 +359,19 @@ conversational memory across sessions. Extensible skill framework: registry, executor, sandbox, built-in skills, custom skill support, interception, and injection. +#### Skills Internals + +- **`registry.ts`**: DB-backed skill registration and discovery. Skills have metadata + (name, description, version, enabled status) stored in SQLite. +- **`executor.ts`**: Execution engine with configurable timeout and retry logic. + Receives skill name + input, looks up the skill, runs it in the sandbox. +- **`sandbox.ts`**: Isolation layer for custom (user-provided) skills. Limits resource + access and execution time. +- **Built-in skills**: Ship with OmniRoute (e.g., quota management, routing). Located + alongside the registry. +- **Interception/Injection**: Skills can intercept requests in the pipeline (pre/post + processing) or inject context into prompts. + ### Compliance (`src/lib/compliance/`) Policy index for compliance enforcement. @@ -259,6 +394,14 @@ Request middleware including `promptInjectionGuard.ts`. --- +## Subdirectory AGENTS.md Files + +- **[`open-sse/AGENTS.md`](open-sse/AGENTS.md)** — Streaming engine, request pipeline, handlers, and executors +- **[`src/lib/db/AGENTS.md`](src/lib/db/AGENTS.md)** — SQLite persistence, domain modules, migrations +- **[`open-sse/services/AGENTS.md`](open-sse/services/AGENTS.md)** — Routing engine, combo resolution, strategy selection + +--- + ## Review Focus - **DB ops** go through `src/lib/db/` modules, never raw SQL in routes diff --git a/CHANGELOG.md b/CHANGELOG.md index 3afd902107..55de93ba1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,54 @@ --- +## [3.6.5] — 2026-04-13 + +### ✨ New Features + +- **Antigravity AI Credits Fallback:** Automatically retries with `GOOGLE_ONE_AI` credit injection when free-tier quota is exhausted. Per-account credit balance (5-hour TTL) is cached from SSE `remainingCredits` and exposed as a numeric badge in the Provider Usage dashboard (#1190 — thanks @sFaxsy) +- **Claude Code Native Parity:** Full header/body signing parity with the Claude Code 2.1.87 OAuth client — CCH xxHash64 body signing with singleton WASM initialization promise (fixing race conditions), dynamic per-request fingerprint, bidirectional TitleCase ↔ lowercase tool name remapping (14 tools), API constraint enforcement (`temperature=1` for thinking, max 4 `cache_control` blocks, auto-inject ephemeral on last user message), and optional ZWJ obfuscation. Wired into `BaseExecutor` for automatic CCH signing on all `anthropic-compatible-cc-*` providers and into `chatCore` for synchronous parity pipeline steps (#1188 — thanks @RaviTharuma) +- **Per-Connection Codex Defaults:** Codex Fast Service Tier and Reasoning Effort settings are now per-connection instead of a single global toggle. Existing connections are migrated automatically on startup via an idempotent backfill migration (#1176 — thanks @rdself) +- **Cursor Usage Dashboard:** New `getCursorUsage()` fetches quotas from Cursor's `/api/usage`, `/api/auth/me`, and `/api/subscription` endpoints. Displays standard requests, on-demand usage, and per-plan limits (Free/Pro/Business/Team). Client version bumped to `3.1.0` and `x-cursor-user-agent` header added for parity +- **Database Health Check System:** Automated periodic SQLite integrity monitoring via `runDbHealthCheck()` — detects orphan quota/domain rows, broken combo references, stale snapshots, and invalid JSON state. Runs every 6 hours (configurable via `OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS`), with auto-repair and pre-repair backup. Exposed as **MCP tool #18** (`omniroute_db_health_check`) with Zod schemas and `autoRepair` option. Dashboard panel in Health page with status card, issue count, repaired count, and one-click repair button +- **OpenAI Responses API Store Opt-In:** Per-connection `openaiStoreEnabled` flag controls whether the `store` field is preserved or forced to `false` on Codex Responses API requests. When enabled, `previous_response_id`, `prompt_cache_key`, `session_id`, and `conversation_id` fields are round-tripped through the Chat Completions → Responses translation, enabling multi-turn context caching on supported providers +- **Email Privacy Toggle (Combos Page):** Global email visibility toggle (`EmailPrivacyToggle`) added to the Combos page header with responsive layout, tooltip guidance, and per-connection label masking via `pickDisplayValue()`. All combo builder options, provider connection lists, and quota screens now respect the global privacy state from `emailPrivacyStore` +- **skills.sh Integration:** Added `skills.sh` as an external skill provider. Users can now search, browse, and install agent skills directly from a new "skills.sh" tab in the Skills dashboard. Includes backend API resolvers, frontend implementation with search/install states, and a dedicated unit test suite (#1223 — thanks @RaviTharuma) +- **Stabilization Settings:** Added persistence support for `lkgpEnabled` and `backgroundDegradation` settings, integrated into `instrumentation-node.ts` for improved lifecycle awareness (#1212) +- **xxhash-wasm dependency:** Added `xxhash-wasm@^1.1.0` for CCH signing (xxHash64 with seed `0x6E52736AC806831E`) + +### 🐛 Bug Fixes + +- **Codex `stream: false` via Combo (ALL_ACCOUNTS_INACTIVE):** Fixed a critical bug where Codex combos returned `ALL_ACCOUNTS_INACTIVE` or empty content when the client sent `stream: false`. Root cause was triple: (1) `CodexExecutor.transformRequest()` mutated `body.stream` in-place to `true`, contaminating the combo's quality check which skipped validation thinking it was streaming; (2) the non-stream SSE parser used the wrong format (Chat Completions instead of Responses API) for Codex SSE output; (3) combo quality validation read the mutated `body.stream` instead of the client's original intent. Fixed by: cloning the body via `structuredClone()` in CodexExecutor, detecting Codex/Responses SSE format in the non-stream fallback path (with auto-translation back to Chat Completions), and capturing `clientRequestedStream` before the combo loop +- **Gemini CLI Tool Schema Rejection:** Fixed 400 Bad Request errors from the Google API by strictly filtering non-standard vendor extensions (starting with `x-`) and `deprecated` fields from tool parameter schemas (#1206) +- **SOCKS5 Proxy Interop (Node.js 22):** Resolved `invalid onRequestStart method` crashes caused by `undici` version mismatches between dispatchers and the built-in fetch. Hardened `proxyFetch.ts` to strictly use the library's fetch implementation for custom dispatchers (#1219) +- **Search Cache Coalescing with TTL=0:** Fixed a bug where providers configured with `cacheTTLMs: 0` (caching explicitly disabled) still had concurrent requests coalesced and returned `{ cached: true }`. Now each call gets its own independent upstream fetch (#1178 — thanks @sjhddh) +- **Antigravity Credit Cache Alignment (PR #1190):** Reconciled `accountId` derivation between `AntigravityExecutor.collectStreamToResponse` and `getAntigravityUsage` to use consistent cache keys (`email || sub || "unknown"`). Previously, SSE-parsed credit balances could be written under a different key than the one read by the usage dashboard, causing stale/missing credit badges +- **Non-streaming reasoning_content Duplication:** Fixed clients rendering duplicated reasoning panels when both `reasoning_content` and visible `content` were present in non-streaming responses. `responseSanitizer` now strips `reasoning_content` from messages that already have visible text content, preserving it only for reasoning-only messages +- **Streaming Regression Fix:** Hardened the `sanitize` TransformStream in the combo engine to strip both literal and JSON-escaped newline sequences, eliminating leading `\n\n` prefixes in assistant responses (#1211) +- **Gemini Empty Choice Fix:** Ensured initial assistant deltas always include an empty `content: ""` string to satisfy strict OpenAI client requirements and prevent empty choice responses in tools (#1209) +- **Gemini Tools Sanitizer Deduplication:** Extracted shared tool conversion logic into `buildGeminiTools()` helper (`geminiToolsSanitizer.ts`), eliminating duplicate implementations between `openai-to-gemini.ts` and `claude-to-gemini.ts`. The new helper correctly handles `web_search` / `web_search_preview` tool types by emitting `googleSearch` tools with priority over function declarations +- **Qwen/Qoder Thinking+Tool_Choice Conflict:** Added `sanitizeQwenThinkingToolChoice()` to both `DefaultExecutor` (for Qwen provider) and `QoderExecutor` to prevent provider-side 400 errors when clients send `tool_choice` alongside thinking/reasoning parameters that are mutually exclusive upstream +- **API Key Deletion Orphan Cleanup:** Deleting an API key now also removes associated `domain_budgets` and `domain_cost_history` rows, preventing orphan data accumulation +- **CC-compatible test assertion:** Fixed pre-existing test that expected no `cache_control` on system blocks — the billing header system block now carries `cache_control: { type: "ephemeral" }` per PR #1188 design +- **Codex Combo Smoke Test False Positives:** Fixed combo tests incorrectly reporting `ERROR` for valid Codex streaming responses when `response.output` is empty but text deltas were emitted. The summary now falls back to accumulated delta text (#1176 — thanks @rdself) +- **Electron Builder Version Mismatch:** Fixed Electron desktop startup failures on Windows packaged builds caused by native modules (`better-sqlite3`) being under `app.asar.unpacked` while helpers were in `app/node_modules`. `resolveServerNodePath()` now merges both locations with deduplication and existence checks (#1172 — thanks @backryun) + +### 🔧 Internal Improvements + +- **SSE Parser: Responses API Non-Stream Conversion:** Added full `parseSSEToResponsesOutput()` implementation in `sseParser.ts` (255+ lines) — reconstructs complete Responses API objects from SSE event streams, handling `response.output_text.delta/done`, `response.reasoning_summary_text.delta/done`, `response.function_call_arguments.delta/done`, and terminal events. Used by the new chatCore non-stream fallback path for Codex +- **Cursor Executor Version Sync:** Updated Cursor client User-Agent to `3.1.0` and centralized version constants (`CURSOR_CLIENT_VERSION`, `CURSOR_USER_AGENT`) for consistent fingerprinting across executor, usage fetcher, and OAuth flows +- **Responses API Translator Parity:** `convertResponsesApiFormat()` now accepts credentials and passes them through to the translator, enabling store-aware field propagation. Round-trip preservation of `previous_response_id`, `prompt_cache_key`, `session_id`, and `conversation_id` fields +- **Provider Schema Validation:** Added `openaiStoreEnabled` boolean validation to `providerSpecificData` Zod schema +- **Combo Error Response Normalization:** Empty combo targets now return 404 (`comboModelNotFoundResponse`) instead of generic 503, improving client-side error differentiation +- **Dependency Updates:** Bumps `typescript-eslint` to `8.58.2` (dev), `axios` to `1.15.0` (prod), and `next` to `16.2.2` (prod) (#1224, #1225) + +### ⚠️ Breaking Changes + +- **`DELETE /api/settings/codex-service-tier` removed:** This endpoint no longer exists. Codex Service Tier configuration has moved to per-connection `providerSpecificData.requestDefaults`. Existing connections are migrated automatically on first startup after upgrade. Any external scripts or integrations that call this endpoint should be updated — use `PUT /api/providers/:id` with `providerSpecificData.requestDefaults.serviceTier` instead (#1176). +- **CCH signing on CC-compatible providers:** All requests to `anthropic-compatible-cc-*` providers now include an xxHash64 integrity token (`cch=...`) in the billing header. Providers that do not validate CCH will ignore it (no behavioral change), but any custom middleware inspecting the billing header should expect a 5-character hex token instead of the `00000` placeholder + +--- + ## [3.6.4] — 2026-04-12 ### ✨ New Features diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..5f806ee437 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,225 @@ +# CLAUDE.md — AI Agent Session Bootstrap + +> Quick-start context for AI coding agents. For deep architecture details, see `AGENTS.md`. +> For contribution workflow, see `CONTRIBUTING.md`. + +## Quick Start + +```bash +npm install # Install deps (auto-generates .env from .env.example) +npm run dev # Dev server at http://localhost:20128 +npm run build # Production build (Next.js 16 standalone) +npm run lint # ESLint (0 errors expected; warnings are pre-existing) +npm run typecheck:core # TypeScript check (should be clean) +npm run test:coverage # Unit tests + coverage gate (60% min) +npm run check # lint + test combined +``` + +### Running a Single Test + +```bash +# Node.js native test runner (most tests) +node --import tsx/esm --test tests/unit/your-file.test.mjs + +# Vitest (MCP server, autoCombo, cache) +npm run test:vitest +``` + +--- + +## Project at a Glance + +**OmniRoute** — unified AI proxy/router. One endpoint, 100+ LLM providers, auto-fallback. + +| Layer | Location | Purpose | +| --------------- | ------------------------ | ------------------------------------------ | +| API Routes | `src/app/api/v1/` | Next.js App Router — entry points | +| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) | +| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch | +| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) | +| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc | +| Database | `src/lib/db/` | SQLite domain modules (22 files) | +| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | +| MCP Server | `open-sse/mcp-server/` | 25 tools, 3 transports, 10 scopes | +| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol | +| Skills | `src/lib/skills/` | Extensible skill framework | +| Memory | `src/lib/memory/` | Persistent conversational memory | +| UI Components | `src/shared/components/` | React components (Tailwind CSS v4) | +| Provider Consts | `src/shared/constants/` | Provider registry (Zod-validated) | +| Validation | `src/shared/validation/` | Zod v4 schemas | +| Tests | `tests/` | Unit, integration, e2e, security, load | + +### Monorepo Layout + +``` +OmniRoute/ # Root package +├── src/ # Next.js 16 app (TypeScript) +├── open-sse/ # @omniroute/open-sse workspace (streaming engine) +├── electron/ # Desktop app (Electron) +├── tests/ # All test suites +├── docs/ # Documentation +└── bin/ # CLI entry point +``` + +--- + +## Request Pipeline (Abbreviated) + +``` +Client → /v1/chat/completions (Next.js route) + → CORS → Zod validation → auth? → policy check → prompt injection guard + → handleChatCore() [open-sse/handlers/chatCore.ts] + → cache check → rate limit → combo routing? + → resolveComboTargets() → handleSingleModel() per target + → translateRequest() → getExecutor() → executor.execute() + → fetch() upstream → retry w/ backoff + → response translation → SSE stream or JSON +``` + +--- + +## Key Conventions + +### Code Style + +- **2 spaces**, semicolons, double quotes, 100 char width, es5 trailing commas +- **Imports**: external → internal (`@/`, `@omniroute/open-sse`) → relative +- **Naming**: files=camelCase/kebab, components=PascalCase, constants=UPPER_SNAKE + +### Database Access + +- **Always** go through `src/lib/db/` domain modules +- **Never** write raw SQL in routes or handlers +- **Never** add logic to `src/lib/localDb.ts` (re-export layer only) +- **Never** barrel-import from `localDb.ts` — import specific `db/` modules +- DB singleton: `getDbInstance()` from `src/lib/db/core.ts` (WAL journaling) +- Migrations: `src/lib/db/migrations/` — 21 versioned SQL files + +### Error Handling + +- try/catch with specific error types, log with pino context +- Never swallow errors in SSE streams — use abort signals +- Return proper HTTP status codes (4xx/5xx) + +### Security + +- **Never** commit secrets/credentials +- **Never** use `eval()`, `new Function()`, or implied eval +- Validate all inputs with Zod schemas +- Encrypt credentials at rest (AES-256-GCM) + +--- + +## Common Modification Scenarios + +### Adding a New Provider + +1. Register in `src/shared/constants/providers.ts` (Zod-validated at load) +2. Add executor in `open-sse/executors/` if custom logic needed +3. Add translator in `open-sse/translator/` if non-OpenAI format +4. Add OAuth config in `src/lib/oauth/constants/oauth.ts` if OAuth-based +5. Register models in `open-sse/config/providerRegistry.ts` +6. Write tests in `tests/unit/` (registration, translation, error handling) + +### Adding a New API Route + +1. Create directory under `src/app/api/v1/your-route/` +2. Create `route.ts` with `GET`/`POST` handlers +3. Follow pattern: CORS → Zod body validation → optional auth → handler delegation +4. Handler goes in `open-sse/handlers/` (import from there, not inline) +5. Add tests + +### Adding a New DB Module + +1. Create `src/lib/db/yourModule.ts` +2. Import `getDbInstance` from `./core.ts` +3. Export CRUD functions for your domain table(s) +4. Add migration in `src/lib/db/migrations/` if new tables needed +5. Re-export from `src/lib/localDb.ts` (add to the re-export list only) +6. Write tests + +### Adding a New MCP Tool + +1. Add tool definition in `open-sse/mcp-server/tools/` +2. Define Zod input schema + async handler +3. Register in tool set (wired by `createMcpServer()`) +4. Assign to appropriate scope(s) +5. Write tests (tool invocation logged to `mcp_audit` table) + +### Adding a New A2A Skill + +1. Create skill in `src/lib/a2a/skills/` +2. Skill receives task context (messages, metadata) → returns structured result +3. Register in the DB-backed skill registry +4. Write tests + +--- + +## Testing Cheat Sheet + +| What | Command | +| ----------------------- | ------------------------------------------------------- | +| All tests | `npm run test:all` | +| Unit tests | `npm run test:unit` | +| Single file | `node --import tsx/esm --test tests/unit/file.test.mjs` | +| Vitest (MCP, autoCombo) | `npm run test:vitest` | +| E2E (Playwright) | `npm run test:e2e` | +| Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` | +| Ecosystem | `npm run test:ecosystem` | +| Coverage gate | `npm run test:coverage` (60% min all metrics) | +| Coverage report | `npm run coverage:report` | + +**PR rule**: If you change production code in `src/`, `open-sse/`, `electron/`, or `bin/`, +you must include or update tests in the same PR. + +--- + +## Git Workflow + +```bash +# Never commit directly to main +git checkout -b feat/your-feature +# ... make changes ... +git commit -m "feat: describe your change" +git push -u origin feat/your-feature +``` + +**Branch prefixes**: `feat/`, `fix/`, `refactor/`, `docs/`, `test/`, `chore/` + +**Commit format** ([Conventional Commits](https://www.conventionalcommits.org/)): + +``` +feat: add circuit breaker for provider calls +fix: resolve JWT secret validation edge case +docs: update AGENTS.md with pipeline internals +test: add MCP tool unit tests +refactor(db): consolidate rate limit tables +``` + +**Scopes**: `db`, `sse`, `oauth`, `dashboard`, `api`, `cli`, `docker`, `ci`, `mcp`, `a2a`, +`memory`, `skills`. + +--- + +## Environment + +- **Runtime**: Node.js ≥18 <24, ES Modules +- **TypeScript**: 5.9, target ES2022, module esnext, resolution bundler +- **Path aliases**: `@/*` → `src/`, `@omniroute/open-sse` → `open-sse/` +- **Default port**: 20128 (API + dashboard on same port) +- **Data directory**: `DATA_DIR` env var, defaults to `~/.omniroute/` +- **Key env vars**: `PORT`, `JWT_SECRET`, `INITIAL_PASSWORD`, `REQUIRE_API_KEY`, `APP_LOG_LEVEL` + +--- + +## Hard Rules (Never Violate) + +1. Never commit secrets or credentials +2. Never add logic to `localDb.ts` +3. Never use `eval()` / `new Function()` / implied eval +4. Never commit directly to `main` +5. Never write raw SQL in routes — use `src/lib/db/` modules +6. Never silently swallow errors in SSE streams +7. Always validate inputs with Zod schemas +8. Always include tests when changing production code +9. Coverage must stay ≥60% (statements, lines, functions, branches) diff --git a/README.md b/README.md index a6d9876fa1..e4411783c1 100644 --- a/README.md +++ b/README.md @@ -799,6 +799,8 @@ For most deployments, you only need: 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. + Advanced overrides are available if you need finer control: | Variable | Default | Purpose | diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9d6c58ea44..5350b10d54 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.6.4 + version: 3.6.5 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/main.js b/electron/main.js index ac2c8fa7e7..acf0b94e5f 100644 --- a/electron/main.js +++ b/electron/main.js @@ -71,6 +71,37 @@ function resolveNodeExecutable(env = process.env) { return process.execPath; } +function resolveServerNodePath(env = process.env) { + const seen = new Set(); + const entries = []; + + const addEntry = (entry) => { + if (!entry || typeof entry !== "string") return; + const trimmed = entry.trim(); + if (!trimmed) return; + const normalized = path.normalize(trimmed); + if (seen.has(normalized)) return; // already included + if (!fs.existsSync(normalized)) { + console.debug("[Electron] NODE_PATH candidate not found (skipped):", normalized); + return; + } + seen.add(normalized); + entries.push(normalized); + }; + + for (const existing of (env.NODE_PATH || "").split(path.delimiter)) { + addEntry(existing); + } + + // Electron-builder installs native modules like better-sqlite3 under + // app.asar.unpacked, while the standalone bundle still carries helper deps + // such as bindings/file-uri-to-path inside resources/app/node_modules. + addEntry(path.join(process.resourcesPath, "app.asar.unpacked", "node_modules")); + addEntry(path.join(NEXT_SERVER_PATH, "node_modules")); + + return entries.join(path.delimiter); +} + function resolveDataDir(overridePath, env = process.env) { if (overridePath && overridePath.trim()) return path.resolve(overridePath); @@ -538,7 +569,7 @@ function startNextServer() { PORT: String(serverPort), NODE_ENV: "production", ELECTRON_RUN_AS_NODE: "1", - NODE_PATH: path.join(process.resourcesPath, "app.asar.unpacked", "node_modules"), + NODE_PATH: resolveServerNodePath(serverEnv), }, stdio: "pipe", }); diff --git a/electron/package.json b/electron/package.json index 4b72db1048..c8a6cfdb02 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.6.4", + "version": "3.6.5", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/open-sse/config/cliFingerprints.ts b/open-sse/config/cliFingerprints.ts index 3a18d2af3d..607d2bd8b6 100644 --- a/open-sse/config/cliFingerprints.ts +++ b/open-sse/config/cliFingerprints.ts @@ -87,6 +87,7 @@ export const CLI_FINGERPRINTS: Record = { "x-app", "User-Agent", "X-Claude-Code-Session-Id", + "x-client-request-id", "X-Stainless-Retry-Count", "X-Stainless-Timeout", "X-Stainless-Lang", @@ -97,14 +98,15 @@ export const CLI_FINGERPRINTS: Record = { "X-Stainless-Runtime-Version", "Accept", "accept-language", - "sec-fetch-mode", "accept-encoding", + "Connection", ], bodyFieldOrder: [ "model", "messages", "system", "tools", + "tool_choice", "metadata", "max_tokens", "thinking", diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 38816c9559..9de66365e2 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -570,9 +570,9 @@ export const REGISTRY: Record = { "connect-accept-encoding": "gzip", "connect-protocol-version": "1", "Content-Type": "application/connect+proto", - "User-Agent": "connect-es/1.6.1", + "User-Agent": "Cursor/3.1.0", }, - clientVersion: "1.1.3", + clientVersion: "3.1.0", models: [ { id: "default", name: "Auto (Server Picks)" }, { id: "claude-4.6-opus-high-thinking", name: "Claude 4.6 Opus High Thinking" }, diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index e88599a3a6..d56403b98d 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -1,12 +1,61 @@ import crypto, { randomUUID } from "crypto"; import { BaseExecutor, mergeUpstreamExtraHeaders } from "./base.ts"; import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS } from "../config/constants.ts"; +import { scrubProxyAndFingerprintHeaders } from "../services/antigravityHeaderScrub.ts"; +import { antigravityUserAgent, googApiClientHeader } from "../services/antigravityHeaders.ts"; +import { classify429, decide429, type Decision } from "../services/antigravity429Engine.ts"; +import { + injectCreditsField, + shouldRetryWithCredits, + handleCreditsFailure, +} from "../services/antigravityCredits.ts"; +import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts"; const MAX_RETRY_AFTER_MS = 60_000; const LONG_RETRY_THRESHOLD_MS = 60_000; +const CREDITS_EXHAUSTED_TTL_MS = 5 * 60 * 60 * 1000; // 5 hours const BARE_PRO_IDS = new Set(["gemini-3.1-pro"]); +/** + * Per-account GOOGLE_ONE_AI credits-exhausted tracker. + * Key: accountId (OAuth subject / email). Value: expiry timestamp. + * When credits hit 0 we skip the credit retry for CREDITS_EXHAUSTED_TTL_MS. + */ +const creditsExhaustedUntil = new Map(); + +/** + * Per-account GOOGLE_ONE_AI remaining credit balance cache. + * Populated from the final SSE chunk's `remainingCredits` field after every + * successful credit-injected request. Keyed by accountId. + */ +const creditBalanceCache = new Map(); + +/** Read the last-known GOOGLE_ONE_AI credit balance for a given account. */ +export function getAntigravityRemainingCredits(accountId: string): number | null { + const balance = creditBalanceCache.get(accountId); + return balance !== undefined ? balance : null; +} + +/** Update the balance cache — called when we parse `remainingCredits` from an SSE stream. */ +export function updateAntigravityRemainingCredits(accountId: string, balance: number): void { + creditBalanceCache.set(accountId, balance); +} + +function isCreditsExhausted(accountId: string): boolean { + const until = creditsExhaustedUntil.get(accountId); + if (!until) return false; + if (Date.now() >= until) { + creditsExhaustedUntil.delete(accountId); + return false; + } + return true; +} + +function markCreditsExhausted(accountId: string): void { + creditsExhaustedUntil.set(accountId, Date.now() + CREDITS_EXHAUSTED_TTL_MS); +} + /** * Strip provider prefixes (e.g. "antigravity/model" → "model"). * Ensures the model name sent to the upstream API never contains a routing prefix. @@ -39,13 +88,16 @@ export class AntigravityExecutor extends BaseExecutor { } buildHeaders(credentials, stream = true) { - return { + const raw = { "Content-Type": "application/json", Authorization: `Bearer ${credentials.accessToken}`, - "User-Agent": this.config.headers?.["User-Agent"] || "antigravity/1.104.0 darwin/arm64", - "X-OmniRoute-Source": "omniroute", + "User-Agent": antigravityUserAgent(), + "X-Goog-Api-Client": googApiClientHeader(), Accept: "text/event-stream", + "X-OmniRoute-Source": "omniroute", }; + // Scrub proxy/fingerprint headers that reveal non-native traffic + return scrubProxyAndFingerprintHeaders(raw); } transformRequest(model, body, stream, credentials) { @@ -119,6 +171,20 @@ export class AntigravityExecutor extends BaseExecutor { const upstreamModel = cleanModelName(model); + // Obfuscate sensitive client names in user content (e.g. "OpenCode", "Cursor") + const requestContents = transformedRequest.contents; + if (Array.isArray(requestContents)) { + for (const msg of requestContents) { + if (Array.isArray(msg.parts)) { + for (const part of msg.parts) { + if (typeof part.text === "string") { + part.text = obfuscateSensitiveWords(part.text); + } + } + } + } + } + return { ...body, project: projectId, @@ -213,7 +279,12 @@ export class AntigravityExecutor extends BaseExecutor { if (match[2]) totalMs += parseInt(match[2]) * 60 * 1000; // minutes if (match[3]) totalMs += parseInt(match[3]) * 1000; // seconds - return totalMs > 0 ? totalMs : null; + // "reset after 0s" = burst/RPM limit, not quota exhaustion. + // Return a minimum backoff so the auto-retry loop handles it + // instead of falling through to the 24h exhaustion classifier. + if (totalMs === 0) return 2_000; // 2s minimum burst-limit backoff + + return totalMs; } /** @@ -259,6 +330,7 @@ export class AntigravityExecutor extends BaseExecutor { let textContent = ""; let finishReason = "stop"; let usage: Record | null = null; + let remainingCredits: Array<{ creditType: string; creditAmount: string }> | null = null; const lines = rawSSE.split("\n"); for (const line of lines) { const trimmed = line.trim(); @@ -289,6 +361,10 @@ export class AntigravityExecutor extends BaseExecutor { total_tokens: um.totalTokenCount || 0, }; } + // Credit balance — arrives in the final chunk alongside consumedCredits + if (Array.isArray(parsed?.remainingCredits)) { + remainingCredits = parsed.remainingCredits; + } } catch (e) { log?.debug?.("SSE_PARSE", `Skipping malformed SSE line: ${payload.slice(0, 80)}`); } @@ -307,6 +383,8 @@ export class AntigravityExecutor extends BaseExecutor { }, ], ...(usage && { usage }), + // Expose credit balance for upstream consumers (usage service, dashboard) + ...(remainingCredits && { _remainingCredits: remainingCredits }), }; const syntheticStatus = timedOut ? 504 : response.status; @@ -334,6 +412,12 @@ export class AntigravityExecutor extends BaseExecutor { // non-streaming Response so chatCore's non-streaming path stays unchanged. const upstreamStream = true; + // Account ID for credits-exhausted tracking. + // Key must match getAntigravityUsage() in fetcher.ts (providerSpecificData?.email || sub). + // credentials.email and credentials.sub are populated from the same OAuth token store, + // so the cache keys written here and read in the fetcher will always match. + const accountId: string = credentials?.email || credentials?.sub || "unknown"; + for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, upstreamStream, urlIndex); const headers = this.buildHeaders(credentials, upstreamStream); @@ -369,30 +453,82 @@ export class AntigravityExecutor extends BaseExecutor { const errorBody = await response.clone().text(); const errorJson = JSON.parse(errorBody); const errorMessage = errorJson?.error?.message || errorJson?.message || ""; - retryMs = this.parseRetryFromErrorMessage(errorMessage); - if (!retryMs) { - // Dynamic quota interpretation logic for Free vs Pro accounts - const lowerMsg = errorMessage.toLowerCase(); + // 1. Try to parse explicit retry time from message + const parsedRetryMs = this.parseRetryFromErrorMessage(errorMessage); - if ( - lowerMsg.includes("free tier") || - lowerMsg.includes("exhausted your capacity") || - lowerMsg.includes("daily limit") || - lowerMsg.includes("quota exceeded") - ) { - // Hard limit hit for Free accounts (or exhausting general capacity), fallback immediately. - // Setting a massive retryMs forces an instant fallback. - retryMs = 24 * 60 * 60 * 1000; // 24 hours - } else if ( - lowerMsg.includes("pro") || - lowerMsg.includes("per minute") || - lowerMsg.includes("rpm") - ) { - // RPM limit for Pro counts, backoff up to 1 minute, then fallback - retryMs = 60 * 1000; // 60s + // 2. Classify 429 + const category = classify429(errorMessage); + + // 3. For quota_exhausted, attempt Google One AI credits retry FIRST! + if ( + category === "quota_exhausted" && + shouldRetryWithCredits( + credentials?.accessToken || "", + process.env.ANTIGRAVITY_CREDITS === "1" || + process.env.ANTIGRAVITY_CREDITS === "true" + ) + ) { + log?.info?.("AG_CREDITS", "Retrying with Google One AI credits"); + const creditsBody = injectCreditsField(transformedBody); + try { + const creditsResp = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(creditsBody), + signal, + }); + if (creditsResp.ok || creditsResp.status !== HTTP_STATUS.RATE_LIMITED) { + log?.info?.("AG_CREDITS", `Credits retry succeeded: ${creditsResp.status}`); + if (!stream) { + const collected = await this.collectStreamToResponse( + creditsResp, + model, + url, + headers, + creditsBody, + log, + signal + ); + // Parse _remainingCredits from the synthetic response and cache + try { + const syntheticJson = await collected.response.clone().json(); + const rc = syntheticJson?._remainingCredits; + if (Array.isArray(rc)) { + const googleCredit = rc.find((c) => c.creditType === "GOOGLE_ONE_AI"); + if (googleCredit) { + const balance = parseInt(googleCredit.creditAmount, 10); + if (!isNaN(balance)) + updateAntigravityRemainingCredits(accountId, balance); + } + } + } catch { + /**/ + } + return collected; + } + return { response: creditsResp, url, headers, transformedBody: creditsBody }; + } + + // Credit retry also 429'd + handleCreditsFailure(credentials?.accessToken || ""); + log?.warn?.("AG_CREDITS", "Credits retry also 429'd"); + + // Also mark in our legacy exhaustion map to avoid retrying other routes + markCreditsExhausted(accountId); + } catch (creditsErr) { + handleCreditsFailure(credentials?.accessToken || ""); + log?.warn?.("AG_CREDITS", `Credits retry failed: ${creditsErr}`); } } + + // 4. Decide final retry time (apply 4-tier engine) + const decision: Decision = decide429(category, parsedRetryMs); + retryMs = decision.retryAfterMs; + log?.debug?.( + "AG_429", + `Category: ${category}, Decision: ${decision.kind} — ${decision.reason}` + ); } catch (e) { // Ignore parse errors, will fall back to exponential backoff } diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 58301632d3..20e149f8dc 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1,7 +1,8 @@ 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 } from "../services/provider.ts"; +import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts"; +import { signRequestBody } from "../services/claudeCodeCCH.ts"; /** * Sanitizes a custom API path to prevent path traversal attacks. @@ -329,6 +330,13 @@ export class BaseExecutor { bodyString = fingerprinted.bodyString; } + // CCH signing: Claude Code-compatible providers require an xxHash64 integrity + // token over the serialized body. Sign after fingerprint ordering so the hash + // covers the exact bytes that will be sent upstream. + if (isClaudeCodeCompatible(this.provider)) { + bodyString = await signRequestBody(bodyString); + } + mergeUpstreamExtraHeaders(finalHeaders, upstreamExtraHeaders); const fetchOptions: RequestInit = { diff --git a/open-sse/executors/cliproxyapi.ts b/open-sse/executors/cliproxyapi.ts index 77ba640d38..83549c8334 100644 --- a/open-sse/executors/cliproxyapi.ts +++ b/open-sse/executors/cliproxyapi.ts @@ -1,8 +1,31 @@ -import { BaseExecutor, mergeUpstreamExtraHeaders, mergeAbortSignals } from "./base.ts"; +/** + * CLIProxyAPI Executor — routes requests to a local CLIProxyAPI instance. + * + * Always uses the OpenAI-compatible /v1/chat/completions endpoint. CLIProxyAPI + * internally detects Claude models and routes them through its Claude executor + * with full emulation (CCH signing, billing header, system prompt, uTLS, + * multi-account rotation, device profile learning, etc.). + * + * The UI toggle (cliproxyapiMode in providerSpecificData) controls WHETHER + * to use CLIProxyAPI as the backend, not the wire format. Response format + * is always OpenAI-compatible, so chatCore's SSE parsing works unchanged. + * + * Activation: + * 1. Per-provider upstream_proxy_config (mode=cliproxyapi or fallback) + * 2. Per-connection cliproxyapiMode toggle in providerSpecificData (UI) + */ + +import { + BaseExecutor, + mergeUpstreamExtraHeaders, + mergeAbortSignals, + type ProviderCredentials, +} from "./base.ts"; import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; const DEFAULT_PORT = 8317; const DEFAULT_HOST = "127.0.0.1"; +const HEALTH_CHECK_TIMEOUT_MS = 5000; function resolveCliproxyapiBaseUrl(): string { const host = process.env.CLIPROXYAPI_HOST || DEFAULT_HOST; @@ -12,6 +35,16 @@ function resolveCliproxyapiBaseUrl(): string { export { resolveCliproxyapiBaseUrl }; +/** + * Check if a connection has CLIProxyAPI deep mode enabled via UI toggle. + * Used by chatCore's resolveExecutorWithProxy to decide routing. + */ +export function isCliproxyapiDeepModeEnabled( + providerSpecificData?: Record | null +): boolean { + return providerSpecificData?.cliproxyapiMode === "claude-native"; +} + export class CliproxyapiExecutor extends BaseExecutor { private readonly upstreamBaseUrl: string; @@ -25,20 +58,27 @@ export class CliproxyapiExecutor extends BaseExecutor { this.upstreamBaseUrl = effectiveBase; } - buildUrl(_model: string, _stream: boolean, _urlIndex = 0): string { + buildUrl( + _model: string, + _stream: boolean, + _urlIndex = 0, + _credentials: ProviderCredentials | null = null + ): string { + // Always OpenAI-compatible. CLIProxyAPI detects Claude models internally + // and applies full emulation (CCH, billing header, system prompt, uTLS). return `${this.upstreamBaseUrl}/v1/chat/completions`; } - buildHeaders(credentials: any, stream = true): Record { + buildHeaders(credentials: ProviderCredentials | null, stream = true): Record { + const key = credentials?.apiKey || credentials?.accessToken; + const headers: Record = { "Content-Type": "application/json", }; - const key = credentials?.apiKey || credentials?.accessToken; if (key) { headers["Authorization"] = `Bearer ${key}`; } - if (stream) { headers["Accept"] = "text/event-stream"; } @@ -46,23 +86,32 @@ export class CliproxyapiExecutor extends BaseExecutor { return headers; } - transformRequest(model: string, body: any, _stream: boolean, _credentials: any): any { - if (body && typeof body === "object" && body.model !== model) { - return { ...body, model }; + transformRequest( + model: string, + body: unknown, + _stream: boolean, + _credentials: ProviderCredentials | null + ): unknown { + if (!body || typeof body !== "object") return body; + + const transformed = { ...(body as Record) }; + if (transformed.model !== model) { + transformed.model = model; } - return body; + + return transformed; } async execute(input: { model: string; body: unknown; stream: boolean; - credentials: any; + credentials: ProviderCredentials; signal?: AbortSignal | null; log?: any; upstreamExtraHeaders?: Record | null; }) { - const url = this.buildUrl(input.model, input.stream); + const url = this.buildUrl(input.model, input.stream, 0, input.credentials); const headers = this.buildHeaders(input.credentials, input.stream); const transformedBody = this.transformRequest( input.model, @@ -77,6 +126,11 @@ export class CliproxyapiExecutor extends BaseExecutor { ? mergeAbortSignals(input.signal, timeoutSignal) : timeoutSignal; + input.log?.info?.( + "CPA", + `CLIProxyAPI → ${url} (model: ${input.model})` + ); + const response = await fetch(url, { method: "POST", headers, @@ -90,6 +144,29 @@ export class CliproxyapiExecutor extends BaseExecutor { return { response, url, headers, transformedBody }; } + + /** + * Health check — verifies CLIProxyAPI is reachable. + */ + async healthCheck(): Promise<{ ok: boolean; latencyMs: number; error?: string }> { + const start = Date.now(); + try { + const res = await fetch(`${this.upstreamBaseUrl}/health`, { + signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), + }); + return { + ok: res.ok, + latencyMs: Date.now() - start, + ...(!res.ok ? { error: `HTTP ${res.status}` } : {}), + }; + } catch (err) { + return { + ok: false, + latencyMs: Date.now() - start, + error: err instanceof Error ? err.message : String(err), + }; + } + } } export default CliproxyapiExecutor; diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 5e08855074..967cbc990d 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -1,7 +1,12 @@ +import { + getCodexRequestDefaults, + isOpenAIResponsesStoreEnabled, +} from "@/lib/providers/requestDefaults"; import { BaseExecutor } from "./base.ts"; import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.ts"; import { PROVIDERS } from "../config/constants.ts"; import { refreshCodexToken } from "../services/tokenRefresh.ts"; +import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts"; // ─── T09: Codex vs Spark Scope-Aware Rate Limiting ──────────────────────── // Codex has two independent quota pools: "codex" (standard) and "spark" (premium). @@ -160,7 +165,6 @@ export function getCodexDualWindowCooldownMs( const EFFORT_ORDER = ["none", "low", "medium", "high", "xhigh"] as const; type EffortLevel = (typeof EFFORT_ORDER)[number]; const CODEX_FAST_WIRE_VALUE = "priority"; -let defaultFastServiceTierEnabled = false; function stringifyCodexInstructionContent(content: unknown): string { if (typeof content === "string") { @@ -285,10 +289,6 @@ function normalizeServiceTierValue(value: unknown): string | undefined { return normalized; } -export function setDefaultFastServiceTierEnabled(enabled: boolean): void { - defaultFastServiceTierEnabled = enabled; -} - /** * Maximum reasoning effort allowed per Codex model. * Models not listed here default to "xhigh" (unrestricted). @@ -318,6 +318,18 @@ function clampEffort(model: string, requested: string): string { return requested; } +function normalizeEffortValue(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.trim().toLowerCase(); + return normalized || undefined; +} + +function consumeResponsesStoreMarker(body: Record): unknown { + const marker = body._omnirouteResponsesStore; + delete body._omnirouteResponsesStore; + return marker; +} + /** * Codex Executor - handles OpenAI Codex API (Responses API format) * Automatically injects default instructions if missing. @@ -392,8 +404,18 @@ export class CodexExecutor extends BaseExecutor { * Transform request before sending - inject default instructions if missing */ transformRequest(model, body, stream, credentials) { + // Do not mutate the caller's payload in place. Combo quality checks and + // other post-execute paths still inspect the original request body. + body = + body && typeof body === "object" ? structuredClone(body) : ({} as Record); + const nativeCodexPassthrough = body?._nativeCodexPassthrough === true; const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath); + const requestDefaults = getCodexRequestDefaults(credentials?.providerSpecificData); + const storeEnabled = isOpenAIResponsesStoreEnabled(credentials?.providerSpecificData); + const thinkingBudgetConfig = getThinkingBudgetConfig(); + const allowConnectionReasoningDefaults = thinkingBudgetConfig.mode === ThinkingMode.PASSTHROUGH; + const responsesStoreMarker = consumeResponsesStoreMarker(body); // Codex /responses rejects stream=false, but /responses/compact rejects the stream field entirely. if (isCompactRequest) { @@ -407,8 +429,8 @@ export class CodexExecutor extends BaseExecutor { const requestServiceTier = normalizeServiceTierValue(body.service_tier); if (requestServiceTier) { body.service_tier = requestServiceTier; - } else if (defaultFastServiceTierEnabled) { - body.service_tier = CODEX_FAST_WIRE_VALUE; + } else if (requestDefaults.serviceTier) { + body.service_tier = requestDefaults.serviceTier; } // If no instructions provided, inject default Codex instructions @@ -418,8 +440,11 @@ export class CodexExecutor extends BaseExecutor { body.instructions = CODEX_DEFAULT_INSTRUCTIONS; } - // Ensure store is false (Codex requirement) - body.store = false; + if (!storeEnabled) { + body.store = false; + } else if (responsesStoreMarker !== undefined && body.store === undefined) { + body.store = responsesStoreMarker; + } // Cursor can send native Responses payloads with role=system items inside `input`. // Codex rejects system messages there; they must be folded into `instructions`. @@ -435,38 +460,43 @@ export class CodexExecutor extends BaseExecutor { delete body.messages; delete body.prompt; - if (nativeCodexPassthrough) { - return body; - } - - // Extract thinking level from model name suffix - // e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default) const effortLevels = ["none", "low", "medium", "high", "xhigh"]; let modelEffort: string | null = null; - // Track the clean model name (suffix stripped) for clamp lookup - let cleanModel = model; + let cleanModel = typeof body.model === "string" ? body.model : model; for (const level of effortLevels) { - if (model.endsWith(`-${level}`)) { + if (typeof cleanModel === "string" && cleanModel.endsWith(`-${level}`)) { modelEffort = level; - // Strip suffix from model name for actual API call - body.model = body.model.replace(`-${level}`, ""); + body.model = cleanModel.slice(0, -`-${level}`.length); cleanModel = body.model; break; } } - // Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium) - if (!body.reasoning) { - const rawEffort = body.reasoning_effort || modelEffort || "medium"; - // Clamp effort to the model's maximum allowed level (feature-07) - const effort = clampEffort(cleanModel, rawEffort); - body.reasoning = { effort }; - } else if (body.reasoning.effort) { - // Also clamp if reasoning object was provided directly - body.reasoning.effort = clampEffort(cleanModel, body.reasoning.effort); + const explicitReasoning = normalizeEffortValue(body?.reasoning?.effort); + const requestReasoningEffort = normalizeEffortValue(body.reasoning_effort); + const fallbackReasoningEffort = allowConnectionReasoningDefaults + ? requestDefaults.reasoningEffort || "medium" + : undefined; + const rawEffort = + explicitReasoning || requestReasoningEffort || modelEffort || fallbackReasoningEffort; + + if (explicitReasoning) { + body.reasoning = { + ...(body.reasoning && typeof body.reasoning === "object" ? body.reasoning : {}), + effort: clampEffort(cleanModel, explicitReasoning), + }; + } else if (rawEffort) { + body.reasoning = { + ...(body.reasoning && typeof body.reasoning === "object" ? body.reasoning : {}), + effort: clampEffort(cleanModel, rawEffort), + }; } delete body.reasoning_effort; + if (nativeCodexPassthrough) { + return body; + } + // Remove unsupported parameters for Codex API delete body.temperature; delete body.top_p; diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index 23e045dacc..2f4f7ed2a9 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -33,6 +33,9 @@ import crypto from "crypto"; import { v5 as uuidv5 } from "uuid"; import zlib from "zlib"; +const CURSOR_CLIENT_VERSION = "3.1.0"; +const CURSOR_USER_AGENT = `Cursor/${CURSOR_CLIENT_VERSION}`; + // Detect cloud environment const isCloudEnv = () => { if (typeof caches !== "undefined" && typeof caches === "object") return true; @@ -251,11 +254,11 @@ export class CursorExecutor extends BaseExecutor { "connect-accept-encoding": "gzip", "connect-protocol-version": "1", "content-type": "application/connect+proto", - "user-agent": "connect-es/1.6.1", + "user-agent": CURSOR_USER_AGENT, "x-amzn-trace-id": `Root=${crypto.randomUUID()}`, "x-client-key": crypto.createHash("sha256").update(cleanToken).digest("hex"), "x-cursor-checksum": this.generateChecksum(machineId), - "x-cursor-client-version": "2.3.41", + "x-cursor-client-version": CURSOR_CLIENT_VERSION, "x-cursor-client-type": "ide", "x-cursor-client-os": process.platform === "win32" @@ -265,6 +268,7 @@ export class CursorExecutor extends BaseExecutor { : "linux", "x-cursor-client-arch": process.arch === "arm64" ? "aarch64" : "x64", "x-cursor-client-device-type": "desktop", + "x-cursor-user-agent": CURSOR_USER_AGENT, "x-cursor-config-version": crypto.randomUUID(), "x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", "x-ghost-mode": ghostMode ? "true" : "false", diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 15e2c0ff64..2e2f76b99a 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -9,6 +9,7 @@ import { } from "../services/claudeCodeCompatible.ts"; import { getGigachatAccessToken } from "../services/gigachatAuth.ts"; import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts"; +import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts"; function normalizeBaseUrl(baseUrl) { return (baseUrl || "").trim().replace(/\/$/, ""); @@ -203,6 +204,12 @@ export class DefaultExecutor extends BaseExecutor { * "org/model-name") — we must NOT strip path segments. (Fix #493) */ transformRequest(model, body, stream, credentials) { + void model; + void stream; + void credentials; + if (this.provider === "qwen" && typeof body === "object" && body !== null) { + return sanitizeQwenThinkingToolChoice(body, "QwenExecutor"); + } return body; } diff --git a/open-sse/executors/gemini-cli.ts b/open-sse/executors/gemini-cli.ts index 3c8298a0b6..228b00b38c 100644 --- a/open-sse/executors/gemini-cli.ts +++ b/open-sse/executors/gemini-cli.ts @@ -1,5 +1,8 @@ import { BaseExecutor } from "./base.ts"; import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts"; +import { geminiCLIUserAgent, googApiClientHeader } from "../services/antigravityHeaders.ts"; +import { scrubProxyAndFingerprintHeaders } from "../services/antigravityHeaderScrub.ts"; +import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts"; const LOAD_CODE_ASSIST_URL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"; const PROJECT_TTL_MS = 30_000; // 30 seconds — matches native Gemini CLI @@ -22,19 +25,20 @@ export class GeminiCLIExecutor extends BaseExecutor { } buildHeaders(credentials, stream = true) { - return { + const raw = { "Content-Type": "application/json", Authorization: `Bearer ${credentials.accessToken}`, - // Fingerprint headers matching native GeminiCLI client (prevents upstream rejection) - "User-Agent": "GeminiCLI/0.31.0/unknown (linux; x64)", - "X-Goog-Api-Client": "google-genai-sdk/1.41.0 gl-node/v22.19.0", + // Dynamic headers matching native GeminiCLI client + "User-Agent": geminiCLIUserAgent(this._currentModel || "unknown"), + "X-Goog-Api-Client": googApiClientHeader(), ...(stream && { Accept: "text/event-stream" }), - // NOTE: x-goog-user-project removed — the stored projectId can become stale for - // free-tier accounts, causing 403 "Cloud Code Private API has not been used in - // project X". The API resolves the correct project from the OAuth token alone. }; + return scrubProxyAndFingerprintHeaders(raw); } + // Track current model for dynamic UA (set by transformRequest) + private _currentModel = "unknown"; + /** * Fetch the current cloudaicompanionProject via loadCodeAssist API. * Native Gemini CLI refreshes this every 30 seconds — OmniRoute stores it once @@ -134,15 +138,29 @@ export class GeminiCLIExecutor extends BaseExecutor { } async transformRequest(model, body, stream, credentials) { + // Track model for dynamic User-Agent + this._currentModel = model || "unknown"; + // Refresh the project ID via loadCodeAssist (cached for 30s). - // The translator builds the envelope with the stale stored projectId — - // we replace it here with the fresh one before sending to the API. if (body && typeof body === "object" && body.request && credentials.accessToken) { const freshProject = await this.refreshProject(credentials.accessToken); if (freshProject) { body.project = freshProject; } - // If refresh failed, keep the stale projectId as a best-effort fallback + + // Obfuscate sensitive client names in user content + const contents = body.request?.contents; + if (Array.isArray(contents)) { + for (const msg of contents) { + if (Array.isArray(msg.parts)) { + for (const part of msg.parts) { + if (typeof part.text === "string") { + part.text = obfuscateSensitiveWords(part.text); + } + } + } + } + } } return body; } diff --git a/open-sse/executors/qoder.ts b/open-sse/executors/qoder.ts index 26e1889245..322c1fdeb4 100644 --- a/open-sse/executors/qoder.ts +++ b/open-sse/executors/qoder.ts @@ -5,6 +5,7 @@ import { type ProviderCredentials, } from "./base.ts"; import { PROVIDERS } from "../config/constants.ts"; +import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts"; function getAuthToken(credentials: ProviderCredentials): string { if (typeof credentials.apiKey === "string" && credentials.apiKey.trim()) { @@ -27,6 +28,15 @@ export class QoderExecutor extends BaseExecutor { super("qoder", PROVIDERS.qoder); } + transformRequest(model: string, body: unknown): Record { + const payload = { + ...(typeof body === "object" && body !== null ? body : {}), + model, + }; + + return sanitizeQwenThinkingToolChoice(payload, "QoderExecutor"); + } + async execute({ model, body, stream, credentials, signal, upstreamExtraHeaders }: ExecuteInput) { const token = getAuthToken(credentials); @@ -90,10 +100,7 @@ export class QoderExecutor extends BaseExecutor { mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); - const payload = { - ...(typeof body === "object" && body !== null ? body : {}), - model: mappedModel, - }; + const payload = this.transformRequest(mappedModel, body, stream, credentials); const bodyStr = JSON.stringify(payload); diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 4dcb362d1c..1f37789219 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -129,6 +129,11 @@ 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 @@ -1025,6 +1030,17 @@ 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 @@ -1993,6 +2009,7 @@ export async function handleChatCore({ trackPendingRequest(model, provider, connectionId, false); const contentType = (providerResponse.headers.get("content-type") || "").toLowerCase(); let responseBody; + let responseFormatForTranslation = targetFormat; const rawBody = await providerResponse.text(); const normalizedProviderPayload = normalizePayloadForLog(rawBody); const looksLikeSSE = @@ -2000,10 +2017,19 @@ 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 = - targetFormat === FORMATS.OPENAI_RESPONSES + responseFormatForTranslation === FORMATS.OPENAI_RESPONSES ? parseSSEToResponsesOutput(rawBody, model) - : targetFormat === FORMATS.CLAUDE + : responseFormatForTranslation === FORMATS.CLAUDE ? parseSSEToClaudeResponse(rawBody, model) : parseSSEToOpenAIResponse(rawBody, model); @@ -2195,10 +2221,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(targetFormat, clientResponseFormat) + let translatedResponse = needsTranslation(responseFormatForTranslation, clientResponseFormat) ? translateNonStreamingResponse( responseBody, - targetFormat, + responseFormatForTranslation, clientResponseFormat, toolNameMap as Map | null ) diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 24e3614831..5fe7dfeded 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -32,6 +32,23 @@ function toNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function hasVisibleMessageContent(content: unknown): boolean { + if (typeof content === "string") { + return content.trim().length > 0; + } + + if (!Array.isArray(content)) return false; + + return content.some((contentPart) => { + const part = toRecord(contentPart); + if (!part) return false; + if (typeof part.text === "string" && part.text.trim().length > 0) return true; + if (typeof part.content === "string" && part.content.trim().length > 0) return true; + const partType = toString(part.type); + return Boolean(partType && partType !== "thinking" && partType !== "reasoning"); + }); +} + // Matches ... blocks and ... (greedy, dotAll) const THINK_TAG_REGEX = /<(?:think|thinking)>([\s\S]*?)<\/(?:think|thinking)>/gi; @@ -216,6 +233,13 @@ function sanitizeMessage(msg: unknown): unknown { } } + // Non-streaming responses should not expose both visible content and reasoning_content. + // Some clients drop the visible assistant text or render duplicated panels when both fields + // are present in the final payload. Keep reasoning_content only for reasoning-only messages. + if (sanitized.reasoning_content !== undefined && hasVisibleMessageContent(sanitized.content)) { + delete sanitized.reasoning_content; + } + // Preserve tool_calls if (msgRecord.tool_calls) { sanitized.tool_calls = msgRecord.tool_calls; diff --git a/open-sse/handlers/responsesHandler.ts b/open-sse/handlers/responsesHandler.ts index 1fab9d04fb..dfd9662c8e 100644 --- a/open-sse/handlers/responsesHandler.ts +++ b/open-sse/handlers/responsesHandler.ts @@ -35,7 +35,7 @@ export async function handleResponsesCore({ signal, }) { // Convert Responses API format to Chat Completions format - const convertedBody = convertResponsesApiFormat(body); + const convertedBody = convertResponsesApiFormat(body, credentials); // Ensure stream is enabled convertedBody.stream = true; diff --git a/open-sse/handlers/sseParser.ts b/open-sse/handlers/sseParser.ts index f21f780cc1..4ab94cfbbc 100644 --- a/open-sse/handlers/sseParser.ts +++ b/open-sse/handlers/sseParser.ts @@ -399,6 +399,132 @@ export function parseSSEToClaudeResponse(rawSSE, fallbackModel) { * Convert Responses API SSE events into a single non-streaming response object. * Expects events such as response.created / response.in_progress / response.completed. */ +const RESPONSES_TERMINAL_EVENT_TYPES = new Set([ + "response.completed", + "response.done", + "response.cancelled", + "response.canceled", + "response.failed", + "response.incomplete", +]); + +function toOutputIndex(value) { + if (typeof value === "number" && Number.isInteger(value)) return value; + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number(value); + if (Number.isInteger(parsed)) return parsed; + } + return null; +} + +function cloneResponseItem(item) { + const record = toRecord(item); + return { + ...record, + ...(Array.isArray(record.content) + ? { + content: record.content.map((contentPart) => { + const part = toRecord(contentPart); + return { ...part }; + }), + } + : {}), + ...(Array.isArray(record.summary) + ? { + summary: record.summary.map((summaryPart) => { + const part = toRecord(summaryPart); + return { ...part }; + }), + } + : {}), + }; +} + +function ensureResponsesMessageItem(outputItems, outputIndex) { + const existing = outputItems.get(outputIndex); + if (existing?.type === "message") return existing; + + const next = { + ...(existing && typeof existing === "object" ? existing : {}), + id: existing?.id || `msg_${Date.now()}_${outputIndex}`, + type: "message", + role: "assistant", + content: Array.isArray(existing?.content) + ? existing.content.map((contentPart) => ({ ...toRecord(contentPart) })) + : [{ type: "output_text", annotations: [], text: "" }], + }; + + if (next.content.length === 0) { + next.content.push({ type: "output_text", annotations: [], text: "" }); + } + + outputItems.set(outputIndex, next); + return next; +} + +function ensureResponsesReasoningItem(outputItems, outputIndex, itemId) { + const existing = outputItems.get(outputIndex); + if (existing?.type === "reasoning") return existing; + + const next = { + ...(existing && typeof existing === "object" ? existing : {}), + id: itemId || existing?.id || `rs_${Date.now()}_${outputIndex}`, + type: "reasoning", + summary: Array.isArray(existing?.summary) + ? existing.summary.map((summaryPart) => ({ ...toRecord(summaryPart) })) + : [{ type: "summary_text", text: "" }], + }; + + if (next.summary.length === 0) { + next.summary.push({ type: "summary_text", text: "" }); + } + + outputItems.set(outputIndex, next); + return next; +} + +function ensureResponsesFunctionCallItem(outputItems, outputIndex, itemId, callId, name) { + const existing = outputItems.get(outputIndex); + if (existing?.type === "function_call") { + if (callId && !existing.call_id) existing.call_id = callId; + if (name && !existing.name) existing.name = name; + if (itemId && !existing.id) existing.id = itemId; + return existing; + } + + const next = { + ...(existing && typeof existing === "object" ? existing : {}), + id: itemId || existing?.id || `fc_${callId || `${Date.now()}_${outputIndex}`}`, + type: "function_call", + call_id: callId || existing?.call_id || "", + name: name || existing?.name || "", + arguments: typeof existing?.arguments === "string" ? existing.arguments : "", + }; + + outputItems.set(outputIndex, next); + return next; +} + +function mergeResponseItems(existing, incoming) { + const next = cloneResponseItem(incoming); + if (!existing || typeof existing !== "object") return next; + + return { + ...existing, + ...next, + ...(Array.isArray(next.content) + ? { + content: next.content, + } + : {}), + ...(Array.isArray(next.summary) + ? { + summary: next.summary, + } + : {}), + }; +} + export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { const lines = String(rawSSE || "").split("\n"); const events = []; @@ -409,7 +535,11 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { const payload = trimmed.slice(5).trim(); if (!payload || payload === "[DONE]") continue; try { - events.push(JSON.parse(payload)); + const parsed = JSON.parse(payload); + const record = toRecord(parsed); + if (Object.keys(record).length > 0) { + events.push(record); + } } catch { // Ignore malformed lines and continue best-effort parsing. } @@ -417,12 +547,104 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { if (events.length === 0) return null; - let completed = null; + let terminalResponse = null; + let terminalEventType = ""; let latestResponse = null; + const outputItems = new Map(); for (const evt of events) { - if (evt?.type === "response.completed" && evt.response) { - completed = evt.response; + const eventType = toString(evt?.type); + const outputIndex = toOutputIndex(evt?.output_index); + const item = toRecord(evt?.item); + + if (outputIndex !== null && eventType === "response.output_item.added") { + outputItems.set(outputIndex, cloneResponseItem(item)); + } + + if (outputIndex !== null && eventType === "response.output_item.done") { + const existing = outputItems.get(outputIndex); + outputItems.set(outputIndex, mergeResponseItems(existing, item)); + } + + if (outputIndex !== null && eventType === "response.output_text.delta") { + const messageItem = ensureResponsesMessageItem(outputItems, outputIndex); + const content = Array.isArray(messageItem.content) ? messageItem.content : []; + const firstPart = + content.length > 0 ? { ...toRecord(content[0]) } : { type: "output_text", annotations: [] }; + firstPart.type = firstPart.type || "output_text"; + firstPart.annotations = Array.isArray(firstPart.annotations) ? firstPart.annotations : []; + firstPart.text = `${toString(firstPart.text)}${toString(evt.delta)}`; + content[0] = firstPart; + messageItem.content = content; + } + + if (outputIndex !== null && eventType === "response.output_text.done") { + const messageItem = ensureResponsesMessageItem(outputItems, outputIndex); + const content = Array.isArray(messageItem.content) ? messageItem.content : []; + const firstPart = + content.length > 0 ? { ...toRecord(content[0]) } : { type: "output_text", annotations: [] }; + firstPart.type = firstPart.type || "output_text"; + firstPart.annotations = Array.isArray(firstPart.annotations) ? firstPart.annotations : []; + firstPart.text = toString(evt.text, toString(firstPart.text)); + content[0] = firstPart; + messageItem.content = content; + } + + if (outputIndex !== null && eventType === "response.reasoning_summary_text.delta") { + const reasoningItem = ensureResponsesReasoningItem( + outputItems, + outputIndex, + toString(evt.item_id) + ); + const summary = Array.isArray(reasoningItem.summary) ? reasoningItem.summary : []; + const firstPart = + summary.length > 0 ? { ...toRecord(summary[0]) } : { type: "summary_text", text: "" }; + firstPart.type = firstPart.type || "summary_text"; + firstPart.text = `${toString(firstPart.text)}${toString(evt.delta)}`; + summary[0] = firstPart; + reasoningItem.summary = summary; + } + + if (outputIndex !== null && eventType === "response.reasoning_summary_text.done") { + const reasoningItem = ensureResponsesReasoningItem( + outputItems, + outputIndex, + toString(evt.item_id) + ); + const summary = Array.isArray(reasoningItem.summary) ? reasoningItem.summary : []; + const firstPart = + summary.length > 0 ? { ...toRecord(summary[0]) } : { type: "summary_text", text: "" }; + firstPart.type = firstPart.type || "summary_text"; + firstPart.text = toString(evt.text, toString(firstPart.text)); + summary[0] = firstPart; + reasoningItem.summary = summary; + } + + if (outputIndex !== null && eventType === "response.function_call_arguments.delta") { + const functionCallItem = ensureResponsesFunctionCallItem( + outputItems, + outputIndex, + toString(evt.item_id), + "", + "" + ); + functionCallItem.arguments = `${toString(functionCallItem.arguments)}${toString(evt.delta)}`; + } + + if (outputIndex !== null && eventType === "response.function_call_arguments.done") { + const functionCallItem = ensureResponsesFunctionCallItem( + outputItems, + outputIndex, + toString(evt.item_id), + "", + "" + ); + functionCallItem.arguments = toString(evt.arguments, toString(functionCallItem.arguments)); + } + + if (RESPONSES_TERMINAL_EVENT_TYPES.has(eventType) && evt.response) { + terminalResponse = evt.response; + terminalEventType = eventType; } if (evt?.response && typeof evt.response === "object") { latestResponse = evt.response; @@ -431,16 +653,33 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { } } - const picked = completed || latestResponse; + const picked = terminalResponse || latestResponse; if (!picked || typeof picked !== "object") return null; + const reconstructedOutput = [...outputItems.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([, item]) => item) + .filter((item) => item && typeof item === "object"); + const pickedOutput = Array.isArray(picked.output) ? picked.output : []; + const statusFallback = + terminalEventType === "response.cancelled" + ? "cancelled" + : terminalEventType === "response.canceled" + ? "canceled" + : terminalEventType === "response.failed" + ? "failed" + : terminalEventType === "response.incomplete" + ? "incomplete" + : terminalResponse + ? "completed" + : "in_progress"; return { id: picked.id || `resp_${Date.now()}`, - object: "response", + object: picked.object || "response", model: picked.model || fallbackModel || "unknown", - output: Array.isArray(picked.output) ? picked.output : [], + output: pickedOutput.length > 0 ? pickedOutput : reconstructedOutput, usage: picked.usage || null, - status: picked.status || (completed ? "completed" : "in_progress"), + status: picked.status || statusFallback, created_at: picked.created_at || Math.floor(Date.now() / 1000), metadata: picked.metadata || {}, }; diff --git a/open-sse/mcp-server/__tests__/dbHealthTool.test.ts b/open-sse/mcp-server/__tests__/dbHealthTool.test.ts new file mode 100644 index 0000000000..9aa7565ca4 --- /dev/null +++ b/open-sse/mcp-server/__tests__/dbHealthTool.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { createMcpServer } from "../server.ts"; +import { MCP_TOOL_MAP, dbHealthCheckInput } from "../schemas/tools.ts"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +vi.mock("../audit.ts", () => ({ + logToolCall: vi.fn().mockResolvedValue(undefined), +})); + +describe("omniroute_db_health_check MCP tool", () => { + let client: Client; + + beforeEach(async () => { + mockFetch.mockReset(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(); + await server.connect(serverTransport); + client = new Client({ name: "test-client", version: "1.0.0" }); + await client.connect(clientTransport); + }); + + afterEach(async () => { + await client.close(); + }); + + it("is registered in the MCP tool map", () => { + expect(MCP_TOOL_MAP["omniroute_db_health_check"]).toBeDefined(); + expect(MCP_TOOL_MAP["omniroute_db_health_check"]?.phase).toBe(2); + }); + + it("validates empty input and explicit autoRepair requests", () => { + expect(dbHealthCheckInput.safeParse({}).success).toBe(true); + expect(dbHealthCheckInput.safeParse({ autoRepair: true }).success).toBe(true); + expect(dbHealthCheckInput.safeParse({ autoRepair: "yes" }).success).toBe(false); + }); + + it("dispatches to /api/v1/db/health using POST when autoRepair=true", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + isHealthy: false, + issues: [{ type: "broken_reference", table: "combos", description: "broken", count: 1 }], + repairedCount: 1, + backupCreated: true, + autoRepair: true, + checkedAt: new Date().toISOString(), + }), + }); + + const result = await client.callTool({ + name: "omniroute_db_health_check", + arguments: { autoRepair: true }, + }); + + expect(result.isError).toBeFalsy(); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/db/health"), + expect.objectContaining({ method: "POST" }) + ); + + const content = result.content[0] as { type: string; text: string }; + const payload = JSON.parse(content.text); + expect(payload.repairedCount).toBe(1); + expect(payload.backupCreated).toBe(true); + }); +}); diff --git a/open-sse/mcp-server/schemas/index.ts b/open-sse/mcp-server/schemas/index.ts index ed1a5c9ba6..e233dd03ba 100644 --- a/open-sse/mcp-server/schemas/index.ts +++ b/open-sse/mcp-server/schemas/index.ts @@ -60,6 +60,9 @@ export { getSessionSnapshotInput, getSessionSnapshotOutput, getSessionSnapshotTool, + dbHealthCheckInput, + dbHealthCheckOutput, + dbHealthCheckTool, cacheStatsInput, cacheStatsOutput, cacheStatsTool, diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 8a65141ab5..9138b39abb 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -831,7 +831,51 @@ export const getSessionSnapshotTool: McpToolDefinition< sourceEndpoints: ["/api/usage/analytics", "/api/telemetry/summary"], }; -// --- Tool 18: omniroute_sync_pricing --- +// --- Tool 18: omniroute_db_health_check --- +export const dbHealthCheckInput = z.object({ + autoRepair: z + .boolean() + .optional() + .describe("When true, runs the database auto-repair flow before returning the result"), +}); + +export const dbHealthCheckOutput = z.object({ + isHealthy: z.boolean(), + issues: z.array( + z.object({ + type: z.enum([ + "integrity_check_failed", + "broken_reference", + "stale_snapshot", + "invalid_state", + ]), + table: z.string(), + description: z.string(), + count: z.number(), + }) + ), + repairedCount: z.number(), + backupCreated: z.boolean(), + autoRepair: z.boolean(), + checkedAt: z.string(), +}); + +export const dbHealthCheckTool: McpToolDefinition< + typeof dbHealthCheckInput, + typeof dbHealthCheckOutput +> = { + name: "omniroute_db_health_check", + description: + "Diagnoses OmniRoute database drift such as orphan quota/domain rows, invalid JSON state, and broken combo references. Set autoRepair=true to repair those rows before returning the report.", + inputSchema: dbHealthCheckInput, + outputSchema: dbHealthCheckOutput, + scopes: ["read:health", "write:resilience"], + auditLevel: "full", + phase: 2, + sourceEndpoints: ["/api/v1/db/health"], +}; + +// --- Tool 19: omniroute_sync_pricing --- export const syncPricingInput = z.object({ sources: z .array(z.string()) @@ -959,6 +1003,7 @@ export const MCP_TOOLS = [ bestComboForTaskTool, explainRouteTool, getSessionSnapshotTool, + dbHealthCheckTool, syncPricingTool, cacheStatsTool, cacheFlushTool, diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 01fbfdaaff..3cc53ad4ea 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -38,6 +38,7 @@ import { bestComboForTaskInput, explainRouteInput, getSessionSnapshotInput, + dbHealthCheckInput, syncPricingInput, } from "./schemas/tools.ts"; import { startMcpHeartbeat } from "./runtimeHeartbeat.ts"; @@ -59,6 +60,7 @@ import { handleBestComboForTask, handleExplainRoute, handleGetSessionSnapshot, + handleDbHealthCheck, handleSyncPricing, } from "./tools/advancedTools.ts"; import { memoryTools } from "./tools/memoryTools.ts"; @@ -759,6 +761,18 @@ export function createMcpServer(): McpServer { }) ); + server.registerTool( + "omniroute_db_health_check", + { + description: + "Diagnoses or repairs OmniRoute database drift, including broken combo references and orphan quota/domain rows", + inputSchema: dbHealthCheckInput, + }, + withScopeEnforcement("omniroute_db_health_check", (args) => + handleDbHealthCheck(dbHealthCheckInput.parse(args ?? {})) + ) + ); + server.registerTool( "omniroute_sync_pricing", { diff --git a/open-sse/mcp-server/tools/advancedTools.ts b/open-sse/mcp-server/tools/advancedTools.ts index b6d713c811..271ee70e8e 100644 --- a/open-sse/mcp-server/tools/advancedTools.ts +++ b/open-sse/mcp-server/tools/advancedTools.ts @@ -1,5 +1,5 @@ /** - * OmniRoute MCP Advanced Tools — 10 intelligence tools that differentiate + * OmniRoute MCP Advanced Tools — 11 intelligence tools that differentiate * OmniRoute from all other AI gateways. * * Tools: @@ -12,7 +12,8 @@ * 7. omniroute_best_combo_for_task — AI-powered combo recommendation * 8. omniroute_explain_route — Post-hoc routing decision explainer * 9. omniroute_get_session_snapshot — Full session state snapshot - * 10. omniroute_sync_pricing — Sync provider pricing from external source + * 10. omniroute_db_health_check — Diagnose and repair DB state drift + * 11. omniroute_sync_pricing — Sync provider pricing from external source */ import { logToolCall } from "../audit.ts"; @@ -863,3 +864,33 @@ export async function handleGetSessionSnapshot() { return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } } + +export async function handleDbHealthCheck(args: { autoRepair?: boolean }) { + const start = Date.now(); + const autoRepair = args.autoRepair === true; + + try { + const result = toRecord( + await apiFetch("/api/v1/db/health", { + method: autoRepair ? "POST" : "GET", + }) + ); + + await logToolCall( + "omniroute_db_health_check", + args, + { + isHealthy: toBoolean(result.isHealthy, false), + repairedCount: toNumber(result.repairedCount, 0), + }, + Date.now() - start, + true + ); + + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_db_health_check", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} diff --git a/open-sse/package.json b/open-sse/package.json index 44de98f836..46e3ce00a4 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.6.4", + "version": "3.6.5", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/AGENTS.md b/open-sse/services/AGENTS.md new file mode 100644 index 0000000000..9cfe947ac6 --- /dev/null +++ b/open-sse/services/AGENTS.md @@ -0,0 +1,134 @@ +# open-sse/services/ — Routing Engine & Cross-Cutting Services + +**Purpose**: 36+ service modules powering request routing, rate limiting, quota management, token refresh, fallback strategies, and runtime state. The combo routing engine (`combo.ts`) is the core; supporting services handle resilience, accounting, and decision-making. + +--- + +## Key Services + +### Combo Routing Engine + +- **`combo.ts`** (800 LOC) — Entry point for multi-model routing. **`handleComboChat()`** iterates through targets in order until success or all fail. **`resolveComboTargets()`** expands combo config into ordered `ResolvedComboTarget[]` (provider + model + account + credentials). Enforces per-target circuit breaker and fallback logic. +- **Strategies** (13 total): `priority` (ordered list), `weighted` (probabilistic), `fill-first` (fill quota first), `round-robin`, `P2C` (power of two choices), `random`, `least-used`, `cost-optimized`, `strict-random`, `auto`, `lkgp` (last known good provider), `context-optimized`, `context-relay`. +- **Circuit Breaker**: Per target, tracks consecutive failures; breaks after threshold, reopens on success or timeout. + +### Quota & Rate Limiting + +- **`rateLimitManager.ts`** — Enforces upstream rate limits (429, retry-after headers). Implements token bucket per API key + provider combo. Rejects requests exceeding limits before dispatch. +- **`usage.ts`** — Tracks per-request token/cost consumption. Syncs with `quotaSnapshots` table. Reports cumulative usage for analytics. +- **`quotaCache.ts`** — In-memory quota snapshots. Invalidated on write; pre-loaded at startup. Prevents DB thrashing on high-volume requests. + +### Account & Token Management + +- **`tokenRefresh.ts`** — Handles OAuth token expiration. Detects 401 responses, triggers refresh via provider OAuth endpoint, retries request with new token. +- **`accountFallback.ts`** — If account reaches quota/rate-limit, switches to alternate account (combo targets). Logs account switch event. +- **`sessionManager.ts`** — Manages request session state across retries. Tracks session ID, attempt count, fallback history. + +### Request Routing & Intelligence + +- **`wildcardRouter.ts`** — Matches wildcard routes in combo configs (e.g., `gpt-*` → all GPT models). +- **`intentClassifier.ts`** — Classifies request intent (chat, embedding, image, video, etc.) for intelligent routing. +- **`taskAwareRouter.ts`** — Routes based on task characteristics (reasoning-heavy → o1, code-gen → Cursor, long-context → Claude). +- **`thinkingBudget.ts`** — Allocates thinking tokens for o1/o3 models; enforces per-request budget. +- **`contextManager.ts`** — Injects routing context (system prompts, memory) into requests. + +### Model Lifecycle & Fallback + +- **`modelDeprecation.ts`** — Detects deprecated models (gpt-3.5, claude-2, etc.). Routes to successor models automatically. +- **`modelFamilyFallback.ts`** — T5 intra-family fallback: if `gpt-4-turbo` unavailable, tries `gpt-4-1106-preview`, then `gpt-4`. +- **`emergencyFallback.ts`** — Last-resort fallback when all combo targets fail. Routes to stable free provider (Qwen Code, Gemini CLI fallback). + +### State & Detection + +- **`workflowFSM.ts`** — Finite state machine for multi-turn workflows (prompt engineering → execution → validation). +- **`backgroundTaskDetector.ts`** — Detects long-running background tasks; routes to batch APIs or defers execution. +- **`ipFilter.ts`** — IP-based routing rules (geographic or access control). +- **`signatureCache.ts`** — Caches request signatures for duplicate detection and deduplication. +- **`volumeDetector.ts`** — Detects request volume spikes; triggers rate-limit escalation or load-shedding. +- **`contextHandoff.ts`** — Serializes/restores session context for agent handoff (A2A protocol). + +### Auto-Routing & Adaptive + +- **`autoCombo/`** — Auto-generates combo configs based on historical performance, cost, and latency. +- **`modelFamilyFallback.ts`** — Automatic fallback within model families (T5, GPT-4, Claude). + +### Advanced Services + +- **`promptInjectionGuard.ts`** (middleware) — Clones request, sanitizes user input, detects prompt injection patterns before dispatch +- **`costRules.ts`** (domain layer) — Cost-based routing decisions (cheapest-first, within budget) +- **`degradation.ts`** (domain layer) — Handles service degradation scenarios (provider down, quota exceeded) +- **`resilience.ts`** — Retry logic, exponential backoff, circuit breaker orchestration across all services + +--- + +## Complexity Hotspots + +| Module | Lines | Risk | Mitigation | +| ------------------------ | ----- | ---------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `combo.ts` | ~800 | High — routing logic, strategy dispatch, fallback ordering | Unit tests for each strategy, integration tests for combo sequences | +| `providerRegistry.ts` | 3000+ | High — 100+ provider configs, executor dispatch | Auto-validate via Zod at module load, split into provider-specific sub-modules | +| `rateLimitManager.ts` | ~300 | Medium — token bucket state, concurrent requests | Unit tests for bucket refill, edge cases (clock skew, parallel requests) | +| `modelFamilyFallback.ts` | ~200 | Medium — fallback chains, family detection | Test all family chains, ensure no circular fallbacks | + +--- + +## Testing Strategy + +Each service requires unit and integration tests. For authoritative coverage requirements and test execution guidelines, see [`CONTRIBUTING.md#running-tests`](../../CONTRIBUTING.md#running-tests). + +- **Unit tests** — Each service in isolation with mocked dependencies (combos, models, executors) +- **Integration tests** — Combo routing with real combo configs, verify target resolution and fallback behavior +- **E2E tests** — Full request flow: chat → combo routing → provider selection → response streaming +- **Chaos tests** — Simulate provider failures, rate limits, token expiration; verify graceful degradation +- **Benchmarks** — Measure routing latency, combo resolution time (target: <10ms for 50 targets) + +--- + +## Performance Constraints + +- **Combo resolution**: <10ms for typical configs (5–20 targets) +- **Rate limit checks**: <1ms (in-memory token bucket) +- **Model family fallback**: <5ms (cached family definitions) +- **Request routing dispatch**: <2ms (hot path, pre-computed strategy dispatch) +- **No blocking I/O** in routing hot path — all async, no awaits on DB queries outside context injection + +--- + +## Anti-Patterns + +- ❌ Synchronous DB calls in `combo.ts` hot path — pre-compute and cache +- ❌ Retry logic in handlers; use `retry()` from resilience service +- ❌ Direct provider config access; use `providerRegistry` getter functions +- ❌ Hardcoded fallback chains; define in `modelFamilyFallback.ts` instead +- ❌ State mutations across concurrent requests; use request-scoped context only + +--- + +## Adding a New Service + +1. Create `open-sse/services/[serviceName].ts` with clear responsibilities +2. Export main handler function and any constants +3. Add unit tests in `tests/unit/services/[serviceName].test.mjs` +4. Integrate into request pipeline in `handlers/chatCore.ts` (if routing-related) or expose via combo.ts +5. Update routing logic in `combo.ts` if service affects target selection or fallback +6. Document in this file (table, key decisions section) + +--- + +## Key Decisions + +- **Combo-first design**: All routing decisions go through combo engine; fallback strategies are combo targets, not ad-hoc logic +- **Service composition**: Small focused modules; combo.ts orchestrates them, not monolithic routing +- **Circuit breaker per-target**: Failures isolated to specific provider+account combo; other targets unaffected +- **Caching everywhere**: Models, providers, quotas, family fallbacks all pre-cached; invalidated on write +- **13 strategies** over hardcoded logic: Strategy pattern allows new routing logic without touching combo.ts core + +--- + +## Review Focus + +- New services must not add blocking I/O to routing hot path +- Combo target resolution under 10ms (measure with benchmarks) +- Circuit breaker state per-target, not global +- All fallback chains tested (no infinite loops) +- Coverage requirements: See [`CONTRIBUTING.md#running-tests`](../../CONTRIBUTING.md#running-tests) (60% gate enforced in CI) diff --git a/open-sse/services/antigravity429Engine.ts b/open-sse/services/antigravity429Engine.ts new file mode 100644 index 0000000000..f1b58d7bc0 --- /dev/null +++ b/open-sse/services/antigravity429Engine.ts @@ -0,0 +1,173 @@ +/** + * Antigravity 429 classification and retry decision engine. + * + * CLIProxyAPI classifies 429 responses into 4 categories and makes nuanced + * retry decisions for each. OmniRoute previously had only 2 categories + * (free tier vs RPM). This module brings full parity. + * + * Categories: + * - unknown: Generic 429, exponential backoff + * - rate_limited: Per-minute rate limit, short backoff + same auth retry + * - quota_exhausted: Daily/plan quota gone, switch auth or long cooldown + * - soft_rate_limit: Temporary burst limit, instant retry + * + * Decisions: + * - soft_retry: Wait briefly, retry same auth + * - instant_retry_same_auth: Retry immediately on same auth + * - short_cooldown_switch_auth: 5min cooldown, try next account + * - full_quota_exhausted: 24h cooldown, skip this account + */ + +export type Category = "unknown" | "rate_limited" | "quota_exhausted" | "soft_rate_limit"; + +export type DecisionKind = + | "soft_retry" + | "instant_retry_same_auth" + | "short_cooldown_switch_auth" + | "full_quota_exhausted"; + +export interface Decision { + kind: DecisionKind; + retryAfterMs: number | null; + reason: string; +} + +const QUOTA_EXHAUSTED_KEYWORDS = ["quota_exhausted", "quota exhausted"]; + +const CREDITS_EXHAUSTED_KEYWORDS = [ + "google_one_ai", + "insufficient credit", + "insufficient credits", + "not enough credit", + "not enough credits", + "credit exhausted", + "credits exhausted", + "credit balance", + "minimumcreditamountforusage", + "minimum credit amount for usage", + "minimum credit", + "resource has been exhausted", +]; + +const SHORT_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes +const INSTANT_RETRY_THRESHOLD_MS = 3 * 1000; // 3 seconds +const FULL_QUOTA_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours + +export function classify429(errorMessage: string): Category { + const lower = (errorMessage || "").toLowerCase(); + + // Check for quota exhaustion first (most specific) + for (const kw of QUOTA_EXHAUSTED_KEYWORDS) { + if (lower.includes(kw)) return "quota_exhausted"; + } + + // Check for credits exhaustion (also quota-related) + for (const kw of CREDITS_EXHAUSTED_KEYWORDS) { + if (lower.includes(kw)) return "quota_exhausted"; + } + + // Check for RPM/rate limit indicators + if ( + lower.includes("per minute") || + lower.includes("rpm") || + lower.includes("rate limit") || + lower.includes("rate_limit") || + lower.includes("too many requests") + ) { + return "rate_limited"; + } + + // Check for free tier exhaustion + if ( + lower.includes("free tier") || + lower.includes("daily limit") || + lower.includes("exhausted your capacity") + ) { + return "quota_exhausted"; + } + + // Check for soft/burst limits + if (lower.includes("try again") || lower.includes("temporarily")) { + return "soft_rate_limit"; + } + + return "unknown"; +} + +export function decide429(category: Category, retryAfterMs: number | null): Decision { + switch (category) { + case "soft_rate_limit": + return { + kind: + retryAfterMs && retryAfterMs <= INSTANT_RETRY_THRESHOLD_MS + ? "instant_retry_same_auth" + : "soft_retry", + retryAfterMs: retryAfterMs ?? 2000, + reason: "Soft rate limit — brief backoff", + }; + + case "rate_limited": + return { + kind: + retryAfterMs && retryAfterMs <= SHORT_COOLDOWN_MS + ? "soft_retry" + : "short_cooldown_switch_auth", + retryAfterMs: retryAfterMs ?? 60_000, + reason: "RPM rate limit — switch auth if cooldown is long", + }; + + case "quota_exhausted": + return { + kind: "full_quota_exhausted", + retryAfterMs: retryAfterMs ?? FULL_QUOTA_COOLDOWN_MS, + reason: "Quota exhausted — skip this account", + }; + + default: + return { + kind: "soft_retry", + retryAfterMs: retryAfterMs ?? 5000, + reason: "Unknown 429 — generic backoff", + }; + } +} + +/** + * Track credits failure state per auth key. + * Auto-disables after repeated failures with 5h cooldown. + */ +const creditsFailureMap = new Map< + string, + { + count: number; + disabledUntil: number; + } +>(); + +const CREDITS_DISABLE_THRESHOLD = 3; +const CREDITS_COOLDOWN_MS = 5 * 60 * 60 * 1000; // 5 hours + +export function recordCreditsFailure(authKey: string): boolean { + const state = creditsFailureMap.get(authKey) ?? { count: 0, disabledUntil: 0 }; + state.count++; + + if (state.count >= CREDITS_DISABLE_THRESHOLD) { + state.disabledUntil = Date.now() + CREDITS_COOLDOWN_MS; + creditsFailureMap.set(authKey, state); + return true; // disabled + } + + creditsFailureMap.set(authKey, state); + return false; +} + +export function isCreditsDisabled(authKey: string): boolean { + const state = creditsFailureMap.get(authKey); + if (!state) return false; + if (state.disabledUntil > Date.now()) return true; + // Cooldown expired, reset + creditsFailureMap.delete(authKey); + return false; +} + +export { SHORT_COOLDOWN_MS, FULL_QUOTA_COOLDOWN_MS }; diff --git a/open-sse/services/antigravityCredits.ts b/open-sse/services/antigravityCredits.ts new file mode 100644 index 0000000000..31cd744983 --- /dev/null +++ b/open-sse/services/antigravityCredits.ts @@ -0,0 +1,42 @@ +/** + * Google One AI credits injection for Antigravity. + * + * When Antigravity returns a quota_exhausted 429, CLIProxyAPI retries the + * request with `enabledCreditTypes: ["GOOGLE_ONE_AI"]` injected into the + * body. This uses the user's Google One AI credit balance for the retry, + * which is often available on Pro accounts. + * + * Based on CLIProxyAPI's antigravity_executor.go line 268. + */ + +import { isCreditsDisabled, recordCreditsFailure } from "./antigravity429Engine.ts"; + +/** + * Inject enabledCreditTypes into the request body for a credits retry. + * Returns a new body object with the field added. + */ +export function injectCreditsField(body: Record): Record { + return { + ...body, + enabledCreditTypes: ["GOOGLE_ONE_AI"], + }; +} + +/** + * Determine if a credits retry should be attempted for this auth key. + * Returns false if credits are disabled (too many failures) or if the + * config flag is off. + */ +export function shouldRetryWithCredits(authKey: string, creditsEnabled: boolean): boolean { + if (!creditsEnabled) return false; + if (isCreditsDisabled(authKey)) return false; + return true; +} + +/** + * Handle a credits retry failure. Tracks the failure and returns + * true if credits are now disabled for this auth key. + */ +export function handleCreditsFailure(authKey: string): boolean { + return recordCreditsFailure(authKey); +} diff --git a/open-sse/services/antigravityHeaderScrub.ts b/open-sse/services/antigravityHeaderScrub.ts new file mode 100644 index 0000000000..5ecdd91627 --- /dev/null +++ b/open-sse/services/antigravityHeaderScrub.ts @@ -0,0 +1,62 @@ +/** + * Antigravity header scrubbing. + * + * Real Antigravity is a Node.js app. Its outbound HTTP requests never include + * proxy tracing headers, Stainless SDK headers, or Chromium Sec-Ch-* headers. + * Sending any of these reveals the request came through a third-party proxy. + * + * Based on CLIProxyAPI's ScrubProxyAndFingerprintHeaders (misc/header_utils.go). + */ + +const HEADERS_TO_REMOVE = [ + // Proxy tracing + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "x-forwarded-port", + "x-real-ip", + "forwarded", + "via", + // Client identity (Stainless SDK — Claude Code specific, not Antigravity) + "x-title", + "x-stainless-lang", + "x-stainless-package-version", + "x-stainless-os", + "x-stainless-arch", + "x-stainless-runtime", + "x-stainless-runtime-version", + "x-stainless-timeout", + "x-stainless-retry-count", + "x-stainless-helper-method", + "http-referer", + "referer", + // Browser / Chromium fingerprint (Electron clients, NOT Node.js) + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "sec-fetch-mode", + "sec-fetch-site", + "sec-fetch-dest", + "priority", + // Encoding: Antigravity (Node.js) sends "gzip, deflate, br" by default; + // Electron clients add "zstd" which is a fingerprint mismatch. + "accept-encoding", +]; + +/** + * Remove headers that reveal proxy infrastructure or non-native client identity + * from an outgoing request to Antigravity's upstream API. + */ +export function scrubProxyAndFingerprintHeaders( + headers: Record +): Record { + const cleaned: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (!HEADERS_TO_REMOVE.includes(key.toLowerCase())) { + cleaned[key] = value; + } + } + // Set the standard Node.js accept-encoding + cleaned["Accept-Encoding"] = "gzip, deflate, br"; + return cleaned; +} diff --git a/open-sse/services/antigravityHeaders.ts b/open-sse/services/antigravityHeaders.ts new file mode 100644 index 0000000000..d7adf5e44d --- /dev/null +++ b/open-sse/services/antigravityHeaders.ts @@ -0,0 +1,71 @@ +import os from "node:os"; + +/** + * Antigravity and Gemini CLI header utilities. + * + * Generates User-Agent strings and API client headers that match + * the real Antigravity and Gemini CLI binaries. + * + * Based on CLIProxyAPI's misc/header_utils.go. + */ + +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"; + +function getPlatform(): string { + const p = os.platform(); + switch (p) { + case "win32": + return "win32"; + case "darwin": + return "darwin"; + default: + return p; // "linux", etc. + } +} + +function getArch(): string { + const a = os.arch(); + switch (a) { + case "x64": + return "x64"; + case "ia32": + return "x86"; + case "arm64": + return "arm64"; + default: + return a; + } +} + +/** + * Antigravity User-Agent: "antigravity/VERSION darwin/arm64" + * + * Always claims darwin/arm64 regardless of actual server OS. + * Real Antigravity is a macOS desktop tool — most users are on macOS. + * Claiming linux/amd64 from a datacenter IP is MORE suspicious than + * darwin/arm64. Matches CLIProxyAPI's proven production behavior. + */ +export function antigravityUserAgent(): string { + return `antigravity/${ANTIGRAVITY_VERSION} darwin/arm64`; +} + +/** + * Gemini CLI User-Agent: "GeminiCLI/VERSION/MODEL (OS; ARCH)" + * Example: "GeminiCLI/0.31.0/gemini-3-flash (darwin; arm64)" + */ +export function geminiCLIUserAgent(model: string): string { + return `GeminiCLI/${GEMINI_CLI_VERSION}/${model || "unknown"} (${getPlatform()}; ${getArch()})`; +} + +/** + * X-Goog-Api-Client header value matching the real Gemini SDK. + * Example: "google-genai-sdk/1.41.0 gl-node/v22.19.0" + */ +export function googApiClientHeader(): string { + return `google-genai-sdk/${GEMINI_SDK_VERSION} gl-node/${NODE_VERSION}`; +} + +export { ANTIGRAVITY_VERSION, GEMINI_CLI_VERSION, GEMINI_SDK_VERSION }; diff --git a/open-sse/services/antigravityObfuscation.ts b/open-sse/services/antigravityObfuscation.ts new file mode 100644 index 0000000000..63ebf998c7 --- /dev/null +++ b/open-sse/services/antigravityObfuscation.ts @@ -0,0 +1,50 @@ +/** + * Sensitive word obfuscation for Antigravity requests. + * + * Obfuscates client tool names (OpenCode, Cursor, Claude Code, etc.) using + * zero-width joiners so Google's backend can't grep for them in request logs. + * Matching ZeroGravity's ZEROGRAVITY_SENSITIVE_WORDS and CLIProxyAPI's cloak system. + */ + +const ZWJ = "\u200d"; + +const DEFAULT_WORDS = [ + "opencode", + "open-code", + "cline", + "roo-cline", + "roo_cline", + "cursor", + "windsurf", + "aider", + "continue.dev", + "copilot", + "avante", + "codecompanion", + "claude code", + "claude-code", + "kilo code", + "kilocode", + "omniroute", +]; + +let words = [...DEFAULT_WORDS]; + +export function setAntigravitySensitiveWords(w: string[]): void { + words = w.length > 0 ? w : [...DEFAULT_WORDS]; +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function obfuscateSensitiveWords(text: string): string { + if (!text || words.length === 0) return text; + let result = text; + for (const word of words) { + if (!word) continue; + const regex = new RegExp(escapeRegex(word), "gi"); + result = result.replace(regex, (m) => (m.length <= 1 ? m : m[0] + ZWJ + m.slice(1))); + } + return result; +} diff --git a/open-sse/services/claudeCodeCCH.ts b/open-sse/services/claudeCodeCCH.ts new file mode 100644 index 0000000000..567de2cc15 --- /dev/null +++ b/open-sse/services/claudeCodeCCH.ts @@ -0,0 +1,49 @@ +/** + * Claude Code CCH (Client Content Hash) signing. + * + * Real Claude Code uses Bun/Zig to compute an xxHash64 integrity token over + * the serialized request body. The server verifies this to confirm the request + * came from a genuine Claude Code client. + * + * Algorithm: + * 1. Serialize request body with cch=00000 placeholder + * 2. xxHash64(body_bytes, seed) & 0xFFFFF + * 3. Zero-padded 5-char lowercase hex + * 4. Replace cch=00000 with computed value + */ + +import xxhashInit from "xxhash-wasm"; + +const CCH_SEED = 0x6e52736ac806831en; +const CCH_PATTERN = /\bcch=([0-9a-f]{5});/; + +let xxhashPromise: Promise | null = null; +let xxhash64Fn: ((input: Uint8Array, seed: bigint) => bigint) | null = null; + +async function ensureXxhash() { + if (xxhash64Fn) return; + if (!xxhashPromise) { + xxhashPromise = (async () => { + const hasher = await xxhashInit(); + xxhash64Fn = hasher.h64Raw; + })(); + } + return xxhashPromise; +} + +export async function computeCCH(bodyBytes: Uint8Array): Promise { + await ensureXxhash(); + const hash = xxhash64Fn!(bodyBytes, CCH_SEED); + const masked = hash & 0xfffffn; + return masked.toString(16).padStart(5, "0"); +} + +export async function signRequestBody(bodyString: string): Promise { + if (!CCH_PATTERN.test(bodyString)) return bodyString; + const encoder = new TextEncoder(); + const bodyBytes = encoder.encode(bodyString); + const token = await computeCCH(bodyBytes); + return bodyString.replace(CCH_PATTERN, `cch=${token};`); +} + +export { CCH_PATTERN }; diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 0294bf3199..1430bd4065 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -1,6 +1,17 @@ import { createHash, randomUUID } from "node:crypto"; +import { getStainlessTimeoutSeconds } from "@/shared/utils/runtimeTimeouts"; import { prepareClaudeRequest } from "../translator/helpers/claudeHelper.ts"; +import { signRequestBody } from "./claudeCodeCCH.ts"; +import { computeFingerprint, extractFirstUserMessageText } from "./claudeCodeFingerprint.ts"; +import { remapToolNamesInRequest } from "./claudeCodeToolRemapper.ts"; +import { + enforceThinkingTemperature, + disableThinkingIfToolChoiceForced, + enforceCacheControlLimit, + ensureCacheControlOnLastUserMessage, +} from "./claudeCodeConstraints.ts"; +import { obfuscateInBody } from "./claudeCodeObfuscation.ts"; export const CLAUDE_CODE_COMPATIBLE_PREFIX = "anthropic-compatible-cc-"; export const CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true"; @@ -8,10 +19,24 @@ 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,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24"; -export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.89 (external, sdk-cli)"; -export const CLAUDE_CODE_COMPATIBLE_BILLING_HEADER = - "x-anthropic-billing-header: cc_version=2.1.89.728; cc_entrypoint=sdk-cli; cch=00000;"; + "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"; +export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.87"; +export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = `claude-cli/${CLAUDE_CODE_COMPATIBLE_VERSION} (external, cli)`; +/** + * Build the billing header dynamically with fingerprint and CCH placeholder. + * The cch=00000 placeholder is later replaced by signRequestBody(). + */ +export function buildBillingHeader(messages?: Array<{ role?: string; content?: unknown }>): string { + const msgText = extractFirstUserMessageText(messages); + const fp = computeFingerprint(msgText, CLAUDE_CODE_COMPATIBLE_VERSION); + return `x-anthropic-billing-header: cc_version=${CLAUDE_CODE_COMPATIBLE_VERSION}.${fp}; cc_entrypoint=cli; cch=00000;`; +} + +/** @deprecated Use buildBillingHeader() for dynamic fingerprint */ +export const CLAUDE_CODE_COMPATIBLE_BILLING_HEADER = `x-anthropic-billing-header: cc_version=${CLAUDE_CODE_COMPATIBLE_VERSION}.000; cc_entrypoint=cli; cch=00000;`; +export const CLAUDE_CODE_COMPATIBLE_STAINLESS_TIMEOUT_SECONDS = getStainlessTimeoutSeconds( + process.env +); type HeaderLike = | Headers @@ -97,17 +122,18 @@ export function buildClaudeCodeCompatibleHeaders( "x-app": "cli", "User-Agent": CLAUDE_CODE_COMPATIBLE_USER_AGENT, "X-Stainless-Retry-Count": "0", - "X-Stainless-Timeout": "300", + "X-Stainless-Timeout": String(CLAUDE_CODE_COMPATIBLE_STAINLESS_TIMEOUT_SECONDS), "X-Stainless-Lang": "js", - "X-Stainless-Package-Version": "0.74.0", + "X-Stainless-Package-Version": "0.80.0", "X-Stainless-OS": "MacOS", "X-Stainless-Arch": "arm64", "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v25.8.1", + "X-Stainless-Runtime-Version": "v24.3.0", "accept-language": "*", "sec-fetch-mode": "cors", "accept-encoding": "identity", ...(sessionId ? { "X-Claude-Code-Session-Id": sessionId } : {}), + "x-client-request-id": randomUUID(), }; } @@ -161,12 +187,18 @@ export function buildClaudeCodeCompatibleRequest({ : Array.isArray(normalized.messages) ? buildClaudeCodeCompatibleMessages(normalized.messages as MessageLike[]) : []; + const allMessages = (preparedClaudeBody?.messages || normalized.messages || []) as Array<{ + role?: string; + content?: unknown; + }>; + const billingHeader = buildBillingHeader(allMessages); const system = buildClaudeCodeCompatibleSystemBlocks({ messages: normalized.messages as MessageLike[], systemBlocks: preparedClaudeBody?.system as Record[] | undefined, cwd, now, preserveCacheControl, + billingHeader, }); const resolvedSessionId = sessionId || randomUUID(); const effort = resolveClaudeCodeCompatibleEffort(sourceBody, normalizedBody, model); @@ -219,6 +251,72 @@ export function buildClaudeCodeCompatibleRequest({ }; } +/** + * Full Claude Code request processing pipeline. + * + * Applies all mechanisms that real Claude Code uses: + * 1. Build base request (system prompt, billing header, messages, tools) + * 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 + * + * Returns { bodyString, headers } ready to send upstream. + */ +export async function buildAndSignClaudeCodeRequest( + options: BuildRequestOptions & { apiKey: string; enableObfuscation?: boolean } +): Promise<{ bodyString: string; headers: Record }> { + const { apiKey, enableObfuscation = false, ...buildOptions } = options; + + // Step 1: Build base request + const body = buildClaudeCodeCompatibleRequest(buildOptions); + + // Step 2: Remap tool names + remapToolNamesInRequest(body); + + // Step 3-4: Thinking constraints + enforceThinkingTemperature(body); + disableThinkingIfToolChoiceForced(body); + + // Step 5-6: Cache control + enforceCacheControlLimit(body); + ensureCacheControlOnLastUserMessage(body); + + // Step 7: Obfuscation (optional, per-provider setting) + if (enableObfuscation) { + obfuscateInBody(body); + } + + // Step 8: Serialize with CCH placeholder + const serialized = JSON.stringify(body); + + // Step 9: Sign with xxHash64 + const bodyString = await signRequestBody(serialized); + + // Build headers + const sessionId = options.sessionId || resolveClaudeCodeCompatibleSessionId(); + const headers = buildClaudeCodeCompatibleHeaders(apiKey, options.stream ?? false, sessionId); + + return { bodyString, headers }; +} + +/** + * Re-export for consumers that need to post-process SSE response chunks. + */ +export { remapToolNamesInResponse } from "./claudeCodeToolRemapper.ts"; +export { signRequestBody } from "./claudeCodeCCH.ts"; +export { computeFingerprint } from "./claudeCodeFingerprint.ts"; +export { obfuscateSensitiveWords, setSensitiveWords } from "./claudeCodeObfuscation.ts"; +export { + enforceThinkingTemperature, + disableThinkingIfToolChoiceForced, + enforceCacheControlLimit, +} from "./claudeCodeConstraints.ts"; + export function resolveClaudeCodeCompatibleEffort( sourceBody?: Record | null, normalizedBody?: Record | null, @@ -381,12 +479,14 @@ function buildClaudeCodeCompatibleSystemBlocks({ cwd, now, preserveCacheControl, + billingHeader, }: { messages: MessageLike[] | undefined; systemBlocks?: Array> | undefined; cwd: string; now: Date; preserveCacheControl: boolean; + billingHeader: string; }) { const customSystemBlocks = Array.isArray(systemBlocks) && systemBlocks.length > 0 @@ -397,7 +497,8 @@ function buildClaudeCodeCompatibleSystemBlocks({ const blocks: Array> = [ { type: "text", - text: CLAUDE_CODE_COMPATIBLE_BILLING_HEADER, + text: billingHeader, + cache_control: { type: "ephemeral" }, }, { type: "text", diff --git a/open-sse/services/claudeCodeConstraints.ts b/open-sse/services/claudeCodeConstraints.ts new file mode 100644 index 0000000000..e220a0272b --- /dev/null +++ b/open-sse/services/claudeCodeConstraints.ts @@ -0,0 +1,127 @@ +/** + * Claude Code API constraints. + * + * Enforces Anthropic API requirements that real Claude Code handles: + * 1. temperature=1 when thinking is enabled + * 2. Disable thinking when tool_choice forces a specific tool + * 3. Enforce max 4 cache_control breakpoints + * 4. Normalize cache_control TTL ordering + */ + +export function enforceThinkingTemperature(body: Record): void { + const thinking = body.thinking as Record | undefined; + if (thinking?.type === "enabled" || thinking?.type === "adaptive") { + body.temperature = 1; + } +} + +export function disableThinkingIfToolChoiceForced(body: Record): void { + const toolChoice = body.tool_choice as Record | string | undefined; + if (!toolChoice) return; + + const isForced = + toolChoice === "any" || + (typeof toolChoice === "object" && (toolChoice.type === "any" || toolChoice.type === "tool")); + + if (isForced && body.thinking) { + delete body.thinking; + } +} + +const MAX_CACHE_CONTROL_BLOCKS = 4; + +export function enforceCacheControlLimit(body: Record): void { + let count = 0; + + // Count in system blocks + const system = body.system as Array> | undefined; + if (Array.isArray(system)) { + for (const block of system) { + if (block.cache_control) count++; + } + } + + // Count in messages + const messages = body.messages as Array> | undefined; + if (Array.isArray(messages)) { + for (const msg of messages) { + const content = msg.content as Array> | undefined; + if (!Array.isArray(content)) continue; + for (const block of content) { + if (block.cache_control) count++; + } + } + } + + // Count in tools + const tools = body.tools as Array> | undefined; + if (Array.isArray(tools)) { + for (const tool of tools) { + if (tool.cache_control) count++; + } + } + + if (count <= MAX_CACHE_CONTROL_BLOCKS) return; + + // Strip excess cache_control blocks from the end (keep first 4) + let remaining = MAX_CACHE_CONTROL_BLOCKS; + + if (Array.isArray(system)) { + for (const block of system) { + if (block.cache_control) { + if (remaining > 0) { + remaining--; + } else { + delete block.cache_control; + } + } + } + } + + if (Array.isArray(messages)) { + for (const msg of messages) { + const content = msg.content as Array> | undefined; + if (!Array.isArray(content)) continue; + for (const block of content) { + if (block.cache_control) { + if (remaining > 0) { + remaining--; + } else { + delete block.cache_control; + } + } + } + } + } + + if (Array.isArray(tools)) { + for (const tool of tools) { + if (tool.cache_control) { + if (remaining > 0) { + remaining--; + } else { + delete tool.cache_control; + } + } + } + } +} + +export function ensureCacheControlOnLastUserMessage(body: Record): void { + const messages = body.messages as Array> | undefined; + if (!Array.isArray(messages) || messages.length === 0) return; + + // Find the last user message + for (let i = messages.length - 1; i >= 0; i--) { + if (String(messages[i].role) === "user") { + const content = messages[i].content; + if (Array.isArray(content) && content.length > 0) { + const lastBlock = content[content.length - 1] as Record; + if (!lastBlock.cache_control) { + lastBlock.cache_control = { type: "ephemeral" }; + } + } + break; + } + } +} diff --git a/open-sse/services/claudeCodeFingerprint.ts b/open-sse/services/claudeCodeFingerprint.ts new file mode 100644 index 0000000000..3e31c8a74d --- /dev/null +++ b/open-sse/services/claudeCodeFingerprint.ts @@ -0,0 +1,46 @@ +/** + * Claude Code fingerprint computation. + * + * The billing header includes a 3-char fingerprint derived from: + * SHA256(SALT + msg[4] + msg[7] + msg[20] + version)[:3] + * + * This fingerprint is computed from the first user message text and + * included in cc_version=VERSION.FINGERPRINT in the billing header. + */ + +import { createHash } from "node:crypto"; + +const FINGERPRINT_SALT = "59cf53e54c78"; + +export function computeFingerprint(firstUserMessageText: string, version: string): string { + const indices = [4, 7, 20]; + const chars = indices.map((i) => firstUserMessageText[i] || "0").join(""); + const input = `${FINGERPRINT_SALT}${chars}${version}`; + const hash = createHash("sha256").update(input).digest("hex"); + return hash.slice(0, 3); +} + +export function extractFirstUserMessageText( + messages: Array<{ role?: string; content?: unknown }> | undefined +): string { + if (!Array.isArray(messages)) return ""; + for (const msg of messages) { + if (String(msg?.role).toLowerCase() !== "user") continue; + const content = msg?.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + for (const block of content) { + if ( + block && + typeof block === "object" && + "text" in block && + typeof (block as Record).text === "string" + ) { + return (block as Record).text as string; + } + } + } + return ""; + } + return ""; +} diff --git a/open-sse/services/claudeCodeObfuscation.ts b/open-sse/services/claudeCodeObfuscation.ts new file mode 100644 index 0000000000..38e6e1e974 --- /dev/null +++ b/open-sse/services/claudeCodeObfuscation.ts @@ -0,0 +1,77 @@ +/** + * Sensitive word obfuscation for Claude Code requests. + * + * Obfuscates configurable words in user messages to prevent detection + * by upstream content filters. Uses zero-width characters to break + * pattern matching while preserving readability. + */ + +// Unicode zero-width joiner inserted between characters +const ZWJ = "\u200d"; + +const DEFAULT_SENSITIVE_WORDS = [ + "opencode", + "open-code", + "cline", + "roo-cline", + "roo_cline", + "cursor", + "windsurf", + "aider", + "continue.dev", + "copilot", + "avante", + "codecompanion", +]; + +let sensitiveWords = [...DEFAULT_SENSITIVE_WORDS]; + +export function setSensitiveWords(words: string[]): void { + sensitiveWords = words.length > 0 ? words : [...DEFAULT_SENSITIVE_WORDS]; +} + +export function getSensitiveWords(): string[] { + return [...sensitiveWords]; +} + +function obfuscateWord(word: string): string { + if (word.length <= 1) return word; + // Insert ZWJ after first character + return word[0] + ZWJ + word.slice(1); +} + +export function obfuscateSensitiveWords(text: string): string { + if (!text || sensitiveWords.length === 0) return text; + + let result = text; + for (const word of sensitiveWords) { + if (!word) continue; + // Case-insensitive replacement + const regex = new RegExp(escapeRegex(word), "gi"); + result = result.replace(regex, (match) => obfuscateWord(match)); + } + return result; +} + +export function obfuscateInBody(body: Record): void { + const messages = body.messages as Array> | undefined; + if (!Array.isArray(messages)) return; + + for (const msg of messages) { + if (String(msg.role) !== "user") continue; + const content = msg.content; + if (typeof content === "string") { + msg.content = obfuscateSensitiveWords(content); + } else if (Array.isArray(content)) { + for (const block of content as Array>) { + if (typeof block.text === "string") { + block.text = obfuscateSensitiveWords(block.text); + } + } + } + } +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/open-sse/services/claudeCodeToolRemapper.ts b/open-sse/services/claudeCodeToolRemapper.ts new file mode 100644 index 0000000000..c17a3f6185 --- /dev/null +++ b/open-sse/services/claudeCodeToolRemapper.ts @@ -0,0 +1,80 @@ +/** + * Claude Code tool name remapping. + * + * Anthropic uses tool name fingerprinting to detect third-party clients. + * Real Claude Code uses TitleCase tool names (Bash, Read, Write, etc.) + * while third-party clients like OpenCode use lowercase. + * + * This module remaps tool names in both directions: + * - Request path: lowercase → TitleCase (before sending to Anthropic) + * - Response path: TitleCase → lowercase (for clients expecting lowercase) + */ + +const TOOL_RENAME_MAP: Record = { + bash: "Bash", + read: "Read", + write: "Write", + edit: "Edit", + glob: "Glob", + grep: "Grep", + task: "Task", + webfetch: "WebFetch", + todowrite: "TodoWrite", + todoread: "TodoRead", + question: "Question", + skill: "Skill", + multiedit: "MultiEdit", + notebook: "Notebook", +}; + +const REVERSE_MAP: Record = {}; +for (const [k, v] of Object.entries(TOOL_RENAME_MAP)) { + REVERSE_MAP[v] = k; +} + +export function remapToolNamesInRequest(body: Record): void { + // Remap tool definitions + const tools = body.tools as Array> | undefined; + if (Array.isArray(tools)) { + for (const tool of tools) { + const name = String(tool.name || ""); + if (TOOL_RENAME_MAP[name]) { + tool.name = TOOL_RENAME_MAP[name]; + } + } + } + + // Remap tool_result references in messages + const messages = body.messages as Array> | undefined; + if (Array.isArray(messages)) { + for (const msg of messages) { + const content = msg.content as Array> | undefined; + if (!Array.isArray(content)) continue; + for (const block of content) { + if (block.type === "tool_use" && typeof block.name === "string") { + const mapped = TOOL_RENAME_MAP[block.name]; + if (mapped) block.name = mapped; + } + } + } + } + + // Remap tool_choice + const toolChoice = body.tool_choice as Record | undefined; + if (toolChoice?.type === "tool" && typeof toolChoice.name === "string") { + const mapped = TOOL_RENAME_MAP[toolChoice.name]; + if (mapped) toolChoice.name = mapped; + } +} + +export function remapToolNamesInResponse(text: string): string { + // Replace TitleCase tool names back to lowercase in SSE chunks + for (const [titleCase, lower] of Object.entries(REVERSE_MAP)) { + // Match in "name":"ToolName" patterns + text = text.replaceAll(`"name":"${titleCase}"`, `"name":"${lower}"`); + text = text.replaceAll(`"name": "${titleCase}"`, `"name": "${lower}"`); + } + return text; +} + +export { TOOL_RENAME_MAP, REVERSE_MAP }; diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index ad23e8ca2c..6c1e2e9bc4 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -5,7 +5,7 @@ */ import { checkFallbackError, formatRetryAfter, getProviderProfile } from "./accountFallback.ts"; -import { unavailableResponse } from "../utils/error.ts"; +import { errorResponse, unavailableResponse } from "../utils/error.ts"; import { recordComboIntent, recordComboRequest, getComboMetrics } from "./comboMetrics.ts"; import { resolveComboConfig, getDefaultComboConfig } from "./comboConfig.ts"; import { maybeGenerateHandoff, resolveContextRelayConfig } from "./contextHandoff.ts"; @@ -45,6 +45,10 @@ const COMBO_BAD_REQUEST_FALLBACK_PATTERNS = [ const MAX_COMBO_DEPTH = 3; +function comboModelNotFoundResponse(message: string) { + return errorResponse(404, message); +} + // Bootstrap defaults from ClawRouter benchmark (used when no local latency history exists yet) const DEFAULT_MODEL_P95_MS = { "grok-4-fast-non-reasoning": 1143, @@ -913,6 +917,7 @@ export async function handleComboChat({ if (pinnedModel) { log.info("COMBO", `[#401] Context caching: pinned model=${pinnedModel}`); } + const clientRequestedStream = body?.stream === true; // Wrap handleSingleModel to inject context caching tag on response (#401) const handleSingleModelWrapped = combo.context_cache_protection ? async (b, modelStr, target) => { @@ -951,7 +956,7 @@ export async function handleComboChat({ // SDKs close the connection on finish_reason, so anything sent after // that marker is silently dropped. if (!res.body) return res; - const tagContent = `\\n${modelStr}\\n`; + const tagContent = `${modelStr}`; const encoder = new TextEncoder(); const decoder = new TextDecoder(); let tagInjected = false; @@ -1037,7 +1042,10 @@ export async function handleComboChat({ const text = sanitizeDecoder.decode(chunk, { stream: true }); if (text) { if (text.includes("")) { - const cleaned = text.replace(/\n?[^<]+<\/omniModel>\n?/g, ""); + const cleaned = text.replace( + /(?:\\n|\n)?[^<]+<\/omniModel>(?:\\n|\n)?/g, + "" + ); if (cleaned) controller.enqueue(encoder.encode(cleaned)); } else { controller.enqueue(encoder.encode(text)); @@ -1048,7 +1056,10 @@ export async function handleComboChat({ const tail = sanitizeDecoder.decode(); if (tail) { if (tail.includes("")) { - const cleaned = tail.replace(/\n?[^<]+<\/omniModel>\n?/g, ""); + const cleaned = tail.replace( + /(?:\\n|\n)?[^<]+<\/omniModel>(?:\\n|\n)?/g, + "" + ); if (cleaned) controller.enqueue(encoder.encode(cleaned)); } else { controller.enqueue(encoder.encode(tail)); @@ -1287,7 +1298,7 @@ export async function handleComboChat({ } if (orderedTargets.length === 0) { - return unavailableResponse(503, "Combo has no executable targets"); + return comboModelNotFoundResponse("Combo has no executable targets"); } let lastError = null; @@ -1344,7 +1355,7 @@ export async function handleComboChat({ // Success — validate response quality before returning if (result.ok) { - const quality = await validateResponseQuality(result, !!body.stream, log); + const quality = await validateResponseQuality(result, clientRequestedStream, log); if (!quality.valid) { log.warn( "COMBO", @@ -1615,7 +1626,7 @@ async function handleRoundRobinCombo({ const orderedTargets = resolveComboTargets(combo, allCombos); const modelCount = orderedTargets.length; if (modelCount === 0) { - return unavailableResponse(503, "Round-robin combo has no executable targets"); + return comboModelNotFoundResponse("Round-robin combo has no executable targets"); } // Get and increment atomic counter @@ -1623,6 +1634,7 @@ async function handleRoundRobinCombo({ rrCounters.set(combo.name, counter + 1); const startIndex = counter % modelCount; + const clientRequestedStream = body?.stream === true; const startTime = Date.now(); let lastError = null; let lastStatus = null; @@ -1697,7 +1709,7 @@ async function handleRoundRobinCombo({ // Success — validate response quality before returning if (result.ok) { - const quality = await validateResponseQuality(result, !!body.stream, log); + const quality = await validateResponseQuality(result, clientRequestedStream, log); if (!quality.valid) { log.warn( "COMBO-RR", diff --git a/open-sse/services/comboAgentMiddleware.ts b/open-sse/services/comboAgentMiddleware.ts index 4903c9bd99..6f2a79a631 100644 --- a/open-sse/services/comboAgentMiddleware.ts +++ b/open-sse/services/comboAgentMiddleware.ts @@ -62,7 +62,7 @@ export function injectModelTag(messages: Message[], providerModel: string): Mess if (lastAssistantIdx === -1) { return [ ...cleaned, - { role: "assistant", content: `\n${providerModel}` }, + { role: "assistant", content: `${providerModel}` }, ]; } @@ -75,14 +75,14 @@ export function injectModelTag(messages: Message[], providerModel: string): Mess // message with the tag rather than silently failing. return [ ...cleaned, - { role: "assistant", content: `\n${providerModel}` }, + { role: "assistant", content: `${providerModel}` }, ]; } const tagged = [...cleaned]; tagged[lastAssistantIdx] = { ...msg, - content: `${msg.content}\n${providerModel}`, + content: `${msg.content}${providerModel}`, }; return tagged; } diff --git a/open-sse/services/qwenThinking.ts b/open-sse/services/qwenThinking.ts new file mode 100644 index 0000000000..074534b08b --- /dev/null +++ b/open-sse/services/qwenThinking.ts @@ -0,0 +1,44 @@ +type JsonRecord = Record; + +export function isQwenThinkingActive(body: JsonRecord): boolean { + const thinking = body.thinking; + + if (thinking === true || body.enable_thinking === true) { + return true; + } + + return ( + typeof thinking === "object" && + thinking !== null && + !Array.isArray(thinking) && + (thinking as JsonRecord).type === "enabled" + ); +} + +export function isQwenThinkingToolChoiceIncompatible(toolChoice: unknown): boolean { + return toolChoice === "required" || (typeof toolChoice === "object" && toolChoice !== null); +} + +export function sanitizeQwenThinkingToolChoice( + body: JsonRecord, + providerLabel = "Qwen" +): JsonRecord { + if (!isQwenThinkingActive(body)) { + return body; + } + + const toolChoice = body.tool_choice; + if (!isQwenThinkingToolChoiceIncompatible(toolChoice)) { + return body; + } + + const toolChoiceLabel = typeof toolChoice === "string" ? toolChoice : "object"; + console.warn( + `[${providerLabel}] Neutralizing incompatible tool_choice ${toolChoiceLabel} to "auto" (thinking mode active)` + ); + + return { + ...body, + tool_choice: "auto", + }; +} diff --git a/open-sse/services/searchCache.ts b/open-sse/services/searchCache.ts index 22d304be2f..17614054c6 100644 --- a/open-sse/services/searchCache.ts +++ b/open-sse/services/searchCache.ts @@ -85,7 +85,7 @@ function evictIfNeeded(): void { * or execute the fetch function and cache the result. * * @param key - Cache key from computeCacheKey() - * @param ttlMs - TTL in milliseconds (0 to bypass cache) + * @param ttlMs - TTL in milliseconds (0 to bypass cache AND coalescing) * @param fetchFn - Function to execute on cache miss * @returns The cached or freshly fetched data */ @@ -94,6 +94,17 @@ export async function getOrCoalesce( ttlMs: number, fetchFn: () => Promise ): Promise<{ data: T; cached: boolean }> { + // When ttlMs === 0 the caller explicitly wants to bypass the cache. + // Skip both the cache lookup AND the inflight-coalescing step so every + // concurrent call gets its own independent upstream fetch. Without this + // guard, ttlMs=0 callers still get coalesced results and receive + // { cached: true } even though caching was explicitly disabled. + if (ttlMs <= 0) { + misses++; + const data = await fetchFn(); + return { data, cached: false }; + } + // 1. Check cache const cached = cache.get(key) as CacheEntry | undefined; if (cached && cached.expiresAt > Date.now()) { @@ -117,11 +128,8 @@ export async function getOrCoalesce( try { const data = await promise; - // Store in cache - if (ttlMs > 0) { - evictIfNeeded(); - cache.set(key, { data, expiresAt: Date.now() + ttlMs }); - } + evictIfNeeded(); + cache.set(key, { data, expiresAt: Date.now() + ttlMs }); return { data, cached: false }; } finally { diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 392de29d2e..8e7e4afdb9 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -45,6 +45,14 @@ const KIMI_CONFIG = { apiVersion: "2023-06-01", }; +const CURSOR_USAGE_CONFIG = { + usageUrl: "https://www.cursor.com/api/usage", + userMetaUrl: "https://www.cursor.com/api/auth/me", + subscriptionUrl: "https://www.cursor.com/api/subscription", + clientVersion: "3.1.0", + userAgent: "Cursor/3.1.0", +}; + type JsonRecord = Record; type UsageQuota = { used: number; @@ -185,6 +193,8 @@ export async function getUsageForProvider(connection) { return await getIflowUsage(accessToken); case "glm": return await getGlmUsage(apiKey, providerSpecificData); + case "cursor": + return await getCursorUsage(accessToken); default: return { message: `Usage API not implemented for ${provider}` }; } @@ -418,6 +428,178 @@ function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null): return "GitHub Copilot"; } +function buildCursorUsageHeaders(accessToken: string): Record { + return { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + "User-Agent": CURSOR_USAGE_CONFIG.userAgent, + "x-cursor-client-version": CURSOR_USAGE_CONFIG.clientVersion, + "x-cursor-user-agent": CURSOR_USAGE_CONFIG.userAgent, + }; +} + +function getFirstPositiveNumber(...values: unknown[]): number { + for (const value of values) { + const parsed = toNumber(value, Number.NaN); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return 0; +} + +function getCursorMonthlyRequestLimit(usageData: JsonRecord, subscriptionData: JsonRecord): number { + return getFirstPositiveNumber( + getFieldValue(subscriptionData, "team_max_monthly_requests", "teamMaxMonthlyRequests"), + getFieldValue(usageData, "team_max_request_usage", "teamMaxRequestUsage"), + getFieldValue(subscriptionData, "team_max_request_usage", "teamMaxRequestUsage"), + getFieldValue(usageData, "hard_limit", "hardLimit"), + getFieldValue(subscriptionData, "max_monthly_requests", "maxMonthlyRequests") + ); +} + +function getCursorOnDemandLimit(usageData: JsonRecord, subscriptionData: JsonRecord): number { + const onDemand = toRecord(getFieldValue(usageData, "on_demand", "onDemand")); + return getFirstPositiveNumber( + getFieldValue(onDemand, "max_requests", "maxRequests"), + getCursorMonthlyRequestLimit(usageData, subscriptionData) + ); +} + +function formatCursorQuota( + usedValue: unknown, + totalValue: unknown, + resetValue: unknown +): UsageQuota { + const total = Math.max(0, toNumber(totalValue, 0)); + const rawUsed = Math.max(0, toNumber(usedValue, 0)); + const used = total > 0 ? Math.min(rawUsed, total) : rawUsed; + const remaining = total > 0 ? Math.max(total - used, 0) : 0; + + return { + used, + total, + remaining, + remainingPercentage: total > 0 ? clampPercentage((remaining / total) * 100) : 0, + resetAt: parseResetTime(resetValue), + unlimited: false, + }; +} + +function inferCursorPlanName(userMeta: JsonRecord, subscriptionData: JsonRecord): string { + const teamInfo = toRecord(getFieldValue(userMeta, "team_info", "teamInfo")); + const candidates = [ + getFieldValue(userMeta, "plan", "plan"), + getFieldValue(userMeta, "subscription_type", "subscriptionType"), + getFieldValue(subscriptionData, "subscription_type", "subscriptionType"), + getFieldValue(subscriptionData, "plan", "plan"), + ]; + const planText = candidates.find((value) => typeof value === "string" && value.trim().length > 0); + const normalized = typeof planText === "string" ? planText.trim().toLowerCase() : ""; + + if (Object.keys(teamInfo).length > 0 || normalized.includes("team")) return "Cursor Team"; + if (normalized.includes("enterprise")) return "Cursor Enterprise"; + if (normalized.includes("pro")) return "Cursor Pro"; + if (normalized.includes("free")) return "Cursor Free"; + return "Cursor"; +} + +async function fetchCursorUsageDocument(url: string, accessToken: string) { + const response = await fetch(url, { + method: "GET", + headers: buildCursorUsageHeaders(accessToken), + }); + + const text = await response.text(); + if (!response.ok) { + return { + ok: false, + status: response.status, + data: {} as JsonRecord, + text, + }; + } + + try { + const parsed = text ? JSON.parse(text) : {}; + return { + ok: true, + status: response.status, + data: toRecord(parsed), + text, + }; + } catch { + return { + ok: false, + status: response.status, + data: {} as JsonRecord, + text, + }; + } +} + +async function getCursorUsage(accessToken: string) { + try { + if (!accessToken) { + return { + message: "Cursor token expired or unavailable. Please re-authenticate the connection.", + }; + } + + const [usageSummary, userMeta, subscription] = await Promise.all([ + fetchCursorUsageDocument(CURSOR_USAGE_CONFIG.usageUrl, accessToken), + fetchCursorUsageDocument(CURSOR_USAGE_CONFIG.userMetaUrl, accessToken), + fetchCursorUsageDocument(CURSOR_USAGE_CONFIG.subscriptionUrl, accessToken), + ]); + + const authDenied = [usageSummary, userMeta, subscription].some( + (result) => result.status === 401 || result.status === 403 + ); + if (authDenied) { + return { + message: + "Cursor token expired or permission denied. Please re-authenticate the connection.", + }; + } + + const usageData = usageSummary.data; + const userMetaData = userMeta.data; + const subscriptionData = subscription.data; + const plan = inferCursorPlanName(userMetaData, subscriptionData); + + const quotas: Record = {}; + const totalUsed = getFieldValue(usageData, "num_requests_total", "numRequestsTotal"); + const totalLimit = getCursorMonthlyRequestLimit(usageData, subscriptionData); + const totalReset = + getFieldValue(usageData, "reset_date", "resetDate") || + getFieldValue(subscriptionData, "reset_date", "resetDate"); + + if (toNumber(totalUsed, 0) > 0 || totalLimit > 0) { + quotas.requests = formatCursorQuota(totalUsed, totalLimit, totalReset); + } + + const onDemand = toRecord(getFieldValue(usageData, "on_demand", "onDemand")); + const onDemandUsed = getFieldValue(onDemand, "num_requests", "numRequests"); + const onDemandLimit = getCursorOnDemandLimit(usageData, subscriptionData); + const onDemandReset = + getFieldValue(onDemand, "reset_date", "resetDate") || + getFieldValue(usageData, "reset_date", "resetDate") || + getFieldValue(subscriptionData, "reset_date", "resetDate"); + + if (toNumber(onDemandUsed, 0) > 0 || onDemandLimit > 0) { + quotas.on_demand = formatCursorQuota(onDemandUsed, onDemandLimit, onDemandReset); + } + + if (Object.keys(quotas).length > 0) { + return { plan, quotas }; + } + + return { plan, message: "Cursor connected. Unable to parse quota data." }; + } catch (error) { + return { message: `Unable to fetch Cursor usage: ${(error as Error).message}` }; + } +} + // ── Gemini CLI subscription info cache ────────────────────────────────────── // Prevents duplicate loadCodeAssist calls within the same quota cycle. // Key: accessToken → { data, fetchedAt } @@ -1352,6 +1534,11 @@ export const __testing = { parseResetTime, formatGitHubQuotaSnapshot, inferGitHubPlanName, + buildCursorUsageHeaders, + formatCursorQuota, + getCursorMonthlyRequestLimit, + getCursorOnDemandLimit, + inferCursorPlanName, getGeminiCliPlanLabel, getAntigravityPlanLabel, }; diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index cd6eb63538..a0c671917b 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -42,6 +42,7 @@ export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [ "contentMediaType", "contentEncoding", // Non-standard schema fields (not recognized by Gemini API) + "deprecated", "optional", // UI/Styling properties (from Cursor tools - NOT JSON Schema standard) "cornerRadius", @@ -185,9 +186,9 @@ function removeUnsupportedKeywords(obj, keywords) { } } else { // Delete unsupported keys at current level - for (const keyword of keywords) { - if (keyword in obj) { - delete obj[keyword]; + for (const key of Object.keys(obj)) { + if (keywords.includes(key) || key.startsWith("x-")) { + delete obj[key]; } } // Recurse into remaining values diff --git a/open-sse/translator/helpers/geminiToolsSanitizer.ts b/open-sse/translator/helpers/geminiToolsSanitizer.ts new file mode 100644 index 0000000000..b8628c6e38 --- /dev/null +++ b/open-sse/translator/helpers/geminiToolsSanitizer.ts @@ -0,0 +1,124 @@ +import { cleanJSONSchemaForAntigravity } from "./geminiHelper.ts"; + +type GeminiFunctionDeclaration = { + name: string; + description: string; + parameters: unknown; +}; + +type GeminiTool = { + functionDeclarations?: GeminiFunctionDeclaration[]; + googleSearch?: Record; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function toGeminiGoogleSearchTool(tool: Record): GeminiTool | null { + if (isRecord(tool.googleSearch)) { + return { googleSearch: tool.googleSearch }; + } + if (tool.googleSearch !== undefined) { + return { googleSearch: {} }; + } + + if (isRecord(tool.google_search)) { + return { googleSearch: tool.google_search }; + } + if (tool.google_search !== undefined) { + return { googleSearch: {} }; + } + + const toolType = typeof tool.type === "string" ? tool.type : ""; + if ( + toolType === "googleSearch" || + toolType === "google_search" || + toolType === "web_search" || + toolType === "web_search_preview" + ) { + return { googleSearch: {} }; + } + + return null; +} + +export function buildGeminiTools(tools: unknown): GeminiTool[] | undefined { + if (!Array.isArray(tools) || tools.length === 0) { + return undefined; + } + + const functionDeclarations: GeminiFunctionDeclaration[] = []; + let googleSearchTool: GeminiTool | null = null; + + for (const rawTool of tools) { + if (!isRecord(rawTool)) { + continue; + } + + const normalizedGoogleSearchTool = toGeminiGoogleSearchTool(rawTool); + if (normalizedGoogleSearchTool) { + googleSearchTool = normalizedGoogleSearchTool; + continue; + } + + if (Array.isArray(rawTool.functionDeclarations)) { + for (const fn of rawTool.functionDeclarations) { + if (!isRecord(fn) || typeof fn.name !== "string" || !fn.name.trim()) { + continue; + } + + functionDeclarations.push({ + name: fn.name, + description: typeof fn.description === "string" ? fn.description : "", + parameters: cleanJSONSchemaForAntigravity( + fn.parameters || { type: "object", properties: {} } + ), + }); + } + continue; + } + + if (typeof rawTool.name === "string" && rawTool.name.trim()) { + functionDeclarations.push({ + name: rawTool.name, + description: typeof rawTool.description === "string" ? rawTool.description : "", + parameters: cleanJSONSchemaForAntigravity( + rawTool.input_schema || { type: "object", properties: {} } + ), + }); + continue; + } + + if (rawTool.type === "function" && isRecord(rawTool.function)) { + const fn = rawTool.function; + if (typeof fn.name !== "string" || !fn.name.trim()) { + continue; + } + + functionDeclarations.push({ + name: fn.name, + description: typeof fn.description === "string" ? fn.description : "", + parameters: cleanJSONSchemaForAntigravity( + fn.parameters || { type: "object", properties: {} } + ), + }); + } + } + + if (googleSearchTool && functionDeclarations.length > 0) { + console.warn( + `[GeminiTools] Removing ${functionDeclarations.length} functionDeclarations because googleSearch cannot be mixed with Gemini function tools` + ); + } + + if (googleSearchTool) { + return [googleSearchTool]; + } + + if (functionDeclarations.length > 0) { + return [{ functionDeclarations }]; + } + + return undefined; +} diff --git a/open-sse/translator/helpers/responsesApiHelper.ts b/open-sse/translator/helpers/responsesApiHelper.ts index 49ab141f55..32d971835b 100644 --- a/open-sse/translator/helpers/responsesApiHelper.ts +++ b/open-sse/translator/helpers/responsesApiHelper.ts @@ -4,6 +4,6 @@ */ import { openaiResponsesToOpenAIRequest } from "../request/openai-responses.ts"; -export function convertResponsesApiFormat(body) { - return openaiResponsesToOpenAIRequest(null, body, null, null); +export function convertResponsesApiFormat(body, credentials = null) { + return openaiResponsesToOpenAIRequest(null, body, null, credentials); } diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index 2cee5d98f7..b36e064b2c 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -6,6 +6,7 @@ import { cleanJSONSchemaForAntigravity, } from "../helpers/geminiHelper.ts"; import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; +import { buildGeminiTools } from "../helpers/geminiToolsSanitizer.ts"; /** * Direct Claude → Gemini request translator. @@ -168,22 +169,9 @@ export function claudeToGeminiRequest(model, body, stream) { } // ── Convert tools ────────────────────────────────────────────── - if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) { - const functionDeclarations = []; - for (const tool of body.tools) { - if (tool.name) { - functionDeclarations.push({ - name: tool.name, - description: tool.description || "", - parameters: cleanJSONSchemaForAntigravity( - tool.input_schema || { type: "object", properties: {} } - ), - }); - } - } - if (functionDeclarations.length > 0) { - result.tools = [{ functionDeclarations }]; - } + const geminiTools = buildGeminiTools(body.tools); + if (geminiTools) { + result.tools = geminiTools; } // ── Thinking config ──────────────────────────────────────────── diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 6bc09b829f..bbeaff2e5a 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -4,11 +4,13 @@ * Responses API uses: { input: [...], instructions: "..." } * Chat API uses: { messages: [...] } */ -import { register } from "../registry.ts"; +import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults"; import { FORMATS } from "../formats.ts"; import { generateToolCallId } from "../helpers/toolCallHelper.ts"; +import { register } from "../registry.ts"; type JsonRecord = Record; +const RESPONSES_STORE_MARKER = "_omnirouteResponsesStore"; function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; @@ -44,6 +46,8 @@ export function openaiResponsesToOpenAIRequest( const root = toRecord(body); if (root.input === undefined) return body; + const credentialRecord = toRecord(credentials); + const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData); // Validate tool types — only function tools can be translated to Chat Completions const tools = toArray(root.tools); @@ -271,6 +275,9 @@ export function openaiResponsesToOpenAIRequest( delete result.input; delete result.instructions; delete result.include; + if (storeEnabled && root.store !== undefined) { + result[RESPONSES_STORE_MARKER] = root.store; + } delete result.store; delete result.reasoning; @@ -287,15 +294,18 @@ export function openaiToOpenAIResponsesRequest( credentials: unknown ): unknown { void stream; - void credentials; const root = toRecord(body); + const credentialRecord = toRecord(credentials); + const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData); const result: JsonRecord = { model, input: [], stream: true, - store: false, }; + if (!storeEnabled) { + result.store = false; + } const input = result.input as JsonRecord[]; @@ -514,10 +524,29 @@ export function openaiToOpenAIResponsesRequest( } // Pass through relevant fields + if (root.previous_response_id !== undefined) { + result.previous_response_id = root.previous_response_id; + } + if (root.prompt_cache_key !== undefined) { + result.prompt_cache_key = root.prompt_cache_key; + } + if (root.session_id !== undefined) { + result.session_id = root.session_id; + } + if (root.conversation_id !== undefined) { + result.conversation_id = root.conversation_id; + } 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; if (root.top_p !== undefined) result.top_p = root.top_p; + if (storeEnabled) { + if (root[RESPONSES_STORE_MARKER] !== undefined) { + result.store = root[RESPONSES_STORE_MARKER]; + } else if (root.store !== undefined) { + result.store = root.store; + } + } return result; } diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index ba299fe2c3..bb904ed247 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -25,6 +25,7 @@ import { generateSessionId, cleanJSONSchemaForAntigravity, } from "../helpers/geminiHelper.ts"; +import { buildGeminiTools } from "../helpers/geminiToolsSanitizer.ts"; type GeminiPart = Record; type GeminiContent = { role: string; parts: GeminiPart[] }; @@ -54,7 +55,10 @@ type GeminiRequest = { generationConfig: GeminiGenerationConfig; safetySettings: unknown; systemInstruction?: GeminiContent; - tools?: Array<{ functionDeclarations: GeminiFunctionDeclaration[] }>; + tools?: Array<{ + functionDeclarations?: GeminiFunctionDeclaration[]; + googleSearch?: Record; + }>; cachedContent?: string; }; @@ -69,7 +73,10 @@ type CloudCodeEnvelope = { contents: GeminiContent[]; systemInstruction?: GeminiContent; generationConfig: GeminiGenerationConfig; - tools?: Array<{ functionDeclarations: GeminiFunctionDeclaration[] }>; + tools?: Array<{ + functionDeclarations?: GeminiFunctionDeclaration[]; + googleSearch?: Record; + }>; safetySettings?: unknown; toolConfig?: { functionCallingConfig: { mode: string }; @@ -277,35 +284,9 @@ function openaiToGeminiBase(model, body, stream) { } // Convert tools - if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) { - const functionDeclarations = []; - for (const t of body.tools) { - // Check if already in Anthropic/Claude format (no type field, direct name/description/input_schema) - if (t.name && t.input_schema) { - functionDeclarations.push({ - name: t.name, - description: t.description || "", - parameters: cleanJSONSchemaForAntigravity( - t.input_schema || { type: "object", properties: {} } - ), - }); - } - // OpenAI format - else if (t.type === "function" && t.function) { - const fn = t.function; - functionDeclarations.push({ - name: fn.name, - description: fn.description || "", - parameters: cleanJSONSchemaForAntigravity( - fn.parameters || { type: "object", properties: {} } - ), - }); - } - } - - if (functionDeclarations.length > 0) { - result.tools = [{ functionDeclarations }]; - } + const geminiTools = buildGeminiTools(body.tools); + if (geminiTools) { + result.tools = geminiTools; } // Convert response_format to Gemini's responseMimeType/responseSchema @@ -437,7 +418,7 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra } // Add toolConfig for Antigravity - if (geminiCLI.tools?.length > 0) { + if (geminiCLI.tools?.some((tool) => Array.isArray(tool.functionDeclarations))) { envelope.request.toolConfig = { functionCallingConfig: { mode: "VALIDATED" }, }; @@ -534,19 +515,9 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu // Convert Claude tools to Gemini functionDeclarations if (claudeRequest.tools && Array.isArray(claudeRequest.tools)) { - const functionDeclarations = []; - for (const tool of claudeRequest.tools) { - if (tool.name && tool.input_schema) { - const cleanedSchema = cleanJSONSchemaForAntigravity(tool.input_schema); - functionDeclarations.push({ - name: tool.name, - description: tool.description || "", - parameters: cleanedSchema, - }); - } - } - if (functionDeclarations.length > 0) { - envelope.request.tools = [{ functionDeclarations }]; + const geminiTools = buildGeminiTools(claudeRequest.tools); + if (geminiTools) { + envelope.request.tools = geminiTools; envelope.request.toolConfig = { functionCallingConfig: { mode: "VALIDATED" }, }; diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index bc6d6bb877..b13cf46695 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -28,7 +28,7 @@ export function geminiToOpenAIResponse(chunk, state) { choices: [ { index: 0, - delta: { role: "assistant" }, + delta: { role: "assistant", content: "" }, finish_reason: null, }, ], diff --git a/open-sse/utils/cursorChecksum.ts b/open-sse/utils/cursorChecksum.ts index 8b6bbe5c8c..4892224416 100644 --- a/open-sse/utils/cursorChecksum.ts +++ b/open-sse/utils/cursorChecksum.ts @@ -8,6 +8,9 @@ import crypto from "crypto"; import { v5 as uuidv5 } from "uuid"; +const CURSOR_CLIENT_VERSION = "3.1.0"; +const CURSOR_USER_AGENT = `Cursor/${CURSOR_CLIENT_VERSION}`; + /** * Generate SHA-256 hash like generateHashed64Hex * @param {string} input - Input string @@ -112,11 +115,12 @@ export function buildCursorHeaders(accessToken, machineId = null, ghostMode = tr "connect-accept-encoding": "gzip", "connect-protocol-version": "1", "Content-Type": "application/connect+proto", - "User-Agent": "connect-es/1.6.1", + "User-Agent": CURSOR_USER_AGENT, "x-amzn-trace-id": `Root=${crypto.randomUUID()}`, "x-client-key": clientKey, "x-cursor-checksum": checksum, - "x-cursor-client-version": "1.1.3", + "x-cursor-client-version": CURSOR_CLIENT_VERSION, + "x-cursor-user-agent": CURSOR_USER_AGENT, "x-cursor-config-version": crypto.randomUUID(), "x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", "x-ghost-mode": ghostMode ? "true" : "false", diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index ff0a2de58a..ccbb986a81 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -165,6 +165,9 @@ export async function runWithProxyContext(proxyConfig, fn) { async function patchedFetch(input: RequestInfo | URL, options: FetchWithDispatcherOptions = {}) { if (options?.dispatcher) { + // When a dispatcher is present, we MUST use the undici library fetch + // to ensure version compatibility. Node 22 built-in fetch (undici v6) + // is incompatible with undici v8 dispatchers (missing onRequestStart, etc.) return (undiciFetch as unknown as (...args: unknown[]) => Promise)(input, options); } @@ -206,7 +209,16 @@ async function patchedFetch(input: RequestInfo | URL, options: FetchWithDispatch dispatcher: getDefaultDispatcher(), }); } catch (dispatcherError) { - const msg = dispatcherError instanceof Error ? dispatcherError.message : String(dispatcherError); + const msg = + dispatcherError instanceof Error ? dispatcherError.message : String(dispatcherError); + // CAUTION: Do NOT fallback to native fetch if the error is a version mismatch (invalid onRequestStart) + // because the native fetch will definitely fail with the undici v8 dispatcher. + if (msg.includes("onRequestStart")) { + console.error( + `[ProxyFetch] Fatal version mismatch: Dispatcher (v8) vs Fetch (v6/native). Hardware upgrade or SOCKS5 config isolation required. Error: ${msg}` + ); + throw dispatcherError; + } // Only fallback for connection/dispatcher errors, not HTTP errors if (msg.includes("fetch failed") || msg.includes("ECONNREFUSED") || msg.includes("UND_ERR")) { console.warn(`[ProxyFetch] Undici dispatcher failed, falling back to native fetch: ${msg}`); diff --git a/open-sse/utils/streamPayloadCollector.ts b/open-sse/utils/streamPayloadCollector.ts index a9c9e8c9c5..96395bee99 100644 --- a/open-sse/utils/streamPayloadCollector.ts +++ b/open-sse/utils/streamPayloadCollector.ts @@ -261,6 +261,16 @@ function buildResponsesSummary( let latestResponse: JsonRecord | null = null; let usage: JsonRecord | null = null; const textParts: string[] = []; + const buildOutputFromText = () => + textParts.length > 0 + ? [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: textParts.join("") }], + }, + ] + : []; for (const payload of payloads) { const eventType = toString(payload.type); @@ -292,11 +302,12 @@ function buildResponsesSummary( const picked = completed || latestResponse; if (picked && Object.keys(picked).length > 0) { + const pickedOutput = Array.isArray(picked.output) ? picked.output : []; return { id: toString(picked.id, `resp_${Date.now()}`), object: "response", model: toString(picked.model, fallbackModel || "unknown"), - output: Array.isArray(picked.output) ? picked.output : [], + output: pickedOutput.length > 0 ? pickedOutput : buildOutputFromText(), usage: picked.usage ?? usage ?? null, status: toString(picked.status, completed ? "completed" : "in_progress"), created_at: toNumber(picked.created_at, Math.floor(Date.now() / 1000)), @@ -308,16 +319,7 @@ function buildResponsesSummary( id: `resp_${Date.now()}`, object: "response", model: fallbackModel || "unknown", - output: - textParts.length > 0 - ? [ - { - type: "message", - role: "assistant", - content: [{ type: "output_text", text: textParts.join("") }], - }, - ] - : [], + output: buildOutputFromText(), usage: usage ?? null, status: "completed", created_at: Math.floor(Date.now() / 1000), diff --git a/package-lock.json b/package-lock.json index 83d250dd37..a504791e48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.6.4", + "version": "3.6.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.6.4", + "version": "3.6.5", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -42,9 +42,10 @@ "recharts": "^3.7.0", "selfsigned": "^5.5.0", "tsx": "^4.21.0", - "undici": "^8.0.2", + "undici": "^8.1.0", "uuid": "^13.0.0", "wreq-js": "^2.0.1", + "xxhash-wasm": "^1.1.0", "yazl": "^3.3.1", "zod": "^4.3.6", "zustand": "^5.0.10" @@ -6536,17 +6537,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", - "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", + "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/type-utils": "8.58.1", - "@typescript-eslint/utils": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -6559,7 +6560,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.1", + "@typescript-eslint/parser": "^8.58.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -6575,16 +6576,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", - "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", + "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "engines": { @@ -6600,14 +6601,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", - "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", + "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.1", - "@typescript-eslint/types": "^8.58.1", + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "engines": { @@ -6622,14 +6623,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", - "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", + "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1" + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6640,9 +6641,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", - "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", + "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", "dev": true, "license": "MIT", "engines": { @@ -6657,15 +6658,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", - "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", + "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -6682,9 +6683,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", - "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", "dev": true, "license": "MIT", "engines": { @@ -6696,16 +6697,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", - "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", + "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.58.1", - "@typescript-eslint/tsconfig-utils": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -6776,16 +6777,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", - "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", + "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1" + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6800,13 +6801,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", - "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", + "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -7893,9 +7894,9 @@ } }, "node_modules/better-sqlite3": { - "version": "12.8.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", - "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -13099,9 +13100,9 @@ } }, "node_modules/jsdom/node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "dev": true, "license": "MIT", "engines": { @@ -19684,16 +19685,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz", - "integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz", + "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.58.1", - "@typescript-eslint/parser": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/utils": "8.58.1" + "@typescript-eslint/eslint-plugin": "8.58.2", + "@typescript-eslint/parser": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -19734,9 +19735,9 @@ } }, "node_modules/undici": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.0.2.tgz", - "integrity": "sha512-B9MeU5wuFhkFAuNeA19K2GDFcQXZxq33fL0nRy2Aq30wdufZbyyvxW3/ChaeipXVfy/wUweZyzovQGk39+9k2w==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.1.0.tgz", + "integrity": "sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==", "license": "MIT", "engines": { "node": ">=22.19.0" @@ -20741,6 +20742,12 @@ "dev": true, "license": "MIT" }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -20955,7 +20962,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.6.4" + "version": "3.6.5" } } } diff --git a/package.json b/package.json index 4b121598d7..1b34436009 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.6.4", + "version": "3.6.5", "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": { @@ -121,9 +121,10 @@ "recharts": "^3.7.0", "selfsigned": "^5.5.0", "tsx": "^4.21.0", - "undici": "^8.0.2", + "undici": "^8.1.0", "uuid": "^13.0.0", "wreq-js": "^2.0.1", + "xxhash-wasm": "^1.1.0", "yazl": "^3.3.1", "zod": "^4.3.6", "zustand": "^5.0.10" diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 9e46f4d163..a94d9b7576 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -11,7 +11,10 @@ import Input from "@/shared/components/Input"; import Modal from "@/shared/components/Modal"; import Toggle from "@/shared/components/Toggle"; import Tooltip from "@/shared/components/Tooltip"; +import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { pickDisplayValue } from "@/shared/utils/maskEmail"; +import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import { useNotificationStore } from "@/store/notificationStore"; import { ROUTING_STRATEGIES } from "@/shared/constants/routingStrategies"; import { @@ -467,10 +470,12 @@ function formatComboEntryDisplay( providerNodes = [], builderProviders = [], includeConnection = false, + showFullEmails = true, }: { providerNodes?: any[]; builderProviders?: any[]; includeConnection?: boolean; + showFullEmails?: boolean; } = {} ) { const normalizedEntry = normalizeModelEntry(entry); @@ -493,11 +498,14 @@ function formatComboEntryDisplay( } const connectionId = normalizedEntry.connectionId || null; - const connectionLabel = + const rawConnectionLabel = (connectionId && builderProvider?.connections?.find((connection) => connection.id === connectionId)?.label) || normalizedEntry.label || null; + const connectionLabel = rawConnectionLabel + ? pickDisplayValue([rawConnectionLabel], showFullEmails, rawConnectionLabel) + : null; if (connectionId) { return `${providerLabel}/${modelLabel} · ${connectionLabel || `acct ${connectionId.slice(0, 8)}`}`; @@ -516,6 +524,7 @@ function formatComboEntryDisplay( export default function CombosPage() { const t = useTranslations("combos"); const tc = useTranslations("common"); + const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); const router = useRouter(); const searchParams = useSearchParams(); const [combos, setCombos] = useState([]); @@ -848,12 +857,38 @@ export default function CombosPage() { return (
{/* Header */} -
+

{t("title")}

{t("description")}

-
+
+
+ + {getI18nOrFallback( + t, + "emailVisibilityHint", + "Account emails here follow the global privacy toggle." + )} + + + + + + + + {emailsVisible + ? getI18nOrFallback(t, "emailVisibilityStateOn", "Emails visible globally") + : getI18nOrFallback(t, "emailVisibilityStateOff", "Emails masked globally")} + +
{!showUsageGuide && ( + {dbHealth?.backupCreated && ( +

+ A repair backup was created before mutating. +

+ )} + {dbHealthError &&

{dbHealthError}

} +
+
+ {Array.isArray(dbHealth?.issues) && dbHealth.issues.length > 0 && ( +
+ {dbHealth.issues.map((issue, index) => ( +
+
+

{issue.description}

+ {issue.count} +
+

+ {issue.table} · {issue.type} +

+
+ ))} +
+ )} + + {/* System Info Cards */}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index d1f600a35d..55ba8d2d4b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -46,6 +46,7 @@ import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases"; import { maskEmail, pickMaskedDisplayValue, pickDisplayValue } from "@/shared/utils/maskEmail"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; +import { getCodexRequestDefaults as _getCodexRequestDefaults } from "@/lib/providers/requestDefaults"; type CompatByProtocolMap = Partial< Record< @@ -462,6 +463,9 @@ interface ConnectionRowProps { onToggleRateLimit: (enabled?: boolean) => void; onToggleCodex5h?: (enabled?: boolean) => void; onToggleCodexWeekly?: (enabled?: boolean) => void; + isCcCompatible?: boolean; + cliproxyapiEnabled?: boolean; + onToggleCliproxyapiMode?: (enabled?: boolean) => void; onRetest: () => void; isRetesting?: boolean; onEdit: () => void; @@ -535,6 +539,13 @@ interface EditCompatibleNodeModalProps { const CC_COMPATIBLE_LABEL = "CC Compatible"; const CC_COMPATIBLE_DETAILS_TITLE = "CC Compatible Details"; const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true"; +const CODEX_REASONING_STRENGTH_OPTIONS = [ + { value: "none", label: "None" }, + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "XHigh" }, +]; function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly: boolean } { const record = @@ -547,6 +558,21 @@ function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly }; } +/** + * UI adapter around the canonical getCodexRequestDefaults from requestDefaults.ts. + * Adds the "medium" fallback for reasoningEffort required by the connection form. + */ +function getCodexRequestDefaults(providerSpecificData: unknown): { + reasoningEffort: string; + serviceTier?: "priority"; +} { + const defaults = _getCodexRequestDefaults(providerSpecificData); + return { + reasoningEffort: defaults.reasoningEffort ?? "medium", + ...(defaults.serviceTier ? { serviceTier: defaults.serviceTier } : {}), + }; +} + function compatProtocolLabelKey(protocol: string): string { if (protocol === "openai") return "compatProtocolOpenAI"; if (protocol === "openai-responses") return "compatProtocolOpenAIResponses"; @@ -889,6 +915,7 @@ export default function ProviderDetailPage() { const [headerImgError, setHeaderImgError] = useState(false); const { copied, copy } = useCopyToClipboard(); const t = useTranslations("providers"); + const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); const notify = useNotificationStore(); const [proxyTarget, setProxyTarget] = useState(null); const [proxyConfig, setProxyConfig] = useState(null); @@ -1312,6 +1339,62 @@ export default function ProviderDetailPage() { } }; + const [cpaProviderEnabled, setCpaProviderEnabled] = useState(false); + + // Load upstream proxy config for this provider on mount + useEffect(() => { + if (!isCcCompatible) return; + fetch(`/api/settings`) + .then((r) => r.json()) + .then((data) => { + // Check if this provider has CLIProxyAPI routing enabled + // The upstream_proxy_config is synced via the settings API + }) + .catch(() => {}); + + // Also check via direct upstream proxy config lookup + fetch(`/api/upstream-proxy/${providerId}`) + .then((r) => { + if (!r.ok) return null; + return r.json(); + }) + .then((data) => { + if (data?.enabled && (data.mode === "cliproxyapi" || data.mode === "fallback")) { + setCpaProviderEnabled(true); + } + }) + .catch(() => {}); + }, [isCcCompatible, providerId]); + + const handleToggleCliproxyapiMode = async (_connectionId, enabled) => { + try { + // Write to upstream_proxy_config table which resolveExecutorWithProxy reads + const res = await fetch(`/api/upstream-proxy/${providerId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mode: enabled ? "cliproxyapi" : "native", + enabled: enabled, + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + notify.error(data.error || "Failed to update CLIProxyAPI routing"); + return; + } + + setCpaProviderEnabled(enabled); + notify.success( + enabled + ? "Requests now route through CLIProxyAPI (deeper emulation)" + : "Requests now use native OmniRoute (direct)" + ); + } catch { + notify.error("Failed to update CLIProxyAPI routing"); + } + }; + const handleToggleCodexLimit = async (connectionId, field, enabled) => { try { const target = connections.find((connection) => connection.id === connectionId); @@ -2589,6 +2672,11 @@ export default function ProviderDetailPage() { onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)} onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)} isCodex={providerId === "codex"} + isCcCompatible={isCcCompatible} + cliproxyapiEnabled={cpaProviderEnabled} + onToggleCliproxyapiMode={(enabled) => + handleToggleCliproxyapiMode(conn.id, enabled) + } onToggleCodex5h={(enabled) => handleToggleCodexLimit(conn.id, "use5h", enabled) } @@ -2625,11 +2713,7 @@ export default function ProviderDetailPage() { setProxyTarget({ level: "key", id: conn.id, - label: pickDisplayValue( - [conn.name, conn.email], - useEmailPrivacyStore.getState().emailsVisible, - conn.id - ), + label: pickDisplayValue([conn.name, conn.email], emailsVisible, conn.id), }) } hasProxy={!!connProxyMap[conn.id]?.proxy} @@ -2740,7 +2824,7 @@ export default function ProviderDetailPage() { id: conn.id, label: pickDisplayValue( [conn.name, conn.email], - useEmailPrivacyStore.getState().emailsVisible, + emailsVisible, conn.id ), }) @@ -2915,7 +2999,9 @@ export default function ProviderDetailPage() { {r.valid ? "check_circle" : "error"}
- {r.connectionName} + + {pickDisplayValue([r.connectionName], emailsVisible, r.connectionName)} +
{r.latencyMs !== undefined && ( @@ -4555,6 +4641,8 @@ function ConnectionRow({ connection, isOAuth, isCodex, + isCcCompatible, + cliproxyapiEnabled, isFirst, isLast, onMoveUp, @@ -4563,6 +4651,7 @@ function ConnectionRow({ onToggleRateLimit, onToggleCodex5h, onToggleCodexWeekly, + onToggleCliproxyapiMode, onRetest, isRetesting, onEdit, @@ -4654,6 +4743,7 @@ function ConnectionRow({ const normalizedCodexPolicy = normalizeCodexLimitPolicy(codexPolicy); const codex5hEnabled = normalizedCodexPolicy.use5h; const codexWeeklyEnabled = normalizedCodexPolicy.useWeekly; + const cliproxyapiDeepMode = !!cliproxyapiEnabled; return (
shield {rateLimitEnabled ? t("rateLimitProtected") : t("rateLimitUnprotected")} + {isCcCompatible && ( + <> + | + + + )} {isCodex && ( <> | @@ -4932,6 +5043,9 @@ ConnectionRow.propTypes = { onToggleRateLimit: PropTypes.func.isRequired, onToggleCodex5h: PropTypes.func, onToggleCodexWeekly: PropTypes.func, + isCcCompatible: PropTypes.bool, + cliproxyapiEnabled: PropTypes.bool, + onToggleCliproxyapiMode: PropTypes.func, onRetest: PropTypes.func.isRequired, isRetesting: PropTypes.bool, onEdit: PropTypes.func.isRequired, @@ -5340,6 +5454,9 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec tag: "", customUserAgent: "", accountId: "", + codexReasoningEffort: "medium", + codexFastServiceTier: false, + codexOpenaiStoreEnabled: false, }); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); @@ -5358,6 +5475,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec const isVertex = connection?.provider === "vertex"; const isGlm = connection?.provider === "glm"; const isCloudflare = connection?.provider === "cloudflare-ai"; + const isCodex = connection?.provider === "codex"; const defaultRegion = "us-central1"; useEffect(() => { @@ -5371,6 +5489,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec typeof rawCustomUserAgent === "string" ? rawCustomUserAgent : ""; const rawAccountId = connection.providerSpecificData?.accountId; const existingAccountId = typeof rawAccountId === "string" ? rawAccountId : ""; + const codexRequestDefaults = getCodexRequestDefaults(connection.providerSpecificData); setFormData({ name: connection.name || "", priority: connection.priority || 1, @@ -5383,6 +5502,9 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec tag: (connection.providerSpecificData?.tag as string) || "", customUserAgent: existingCustomUserAgent, accountId: existingAccountId, + codexReasoningEffort: codexRequestDefaults.reasoningEffort, + codexFastServiceTier: codexRequestDefaults.serviceTier === "priority", + codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true, }); // Load existing extra keys from providerSpecificData const existing = connection.providerSpecificData?.extraApiKeys; @@ -5533,6 +5655,14 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec ...(connection.providerSpecificData || {}), tag: formData.tag.trim() || undefined, }; + if (isCodex) { + updates.providerSpecificData.requestDefaults = { + reasoningEffort: formData.codexReasoningEffort, + ...(formData.codexFastServiceTier ? { serviceTier: "priority" } : {}), + }; + updates.providerSpecificData.openaiStoreEnabled = + formData.codexOpenaiStoreEnabled === true; + } } const error = (await onSave(updates)) as void | unknown; if (error) { @@ -5570,6 +5700,29 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec placeholder="e.g. personal, work, team-a" hint="Used to group accounts in the provider view" /> + {isCodex && ( +
+ setShQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && searchSkillsSh()} + placeholder="Search skills.sh..." + className="flex-1 px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500" + /> + +
+ {shError && ( +
+ {shError} +
+ )} + + {shResults.length > 0 && ( +
+ {shResults.map((skill) => ( + +
+
+

{skill.name}

+

+ {skill.source} · {skill.installs.toLocaleString()} installs +

+
+ +
+
+ ))} +
+ )} + {!shLoading && shResults.length === 0 && !shError && ( + +
+ Search the skills.sh open directory to discover and install agent skills. +
+
+ )} +
+ )} + {showInstallModal && (
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index d01f829eac..ba2abdd5e2 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -612,48 +612,70 @@ export default function ProviderLimits() { return (
0 ? "border-l border-border/80 pl-3 ml-1" : "" }`} > - {/* Model label */} - - {shortName} - + {q.isCredits ? ( + /* ── AI Credits counter ── */ + <> + + 🪙 {formatQuotaLabel(q.name)} + + + {q.creditCount ?? q.remaining} + + left + + ) : ( + /* ── Standard quota bar ── */ + <> + {/* Model label */} + + {shortName} + - {/* Countdown */} - {staleAfterReset ? ( - - ⟳ Refreshing... - - ) : cd ? ( - - ⏱ {cd} - - ) : null} + {/* Countdown */} + {staleAfterReset ? ( + + ⟳ Refreshing... + + ) : cd ? ( + + ⏱ {cd} + + ) : null} - {/* Progress bar */} -
-
-
+ {/* Progress bar */} +
+
+
- {/* Percentage */} - - {remainingPercentage}% - + {/* Percentage */} + + {remainingPercentage}% + + + )}
); }) diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index 995d1e4493..929d690af2 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -19,6 +19,8 @@ const QUOTA_LABEL_MAP: Record = { code_review: "Code Review", agentic_request: "Agentic", agentic_request_freetrial: "Agentic (Trial)", + credits: "AI Credits", + models: "Models", }; function toRecord(value: unknown): Record { @@ -203,6 +205,27 @@ export function parseQuotaData(provider, data) { case "antigravity": if (data.quotas) { Object.entries(data.quotas).forEach(([modelKey, quota]: [string, any]) => { + if (modelKey === "credits") { + // Credit balance: render as "N credits remaining" counter, not a progress bar + const remaining = Number(quota?.remaining ?? 0); + normalizedQuotas.push({ + name: "credits", + used: 0, + total: 0, + remaining, + resetAt: null, + unlimited: false, + isCredits: true, + // Show green if >50, yellow if >10, red if ≤10 + remainingPercentage: remaining > 50 ? 100 : remaining > 10 ? 60 : 20, + creditCount: remaining, + }); + return; + } + if (modelKey === "models") { + // Summary row: skip — individual models are shown via modelQuotas if needed + return; + } if (quota?.unlimited && (!quota?.total || quota.total <= 0)) { return; } diff --git a/src/app/api/cli-tools/status/route.ts b/src/app/api/cli-tools/status/route.ts index 2d8108aaf4..ebcb7067be 100644 --- a/src/app/api/cli-tools/status/route.ts +++ b/src/app/api/cli-tools/status/route.ts @@ -20,28 +20,62 @@ async function checkToolConfigStatus(toolId: string): Promise { if (!configPath) return "unknown"; const content = await fs.readFile(configPath, "utf-8"); + + // Codex uses TOML config — parse as raw text, not JSON + if (toolId === "codex") { + const lower = content.toLowerCase(); + const hasOmniRoute = + lower.includes("omniroute") || + lower.includes(`localhost:${apiPort}`) || + lower.includes(`127.0.0.1:${apiPort}`); + if (!hasOmniRoute) return "not_configured"; + + // Also verify auth.json has an API key (not masked/empty) + try { + const authPath = configPath.replace(/config\.toml$/, "auth.json"); + const authContent = await fs.readFile(authPath, "utf-8"); + const auth = JSON.parse(authContent); + const apiKey = auth?.OPENAI_API_KEY || ""; + if (!apiKey || apiKey.includes("****") || apiKey.length < 20) { + return "not_configured"; + } + } catch { + return "not_configured"; + } + + return "configured"; + } + const config = JSON.parse(content); // Each tool stores OmniRoute config differently switch (toolId) { case "claude": return config?.env?.ANTHROPIC_BASE_URL ? "configured" : "not_configured"; - case "codex": - return config?.providers?.omniroute || config?.providers?.["openai-compatible"] - ? "configured" - : "not_configured"; case "droid": case "openclaw": case "cline": case "kilo": // Generic check: look for OmniRoute-specific markers in the config const configStr = JSON.stringify(config).toLowerCase(); - return configStr.includes("omniroute") || + if ( + configStr.includes("omniroute") || configStr.includes("sk_omniroute") || configStr.includes(`localhost:${apiPort}`) || configStr.includes(`127.0.0.1:${apiPort}`) - ? "configured" - : "not_configured"; + ) { + return "configured"; + } + // Also accept openai-compatible provider with any non-empty baseUrl + // (user may configure an external domain instead of localhost) + if ( + toolId === "cline" && + (config.actModeApiProvider === "openai" || config.planModeApiProvider === "openai") && + (config.openAiBaseUrl || "").trim().length > 0 + ) { + return "configured"; + } + return "not_configured"; default: return "unknown"; } diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index 43c6624736..5ade90e58b 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -9,6 +9,7 @@ import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/lib/cloudSync"; import { updateProviderConnectionSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults"; function normalizeCodexLimitPolicy( incoming: unknown, @@ -142,7 +143,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: } } - updateData.providerSpecificData = mergedPsd; + updateData.providerSpecificData = + normalizeProviderSpecificData(existing.provider, mergedPsd) || {}; } const updated = await updateProviderConnection(id, updateData); diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index bfdb267c0f..e09af29cb1 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -16,6 +16,7 @@ import { syncToCloud } from "@/lib/cloudSync"; import { createProviderSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { normalizeQoderPatProviderData } from "@omniroute/open-sse/services/qoderCli"; +import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults"; // GET /api/providers - List all connections export async function GET() { @@ -132,6 +133,8 @@ export async function POST(request: Request) { }; } + providerSpecificData = normalizeProviderSpecificData(provider, providerSpecificData) || null; + const newConnection = await createProviderConnection({ provider, authType: "apikey", diff --git a/src/app/api/settings/background-degradation/route.ts b/src/app/api/settings/background-degradation/route.ts index 6fb7021c91..cb19443fd6 100644 --- a/src/app/api/settings/background-degradation/route.ts +++ b/src/app/api/settings/background-degradation/route.ts @@ -53,7 +53,7 @@ export async function PUT(request) { // Persist to database (excluding stats) const { stats, ...persistable } = getBackgroundDegradationConfig(); - await updateSettings({ backgroundDegradation: JSON.stringify(persistable) }); + await updateSettings({ backgroundDegradation: persistable }); return NextResponse.json({ success: true, ...getBackgroundDegradationConfig() }); } catch (error) { diff --git a/src/app/api/settings/codex-service-tier/route.ts b/src/app/api/settings/codex-service-tier/route.ts deleted file mode 100644 index 8ec575ed06..0000000000 --- a/src/app/api/settings/codex-service-tier/route.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { NextResponse } from "next/server"; -import { getSettings, updateSettings } from "@/lib/localDb"; -import { setDefaultFastServiceTierEnabled } from "@omniroute/open-sse/executors/codex.ts"; -import { updateCodexServiceTierSchema } from "@/shared/validation/schemas"; -import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; - -export async function GET() { - try { - const settings = await getSettings(); - const persisted = - typeof settings.codexServiceTier === "string" - ? JSON.parse(settings.codexServiceTier) - : settings.codexServiceTier; - - return NextResponse.json({ - enabled: typeof persisted?.enabled === "boolean" ? persisted.enabled : false, - }); - } catch (error) { - console.error("[API ERROR] /api/settings/codex-service-tier GET:", error); - return NextResponse.json({ error: "Failed to get config" }, { status: 500 }); - } -} - -export async function PUT(request: Request) { - let rawBody; - try { - rawBody = await request.json(); - } catch { - return NextResponse.json( - { - error: { - message: "Invalid request", - details: [{ field: "body", message: "Invalid JSON body" }], - }, - }, - { status: 400 } - ); - } - - try { - const validation = validateBody(updateCodexServiceTierSchema, rawBody); - if (isValidationFailure(validation)) { - return NextResponse.json({ error: validation.error }, { status: 400 }); - } - - const config = validation.data; - await updateSettings({ codexServiceTier: config }); - setDefaultFastServiceTierEnabled(config.enabled); - - return NextResponse.json(config); - } catch (error) { - console.error("[API ERROR] /api/settings/codex-service-tier PUT:", error); - return NextResponse.json({ error: "Failed to update config" }, { status: 500 }); - } -} diff --git a/src/app/api/skills/skillssh/install/route.ts b/src/app/api/skills/skillssh/install/route.ts new file mode 100644 index 0000000000..3e6d24cf44 --- /dev/null +++ b/src/app/api/skills/skillssh/install/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { skillRegistry } from "@/lib/skills/registry"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { fetchSkillMd } from "@/lib/skills/skillssh"; + +const skillsshInstallSchema = z.object({ + name: z.string().min(1).max(64), + description: z.string().min(1).max(1024), + source: z.string().min(1), + skillId: z.string().min(1), + version: z.string().default("1.0.0"), +}); + +export async function POST(request: Request) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const rawBody = await request.json(); + const validation = validateBody(skillsshInstallSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json(validation.error, { status: 400 }); + } + const { name, description, source, skillId, version } = validation.data; + + const skillMdContent = await fetchSkillMd(source, skillId); + + const skill = await skillRegistry.register({ + name, + version, + description, + schema: { input: { content: "string" }, output: { result: "string" } }, + handler: `// Installed from skills.sh\n// Source: ${source}/${skillId}\n// SKILL.md content:\n${skillMdContent}`, + apiKeyId: "skillssh", + enabled: true, + }); + + return NextResponse.json({ success: true, id: skill.id }); + } catch (err: unknown) { + const error = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error }, { status: 500 }); + } +} diff --git a/src/app/api/skills/skillssh/route.ts b/src/app/api/skills/skillssh/route.ts new file mode 100644 index 0000000000..ab587fddef --- /dev/null +++ b/src/app/api/skills/skillssh/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { searchSkillsSh } from "@/lib/skills/skillssh"; + +export async function GET(request: Request) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const { searchParams } = new URL(request.url); + const q = searchParams.get("q") || ""; + const limit = Math.min(Math.max(Number(searchParams.get("limit")) || 20, 1), 100); + + const data = await searchSkillsSh(q, limit); + return NextResponse.json({ + skills: data.skills.map((s) => ({ + id: s.id, + skillId: s.skillId, + name: s.name, + installs: s.installs, + source: s.source, + })), + }); + } catch (err: unknown) { + const error = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error }, { status: 500 }); + } +} diff --git a/src/app/api/upstream-proxy/[providerId]/route.ts b/src/app/api/upstream-proxy/[providerId]/route.ts new file mode 100644 index 0000000000..658b488665 --- /dev/null +++ b/src/app/api/upstream-proxy/[providerId]/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { + getUpstreamProxyConfig, + upsertUpstreamProxyConfig, + deleteUpstreamProxyConfig, +} from "@/lib/db/upstreamProxy"; + +import { z } from "zod"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; + +const upstreamProxySchema = z.object({ + mode: z.enum(["native", "cliproxyapi", "fallback"]).default("native"), + enabled: z.boolean().optional().default(true), +}); + +export async function GET( + _request: Request, + { params }: { params: Promise<{ providerId: string }> } +) { + const { providerId } = await params; + if (!providerId) { + return NextResponse.json({ error: "providerId required" }, { status: 400 }); + } + const config = await getUpstreamProxyConfig(providerId); + if (!config) { + return NextResponse.json({ enabled: false, mode: "native" }); + } + return NextResponse.json(config); +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ providerId: string }> } +) { + const { providerId } = await params; + if (!providerId) { + return NextResponse.json({ error: "providerId required" }, { status: 400 }); + } + + const rawBody = await request.json(); + const validation = validateBody(upstreamProxySchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json(validation.error, { status: 400 }); + } + + const { mode, enabled } = validation.data; + + const config = await upsertUpstreamProxyConfig({ + providerId, + mode, + enabled, + }); + + return NextResponse.json(config); +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ providerId: string }> } +) { + const { providerId } = await params; + if (!providerId) { + return NextResponse.json({ error: "providerId required" }, { status: 400 }); + } + const deleted = await deleteUpstreamProxyConfig(providerId); + return NextResponse.json({ deleted }); +} diff --git a/src/app/api/v1/db/health/route.ts b/src/app/api/v1/db/health/route.ts new file mode 100644 index 0000000000..dfe84dda6c --- /dev/null +++ b/src/app/api/v1/db/health/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { runManagedDbHealthCheck } from "@/lib/db/core"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; + +export async function GET(request: Request) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 }); + } + + try { + return NextResponse.json(runManagedDbHealthCheck({ autoRepair: false })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error("[API] DB health diagnosis failed:", message); + return NextResponse.json({ error: { message } }, { status: 500 }); + } +} + +export async function POST(request: Request) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 }); + } + + try { + return NextResponse.json(runManagedDbHealthCheck({ autoRepair: true })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error("[API] DB health repair failed:", message); + return NextResponse.json({ error: { message } }, { status: 500 }); + } +} diff --git a/src/app/api/v1/search/route.ts b/src/app/api/v1/search/route.ts index dc119b981c..8a1ecc9190 100644 --- a/src/app/api/v1/search/route.ts +++ b/src/app/api/v1/search/route.ts @@ -191,7 +191,7 @@ export async function POST(request: Request) { { filters: body.filters, offset: body.offset, time_range: body.time_range } ); - const ttl = providerConfig.cacheTTLMs || SEARCH_CACHE_DEFAULT_TTL_MS; + const ttl = providerConfig.cacheTTLMs ?? SEARCH_CACHE_DEFAULT_TTL_MS; try { const { data: searchResult, cached } = await getOrCoalesce(cacheKey, ttl, async () => { diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 2e4d113e3b..88bab3ae68 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -105,10 +105,11 @@ export async function registerNodejs(): Promise { } try { - const [{ setCustomAliases }, { setDefaultFastServiceTierEnabled }] = await Promise.all([ - import("@omniroute/open-sse/services/modelDeprecation.ts"), - import("@omniroute/open-sse/executors/codex.ts"), - ]); + const [{ setCustomAliases }, { migrateCodexConnectionDefaultsFromLegacySettings }] = + await Promise.all([ + import("@omniroute/open-sse/services/modelDeprecation.ts"), + import("@/lib/providers/codexConnectionDefaults"), + ]); const settings = await getSettings(); if (settings.modelAliases) { @@ -124,16 +125,35 @@ export async function registerNodejs(): Promise { } } - const persisted = - typeof settings.codexServiceTier === "string" - ? JSON.parse(settings.codexServiceTier) - : settings.codexServiceTier; + if (settings.backgroundDegradation) { + try { + const bgSettings = + typeof settings.backgroundDegradation === "string" + ? JSON.parse(settings.backgroundDegradation) + : settings.backgroundDegradation; + const { setBackgroundDegradationConfig } = + await import("@omniroute/open-sse/services/backgroundTaskDetector.ts"); + setBackgroundDegradationConfig(bgSettings); + console.log(`[STARTUP] Restored background task degradation config from settings`); + } catch (err: unknown) { + console.warn(`[STARTUP] Failed to parse background degradation settings:`, err); + } + } - if (typeof persisted?.enabled === "boolean") { - setDefaultFastServiceTierEnabled(persisted.enabled); + const migration = await migrateCodexConnectionDefaultsFromLegacySettings(); + if (migration.migrated) { console.log( - `[STARTUP] Restored Codex fast service tier: ${persisted.enabled ? "on" : "off"}` + `[STARTUP] Migrated Codex connection defaults for ${migration.updatedConnectionIds.length} connection(s)` ); + if (settings.cloudEnabled === true) { + const [{ syncToCloud }, { getConsistentMachineId }] = await Promise.all([ + import("@/lib/cloudSync"), + import("@/shared/utils/machineId"), + ]); + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + console.log("[STARTUP] Synced migrated Codex connection defaults to cloud"); + } } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); diff --git a/src/lib/db/AGENTS.md b/src/lib/db/AGENTS.md new file mode 100644 index 0000000000..6ea7c37941 --- /dev/null +++ b/src/lib/db/AGENTS.md @@ -0,0 +1,102 @@ +# src/lib/db/ — SQLite Persistence Layer + +**Purpose**: Domain-driven SQLite persistence. Each module owns a specific table set. Schema migrations are versioned and idempotent. No raw SQL in routes — all ops go through `src/lib/db/` modules. + +--- + +## Key Modules + +### Core Infrastructure + +- **`core.ts`** — `getDbInstance()` returns singleton `better-sqlite3` with WAL journaling. Exports `rowToCamel()` (snake_case → camelCase), `encryptConnectionFields()` for provider credentials at rest. `SCHEMA_SQL` defines 15 base tables. +- **`migrationRunner.ts`** — Applies versioned SQL files from `db/migrations/` inside transactions. Tracks applied migrations in `_omniroute_migrations`. Runs at startup; each migration is idempotent. +- **`db/migrations/`** — 21 SQL files (`001_initial_schema.sql` → `021_combo_call_log_targets.sql`). Each migration has single responsibility, runs in a transaction, never fails partially. +- **`localDb.ts`** — Re-export layer only. Never add logic here. Consumers import domain modules from this file for convenience. + +### Domain Modules (22 total) + +Each module owns specific tables + CRUD operations: + +| Module | Tables | Responsibility | +| ----------------------- | ------------------------- | ------------------------------------------------------- | +| `providers.ts` | `provider_connections` | OAuth/API key provider registration and credentials | +| `models.ts` | `models` | Model definitions, capabilities, pricing | +| `combos.ts` | `combos`, `combo_targets` | Combo routing configs, target ordering | +| `apiKeys.ts` | `api_keys` | API key lifecycle, scopes, quota tracking | +| `settings.ts` | `settings` | KV store for system configuration | +| `backup.ts` | Backup export/import ops | Serialize/deserialize entire DB state | +| `proxies.ts` | `proxies` | MITM proxy configs and routing rules | +| `prompts.ts` | `prompts` | Reusable prompt templates, versioning | +| `webhooks.ts` | `webhooks` | Event-driven webhook subscriptions and logs | +| `detailedLogs.ts` | `detailed_logs` | Per-request audit logging (optional, high volume) | +| `domainState.ts` | `domain_state` | Transient runtime state (not persisted across restarts) | +| `registeredKeys.ts` | `registered_keys` | Whitelisted API keys for MCP/A2A access | +| `quotaSnapshots.ts` | `quota_snapshots` | Historical quota usage for analytics | +| `modelComboMappings.ts` | `model_combo_mappings` | Map models to combo defaults | +| `cliToolState.ts` | `cli_tool_state` | CLI-specific persistent state | +| `encryption.ts` | — | Helpers for encrypting/decrypting sensitive fields | +| `readCache.ts` | — | In-memory cache for read-heavy ops (models, providers) | +| `secrets.ts` | `secrets` | Encrypted secret storage (API keys at rest) | +| `stateReset.ts` | — | Wipe/reset DB state for testing or recovery | +| `contextHandoffs.ts` | `context_handoffs` | Store/retrieve session context for agent handoff | +| `migrations/` | — | Versioned SQL schema evolution | +| `core.ts` | — | Singleton DB instance, helpers, schema definition | + +### Encryption & Security + +- **Sensitive fields** (API keys, OAuth tokens, connection strings) encrypted at rest using `src/lib/encryption/` utilities +- **`encryptConnectionFields()`** in `core.ts` — Automatic encryption when storing provider credentials +- **`secrets.ts`** — Dedicated encrypted store for long-term secret handling +- **Never log** SQLite encryption keys or raw secrets; always use redacted values in logs + +### Testing Strategy + +For authoritative coverage requirements and test execution guidelines, see [`CONTRIBUTING.md#running-tests`](../../CONTRIBUTING.md#running-tests) (lines 136–162). + +- **Unit tests** mock `getDbInstance()` to return isolated sqlite in-memory instance +- **Integration tests** use real SQLite with migrations applied, data cleaned up after each test +- **No fixture interdependencies** — each test runs migrations fresh +- Test files: `tests/unit/db/*.test.mjs`, `tests/integration/db/*.test.mjs` + +### Anti-Patterns + +- ❌ Raw SQL in routes — always use domain module functions +- ❌ Direct `prepare()` statements outside `db/` modules — breaks modularity +- ❌ Mixing encryption logic in domain modules — use `encryption.ts` helpers only +- ❌ Accessing `provider_connections` table from `combos.ts` — each module owns its tables +- ❌ Skipping migrations for schema changes — all changes go through `db/migrations/` + +### Adding a New Domain Module + +1. Create `src/lib/db/[module].ts` with CRUD functions (create, read, update, delete, list) +2. Export from `src/lib/db/localDb.ts` (add re-export) +3. If new tables required: create migration in `db/migrations/NNN_[description].sql` +4. Run migration via `migrationRunner.ts` at startup (automatic) +5. Add unit tests in `tests/unit/db/[module].test.mjs` +6. Ensure tests meet the coverage requirements in [`CONTRIBUTING.md#running-tests`](../../CONTRIBUTING.md#running-tests) + +### Performance Notes + +- **Read cache** (`readCache.ts`) — Pre-loads frequently accessed data (models, providers) at startup; invalidated on write +- **WAL journaling** (`core.ts`) — Enables concurrent reads during writes +- **Batch operations** — Use prepared statements with parameter binding to avoid SQL injection +- **Connection pooling** — Singleton pattern prevents per-request connection overhead + +--- + +## Key Decisions + +- **SQLite over PostgreSQL**: Simpler deployment, no separate database server, encryption at application layer +- **Versioned migrations**: Each schema change is tracked, reproducible, reversible with effort +- **Domain modules**: Enforces single responsibility, prevents cross-module table access +- **Re-export layer**: Convenience for consumers; `localDb.ts` is re-export-only to prevent circular dependencies + +--- + +## Review Focus + +- DB module changes must preserve domain boundaries (one module = one table set) +- New migrations must be idempotent and run inside transactions +- Encryption helpers used for all sensitive fields +- Test coverage and PR requirements: see [`CONTRIBUTING.md#running-tests`](../../CONTRIBUTING.md#running-tests) (lines 136–162) +- No raw SQL in routes or non-db modules diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 0b588cbcd7..34d7d3c5f1 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -554,6 +554,8 @@ export async function deleteApiKey(id: string) { if (result.changes === 0) return false; + db.prepare("DELETE FROM domain_budgets WHERE api_key_id = ?").run(id); + db.prepare("DELETE FROM domain_cost_history WHERE api_key_id = ?").run(id); setNoLog(id, false); // Invalidate caches since a key was removed diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index e906c364a7..ee090c6141 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -9,6 +9,7 @@ import path from "path"; import fs from "fs"; import { resolveDataDir, getLegacyDotDataDir } from "../dataPaths"; import { runMigrations } from "./migrationRunner"; +import { runDbHealthCheck } from "./healthCheck"; type SqliteDatabase = import("better-sqlite3").Database; type JsonRecord = Record; @@ -455,6 +456,96 @@ function hasColumn(db: SqliteDatabase, tableName: string, columnName: string): b return rows.some((row) => row.name === columnName); } +function isAutomatedTestProcess(): boolean { + return ( + typeof process !== "undefined" && + (process.env.NODE_ENV === "test" || + process.env.VITEST !== undefined || + process.argv.some((arg) => arg.includes("test"))) + ); +} + +function shouldRunStartupDbHealthCheck(): boolean { + if (process.env.OMNIROUTE_FORCE_DB_HEALTHCHECK === "1") return true; + return !isAutomatedTestProcess(); +} + +function createHealthCheckBackup(db: SqliteDatabase): boolean { + const isTest = isAutomatedTestProcess(); + if (isTest) return false; + + try { + const backupDir = DB_BACKUPS_DIR || path.join(DATA_DIR, "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}_health-check-repair.sqlite`); + const escapedBackupPath = backupPath.replace(/'/g, "''"); + + db.exec(`VACUUM INTO '${escapedBackupPath}'`); + console.log(`[DB] Health-check backup created: ${backupPath}`); + return true; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.warn("[DB] Failed to create health-check backup:", message); + return false; + } +} + +let dbHealthCheckTimer: NodeJS.Timeout | null = null; + +function getDbHealthCheckIntervalMs(): number { + const rawValue = process.env.OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS; + if (typeof rawValue === "string" && rawValue.trim().length > 0) { + const parsed = Number(rawValue); + if (Number.isFinite(parsed) && parsed >= 0) { + return parsed; + } + } + return 6 * 60 * 60 * 1000; +} + +function clearDbHealthCheckScheduler() { + if (dbHealthCheckTimer) { + clearInterval(dbHealthCheckTimer); + dbHealthCheckTimer = null; + } +} + +function startDbHealthCheckScheduler(db: SqliteDatabase) { + clearDbHealthCheckScheduler(); + if (isCloud || isBuildPhase || isAutomatedTestProcess()) return; + + const intervalMs = getDbHealthCheckIntervalMs(); + if (intervalMs <= 0) return; + + dbHealthCheckTimer = setInterval(() => { + try { + if (!db.open) return; + runDbHealthCheck(db, { + autoRepair: true, + expectedSchemaVersion: "1", + createBackupBeforeRepair: () => createHealthCheckBackup(db), + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.warn("[DB] Periodic health-check failed:", message); + } + }, intervalMs); + dbHealthCheckTimer.unref?.(); +} + +export function runManagedDbHealthCheck(options?: { autoRepair?: boolean }) { + const db = getDbInstance(); + return runDbHealthCheck(db, { + autoRepair: options?.autoRepair === true, + expectedSchemaVersion: "1", + createBackupBeforeRepair: () => createHealthCheckBackup(db), + }); +} + export function getDbInstance(): SqliteDatabase { const existing = getDb(); if (existing) return existing; @@ -532,9 +623,13 @@ export function getDbInstance(): SqliteDatabase { } } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); - console.warn("[DB] Could not probe existing DB, will create fresh:", message); + console.warn("[DB] Could not probe existing DB:", message); + // SAFETY: Never delete the database — rename to backup so data can be recovered. + // The old code would silently destroy all user data on any probe failure. + const failedPath = sqliteFile + `.probe-failed-${Date.now()}`; try { - fs.unlinkSync(sqliteFile); + fs.renameSync(sqliteFile, failedPath); + console.warn(`[DB] Renamed corrupt DB to ${path.basename(failedPath)}`); } catch { /* ok */ } @@ -611,13 +706,22 @@ export function getDbInstance(): SqliteDatabase { "INSERT OR REPLACE INTO db_meta (key, value) VALUES ('schema_version', '1')" ); versionStmt.run(); + if (shouldRunStartupDbHealthCheck()) { + runDbHealthCheck(db, { + autoRepair: true, + expectedSchemaVersion: "1", + createBackupBeforeRepair: () => createHealthCheckBackup(db), + }); + } setDb(db); + startDbHealthCheckScheduler(db); console.log(`[DB] SQLite database ready: ${sqliteFile}`); return db; } export function closeDbInstance(options?: { checkpointMode?: CheckpointMode | null }): boolean { + clearDbHealthCheckScheduler(); const db = getDb(); if (!db) return false; diff --git a/src/lib/db/healthCheck.ts b/src/lib/db/healthCheck.ts new file mode 100644 index 0000000000..5b84550ae6 --- /dev/null +++ b/src/lib/db/healthCheck.ts @@ -0,0 +1,548 @@ +import { normalizeComboStep } from "@/lib/combos/steps"; + +type SqliteDatabase = import("better-sqlite3").Database; +type JsonRecord = Record; + +export type DbHealthIssueType = + | "integrity_check_failed" + | "broken_reference" + | "stale_snapshot" + | "invalid_state"; + +export interface DbHealthIssue { + type: DbHealthIssueType; + table: string; + description: string; + count: number; +} + +export interface DbHealthCheckResult { + isHealthy: boolean; + issues: DbHealthIssue[]; + repairedCount: number; + backupCreated: boolean; + autoRepair: boolean; + checkedAt: string; +} + +interface RunDbHealthCheckOptions { + autoRepair?: boolean; + createBackupBeforeRepair?: () => boolean; + expectedSchemaVersion?: string; +} + +interface ComboRow { + id: string; + name: string; + data: string; + sort_order?: number | null; + created_at?: string | null; + updated_at?: string | null; +} + +interface ComboRepairResult { + issueCount: number; + repairedCount: number; +} + +interface QuotaSnapshotRow { + id?: number; + provider?: string | null; + connection_id?: string | null; + created_at?: string | null; +} + +function isRecord(value: unknown): value is JsonRecord { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function toRecord(value: unknown): JsonRecord { + return isRecord(value) ? value : {}; +} + +function toTrimmedString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function parseJsonRecord(value: string): JsonRecord | null { + try { + const parsed = JSON.parse(value); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function isFiniteNumber(value: unknown): boolean { + return typeof value === "number" && Number.isFinite(value); +} + +function hasRows(db: SqliteDatabase, table: string): boolean { + const row = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(table) as { name?: string } | undefined; + return row?.name === table; +} + +function hasProviderConnection(db: SqliteDatabase, connectionId: string): boolean { + const row = db + .prepare("SELECT 1 AS ok FROM provider_connections WHERE id = ? LIMIT 1") + .get(connectionId) as { ok?: number } | undefined; + return row?.ok === 1; +} + +function isValidIsoTimestamp(value: unknown): boolean { + if (typeof value !== "string" || value.trim().length === 0) return false; + return !Number.isNaN(Date.parse(value)); +} + +function buildRepairNote(message: string, checkedAt: string): string { + return `[db-health:${checkedAt}] ${message}`; +} + +function buildDisabledCombo(row: ComboRow, checkedAt: string): JsonRecord { + const now = checkedAt; + return { + id: row.id, + name: row.name, + version: 2, + strategy: "priority", + models: [], + config: {}, + isActive: false, + isHidden: false, + sortOrder: typeof row.sort_order === "number" ? row.sort_order : 0, + createdAt: row.created_at || now, + updatedAt: now, + repairNote: buildRepairNote("Combo payload was rebuilt after invalid JSON was detected.", now), + }; +} + +function normalizeComboModels(models: unknown): unknown[] { + return Array.isArray(models) ? models : []; +} + +function repairComboRows( + db: SqliteDatabase, + rows: ComboRow[], + checkedAt: string, + options: { autoRepair: boolean } +): ComboRepairResult { + if (rows.length === 0) return { issueCount: 0, repairedCount: 0 }; + + const existingComboNames = new Set(rows.map((row) => row.name)); + let issueCount = 0; + let repairedCount = 0; + + const updateComboStmt = db.prepare("UPDATE combos SET data = ?, updated_at = ? WHERE id = ?"); + + for (const row of rows) { + const parsed = parseJsonRecord(row.data); + if (!parsed) { + issueCount += 1; + if (options.autoRepair) { + const repaired = buildDisabledCombo(row, checkedAt); + updateComboStmt.run(JSON.stringify(repaired), checkedAt, row.id); + repairedCount += 1; + } + continue; + } + + const currentModels = normalizeComboModels(parsed.models); + if (currentModels.length === 0) continue; + + const nextModels: unknown[] = []; + let removedSteps = 0; + let clearedConnectionPins = 0; + let normalizedLegacyComboRefs = 0; + + for (const [index, rawStep] of currentModels.entries()) { + if (!isRecord(rawStep)) { + if (typeof rawStep === "string") { + const normalizedStep = normalizeComboStep(rawStep, { + comboName: row.name, + index, + allCombos: existingComboNames, + }); + if (normalizedStep?.kind === "combo-ref") { + if ( + normalizedStep.comboName === row.name || + !existingComboNames.has(normalizedStep.comboName) + ) { + removedSteps += 1; + continue; + } + nextModels.push(normalizedStep); + normalizedLegacyComboRefs += 1; + continue; + } + } + nextModels.push(rawStep); + continue; + } + + if (rawStep.kind === "combo-ref") { + const comboName = toTrimmedString(rawStep.comboName); + if (!comboName || comboName === row.name || !existingComboNames.has(comboName)) { + removedSteps += 1; + continue; + } + nextModels.push(rawStep); + continue; + } + + const connectionId = toTrimmedString(rawStep.connectionId); + if (connectionId && !hasProviderConnection(db, connectionId)) { + const repairedStep = { ...rawStep }; + delete repairedStep.connectionId; + nextModels.push(repairedStep); + clearedConnectionPins += 1; + continue; + } + + nextModels.push(rawStep); + } + + if (removedSteps === 0 && clearedConnectionPins === 0 && normalizedLegacyComboRefs === 0) { + continue; + } + + issueCount += removedSteps + clearedConnectionPins + normalizedLegacyComboRefs; + if (!options.autoRepair) continue; + + const nextCombo = { + ...parsed, + models: nextModels, + updatedAt: checkedAt, + repairNote: buildRepairNote( + [ + removedSteps > 0 ? `${removedSteps} broken combo step(s) removed.` : null, + clearedConnectionPins > 0 + ? `${clearedConnectionPins} missing connection pin(s) cleared.` + : null, + normalizedLegacyComboRefs > 0 + ? `${normalizedLegacyComboRefs} legacy combo ref step(s) canonicalized.` + : null, + ] + .filter(Boolean) + .join(" "), + checkedAt + ), + ...(nextModels.length === 0 ? { isActive: false } : {}), + }; + + updateComboStmt.run(JSON.stringify(nextCombo), checkedAt, row.id); + repairedCount += removedSteps + clearedConnectionPins + normalizedLegacyComboRefs; + } + + return { issueCount, repairedCount }; +} + +function getBrokenQuotaSnapshotRowIds(db: SqliteDatabase): number[] { + if (!hasRows(db, "quota_snapshots")) return []; + + const brokenRowIds = new Set(); + const rows = db + .prepare("SELECT id, provider, connection_id, created_at FROM quota_snapshots") + .all() as QuotaSnapshotRow[]; + + for (const row of rows) { + const connectionId = toTrimmedString(row.connection_id); + const missingConnection = !!connectionId && !hasProviderConnection(db, connectionId); + const invalidTimestamp = !isValidIsoTimestamp(row.created_at); + if ((missingConnection || invalidTimestamp) && typeof row.id === "number") { + brokenRowIds.add(row.id); + } + } + + return Array.from(brokenRowIds); +} + +function countOrphanQuotaSnapshots(db: SqliteDatabase): number { + return getBrokenQuotaSnapshotRowIds(db).length; +} + +function repairQuotaSnapshots(db: SqliteDatabase): number { + if (!hasRows(db, "quota_snapshots")) return 0; + const brokenRowIds = getBrokenQuotaSnapshotRowIds(db); + if (brokenRowIds.length === 0) return 0; + + const deleteByRowId = db.prepare("DELETE FROM quota_snapshots WHERE id = ?"); + let repaired = 0; + for (const rowId of brokenRowIds) { + repaired += deleteByRowId.run(rowId).changes; + } + return repaired; +} + +function countOrphanDomainRows( + db: SqliteDatabase, + table: "domain_budgets" | "domain_cost_history" +) { + if (!hasRows(db, table)) return 0; + const row = db + .prepare( + `SELECT COUNT(*) AS count + FROM ${table} + WHERE api_key_id NOT IN (SELECT id FROM api_keys)` + ) + .get() as { count?: number } | undefined; + return row?.count || 0; +} + +function repairOrphanDomainRows( + db: SqliteDatabase, + table: "domain_budgets" | "domain_cost_history" +): number { + if (!hasRows(db, table)) return 0; + return db.prepare(`DELETE FROM ${table} WHERE api_key_id NOT IN (SELECT id FROM api_keys)`).run() + .changes; +} + +function countInvalidJsonRows( + db: SqliteDatabase, + table: "domain_fallback_chains" | "domain_lockout_state" | "domain_circuit_breakers", + column: "chain" | "attempts" | "options" +): number { + if (!hasRows(db, table)) return 0; + const rows = db.prepare(`SELECT ${column} FROM ${table}`).all() as Array>; + let invalid = 0; + for (const row of rows) { + const raw = row[column]; + if (raw == null && column === "options") continue; + if (typeof raw !== "string") { + invalid += 1; + continue; + } + try { + JSON.parse(raw); + } catch { + invalid += 1; + } + } + return invalid; +} + +function repairInvalidJsonRows( + db: SqliteDatabase, + table: "domain_fallback_chains" | "domain_lockout_state" | "domain_circuit_breakers", + column: "chain" | "attempts" | "options" +): number { + if (!hasRows(db, table)) return 0; + + const rows = db.prepare(`SELECT rowid, ${column} FROM ${table}`).all() as Array<{ + rowid: number; + [key: string]: unknown; + }>; + + const deleteByRowId = db.prepare(`DELETE FROM ${table} WHERE rowid = ?`); + const clearOptionsByRowId = db.prepare( + "UPDATE domain_circuit_breakers SET options = NULL WHERE rowid = ?" + ); + let repaired = 0; + + for (const row of rows) { + const raw = row[column]; + if (raw == null && table === "domain_circuit_breakers") { + continue; + } + if (typeof raw !== "string") { + if (table === "domain_circuit_breakers") { + repaired += clearOptionsByRowId.run(row.rowid).changes; + continue; + } + deleteByRowId.run(row.rowid); + repaired += 1; + continue; + } + try { + JSON.parse(raw); + } catch { + if (table === "domain_circuit_breakers") { + repaired += clearOptionsByRowId.run(row.rowid).changes; + continue; + } + deleteByRowId.run(row.rowid); + repaired += 1; + } + } + + return repaired; +} + +function getSchemaVersionIssueCount(db: SqliteDatabase, expectedSchemaVersion: string): number { + if (!hasRows(db, "db_meta")) return 0; + const row = db.prepare("SELECT value FROM db_meta WHERE key = 'schema_version'").get() as + | { value?: string | null } + | undefined; + const current = typeof row?.value === "string" ? row.value : null; + return current === expectedSchemaVersion ? 0 : 1; +} + +function repairSchemaVersion(db: SqliteDatabase, expectedSchemaVersion: string): number { + if (!hasRows(db, "db_meta")) return 0; + return db + .prepare("INSERT OR REPLACE INTO db_meta (key, value) VALUES ('schema_version', ?)") + .run(expectedSchemaVersion).changes; +} + +export function runDbHealthCheck( + db: SqliteDatabase, + options: RunDbHealthCheckOptions = {} +): DbHealthCheckResult { + const autoRepair = options.autoRepair === true; + const expectedSchemaVersion = options.expectedSchemaVersion || "1"; + const checkedAt = new Date().toISOString(); + const issues: DbHealthIssue[] = []; + let repairedCount = 0; + let backupCreated = false; + let backupAttempted = false; + + const ensureBackupBeforeRepair = () => { + if (!autoRepair || backupAttempted || typeof options.createBackupBeforeRepair !== "function") { + return; + } + backupAttempted = true; + backupCreated = options.createBackupBeforeRepair(); + }; + + const integrityCheck = db.pragma("integrity_check") as Array<{ integrity_check?: string }>; + if (integrityCheck[0]?.integrity_check !== "ok") { + issues.push({ + type: "integrity_check_failed", + table: "sqlite", + description: "SQLite integrity_check returned a non-ok status.", + count: 1, + }); + } + + if (hasRows(db, "combos")) { + const comboRows = db + .prepare( + "SELECT id, name, data, sort_order, created_at, updated_at FROM combos ORDER BY name COLLATE NOCASE ASC" + ) + .all() as ComboRow[]; + const comboRepair = repairComboRows(db, comboRows, checkedAt, { autoRepair }); + if (comboRepair.issueCount > 0) { + issues.push({ + type: "broken_reference", + table: "combos", + description: + "Combos contained broken combo references, legacy combo refs, invalid JSON, or pinned connections that no longer exist.", + count: comboRepair.issueCount, + }); + if (autoRepair) { + ensureBackupBeforeRepair(); + repairedCount += comboRepair.repairedCount; + } + } + } + + const orphanQuotaCount = countOrphanQuotaSnapshots(db); + if (orphanQuotaCount > 0) { + issues.push({ + type: "stale_snapshot", + table: "quota_snapshots", + description: + "Quota snapshots referenced missing connections or contained invalid timestamps.", + count: orphanQuotaCount, + }); + if (autoRepair) { + ensureBackupBeforeRepair(); + repairedCount += repairQuotaSnapshots(db); + } + } + + const orphanBudgets = countOrphanDomainRows(db, "domain_budgets"); + if (orphanBudgets > 0) { + issues.push({ + type: "broken_reference", + table: "domain_budgets", + description: "Domain budgets referenced API keys that no longer exist.", + count: orphanBudgets, + }); + if (autoRepair) { + ensureBackupBeforeRepair(); + repairedCount += repairOrphanDomainRows(db, "domain_budgets"); + } + } + + const orphanCostHistory = countOrphanDomainRows(db, "domain_cost_history"); + if (orphanCostHistory > 0) { + issues.push({ + type: "broken_reference", + table: "domain_cost_history", + description: "Domain cost history referenced API keys that no longer exist.", + count: orphanCostHistory, + }); + if (autoRepair) { + ensureBackupBeforeRepair(); + repairedCount += repairOrphanDomainRows(db, "domain_cost_history"); + } + } + + const invalidFallbackChains = countInvalidJsonRows(db, "domain_fallback_chains", "chain"); + if (invalidFallbackChains > 0) { + issues.push({ + type: "invalid_state", + table: "domain_fallback_chains", + description: "Fallback chain rows contained invalid JSON payloads.", + count: invalidFallbackChains, + }); + if (autoRepair) { + ensureBackupBeforeRepair(); + repairedCount += repairInvalidJsonRows(db, "domain_fallback_chains", "chain"); + } + } + + const invalidLockoutState = countInvalidJsonRows(db, "domain_lockout_state", "attempts"); + if (invalidLockoutState > 0) { + issues.push({ + type: "invalid_state", + table: "domain_lockout_state", + description: "Lockout state rows contained invalid JSON payloads.", + count: invalidLockoutState, + }); + if (autoRepair) { + ensureBackupBeforeRepair(); + repairedCount += repairInvalidJsonRows(db, "domain_lockout_state", "attempts"); + } + } + + const invalidBreakerOptions = countInvalidJsonRows(db, "domain_circuit_breakers", "options"); + if (invalidBreakerOptions > 0) { + issues.push({ + type: "invalid_state", + table: "domain_circuit_breakers", + description: "Circuit breaker option payloads were invalid JSON.", + count: invalidBreakerOptions, + }); + if (autoRepair) { + ensureBackupBeforeRepair(); + repairedCount += repairInvalidJsonRows(db, "domain_circuit_breakers", "options"); + } + } + + const schemaVersionIssues = getSchemaVersionIssueCount(db, expectedSchemaVersion); + if (schemaVersionIssues > 0) { + issues.push({ + type: "invalid_state", + table: "db_meta", + description: `db_meta.schema_version did not match expected version ${expectedSchemaVersion}.`, + count: schemaVersionIssues, + }); + if (autoRepair) { + ensureBackupBeforeRepair(); + repairedCount += repairSchemaVersion(db, expectedSchemaVersion); + } + } + + return { + isHealthy: issues.length === 0, + issues, + repairedCount, + backupCreated, + autoRepair, + checkedAt, + }; +} diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 756634d870..51dbcf03d6 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -7,6 +7,7 @@ import { getDbInstance, rowToCamel, cleanNulls } from "./core"; import { backupDbFile } from "./backup"; import { encryptConnectionFields, decryptConnectionFields } from "./encryption"; import { invalidateDbCache } from "./readCache"; +import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults"; type JsonRecord = Record; @@ -67,6 +68,10 @@ export async function getProviderConnectionById(id: string) { export async function createProviderConnection(data: JsonRecord) { const db = getDbInstance() as unknown as DbLike; const now = new Date().toISOString(); + const normalizedProviderSpecificData = normalizeProviderSpecificData( + toStringOrNull(data.provider), + data.providerSpecificData + ); // Upsert check // For Codex/OpenAI, a single email can have multiple workspaces (Team + Personal) @@ -121,7 +126,11 @@ export async function createProviderConnection(data: JsonRecord) { if (existing) { const existingId = toStringOrNull(existing.id); if (!existingId) return null; - const merged = { ...toRecord(rowToCamel(existing)), ...data, updatedAt: now }; + const merged: JsonRecord = { ...toRecord(rowToCamel(existing)), ...data, updatedAt: now }; + merged.providerSpecificData = normalizeProviderSpecificData( + toStringOrNull(merged.provider), + merged.providerSpecificData + ); _updateConnectionRow(db, existingId, merged); backupDbFile("pre-write"); return cleanNulls(merged); @@ -192,8 +201,8 @@ export async function createProviderConnection(data: JsonRecord) { connection[field] = data[field]; } } - if (data.providerSpecificData && Object.keys(data.providerSpecificData).length > 0) { - connection.providerSpecificData = data.providerSpecificData; + if (normalizedProviderSpecificData && Object.keys(normalizedProviderSpecificData).length > 0) { + connection.providerSpecificData = normalizedProviderSpecificData; } _insertConnectionRow(db, encryptConnectionFields({ ...connection })); @@ -347,7 +356,15 @@ export async function updateProviderConnection(id: string, data: JsonRecord) { const existing = db.prepare("SELECT * FROM provider_connections WHERE id = ?").get(id); if (!existing) return null; - const merged = { ...rowToCamel(existing), ...data, updatedAt: new Date().toISOString() }; + const merged: JsonRecord = { + ...toRecord(rowToCamel(existing)), + ...data, + updatedAt: new Date().toISOString(), + }; + merged.providerSpecificData = normalizeProviderSpecificData( + toStringOrNull(merged.provider), + merged.providerSpecificData + ); _updateConnectionRow(db, id, encryptConnectionFields({ ...merged })); backupDbFile("pre-write"); invalidateDbCache("connections"); // Bust connections read cache @@ -369,6 +386,7 @@ export async function deleteProviderConnection(id: string) { const existing = db.prepare("SELECT provider FROM provider_connections WHERE id = ?").get(id); if (!existing) return false; + db.prepare("DELETE FROM quota_snapshots WHERE connection_id = ?").run(id); db.prepare("DELETE FROM provider_connections WHERE id = ?").run(id); const existingRecord = toRecord(existing); const providerId = @@ -383,6 +401,22 @@ export async function deleteProviderConnection(id: string) { export async function deleteProviderConnectionsByProvider(providerId: string) { const db = getDbInstance() as unknown as DbLike; + const connectionIds = db + .prepare("SELECT id FROM provider_connections WHERE provider = ?") + .all(providerId) + .map((row) => { + const record = toRecord(row); + return typeof record.id === "string" ? record.id : null; + }) + .filter((id): id is string => id !== null); + + if (connectionIds.length > 0) { + const deleteSnapshots = db.prepare("DELETE FROM quota_snapshots WHERE connection_id = ?"); + for (const connectionId of connectionIds) { + deleteSnapshots.run(connectionId); + } + } + const result = db.prepare("DELETE FROM provider_connections WHERE provider = ?").run(providerId); backupDbFile("pre-write"); return result.changes; diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index d6c3c9127c..7b7200146a 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -131,6 +131,8 @@ export const ANTIGRAVITY_CONFIG = { apiVersion: "v1internal", loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", 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"}`, @@ -205,7 +207,7 @@ export const CURSOR_CONFIG = { agentEndpoint: "https://agent.api5.cursor.sh", // Privacy mode agentNonPrivacyEndpoint: "https://agentn.api5.cursor.sh", // Non-privacy mode // Client metadata - clientVersion: "0.48.6", + clientVersion: "3.1.0", clientType: "ide", // Token storage locations (for user reference) tokenStoragePaths: { diff --git a/src/lib/oauth/services/cursor.ts b/src/lib/oauth/services/cursor.ts index 82b4639b74..c2e95fb560 100644 --- a/src/lib/oauth/services/cursor.ts +++ b/src/lib/oauth/services/cursor.ts @@ -51,11 +51,13 @@ export class CursorService { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/connect+proto", "Connect-Protocol-Version": "1", + "User-Agent": `Cursor/${this.config.clientVersion}`, "x-cursor-client-version": this.config.clientVersion, "x-cursor-client-type": this.config.clientType, "x-cursor-client-os": this.detectOS(), "x-cursor-client-arch": this.detectArch(), "x-cursor-client-device-type": "desktop", + "x-cursor-user-agent": `Cursor/${this.config.clientVersion}`, "x-cursor-checksum": checksum, "x-ghost-mode": ghostMode ? "true" : "false", }; diff --git a/src/lib/oauth/services/qwen.ts b/src/lib/oauth/services/qwen.ts index 547059ef82..699952dcf8 100644 --- a/src/lib/oauth/services/qwen.ts +++ b/src/lib/oauth/services/qwen.ts @@ -1,4 +1,5 @@ import open from "open"; +import { randomUUID } from "node:crypto"; import { QWEN_CONFIG } from "../constants/oauth"; import { getServerCredentials } from "../config/index"; import { generatePKCE } from "../utils/pkce"; @@ -24,6 +25,7 @@ export class QwenService { headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", + "x-request-id": randomUUID(), }, body: new URLSearchParams({ client_id: this.config.clientId, diff --git a/src/lib/oauth/utils/server.ts b/src/lib/oauth/utils/server.ts index e8d2ccc547..2e6491b15f 100644 --- a/src/lib/oauth/utils/server.ts +++ b/src/lib/oauth/utils/server.ts @@ -69,7 +69,7 @@ export function startLocalServer( // Listen on fixed port or find available port const portToUse = fixedPort || 0; - server.listen(portToUse, "127.0.0.1", () => { + server.listen(portToUse, "0.0.0.0", () => { const addr = server.address() as { port: number }; resolve({ server, diff --git a/src/lib/providers/codexConnectionDefaults.ts b/src/lib/providers/codexConnectionDefaults.ts new file mode 100644 index 0000000000..ba124f4d59 --- /dev/null +++ b/src/lib/providers/codexConnectionDefaults.ts @@ -0,0 +1,84 @@ +import { getProviderConnections, updateProviderConnection } from "@/lib/db/providers"; +import { getSettings, updateSettings } from "@/lib/db/settings"; +import { getCodexRequestDefaults } from "./requestDefaults"; + +type JsonRecord = Record; + +const MIGRATION_SETTING_KEY = "codexConnectionDefaultsMigrationV1"; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function parseLegacyCodexServiceTier(value: unknown): { enabled: boolean } { + if (typeof value === "string") { + try { + return parseLegacyCodexServiceTier(JSON.parse(value)); + } catch { + return { enabled: false }; + } + } + + const record = asRecord(value); + return { enabled: record.enabled === true }; +} + +export async function migrateCodexConnectionDefaultsFromLegacySettings(): Promise<{ + migrated: boolean; + updatedConnectionIds: string[]; + legacyFastEnabled: boolean; +}> { + const settings = await getSettings(); + if (settings[MIGRATION_SETTING_KEY]) { + return { + migrated: false, + updatedConnectionIds: [], + legacyFastEnabled: parseLegacyCodexServiceTier(settings.codexServiceTier).enabled, + }; + } + + const legacyFastEnabled = parseLegacyCodexServiceTier(settings.codexServiceTier).enabled; + const codexConnections = await getProviderConnections({ provider: "codex" }); + const updatedConnectionIds: string[] = []; + + for (const connection of codexConnections) { + const providerSpecificData = asRecord(connection.providerSpecificData); + const existingDefaults = getCodexRequestDefaults(providerSpecificData); + const nextDefaults: JsonRecord = { ...existingDefaults }; + + if (!existingDefaults.reasoningEffort) { + nextDefaults.reasoningEffort = "medium"; + } + if (legacyFastEnabled && !existingDefaults.serviceTier) { + nextDefaults.serviceTier = "priority"; + } + + const defaultsChanged = + nextDefaults.reasoningEffort !== existingDefaults.reasoningEffort || + nextDefaults.serviceTier !== existingDefaults.serviceTier; + + if (!defaultsChanged) continue; + + await updateProviderConnection(connection.id, { + providerSpecificData: { + ...providerSpecificData, + requestDefaults: nextDefaults, + }, + }); + updatedConnectionIds.push(connection.id); + } + + await updateSettings({ + [MIGRATION_SETTING_KEY]: { + completedAt: new Date().toISOString(), + updatedConnectionIds, + legacyFastEnabled, + }, + }); + + return { + migrated: true, + updatedConnectionIds, + legacyFastEnabled, + }; +} diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts new file mode 100644 index 0000000000..d49d1021e7 --- /dev/null +++ b/src/lib/providers/requestDefaults.ts @@ -0,0 +1,153 @@ +type JsonRecord = Record; + +export const CODEX_REASONING_EFFORT_VALUES = ["none", "low", "medium", "high", "xhigh"] as const; + +export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_VALUES)[number]; + +const CODEX_REASONING_EFFORT_SET = new Set(CODEX_REASONING_EFFORT_VALUES); + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function normalizeString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.trim().toLowerCase(); + return normalized || undefined; +} + +function hasNonEmptyString(value: unknown): boolean { + return typeof value === "string" && value.trim().length > 0; +} + +export function normalizeCodexReasoningEffort(value: unknown): CodexReasoningEffort | undefined { + const normalized = normalizeString(value); + if (!normalized || !CODEX_REASONING_EFFORT_SET.has(normalized)) { + return undefined; + } + return normalized as CodexReasoningEffort; +} + +export function normalizeCodexServiceTier(value: unknown): "priority" | undefined { + const normalized = normalizeString(value); + if (!normalized) return undefined; + if (normalized === "fast" || normalized === "priority") return "priority"; + return undefined; +} + +export function normalizeRequestDefaults( + provider: string | null | undefined, + value: unknown +): JsonRecord | undefined { + const record = asRecord(value); + if (Object.keys(record).length === 0) return undefined; + + const normalized: JsonRecord = { ...record }; + + if (provider === "codex") { + const reasoningEffort = normalizeCodexReasoningEffort(record.reasoningEffort); + if (reasoningEffort) { + normalized.reasoningEffort = reasoningEffort; + } else { + delete normalized.reasoningEffort; + } + + const serviceTier = normalizeCodexServiceTier(record.serviceTier); + if (serviceTier) { + normalized.serviceTier = serviceTier; + } else { + delete normalized.serviceTier; + } + } + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +export function normalizeProviderSpecificData( + provider: string | null | undefined, + value: unknown +): JsonRecord | undefined { + const record = asRecord(value); + if (Object.keys(record).length === 0) return undefined; + + const normalized: JsonRecord = { ...record }; + + if ("requestDefaults" in normalized) { + const requestDefaults = normalizeRequestDefaults(provider, normalized.requestDefaults); + if (requestDefaults) { + normalized.requestDefaults = requestDefaults; + } else { + delete normalized.requestDefaults; + } + } + + if ("openaiStoreEnabled" in normalized && typeof normalized.openaiStoreEnabled !== "boolean") { + delete normalized.openaiStoreEnabled; + } + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +export function isOpenAIResponsesStoreEnabled(providerSpecificData: unknown): boolean { + return asRecord(providerSpecificData).openaiStoreEnabled === true; +} + +export function buildOpenAIStoreSessionId(sessionId: unknown): string | undefined { + if (!hasNonEmptyString(sessionId)) return undefined; + + const normalized = String(sessionId) + .trim() + .replace(/^ext:/i, "") + .replace(/[^a-zA-Z0-9._:-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 96); + + if (!normalized) return undefined; + return `omniroute-session-${normalized}`; +} + +export function ensureOpenAIStoreSessionFallback( + body: Record, + sessionId: unknown +): Record { + const explicitSessionId = body.session_id; + const explicitConversationId = body.conversation_id; + const promptCacheKey = body.prompt_cache_key ?? body.promptCacheKey; + + if ( + hasNonEmptyString(explicitSessionId) || + hasNonEmptyString(explicitConversationId) || + hasNonEmptyString(promptCacheKey) + ) { + return body; + } + + const fallbackSessionId = buildOpenAIStoreSessionId(sessionId); + if (!fallbackSessionId) return body; + + return { + ...body, + session_id: fallbackSessionId, + }; +} + +export function getProviderRequestDefaults( + provider: string | null | undefined, + providerSpecificData: unknown +): JsonRecord { + return normalizeRequestDefaults(provider, asRecord(providerSpecificData).requestDefaults) || {}; +} + +export function getCodexRequestDefaults(providerSpecificData: unknown): { + reasoningEffort?: CodexReasoningEffort; + serviceTier?: "priority"; +} { + const defaults = getProviderRequestDefaults("codex", providerSpecificData); + const reasoningEffort = normalizeCodexReasoningEffort(defaults.reasoningEffort); + const serviceTier = normalizeCodexServiceTier(defaults.serviceTier); + return { + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(serviceTier ? { serviceTier } : {}), + }; +} diff --git a/src/lib/skills/skillssh.ts b/src/lib/skills/skillssh.ts new file mode 100644 index 0000000000..9d80e96883 --- /dev/null +++ b/src/lib/skills/skillssh.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; + +// ─── skills.sh API response schemas ─── + +export const SkillsShSkillSchema = z.object({ + id: z.string(), + skillId: z.string(), + name: z.string(), + installs: z.number().optional().default(0), + source: z.string(), +}); + +export const SkillsShSearchResponseSchema = z.object({ + query: z.string().optional(), + searchType: z.string().optional(), + skills: z.array(SkillsShSkillSchema).default([]), + count: z.number().optional(), + duration_ms: z.number().optional(), +}); + +export type SkillsShSkill = z.infer; +export type SkillsShSearchResponse = z.infer; + +const SKILLSSH_BASE_URL = "https://skills.sh/api"; +const GITHUB_RAW_BASE = "https://raw.githubusercontent.com"; +const DEFAULT_SEARCH_LIMIT = 20; +const REQUEST_TIMEOUT_MS = 15_000; + +/** + * Search the skills.sh public directory. + * No authentication required. + */ +export async function searchSkillsSh( + query: string, + limit: number = DEFAULT_SEARCH_LIMIT +): Promise { + const url = `${SKILLSSH_BASE_URL}/search?q=${encodeURIComponent(query)}&limit=${limit}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const res = await fetch(url, { signal: controller.signal }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`skills.sh API error: ${res.status} ${body}`); + } + const data = await res.json(); + return SkillsShSearchResponseSchema.parse(data); + } finally { + clearTimeout(timeout); + } +} + +/** + * Fetch SKILL.md content from GitHub for a skills.sh skill. + * + * @param source - GitHub "owner/repo" (e.g. "supabase/agent-skills") + * @param skillId - Skill name (last segment of the full id, e.g. "supabase-postgres-best-practices") + */ +export async function fetchSkillMd(source: string, skillId: string): Promise { + const url = `${GITHUB_RAW_BASE}/${source}/main/skills/${skillId}/SKILL.md`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const res = await fetch(url, { signal: controller.signal }); + if (!res.ok) { + throw new Error(`Failed to fetch SKILL.md: ${res.status} (${url})`); + } + return await res.text(); + } finally { + clearTimeout(timeout); + } +} diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 800f86c356..fa57df9a53 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -68,6 +68,8 @@ type CallLogArtifact = { pipeline?: RequestPipelinePayloads; }; +const CALL_LOG_INLINE_BODY_LIMIT = 256 * 1024; + function asRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } @@ -427,8 +429,8 @@ export async function saveCallLog(entry: any) { comboStepId: toStringOrNull(entry.comboStepId), comboExecutionKey: toStringOrNull(entry.comboExecutionKey) || toStringOrNull(entry.comboStepId), - requestBody: serializePayloadForStorage(protectedRequestBody, 8192), - responseBody: serializePayloadForStorage(protectedResponseBody, 8192), + requestBody: serializePayloadForStorage(protectedRequestBody, CALL_LOG_INLINE_BODY_LIMIT), + responseBody: serializePayloadForStorage(protectedResponseBody, CALL_LOG_INLINE_BODY_LIMIT), error: toStoredErrorString(protectedError), }; diff --git a/src/lib/usage/fetcher.ts b/src/lib/usage/fetcher.ts index ecadb17294..b3723cd817 100644 --- a/src/lib/usage/fetcher.ts +++ b/src/lib/usage/fetcher.ts @@ -3,6 +3,7 @@ */ import { GITHUB_CONFIG, GEMINI_CONFIG, ANTIGRAVITY_CONFIG } from "@/lib/oauth/constants/oauth"; +import { getAntigravityRemainingCredits } from "@omniroute/open-sse/executors/antigravity.ts"; /** * Get usage data for a provider connection @@ -18,7 +19,7 @@ export async function getUsageForProvider(connection) { case "gemini-cli": return await getGeminiUsage(accessToken); case "antigravity": - return await getAntigravityUsage(accessToken); + return await getAntigravityUsage(accessToken, providerSpecificData); case "claude": return await getClaudeUsage(accessToken); case "codex": @@ -146,13 +147,107 @@ async function getGeminiUsage(accessToken) { /** * Antigravity Usage + * Calls fetchAvailableModels to get per-model quota fractions. + * 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) { +async function getAntigravityUsage(accessToken: string, providerSpecificData: Record = {}) { try { - // Similar to Gemini, uses Google Cloud - return { message: "Antigravity connected. Usage tracked via Google Cloud Console." }; + // Derive accountId (same key used in AntigravityExecutor.execute) + const accountId: string = + (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), + }); + + if (!res.ok) { + return { + plan: "Antigravity", + message: "Antigravity connected. Unable to fetch model quotas.", + ...(creditBalance !== null && { + quotas: { + credits: { + used: 0, + total: 0, + remaining: creditBalance, + unlimited: false, + resetAt: null, + }, + }, + }), + }; + } + + const data = await res.json(); + const models: Record = data?.models ?? {}; + + // Walk quota-based models (those with remainingFraction in quotaInfo) + let quotaModelsTotal = 0; + let quotaModelsAvailable = 0; + const modelQuotas: Record = {}; + + for (const [modelId, rawInfo] of Object.entries(models)) { + const info = rawInfo as Record; + if (info.isInternal) continue; + const quotaInfo = (info.quotaInfo as Record) ?? {}; + + if ("remainingFraction" in quotaInfo) { + 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), + resetAt: resetTime, + limited: fraction <= 0, + }; + quotaModelsTotal++; + if (fraction > 0) quotaModelsAvailable++; + } + // Credit-based models have no remainingFraction — their availability is + // tracked via the GOOGLE_ONE_AI credit balance cached from SSE responses. + } + + const allLimited = quotaModelsTotal > 0 && quotaModelsAvailable === 0; + + return { + plan: "Antigravity", + quotas: { + models: { + used: quotaModelsTotal - quotaModelsAvailable, + total: quotaModelsTotal, + remaining: quotaModelsAvailable, + limited: allLimited, + unlimited: false, + resetAt: null, + }, + ...(creditBalance !== null && { + credits: { + used: 0, + total: 0, + remaining: creditBalance, + unlimited: false, + resetAt: null, + }, + }), + }, + modelQuotas, + limitReached: allLimited, + }; } catch (error) { - return { message: "Unable to fetch Antigravity usage." }; + return { message: `Unable to fetch Antigravity usage: ${(error as Error).message}` }; } } diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx index 2a69041e72..098878eb98 100644 --- a/src/shared/components/Sidebar.tsx +++ b/src/shared/components/Sidebar.tsx @@ -5,6 +5,7 @@ import PropTypes from "prop-types"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/shared/utils/cn"; +import { getActiveSidebarHref } from "@/shared/utils/sidebarRouteMatch"; import { APP_CONFIG } from "@/shared/constants/config"; import OmniRouteLogo from "./OmniRouteLogo"; import Button from "./Button"; @@ -91,13 +92,6 @@ export default function Sidebar({ }; }, []); - const isActive = (href, exact) => { - if (exact) { - return pathname === href; - } - return pathname.startsWith(href); - }; - const handleShutdown = async () => { setIsShuttingDown(true); try { @@ -140,9 +134,13 @@ export default function Sidebar({ .filter((item) => !hiddenSidebarSet.has(item.id)), })) .filter((section) => section.items.length > 0); + const activeHref = getActiveSidebarHref( + pathname, + visibleSections.flatMap((section) => section.items) + ); const renderNavLink = (item) => { - const active = !item.external && isActive(item.href, item.exact); + const active = !item.external && activeHref === item.href; const className = cn( "flex items-center gap-3 rounded-lg transition-all group", collapsed ? "justify-center px-2 py-2.5" : "px-4 py-2", @@ -305,9 +303,7 @@ export default function Sidebar({ collapsed ? "p-2 flex flex-col gap-1" : "p-3 flex gap-2" )} style={{ - paddingBottom: isMacElectron - ? "calc(0.75rem + var(--desktop-safe-bottom))" - : undefined, + paddingBottom: isMacElectron ? "calc(0.75rem + var(--desktop-safe-bottom))" : undefined, }} >