Merge pull request #1192 from diegosouzapw/release/v3.6.5

chore(release): v3.6.5 — Claude Code native parity, Antigravity credit fallback
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-04-13 22:04:40 -03:00
committed by GitHub
130 changed files with 7649 additions and 667 deletions

View File

@@ -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)

View File

@@ -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

161
AGENTS.md
View File

@@ -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

View File

@@ -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

225
CLAUDE.md Normal file
View File

@@ -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)

View File

@@ -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 |

View File

@@ -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,

View File

@@ -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",
});

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "3.6.4",
"version": "3.6.5",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {

View File

@@ -87,6 +87,7 @@ export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
"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<string, CliFingerprint> = {
"X-Stainless-Runtime-Version",
"Accept",
"accept-language",
"sec-fetch-mode",
"accept-encoding",
"Connection",
],
bodyFieldOrder: [
"model",
"messages",
"system",
"tools",
"tool_choice",
"metadata",
"max_tokens",
"thinking",

View File

@@ -570,9 +570,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
"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" },

View File

@@ -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<string, number>();
/**
* 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<string, number>();
/** 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<string, unknown> | 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
}

View File

@@ -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 = {

View File

@@ -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<string, unknown> | 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<string, string> {
buildHeaders(credentials: ProviderCredentials | null, stream = true): Record<string, string> {
const key = credentials?.apiKey || credentials?.accessToken;
const headers: Record<string, string> = {
"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<string, unknown>) };
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<string, string> | 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;

View File

@@ -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<string, unknown>): 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<string, unknown>);
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;

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<string, unknown> {
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);

View File

@@ -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<string, unknown> | 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<string, string> | null
)

View File

@@ -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 <think>...</think> blocks and <thinking>...</thinking> (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;

View File

@@ -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;

View File

@@ -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 || {},
};

View File

@@ -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);
});
});

View File

@@ -60,6 +60,9 @@ export {
getSessionSnapshotInput,
getSessionSnapshotOutput,
getSessionSnapshotTool,
dbHealthCheckInput,
dbHealthCheckOutput,
dbHealthCheckTool,
cacheStatsInput,
cacheStatsOutput,
cacheStatsTool,

View File

@@ -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,

View File

@@ -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",
{

View File

@@ -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_checkDiagnose 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 };
}
}

View File

@@ -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",

134
open-sse/services/AGENTS.md Normal file
View File

@@ -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 (520 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)

View File

@@ -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 };

View File

@@ -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<string, unknown>): Record<string, unknown> {
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);
}

View File

@@ -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<string, string>
): Record<string, string> {
const cleaned: Record<string, string> = {};
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;
}

View File

@@ -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 };

View File

@@ -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;
}

View File

@@ -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<void> | 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<string> {
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<string> {
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 };

View File

@@ -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<string, unknown>[] | 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<string, string> }> {
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<string, unknown> | null,
normalizedBody?: Record<string, unknown> | null,
@@ -381,12 +479,14 @@ function buildClaudeCodeCompatibleSystemBlocks({
cwd,
now,
preserveCacheControl,
billingHeader,
}: {
messages: MessageLike[] | undefined;
systemBlocks?: Array<Record<string, unknown>> | 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<Record<string, unknown>> = [
{
type: "text",
text: CLAUDE_CODE_COMPATIBLE_BILLING_HEADER,
text: billingHeader,
cache_control: { type: "ephemeral" },
},
{
type: "text",

View File

@@ -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<string, unknown>): void {
const thinking = body.thinking as Record<string, unknown> | undefined;
if (thinking?.type === "enabled" || thinking?.type === "adaptive") {
body.temperature = 1;
}
}
export function disableThinkingIfToolChoiceForced(body: Record<string, unknown>): void {
const toolChoice = body.tool_choice as Record<string, unknown> | 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<string, unknown>): void {
let count = 0;
// Count in system blocks
const system = body.system as Array<Record<string, unknown>> | undefined;
if (Array.isArray(system)) {
for (const block of system) {
if (block.cache_control) count++;
}
}
// Count in messages
const messages = body.messages as Array<Record<string, unknown>> | undefined;
if (Array.isArray(messages)) {
for (const msg of messages) {
const content = msg.content as Array<Record<string, unknown>> | 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<Record<string, unknown>> | 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<Record<string, unknown>> | 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<string, unknown>): void {
const messages = body.messages as Array<Record<string, unknown>> | 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<string, unknown>;
if (!lastBlock.cache_control) {
lastBlock.cache_control = { type: "ephemeral" };
}
}
break;
}
}
}

View File

@@ -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<string, unknown>).text === "string"
) {
return (block as Record<string, unknown>).text as string;
}
}
}
return "";
}
return "";
}

View File

@@ -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<string, unknown>): void {
const messages = body.messages as Array<Record<string, unknown>> | 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<Record<string, unknown>>) {
if (typeof block.text === "string") {
block.text = obfuscateSensitiveWords(block.text);
}
}
}
}
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -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<string, string> = {
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<string, string> = {};
for (const [k, v] of Object.entries(TOOL_RENAME_MAP)) {
REVERSE_MAP[v] = k;
}
export function remapToolNamesInRequest(body: Record<string, unknown>): void {
// Remap tool definitions
const tools = body.tools as Array<Record<string, unknown>> | 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<Record<string, unknown>> | undefined;
if (Array.isArray(messages)) {
for (const msg of messages) {
const content = msg.content as Array<Record<string, unknown>> | 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<string, unknown> | 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 };

View File

@@ -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<omniModel>${modelStr}</omniModel>\\n`;
const tagContent = `<omniModel>${modelStr}</omniModel>`;
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("<omniModel>")) {
const cleaned = text.replace(/\n?<omniModel>[^<]+<\/omniModel>\n?/g, "");
const cleaned = text.replace(
/(?:\\n|\n)?<omniModel>[^<]+<\/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("<omniModel>")) {
const cleaned = tail.replace(/\n?<omniModel>[^<]+<\/omniModel>\n?/g, "");
const cleaned = tail.replace(
/(?:\\n|\n)?<omniModel>[^<]+<\/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",

View File

@@ -62,7 +62,7 @@ export function injectModelTag(messages: Message[], providerModel: string): Mess
if (lastAssistantIdx === -1) {
return [
...cleaned,
{ role: "assistant", content: `\n<omniModel>${providerModel}</omniModel>` },
{ role: "assistant", content: `<omniModel>${providerModel}</omniModel>` },
];
}
@@ -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<omniModel>${providerModel}</omniModel>` },
{ role: "assistant", content: `<omniModel>${providerModel}</omniModel>` },
];
}
const tagged = [...cleaned];
tagged[lastAssistantIdx] = {
...msg,
content: `${msg.content}\n<omniModel>${providerModel}</omniModel>`,
content: `${msg.content}<omniModel>${providerModel}</omniModel>`,
};
return tagged;
}

View File

@@ -0,0 +1,44 @@
type JsonRecord = Record<string, unknown>;
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",
};
}

View File

@@ -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<T>(
ttlMs: number,
fetchFn: () => Promise<T>
): 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<T> | undefined;
if (cached && cached.expiresAt > Date.now()) {
@@ -117,11 +128,8 @@ export async function getOrCoalesce<T>(
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 {

View File

@@ -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<string, unknown>;
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<string, string> {
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<string, UsageQuota> = {};
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,
};

View File

@@ -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

View File

@@ -0,0 +1,124 @@
import { cleanJSONSchemaForAntigravity } from "./geminiHelper.ts";
type GeminiFunctionDeclaration = {
name: string;
description: string;
parameters: unknown;
};
type GeminiTool = {
functionDeclarations?: GeminiFunctionDeclaration[];
googleSearch?: Record<string, unknown>;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function toGeminiGoogleSearchTool(tool: Record<string, unknown>): 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;
}

View File

@@ -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);
}

View File

@@ -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 ────────────────────────────────────────────

View File

@@ -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<string, unknown>;
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;
}

View File

@@ -25,6 +25,7 @@ import {
generateSessionId,
cleanJSONSchemaForAntigravity,
} from "../helpers/geminiHelper.ts";
import { buildGeminiTools } from "../helpers/geminiToolsSanitizer.ts";
type GeminiPart = Record<string, unknown>;
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<string, unknown>;
}>;
cachedContent?: string;
};
@@ -69,7 +73,10 @@ type CloudCodeEnvelope = {
contents: GeminiContent[];
systemInstruction?: GeminiContent;
generationConfig: GeminiGenerationConfig;
tools?: Array<{ functionDeclarations: GeminiFunctionDeclaration[] }>;
tools?: Array<{
functionDeclarations?: GeminiFunctionDeclaration[];
googleSearch?: Record<string, unknown>;
}>;
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" },
};

View File

@@ -28,7 +28,7 @@ export function geminiToOpenAIResponse(chunk, state) {
choices: [
{
index: 0,
delta: { role: "assistant" },
delta: { role: "assistant", content: "" },
finish_reason: null,
},
],

View File

@@ -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",

View File

@@ -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<Response>)(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}`);

View File

@@ -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),

155
package-lock.json generated
View File

@@ -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"
}
}
}

View File

@@ -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"

View File

@@ -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 (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-semibold">{t("title")}</h1>
<p className="text-sm text-text-muted mt-1">{t("description")}</p>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex items-center gap-2 rounded-lg border border-black/8 dark:border-white/8 bg-black/[0.02] dark:bg-white/[0.02] px-2.5 py-1.5">
<span className="hidden lg:inline text-xs text-text-muted">
{getI18nOrFallback(
t,
"emailVisibilityHint",
"Account emails here follow the global privacy toggle."
)}
</span>
<Tooltip
position="bottom"
content={getI18nOrFallback(
t,
"emailVisibilityTooltip",
"Use the eye icon to reveal or hide account emails globally across combos, providers and quota screens."
)}
>
<span className="inline-flex">
<EmailPrivacyToggle size="md" />
</span>
</Tooltip>
<span className="text-[11px] text-text-muted">
{emailsVisible
? getI18nOrFallback(t, "emailVisibilityStateOn", "Emails visible globally")
: getI18nOrFallback(t, "emailVisibilityStateOff", "Emails masked globally")}
</span>
</div>
{!showUsageGuide && (
<Button size="sm" variant="ghost" onClick={handleShowUsageGuide}>
{getI18nOrFallback(t, "usageGuideShow", "Show guide")}
@@ -1416,6 +1451,7 @@ function ComboCard({
const isDisabled = combo.isActive === false;
const t = useTranslations("combos");
const tc = useTranslations("common");
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
const strategyDescription = getStrategyDescription(t, strategy);
return (
@@ -1501,6 +1537,7 @@ function ComboCard({
{formatComboEntryDisplay(entry, {
providerNodes,
includeConnection: true,
showFullEmails: emailsVisible,
})}
{strategy === "weighted" && weight > 0 ? ` (${weight}%)` : ""}
</code>
@@ -1595,6 +1632,8 @@ function ComboCard({
// Test Results View
// ─────────────────────────────────────────────
function TestResultsView({ results }) {
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
if (results.error) {
return (
<div className="flex items-center gap-2 text-red-500 text-sm">
@@ -1649,7 +1688,9 @@ function TestResultsView({ results }) {
{r.status === "ok" ? "check_circle" : r.status === "skipped" ? "skip_next" : "error"}
</span>
<div className="min-w-0 flex-1">
<code className="font-mono block truncate">{r.label || r.model}</code>
<code className="font-mono block truncate">
{pickDisplayValue([r.label], emailsVisible, r.model)}
</code>
{r.connectionId || r.stepId ? (
<div className="mt-0.5 text-[10px] text-text-muted">
{r.connectionId ? `acct ${r.connectionId.slice(0, 8)}` : "dynamic account"}
@@ -1708,6 +1749,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const t = useTranslations("combos");
const tc = useTranslations("common");
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
const notify = useNotificationStore();
const createDraftStateRef = useRef<CreateDraftSnapshot>(getEmptyCreateDraftSnapshot());
const [name, setName] = useState(combo?.name || "");
@@ -2314,9 +2356,10 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
providerNodes,
builderProviders,
includeConnection: true,
showFullEmails: emailsVisible,
});
},
[builderProviders, providerNodes]
[builderProviders, emailsVisible, providerNodes]
);
const handleMoveUp = (index) => {
@@ -2760,7 +2803,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
</option>
{selectedBuilderConnections.map((connection) => (
<option key={connection.id} value={connection.id}>
{connection.label}
{pickDisplayValue([connection.label], emailsVisible, connection.label)}
{connection.status !== "active" ? ` · ${connection.status}` : ""}
</option>
))}

View File

@@ -43,6 +43,8 @@ export default function HealthPage() {
const tc = useTranslations("common");
const tp = useTranslations("providers");
const [data, setData] = useState(null);
const [dbHealth, setDbHealth] = useState(null);
const [dbHealthError, setDbHealthError] = useState(null);
const [error, setError] = useState(null);
const [lastRefresh, setLastRefresh] = useState(null);
const [telemetry, setTelemetry] = useState(null);
@@ -50,6 +52,7 @@ export default function HealthPage() {
const [signatureCache, setSignatureCache] = useState(null);
const [degradation, setDegradation] = useState(null);
const [resetting, setResetting] = useState(false);
const [repairingDb, setRepairingDb] = useState(false);
const fetchHealth = useCallback(async () => {
try {
@@ -64,6 +67,18 @@ export default function HealthPage() {
}
}, []);
const fetchDbHealth = useCallback(async () => {
try {
const res = await fetch("/api/v1/db/health");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
setDbHealth(json);
setDbHealthError(null);
} catch (err) {
setDbHealthError(err.message);
}
}, []);
// Fetch telemetry, cache, and signature cache stats
const fetchExtras = useCallback(async () => {
const results = await Promise.allSettled([
@@ -83,12 +98,14 @@ export default function HealthPage() {
useEffect(() => {
fetchHealth();
fetchExtras();
fetchDbHealth();
const interval = setInterval(() => {
fetchHealth();
fetchExtras();
fetchDbHealth();
}, 15000);
return () => clearInterval(interval);
}, [fetchHealth, fetchExtras]);
}, [fetchHealth, fetchExtras, fetchDbHealth]);
const handleResetHealth = async () => {
if (!confirm(t("resetConfirm"))) return;
@@ -106,6 +123,24 @@ export default function HealthPage() {
}
};
const handleRepairDb = async () => {
setRepairingDb(true);
try {
const res = await fetch("/api/v1/db/health", { method: "POST" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
setDbHealth(json);
setDbHealthError(null);
await fetchHealth();
await fetchExtras();
} catch (err) {
console.error("Failed to repair database health:", err);
setDbHealthError(err.message);
} finally {
setRepairingDb(false);
}
};
const fmtMs = (ms) =>
ms != null ? t("millisecondsShort", { value: Math.round(ms) }) : t("notAvailable");
@@ -167,6 +202,7 @@ export default function HealthPage() {
onClick={() => {
fetchHealth();
fetchExtras();
fetchDbHealth();
}}
className="p-2 rounded-lg bg-surface hover:bg-surface/80 text-text-muted hover:text-text-main transition-colors"
title={tc("refresh")}
@@ -198,6 +234,87 @@ export default function HealthPage() {
</span>
</div>
<Card className="p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex items-center gap-3 mb-2">
<div
className={`flex items-center justify-center size-9 rounded-lg ${
dbHealth?.isHealthy
? "bg-green-500/10 text-green-500"
: "bg-amber-500/10 text-amber-500"
}`}
>
<span className="material-symbols-outlined text-[18px]">database</span>
</div>
<div>
<h2 className="text-lg font-semibold text-text-main">Database Health</h2>
<p className="text-sm text-text-muted">
Diagnose and repair stale quota/domain rows and broken combo references.
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-4">
<div className="rounded-xl border border-border bg-surface/50 p-3">
<p className="text-xs uppercase tracking-wide text-text-muted">Status</p>
<p
className={`mt-1 text-sm font-medium ${
dbHealth?.isHealthy ? "text-green-400" : "text-amber-400"
}`}
>
{dbHealth?.isHealthy ? "Healthy" : "Attention needed"}
</p>
</div>
<div className="rounded-xl border border-border bg-surface/50 p-3">
<p className="text-xs uppercase tracking-wide text-text-muted">Issues</p>
<p className="mt-1 text-sm font-medium text-text-main">
{dbHealth?.issues?.length ?? 0}
</p>
</div>
<div className="rounded-xl border border-border bg-surface/50 p-3">
<p className="text-xs uppercase tracking-wide text-text-muted">Repairs</p>
<p className="mt-1 text-sm font-medium text-text-main">
{dbHealth?.repairedCount ?? 0}
</p>
</div>
</div>
</div>
<div className="flex flex-col items-stretch gap-2 min-w-[180px]">
<button
onClick={handleRepairDb}
disabled={repairingDb}
className="px-4 py-2 rounded-lg bg-primary/10 text-primary text-sm hover:bg-primary/20 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{repairingDb ? "Repairing..." : "Run Auto-Repair"}
</button>
{dbHealth?.backupCreated && (
<p className="text-xs text-text-muted">
A repair backup was created before mutating.
</p>
)}
{dbHealthError && <p className="text-xs text-red-400">{dbHealthError}</p>}
</div>
</div>
{Array.isArray(dbHealth?.issues) && dbHealth.issues.length > 0 && (
<div className="mt-4 space-y-2">
{dbHealth.issues.map((issue, index) => (
<div
key={`${issue.table}-${issue.type}-${index}`}
className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-3 py-2"
>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-text-main">{issue.description}</p>
<span className="text-xs text-amber-400">{issue.count}</span>
</div>
<p className="text-xs text-text-muted mt-1">
{issue.table} · {issue.type}
</p>
</div>
))}
</div>
)}
</Card>
{/* System Info Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="p-4">

View File

@@ -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"}
</span>
<div className="flex-1 min-w-0">
<span className="font-medium">{r.connectionName}</span>
<span className="font-medium">
{pickDisplayValue([r.connectionName], emailsVisible, r.connectionName)}
</span>
</div>
{r.latencyMs !== undefined && (
<span className="text-text-muted font-mono tabular-nums">
@@ -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 (
<div
@@ -4743,6 +4833,27 @@ function ConnectionRow({
<span className="material-symbols-outlined text-[13px]">shield</span>
{rateLimitEnabled ? t("rateLimitProtected") : t("rateLimitUnprotected")}
</button>
{isCcCompatible && (
<>
<span className="text-text-muted/30 select-none">|</span>
<button
onClick={() => onToggleCliproxyapiMode?.(!cliproxyapiDeepMode)}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
cliproxyapiDeepMode
? "bg-indigo-500/15 text-indigo-500 hover:bg-indigo-500/25"
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
}`}
title={
cliproxyapiDeepMode
? "Using CLIProxyAPI for deeper Claude Code emulation (uTLS, multi-account, device profiles)"
: "Enable CLIProxyAPI backend for deeper Claude Code OAuth emulation"
}
>
<span className="material-symbols-outlined text-[13px]">swap_horiz</span>
CPA {cliproxyapiDeepMode ? "ON" : "OFF"}
</button>
</>
)}
{isCodex && (
<>
<span className="text-text-muted/30 select-none">|</span>
@@ -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 && (
<div className="flex flex-col gap-4 rounded-lg border border-border/50 bg-surface/20 p-4">
<Select
label="Default thinking strength"
value={formData.codexReasoningEffort}
options={CODEX_REASONING_STRENGTH_OPTIONS}
onChange={(e) => setFormData({ ...formData, codexReasoningEffort: e.target.value })}
hint="Used when the client does not send a reasoning effort and the global Thinking Budget mode is passthrough."
/>
<Toggle
checked={formData.codexFastServiceTier}
onChange={(checked) => setFormData({ ...formData, codexFastServiceTier: checked })}
label="Codex Fast Service Tier"
description="When enabled, injects `service_tier=priority` for this connection if the client leaves the tier unset."
/>
<Toggle
checked={formData.codexOpenaiStoreEnabled}
onChange={(checked) => setFormData({ ...formData, codexOpenaiStoreEnabled: checked })}
label="OpenAI Responses Store"
description="Preserves `store`, `previous_response_id`, and adds a stable fallback `session_id` for long Codex sessions. Enable only when the upstream account accepts stored Responses."
/>
</div>
)}
{isOAuth && connection.email && (
<div className="bg-sidebar/50 p-3 rounded-lg">
<p className="text-sm text-text-muted mb-1">{t("email")}</p>

View File

@@ -23,6 +23,8 @@ import {
} from "@/shared/constants/providers";
import Link from "next/link";
import { getErrorCode, getRelativeTime } from "@/shared/utils";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { useNotificationStore } from "@/store/notificationStore";
import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge";
import { useTranslations } from "next-intl";
@@ -1569,6 +1571,7 @@ AddCcCompatibleModal.propTypes = {
function ProviderTestResultsView({ results }) {
const t = useTranslations("providers");
const tc = useTranslations("common");
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
// Guard: never crash on malformed/null results (would trigger error boundary)
if (!results || typeof results !== "object") {
@@ -1636,7 +1639,9 @@ function ProviderTestResultsView({ results }) {
{r.valid ? "check_circle" : "error"}
</span>
<div className="flex-1 min-w-0">
<span className="font-medium">{r.connectionName}</span>
<span className="font-medium">
{pickDisplayValue([r.connectionName], emailsVisible, r.connectionName)}
</span>
<span className="text-text-muted ml-1.5">({r.provider})</span>
</div>
{r.latencyMs !== undefined && (

View File

@@ -1,103 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Card } from "@/shared/components";
export default function CodexServiceTierTab() {
const [enabled, setEnabled] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<"" | "saved" | "error">("");
useEffect(() => {
fetch("/api/settings/codex-service-tier")
.then((res) => res.json())
.then((data) => {
setEnabled(Boolean(data.enabled));
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const save = async (nextEnabled: boolean) => {
setEnabled(nextEnabled);
setSaving(true);
setStatus("");
try {
const res = await fetch("/api/settings/codex-service-tier", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: nextEnabled }),
});
if (res.ok) {
setStatus("saved");
setTimeout(() => setStatus(""), 2000);
} else {
setStatus("error");
setEnabled(!nextEnabled);
}
} catch {
setStatus("error");
setEnabled(!nextEnabled);
} finally {
setSaving(false);
}
};
return (
<Card>
<div className="flex items-center gap-3 mb-5">
<div className="p-2 rounded-lg bg-sky-500/10 text-sky-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
bolt
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">Codex Fast Service Tier</h3>
<p className="text-sm text-text-muted">
Inject `service_tier=priority` into Codex requests when the client leaves it unset.
</p>
</div>
{status === "saved" && (
<span className="text-xs font-medium text-emerald-500 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">check_circle</span>
Saved
</span>
)}
{status === "error" && (
<span className="text-xs font-medium text-rose-500 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">error</span>
Failed to save
</span>
)}
</div>
<div className="flex items-center justify-between p-4 rounded-lg bg-surface/30 border border-border/30">
<div>
<p className="text-sm font-medium">Force fast tier for Codex</p>
<p className="text-xs text-text-muted mt-0.5">
Off by default. Applies only to Codex requests and does not override an explicit tier.
Codex fast mode is sent upstream as `service_tier=priority`.
</p>
</div>
<button
onClick={() => save(!enabled)}
disabled={loading || saving}
className={`relative inline-flex h-6 w-11 items-center rounded-full border transition-colors ${
enabled
? "bg-sky-500 border-sky-500"
: "bg-black/10 border-black/10 dark:bg-white/10 dark:border-white/10"
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
enabled ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
</Card>
);
}

View File

@@ -12,7 +12,6 @@ import ComboDefaultsTab from "./components/ComboDefaultsTab";
import ProxyTab from "./components/ProxyTab";
import AppearanceTab from "./components/AppearanceTab";
import ThinkingBudgetTab from "./components/ThinkingBudgetTab";
import CodexServiceTierTab from "./components/CodexServiceTierTab";
import SystemPromptTab from "./components/SystemPromptTab";
import ModelAliasesUnified from "./components/ModelAliasesUnified";
import BackgroundDegradationTab from "./components/BackgroundDegradationTab";
@@ -93,7 +92,6 @@ export default function SettingsPage() {
{activeTab === "ai" && (
<div className="flex flex-col gap-6">
<ThinkingBudgetTab />
<CodexServiceTierTab />
<SystemPromptTab />
<CacheSettingsTab />
<MemorySkillsTab />

View File

@@ -26,9 +26,9 @@ export default function SkillsPage() {
const [skills, setSkills] = useState<Skill[]>([]);
const [executions, setExecutions] = useState<Execution[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<"skills" | "executions" | "sandbox" | "marketplace">(
"skills"
);
const [activeTab, setActiveTab] = useState<
"skills" | "executions" | "sandbox" | "marketplace" | "skillssh"
>("skills");
const [showInstallModal, setShowInstallModal] = useState(false);
const [installJson, setInstallJson] = useState("");
const [installStatus, setInstallStatus] = useState<{
@@ -50,6 +50,13 @@ export default function SkillsPage() {
const [mpLoading, setMpLoading] = useState(false);
const [mpError, setMpError] = useState("");
const [mpInstallingId, setMpInstallingId] = useState<string | null>(null);
const [shQuery, setShQuery] = useState("");
const [shResults, setShResults] = useState<
{ id: string; skillId: string; name: string; installs: number; source: string }[]
>([]);
const [shLoading, setShLoading] = useState(false);
const [shError, setShError] = useState("");
const [shInstallingId, setShInstallingId] = useState<string | null>(null);
const t = useTranslations("skills");
useEffect(() => {
@@ -180,6 +187,58 @@ export default function SkillsPage() {
}
};
const searchSkillsSh = async () => {
setShLoading(true);
setShError("");
setShResults([]);
try {
const res = await fetch(`/api/skills/skillssh?q=${encodeURIComponent(shQuery)}`);
const data = await res.json();
if (!res.ok) {
setShError(data.error || "Search failed");
} else {
setShResults(data.skills || []);
}
} catch (err) {
setShError(err instanceof Error ? err.message : "Search failed");
} finally {
setShLoading(false);
}
};
const installFromSkillsSh = async (skill: {
id: string;
skillId: string;
name: string;
installs: number;
source: string;
}) => {
setShInstallingId(skill.id);
try {
const res = await fetch("/api/skills/skillssh/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: skill.name,
description: `Installed from skills.sh (${skill.source})`,
source: skill.source,
skillId: skill.skillId,
}),
});
const data = await res.json();
if (res.ok && data.success) {
await refreshSkills();
setShInstallingId(null);
} else {
setShError(data.error || "Install failed");
setShInstallingId(null);
}
} catch (err) {
setShError(err instanceof Error ? err.message : "Install failed");
setShInstallingId(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
@@ -244,6 +303,16 @@ export default function SkillsPage() {
>
Marketplace
</button>
<button
onClick={() => setActiveTab("skillssh")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "skillssh"
? "border-violet-500 text-violet-400"
: "border-transparent text-text-muted hover:text-text-main"
}`}
>
skills.sh
</button>
</div>
{activeTab === "skills" && (
@@ -439,6 +508,66 @@ export default function SkillsPage() {
</div>
)}
{activeTab === "skillssh" && (
<div className="grid gap-4">
<Card>
<h3 className="font-semibold mb-4">skills.sh Directory</h3>
<div className="flex gap-2 mb-4">
<input
type="text"
value={shQuery}
onChange={(e) => 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"
/>
<button
onClick={searchSkillsSh}
disabled={shLoading}
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
>
{shLoading ? "Searching..." : "Search skills.sh"}
</button>
</div>
{shError && (
<div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm mb-4">
{shError}
</div>
)}
</Card>
{shResults.length > 0 && (
<div className="grid gap-3">
{shResults.map((skill) => (
<Card key={skill.id}>
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold">{skill.name}</h4>
<p className="text-sm text-text-muted mt-1">
{skill.source} · {skill.installs.toLocaleString()} installs
</p>
</div>
<button
onClick={() => installFromSkillsSh(skill)}
disabled={shInstallingId === skill.id}
className="px-4 py-1.5 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
>
{shInstallingId === skill.id ? "Installing..." : "Install"}
</button>
</div>
</Card>
))}
</div>
)}
{!shLoading && shResults.length === 0 && !shError && (
<Card>
<div className="text-center py-8 text-text-muted">
Search the skills.sh open directory to discover and install agent skills.
</div>
</Card>
)}
</div>
)}
{showInstallModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-surface border border-border rounded-xl p-6 w-full max-w-lg mx-4">

View File

@@ -612,48 +612,70 @@ export default function ProviderLimits() {
return (
<div
key={i}
className={`flex items-center gap-1.5 min-w-[200px] shrink-0 ${
className={`flex items-center gap-1.5 shrink-0 ${
i > 0 ? "border-l border-border/80 pl-3 ml-1" : ""
}`}
>
{/* Model label */}
<span
title={q.modelKey || q.name}
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap min-w-[60px] text-center"
style={{ background: colors.bg, color: colors.text }}
>
{shortName}
</span>
{q.isCredits ? (
/* ── AI Credits counter ── */
<>
<span
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap"
style={{ background: colors.bg, color: colors.text }}
>
🪙 {formatQuotaLabel(q.name)}
</span>
<span
className="text-[12px] font-bold tabular-nums"
style={{ color: colors.text }}
>
{q.creditCount ?? q.remaining}
</span>
<span className="text-[10px] text-text-muted">left</span>
</>
) : (
/* ── Standard quota bar ── */
<>
{/* Model label */}
<span
title={q.modelKey || q.name}
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap min-w-[60px] text-center"
style={{ background: colors.bg, color: colors.text }}
>
{shortName}
</span>
{/* Countdown */}
{staleAfterReset ? (
<span className="text-[10px] text-text-muted whitespace-nowrap">
Refreshing...
</span>
) : cd ? (
<span className="text-[10px] text-text-muted whitespace-nowrap">
{cd}
</span>
) : null}
{/* Countdown */}
{staleAfterReset ? (
<span className="text-[10px] text-text-muted whitespace-nowrap">
Refreshing...
</span>
) : cd ? (
<span className="text-[10px] text-text-muted whitespace-nowrap">
{cd}
</span>
) : null}
{/* Progress bar */}
<div className="flex-1 h-1.5 rounded-sm bg-black/[0.06] dark:bg-white/[0.06] min-w-[60px] overflow-hidden">
<div
className="h-full rounded-sm transition-[width] duration-300 ease-out"
style={{
width: `${Math.min(remainingPercentage, 100)}%`,
background: colors.bar,
}}
/>
</div>
{/* Progress bar */}
<div className="flex-1 h-1.5 rounded-sm bg-black/[0.06] dark:bg-white/[0.06] min-w-[60px] overflow-hidden">
<div
className="h-full rounded-sm transition-[width] duration-300 ease-out"
style={{
width: `${Math.min(remainingPercentage, 100)}%`,
background: colors.bar,
}}
/>
</div>
{/* Percentage */}
<span
className="text-[11px] font-semibold min-w-[32px] text-right"
style={{ color: colors.text }}
>
{remainingPercentage}%
</span>
{/* Percentage */}
<span
className="text-[11px] font-semibold min-w-[32px] text-right"
style={{ color: colors.text }}
>
{remainingPercentage}%
</span>
</>
)}
</div>
);
})

View File

@@ -19,6 +19,8 @@ const QUOTA_LABEL_MAP: Record<string, string> = {
code_review: "Code Review",
agentic_request: "Agentic",
agentic_request_freetrial: "Agentic (Trial)",
credits: "AI Credits",
models: "Models",
};
function toRecord(value: unknown): Record<string, unknown> {
@@ -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;
}

View File

@@ -20,28 +20,62 @@ async function checkToolConfigStatus(toolId: string): Promise<string> {
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";
}

View File

@@ -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);

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}

View File

@@ -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 });
}
}

View File

@@ -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 () => {

View File

@@ -105,10 +105,11 @@ export async function registerNodejs(): Promise<void> {
}
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<void> {
}
}
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);

102
src/lib/db/AGENTS.md Normal file
View File

@@ -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 136162).
- **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 136162)
- No raw SQL in routes or non-db modules

View File

@@ -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

View File

@@ -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<string, unknown>;
@@ -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;

548
src/lib/db/healthCheck.ts Normal file
View File

@@ -0,0 +1,548 @@
import { normalizeComboStep } from "@/lib/combos/steps";
type SqliteDatabase = import("better-sqlite3").Database;
type JsonRecord = Record<string, unknown>;
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<number>();
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<Record<string, unknown>>;
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,
};
}

View File

@@ -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<string, unknown>;
@@ -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;

View File

@@ -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: {

View File

@@ -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",
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<string, unknown>;
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,
};
}

View File

@@ -0,0 +1,153 @@
type JsonRecord = Record<string, unknown>;
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<string>(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<string, unknown>,
sessionId: unknown
): Record<string, unknown> {
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 } : {}),
};
}

View File

@@ -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<typeof SkillsShSkillSchema>;
export type SkillsShSearchResponse = z.infer<typeof SkillsShSearchResponseSchema>;
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<SkillsShSearchResponse> {
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<string> {
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);
}
}

View File

@@ -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),
};

View File

@@ -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<string, unknown> = {}) {
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<string, unknown> = data?.models ?? {};
// Walk quota-based models (those with remainingFraction in quotaInfo)
let quotaModelsTotal = 0;
let quotaModelsAvailable = 0;
const modelQuotas: Record<string, { remaining: number; resetAt: string | null; limited: boolean }> = {};
for (const [modelId, rawInfo] of Object.entries(models)) {
const info = rawInfo as Record<string, unknown>;
if (info.isInternal) continue;
const quotaInfo = (info.quotaInfo as Record<string, unknown>) ?? {};
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}` };
}
}

View File

@@ -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,
}}
>
<button

View File

@@ -47,6 +47,7 @@ export const MCP_TOOL_SCOPES: Record<string, readonly McpScope[]> = {
omniroute_best_combo_for_task: ["read:combos", "read:health"],
omniroute_explain_route: ["read:health", "read:usage"],
omniroute_get_session_snapshot: ["read:usage"],
omniroute_db_health_check: ["read:health", "write:resilience"],
} as const;
// ============ Scope Groups ============

View File

@@ -121,6 +121,14 @@ export function getUpstreamTimeoutConfig(
};
}
export function getStainlessTimeoutSeconds(
env: EnvSource = process.env,
logger?: TimeoutLogger
): number {
const { fetchTimeoutMs } = getUpstreamTimeoutConfig(env, logger);
return Math.max(1, Math.ceil(fetchTimeoutMs / 1_000));
}
export function getTlsClientTimeoutConfig(
env: EnvSource = process.env,
logger?: TimeoutLogger

View File

@@ -0,0 +1,43 @@
type SidebarLikeItem = {
href: string;
exact?: boolean;
external?: boolean;
};
export function matchesSidebarHref(
pathname: string | null | undefined,
href: string,
exact = false
): boolean {
if (!pathname) return false;
if (exact) return pathname === href;
return pathname === href || pathname.startsWith(`${href}/`);
}
export function getActiveSidebarHref(
pathname: string | null | undefined,
items: SidebarLikeItem[]
): string | null {
let bestMatch: SidebarLikeItem | null = null;
for (const item of items) {
if (item.external) continue;
if (!matchesSidebarHref(pathname, item.href, item.exact === true)) continue;
if (!bestMatch) {
bestMatch = item;
continue;
}
if (item.href.length > bestMatch.href.length) {
bestMatch = item;
continue;
}
if (item.href.length === bestMatch.href.length && item.exact && !bestMatch.exact) {
bestMatch = item;
}
}
return bestMatch?.href || null;
}

View File

@@ -11,6 +11,88 @@ function isHttpUrl(value: string): boolean {
}
}
const CODEX_REASONING_EFFORT_VALUES = new Set(["none", "low", "medium", "high", "xhigh"]);
const REQUEST_DEFAULT_SERVICE_TIER_VALUES = new Set(["priority", "fast"]);
function validateProviderSpecificData(
data: Record<string, unknown> | undefined,
ctx: z.RefinementCtx
): void {
if (!data) return;
const baseUrl = data.baseUrl;
if (baseUrl !== undefined && (typeof baseUrl !== "string" || !isHttpUrl(baseUrl))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "providerSpecificData.baseUrl must be a valid http(s) URL",
path: ["baseUrl"],
});
}
const customUserAgent = data.customUserAgent;
if (
customUserAgent !== undefined &&
customUserAgent !== null &&
(typeof customUserAgent !== "string" || customUserAgent.length > 500)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "providerSpecificData.customUserAgent must be a string up to 500 chars",
path: ["customUserAgent"],
});
}
const openaiStoreEnabled = data.openaiStoreEnabled;
if (openaiStoreEnabled !== undefined && typeof openaiStoreEnabled !== "boolean") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "providerSpecificData.openaiStoreEnabled must be a boolean",
path: ["openaiStoreEnabled"],
});
}
const requestDefaults = data.requestDefaults;
if (requestDefaults === undefined) return;
if (!requestDefaults || typeof requestDefaults !== "object" || Array.isArray(requestDefaults)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "providerSpecificData.requestDefaults must be an object",
path: ["requestDefaults"],
});
return;
}
const requestDefaultsRecord = requestDefaults as Record<string, unknown>;
const reasoningEffort = requestDefaultsRecord.reasoningEffort;
if (
reasoningEffort !== undefined &&
reasoningEffort !== null &&
(typeof reasoningEffort !== "string" ||
!CODEX_REASONING_EFFORT_VALUES.has(reasoningEffort.trim().toLowerCase()))
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"providerSpecificData.requestDefaults.reasoningEffort must be one of none, low, medium, high, xhigh",
path: ["requestDefaults", "reasoningEffort"],
});
}
const serviceTier = requestDefaultsRecord.serviceTier;
if (
serviceTier !== undefined &&
serviceTier !== null &&
(typeof serviceTier !== "string" ||
!REQUEST_DEFAULT_SERVICE_TIER_VALUES.has(serviceTier.trim().toLowerCase()))
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "providerSpecificData.requestDefaults.serviceTier must be priority when provided",
path: ["requestDefaults", "serviceTier"],
});
}
}
// Re-export validation helpers from dedicated module to avoid webpack barrel-file
// optimization bug that truncates exports from large files.
export { validateBody, isValidationFailure } from "./helpers";
@@ -30,27 +112,7 @@ export const createProviderSchema = z.object({
.record(z.string(), z.unknown())
.optional()
.superRefine((data, ctx) => {
if (!data) return;
const baseUrl = data.baseUrl;
if (baseUrl !== undefined && (typeof baseUrl !== "string" || !isHttpUrl(baseUrl))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "providerSpecificData.baseUrl must be a valid http(s) URL",
path: ["baseUrl"],
});
}
const customUserAgent = data.customUserAgent;
if (
customUserAgent !== undefined &&
customUserAgent !== null &&
(typeof customUserAgent !== "string" || customUserAgent.length > 500)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "providerSpecificData.customUserAgent must be a string up to 500 chars",
path: ["customUserAgent"],
});
}
validateProviderSpecificData(data, ctx);
}),
});
@@ -643,12 +705,6 @@ export const updateThinkingBudgetSchema = z
}
});
export const updateCodexServiceTierSchema = z
.object({
enabled: z.boolean(),
})
.strict();
const ipFilterModeSchema = z.enum(["blacklist", "whitelist"]);
const tempBanSchema = z.object({
ip: z.string().trim().min(1),
@@ -1101,27 +1157,7 @@ export const updateProviderConnectionSchema = z
.record(z.string(), z.unknown())
.optional()
.superRefine((data, ctx) => {
if (!data) return;
const baseUrl = data.baseUrl;
if (baseUrl !== undefined && (typeof baseUrl !== "string" || !isHttpUrl(baseUrl))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "providerSpecificData.baseUrl must be a valid http(s) URL",
path: ["baseUrl"],
});
}
const customUserAgent = data.customUserAgent;
if (
customUserAgent !== undefined &&
customUserAgent !== null &&
(typeof customUserAgent !== "string" || customUserAgent.length > 500)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "providerSpecificData.customUserAgent must be a string up to 500 chars",
path: ["customUserAgent"],
});
}
validateProviderSpecificData(data, ctx);
}),
})
.superRefine((value, ctx) => {

View File

@@ -88,5 +88,8 @@ export const updateSettingsSchema = z.object({
skillsmpApiKey: z.string().max(200).optional(),
// models.dev sync settings
modelsDevSyncEnabled: z.boolean().optional(),
modelsDevSyncInterval: z.number().int().min(3600).max(604800).optional(),
modelsDevSyncInterval: z.number().int().min(1000).max(604800000).optional(),
// Missing settings
lkgpEnabled: z.boolean().optional(),
backgroundDegradation: z.unknown().optional(),
});

View File

@@ -21,6 +21,10 @@ import { checkAndRefreshToken } from "../services/tokenRefresh";
import { deleteHandoff, getHandoff } from "@/lib/db/contextHandoffs";
import { getSettings, getCombos } from "@/lib/localDb";
import { sanitizeRequest } from "../../shared/utils/inputSanitizer";
import {
ensureOpenAIStoreSessionFallback,
isOpenAIResponsesStoreEnabled,
} from "@/lib/providers/requestDefaults";
import {
resolveModelOrError,
checkPipelineGates,
@@ -549,6 +553,12 @@ async function handleSingleModelChat(
}
}
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
const storeEnabled = isOpenAIResponsesStoreEnabled(
refreshedCredentials?.providerSpecificData ?? credentials?.providerSpecificData
);
if (provider === "codex" && storeEnabled && runtimeOptions.sessionId) {
requestBody = ensureOpenAIStoreSessionFallback(requestBody, runtimeOptions.sessionId);
}
if (provider === "codex" && refreshedCredentials?.accessToken && credentials.connectionId) {
const workspaceId =
typeof refreshedCredentials?.providerSpecificData?.workspaceId === "string" &&

View File

@@ -140,7 +140,7 @@ test.describe("Combo Unification", () => {
test.skip(page.url().includes("/login"), "Authentication enabled without a login fixture.");
await expect(page.getByRole("button", { name: /all/i })).toBeVisible();
await expect(page.getByRole("button", { name: /^layers all$/i })).toBeVisible();
await expect(page.getByRole("button", { name: /intelligent/i })).toBeVisible();
await expect(page.getByRole("button", { name: /deterministic/i })).toBeVisible();
await expect(page.getByText("Intelligent Routing Dashboard")).toBeVisible();

Some files were not shown because too many files have changed in this diff Show More