mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 22:32:12 +03:00
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:
17
.env.example
17
.env.example
@@ -335,7 +335,21 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
|
||||
# ── Qoder ──
|
||||
QODER_OAUTH_CLIENT_SECRET=4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW
|
||||
|
||||
# ── Qoder (URLs — set these to enable Qoder OAuth login) ──
|
||||
# ── Qoder Browser OAuth (experimental) ──
|
||||
# OmniRoute only enables the browser OAuth flow when ALL 5 variables below are set:
|
||||
# - QODER_OAUTH_AUTHORIZE_URL
|
||||
# - QODER_OAUTH_TOKEN_URL
|
||||
# - QODER_OAUTH_USERINFO_URL
|
||||
# - QODER_OAUTH_CLIENT_ID
|
||||
# - QODER_OAUTH_CLIENT_SECRET
|
||||
#
|
||||
# Redirect URI to register in the Qoder OAuth app:
|
||||
# - Localhost dev with PORT=20128: http://localhost:20128/callback
|
||||
# - LAN access (example): http://192.168.0.15:20128/callback
|
||||
# - Public domain (recommended): https://omniroute.example.com/callback
|
||||
#
|
||||
# Behind reverse proxy / public domain, also set NEXT_PUBLIC_BASE_URL to the same public origin.
|
||||
# If these values are not available, prefer QODER_PERSONAL_ACCESS_TOKEN below.
|
||||
# QODER_OAUTH_AUTHORIZE_URL=
|
||||
# QODER_OAUTH_TOKEN_URL=
|
||||
# QODER_OAUTH_USERINFO_URL=
|
||||
@@ -449,6 +463,7 @@ GEMINI_CLI_USER_AGENT=google-api-nodejs-client/9.15.1
|
||||
|
||||
# ── Upstream fetch (provider calls) ──
|
||||
# FETCH_TIMEOUT_MS=600000 # Total request timeout (default: 600000 = 10 min)
|
||||
# # Also drives anthropic-compatible-cc-* X-Stainless-Timeout.
|
||||
# FETCH_HEADERS_TIMEOUT_MS=600000 # Time to receive response headers
|
||||
# FETCH_BODY_TIMEOUT_MS=600000 # Time to receive full response body
|
||||
# FETCH_CONNECT_TIMEOUT_MS=30000 # TCP connection establishment (default: 30s)
|
||||
|
||||
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -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
161
AGENTS.md
@@ -64,16 +64,11 @@ npm run test:protocols:e2e
|
||||
# Ecosystem compatibility tests
|
||||
npm run test:ecosystem
|
||||
|
||||
# Coverage (60% minimum for statements, lines, functions, and branches)
|
||||
# Coverage (see CONTRIBUTING.md)
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
### PR Coverage Policy
|
||||
|
||||
- `npm run test:coverage` is the PR coverage gate in CI.
|
||||
- The repository minimum is **60%** for statements, lines, functions, and branches.
|
||||
- If a PR changes production code in `src/`, `open-sse/`, `electron/`, or `bin/`, it must include or update automated tests in the same PR.
|
||||
- For agent-driven review or coding flows: if coverage is below the gate or source changes ship without tests, do not stop at reporting. Add or update tests first, rerun the gate, and only then ask for confirmation.
|
||||
**For authoritative coverage requirements, test execution, and PR gates, see [`CONTRIBUTING.md`](CONTRIBUTING.md#running-tests).**
|
||||
|
||||
---
|
||||
|
||||
@@ -141,9 +136,69 @@ All persistence uses SQLite through domain-specific modules:
|
||||
Schema migrations live in `db/migrations/` and run via `migrationRunner.ts`.
|
||||
`src/lib/localDb.ts` is a **re-export layer only** — never add logic there.
|
||||
|
||||
#### DB Internals
|
||||
|
||||
- **`core.ts`**: `getDbInstance()` returns a singleton `better-sqlite3` instance with WAL
|
||||
journaling. `SCHEMA_SQL` defines 15 base tables. Helpers: `rowToCamel`, `encryptConnectionFields`.
|
||||
- **`migrationRunner.ts`**: Applies versioned SQL files from `db/migrations/` inside transactions.
|
||||
Tracks applied migrations in `_omniroute_migrations` table.
|
||||
- **Migrations**: 21 files (`001_initial_schema.sql` → `021_combo_call_log_targets.sql`).
|
||||
Each migration is idempotent and runs in a transaction.
|
||||
- **Domain modules** import `getDbInstance()` from `core.ts` for all CRUD operations.
|
||||
Each module owns a specific table/set of tables (e.g., `providers.ts` → `provider_connections`,
|
||||
`combos.ts` → `combos`). Encryption helpers protect sensitive fields at rest.
|
||||
- **`localDb.ts`** re-exports all domain modules — consumers import from here for convenience.
|
||||
|
||||
### API Route Layer (`src/app/api/v1/`)
|
||||
|
||||
Next.js App Router routes — each follows a consistent pattern:
|
||||
|
||||
```
|
||||
Route → CORS preflight → Body validation (Zod) → Optional auth (extractApiKey/isValidApiKey)
|
||||
→ API key policy enforcement (enforceApiKeyPolicy) → Handler delegation (open-sse)
|
||||
```
|
||||
|
||||
| Route | Handler | Notes |
|
||||
| ------------------------------- | ------------------------- | ----------------------------------------- |
|
||||
| `chat/completions/route.ts` | `handleChat()` | + prompt injection guard (clones request) |
|
||||
| `responses/route.ts` | `handleChat()` (unified) | Responses API format |
|
||||
| `embeddings/route.ts` | `handleEmbedding()` | Model listing + creation |
|
||||
| `images/generations/route.ts` | `handleImageGeneration()` | Model listing + creation |
|
||||
| `audio/transcriptions/route.ts` | audio handler | Multipart form data |
|
||||
| `audio/speech/route.ts` | TTS handler | Binary audio response |
|
||||
| `videos/generations/route.ts` | video handler | ComfyUI/SD WebUI |
|
||||
| `music/generations/route.ts` | music handler | ComfyUI workflows |
|
||||
| `moderations/route.ts` | moderation handler | Content safety |
|
||||
| `rerank/route.ts` | rerank handler | Document relevance |
|
||||
| `search/route.ts` | search handler | Web search (5 providers) |
|
||||
|
||||
**No global Next.js middleware file** — interception is route-specific. Auth is optional
|
||||
(controlled by `REQUIRE_API_KEY` env). Prompt injection guard is unique to chat completions.
|
||||
|
||||
### Request Pipeline (`open-sse/`)
|
||||
|
||||
`chatCore.ts` → executor → upstream provider. Translations in `open-sse/translator/`.
|
||||
The `open-sse/` workspace is the core streaming engine. Full request flow:
|
||||
|
||||
```
|
||||
Client Request
|
||||
→ src/app/api/v1/.../route.ts (Next.js route)
|
||||
→ open-sse/handlers/chatCore.ts::handleChatCore()
|
||||
→ Semantic/signature cache check
|
||||
→ Rate limit check (rateLimitManager)
|
||||
→ Combo routing? → open-sse/services/combo.ts::handleComboChat()
|
||||
→ resolveComboTargets() → ordered ResolvedComboTarget[]
|
||||
→ For each target: handleSingleModel() (wraps chatCore)
|
||||
→ translateRequest() (open-sse/translator/)
|
||||
→ Convert source format (e.g., OpenAI) → target format (e.g., Claude)
|
||||
→ getExecutor() → provider-specific executor instance
|
||||
→ executor.execute() (BaseExecutor → DefaultExecutor or provider-specific)
|
||||
→ buildUrl() + buildHeaders() + transformRequest()
|
||||
→ fetch() to upstream provider
|
||||
→ Retry logic with exponential backoff
|
||||
→ Response translation back to client format
|
||||
→ If Responses API: responsesTransformer.ts TransformStream
|
||||
→ SSE stream or JSON response to client
|
||||
```
|
||||
|
||||
**Handlers** (`open-sse/handlers/`): `chatCore.ts`, `responsesHandler.ts`, `embeddings.ts`,
|
||||
`imageGeneration.ts`, `videoGeneration.ts`, `musicGeneration.ts`, `audioSpeech.ts`,
|
||||
@@ -180,15 +235,46 @@ Provider-specific request executors: `base.ts`, `default.ts`, `cursor.ts`, `code
|
||||
`antigravity.ts`, `github.ts`, `gemini-cli.ts`, `kiro.ts`, `qoder.ts`, `vertex.ts`,
|
||||
`cloudflare-ai.ts`, `opencode.ts`, `pollinations.ts`, `puter.ts`.
|
||||
|
||||
#### Executor Internals
|
||||
|
||||
- **`base.ts`** (`BaseExecutor`): Abstract base with `buildUrl()`, `buildHeaders()`,
|
||||
`transformRequest()`, retry logic (exponential backoff), and `execute()`. Subclasses
|
||||
override URL/header/transform methods for provider-specific behavior.
|
||||
- **`default.ts`** (`DefaultExecutor extends BaseExecutor`): Handles most OpenAI-compatible
|
||||
providers. Reads provider config from `providerRegistry.ts` to resolve base URL, auth
|
||||
header format, and request transformations.
|
||||
- **`getExecutor()`** (`executors/index.ts`): Factory that returns the correct executor
|
||||
instance based on provider ID. Provider-specific executors (Cursor, Codex, Vertex, etc.)
|
||||
override only what differs from the default.
|
||||
|
||||
### Translator (`open-sse/translator/`)
|
||||
|
||||
Translates between API formats (OpenAI-format ↔ Anthropic, Gemini, etc.).
|
||||
Includes request/response translators with helpers for image handling.
|
||||
|
||||
#### Translator Internals
|
||||
|
||||
- **`translator/index.ts`**: Exports `translateRequest()` and format constants. Called by
|
||||
`chatCore.ts` before executor dispatch.
|
||||
- **Flow**: `translateRequest(body, sourceFormat, targetFormat)` → detects source format
|
||||
(OpenAI, Anthropic, Gemini) → applies the matching translator module → returns
|
||||
transformed body ready for the target provider.
|
||||
- **Response translation** runs in reverse after upstream response, converting back to
|
||||
the client's expected format.
|
||||
|
||||
### Transformer (`open-sse/transformer/`)
|
||||
|
||||
`responsesTransformer.ts` — transforms Responses API format to/from Chat Completions format.
|
||||
|
||||
#### Transformer Internals
|
||||
|
||||
- **`createResponsesApiTransformStream()`**: Returns a `TransformStream` that converts
|
||||
Chat Completions SSE chunks (`data: {"choices":[...]}`) into Responses API SSE events
|
||||
(`response.output_item.added`, `response.output_text.delta`, etc.).
|
||||
- Used when the client sends a Responses API request: the request is internally converted
|
||||
to Chat Completions format, dispatched normally, and the response is piped through this
|
||||
transform stream before reaching the client.
|
||||
|
||||
### Services (`open-sse/services/`)
|
||||
|
||||
36+ service modules including: `combo.ts` (routing engine), `usage.ts`, `tokenRefresh.ts`,
|
||||
@@ -198,6 +284,17 @@ Includes request/response translators with helpers for image handling.
|
||||
`emergencyFallback.ts`, `workflowFSM.ts`, `backgroundTaskDetector.ts`, `ipFilter.ts`,
|
||||
`signatureCache.ts`, `volumeDetector.ts`, `contextHandoff.ts`, and more.
|
||||
|
||||
#### Combo Routing Engine (`combo.ts`)
|
||||
|
||||
- **`handleComboChat()`**: Entry point for combo-routed requests. Receives the combo config
|
||||
and iterates through targets in order until one succeeds or all fail.
|
||||
- **`resolveComboTargets()`**: Expands a combo configuration into an ordered array of
|
||||
`ResolvedComboTarget[]`, each specifying provider + model + account + credentials.
|
||||
- **Strategies** (13): priority, weighted, fill-first, round-robin, P2C, random, least-used,
|
||||
cost-optimized, strict-random, auto, lkgp, context-optimized, context-relay.
|
||||
- Each target calls **`handleSingleModel()`** which wraps `handleChatCore()` with
|
||||
per-target error handling and circuit breaker checks.
|
||||
|
||||
### Domain Layer (`src/domain/`)
|
||||
|
||||
Policy engine modules: `policyEngine.ts`, `comboResolver.ts`, `costRules.ts`,
|
||||
@@ -217,12 +314,37 @@ best_combo_for_task, explain_route, get_session_snapshot, sync_pricing.
|
||||
|
||||
**Skill tools** (4): skills_list, skills_enable, skills_execute, skills_executions.
|
||||
|
||||
#### MCP Internals
|
||||
|
||||
- **Tool registration**: Each tool is an object with `{ name, description, inputSchema: ZodSchema,
|
||||
handler: async (args) => {...} }`. Zod validates inputs before the handler fires.
|
||||
- **`createMcpServer()`** and **`startMcpStdio()`** exported from `mcp-server/index.ts`.
|
||||
`createMcpServer()` wires all tool sets; `startMcpStdio()` launches the stdio transport.
|
||||
- **Transports**: stdio (CLI `omniroute --mcp`), SSE (`/api/mcp/sse`), Streamable HTTP
|
||||
(`/api/mcp/stream`). All share the same tool/scope engine.
|
||||
- **Scopes** (10): Control which tool categories an API key can access. Enforcement happens
|
||||
before handler dispatch.
|
||||
- **Audit**: Every tool invocation is logged to SQLite (`mcp_audit` table) with tool name,
|
||||
args, success/failure, API key attribution, and timestamp.
|
||||
|
||||
### A2A Server (`src/lib/a2a/`)
|
||||
|
||||
JSON-RPC 2.0, SSE streaming, Task Manager with TTL cleanup(
|
||||
JSON-RPC 2.0, SSE streaming, Task Manager with TTL cleanup.
|
||||
Agent Card at `/.well-known/agent.json`.
|
||||
Skills: `quotaManagement.ts`, `smartRouting.ts`.
|
||||
|
||||
#### A2A Internals
|
||||
|
||||
- **`taskManager.ts`**: State machine lifecycle for tasks: `submitted → working →
|
||||
completed | failed | canceled`. Tasks have TTL and are cleaned up automatically.
|
||||
- **JSON-RPC methods**: `message/send` (sync), `message/stream` (SSE), `tasks/get`,
|
||||
`tasks/cancel`. Dispatched via `POST /a2a`.
|
||||
- **Skills**: Registered in a DB-backed registry. Each skill receives task context
|
||||
(messages, metadata) and returns structured results. `quotaManagement.ts` summarizes
|
||||
quota; `smartRouting.ts` recommends routing decisions.
|
||||
- **Agent Card**: `/.well-known/agent.json` exposes capabilities, skills, and metadata
|
||||
for client auto-discovery.
|
||||
|
||||
### ACP Module (`src/lib/acp/`)
|
||||
|
||||
Agent Communication Protocol registry and manager.
|
||||
@@ -237,6 +359,19 @@ conversational memory across sessions.
|
||||
Extensible skill framework: registry, executor, sandbox, built-in skills,
|
||||
custom skill support, interception, and injection.
|
||||
|
||||
#### Skills Internals
|
||||
|
||||
- **`registry.ts`**: DB-backed skill registration and discovery. Skills have metadata
|
||||
(name, description, version, enabled status) stored in SQLite.
|
||||
- **`executor.ts`**: Execution engine with configurable timeout and retry logic.
|
||||
Receives skill name + input, looks up the skill, runs it in the sandbox.
|
||||
- **`sandbox.ts`**: Isolation layer for custom (user-provided) skills. Limits resource
|
||||
access and execution time.
|
||||
- **Built-in skills**: Ship with OmniRoute (e.g., quota management, routing). Located
|
||||
alongside the registry.
|
||||
- **Interception/Injection**: Skills can intercept requests in the pipeline (pre/post
|
||||
processing) or inject context into prompts.
|
||||
|
||||
### Compliance (`src/lib/compliance/`)
|
||||
|
||||
Policy index for compliance enforcement.
|
||||
@@ -259,6 +394,14 @@ Request middleware including `promptInjectionGuard.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Subdirectory AGENTS.md Files
|
||||
|
||||
- **[`open-sse/AGENTS.md`](open-sse/AGENTS.md)** — Streaming engine, request pipeline, handlers, and executors
|
||||
- **[`src/lib/db/AGENTS.md`](src/lib/db/AGENTS.md)** — SQLite persistence, domain modules, migrations
|
||||
- **[`open-sse/services/AGENTS.md`](open-sse/services/AGENTS.md)** — Routing engine, combo resolution, strategy selection
|
||||
|
||||
---
|
||||
|
||||
## Review Focus
|
||||
|
||||
- **DB ops** go through `src/lib/db/` modules, never raw SQL in routes
|
||||
|
||||
48
CHANGELOG.md
48
CHANGELOG.md
@@ -4,6 +4,54 @@
|
||||
|
||||
---
|
||||
|
||||
## [3.6.5] — 2026-04-13
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Antigravity AI Credits Fallback:** Automatically retries with `GOOGLE_ONE_AI` credit injection when free-tier quota is exhausted. Per-account credit balance (5-hour TTL) is cached from SSE `remainingCredits` and exposed as a numeric badge in the Provider Usage dashboard (#1190 — thanks @sFaxsy)
|
||||
- **Claude Code Native Parity:** Full header/body signing parity with the Claude Code 2.1.87 OAuth client — CCH xxHash64 body signing with singleton WASM initialization promise (fixing race conditions), dynamic per-request fingerprint, bidirectional TitleCase ↔ lowercase tool name remapping (14 tools), API constraint enforcement (`temperature=1` for thinking, max 4 `cache_control` blocks, auto-inject ephemeral on last user message), and optional ZWJ obfuscation. Wired into `BaseExecutor` for automatic CCH signing on all `anthropic-compatible-cc-*` providers and into `chatCore` for synchronous parity pipeline steps (#1188 — thanks @RaviTharuma)
|
||||
- **Per-Connection Codex Defaults:** Codex Fast Service Tier and Reasoning Effort settings are now per-connection instead of a single global toggle. Existing connections are migrated automatically on startup via an idempotent backfill migration (#1176 — thanks @rdself)
|
||||
- **Cursor Usage Dashboard:** New `getCursorUsage()` fetches quotas from Cursor's `/api/usage`, `/api/auth/me`, and `/api/subscription` endpoints. Displays standard requests, on-demand usage, and per-plan limits (Free/Pro/Business/Team). Client version bumped to `3.1.0` and `x-cursor-user-agent` header added for parity
|
||||
- **Database Health Check System:** Automated periodic SQLite integrity monitoring via `runDbHealthCheck()` — detects orphan quota/domain rows, broken combo references, stale snapshots, and invalid JSON state. Runs every 6 hours (configurable via `OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS`), with auto-repair and pre-repair backup. Exposed as **MCP tool #18** (`omniroute_db_health_check`) with Zod schemas and `autoRepair` option. Dashboard panel in Health page with status card, issue count, repaired count, and one-click repair button
|
||||
- **OpenAI Responses API Store Opt-In:** Per-connection `openaiStoreEnabled` flag controls whether the `store` field is preserved or forced to `false` on Codex Responses API requests. When enabled, `previous_response_id`, `prompt_cache_key`, `session_id`, and `conversation_id` fields are round-tripped through the Chat Completions → Responses translation, enabling multi-turn context caching on supported providers
|
||||
- **Email Privacy Toggle (Combos Page):** Global email visibility toggle (`EmailPrivacyToggle`) added to the Combos page header with responsive layout, tooltip guidance, and per-connection label masking via `pickDisplayValue()`. All combo builder options, provider connection lists, and quota screens now respect the global privacy state from `emailPrivacyStore`
|
||||
- **skills.sh Integration:** Added `skills.sh` as an external skill provider. Users can now search, browse, and install agent skills directly from a new "skills.sh" tab in the Skills dashboard. Includes backend API resolvers, frontend implementation with search/install states, and a dedicated unit test suite (#1223 — thanks @RaviTharuma)
|
||||
- **Stabilization Settings:** Added persistence support for `lkgpEnabled` and `backgroundDegradation` settings, integrated into `instrumentation-node.ts` for improved lifecycle awareness (#1212)
|
||||
- **xxhash-wasm dependency:** Added `xxhash-wasm@^1.1.0` for CCH signing (xxHash64 with seed `0x6E52736AC806831E`)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Codex `stream: false` via Combo (ALL_ACCOUNTS_INACTIVE):** Fixed a critical bug where Codex combos returned `ALL_ACCOUNTS_INACTIVE` or empty content when the client sent `stream: false`. Root cause was triple: (1) `CodexExecutor.transformRequest()` mutated `body.stream` in-place to `true`, contaminating the combo's quality check which skipped validation thinking it was streaming; (2) the non-stream SSE parser used the wrong format (Chat Completions instead of Responses API) for Codex SSE output; (3) combo quality validation read the mutated `body.stream` instead of the client's original intent. Fixed by: cloning the body via `structuredClone()` in CodexExecutor, detecting Codex/Responses SSE format in the non-stream fallback path (with auto-translation back to Chat Completions), and capturing `clientRequestedStream` before the combo loop
|
||||
- **Gemini CLI Tool Schema Rejection:** Fixed 400 Bad Request errors from the Google API by strictly filtering non-standard vendor extensions (starting with `x-`) and `deprecated` fields from tool parameter schemas (#1206)
|
||||
- **SOCKS5 Proxy Interop (Node.js 22):** Resolved `invalid onRequestStart method` crashes caused by `undici` version mismatches between dispatchers and the built-in fetch. Hardened `proxyFetch.ts` to strictly use the library's fetch implementation for custom dispatchers (#1219)
|
||||
- **Search Cache Coalescing with TTL=0:** Fixed a bug where providers configured with `cacheTTLMs: 0` (caching explicitly disabled) still had concurrent requests coalesced and returned `{ cached: true }`. Now each call gets its own independent upstream fetch (#1178 — thanks @sjhddh)
|
||||
- **Antigravity Credit Cache Alignment (PR #1190):** Reconciled `accountId` derivation between `AntigravityExecutor.collectStreamToResponse` and `getAntigravityUsage` to use consistent cache keys (`email || sub || "unknown"`). Previously, SSE-parsed credit balances could be written under a different key than the one read by the usage dashboard, causing stale/missing credit badges
|
||||
- **Non-streaming reasoning_content Duplication:** Fixed clients rendering duplicated reasoning panels when both `reasoning_content` and visible `content` were present in non-streaming responses. `responseSanitizer` now strips `reasoning_content` from messages that already have visible text content, preserving it only for reasoning-only messages
|
||||
- **Streaming Regression Fix:** Hardened the `sanitize` TransformStream in the combo engine to strip both literal and JSON-escaped newline sequences, eliminating leading `\n\n` prefixes in assistant responses (#1211)
|
||||
- **Gemini Empty Choice Fix:** Ensured initial assistant deltas always include an empty `content: ""` string to satisfy strict OpenAI client requirements and prevent empty choice responses in tools (#1209)
|
||||
- **Gemini Tools Sanitizer Deduplication:** Extracted shared tool conversion logic into `buildGeminiTools()` helper (`geminiToolsSanitizer.ts`), eliminating duplicate implementations between `openai-to-gemini.ts` and `claude-to-gemini.ts`. The new helper correctly handles `web_search` / `web_search_preview` tool types by emitting `googleSearch` tools with priority over function declarations
|
||||
- **Qwen/Qoder Thinking+Tool_Choice Conflict:** Added `sanitizeQwenThinkingToolChoice()` to both `DefaultExecutor` (for Qwen provider) and `QoderExecutor` to prevent provider-side 400 errors when clients send `tool_choice` alongside thinking/reasoning parameters that are mutually exclusive upstream
|
||||
- **API Key Deletion Orphan Cleanup:** Deleting an API key now also removes associated `domain_budgets` and `domain_cost_history` rows, preventing orphan data accumulation
|
||||
- **CC-compatible test assertion:** Fixed pre-existing test that expected no `cache_control` on system blocks — the billing header system block now carries `cache_control: { type: "ephemeral" }` per PR #1188 design
|
||||
- **Codex Combo Smoke Test False Positives:** Fixed combo tests incorrectly reporting `ERROR` for valid Codex streaming responses when `response.output` is empty but text deltas were emitted. The summary now falls back to accumulated delta text (#1176 — thanks @rdself)
|
||||
- **Electron Builder Version Mismatch:** Fixed Electron desktop startup failures on Windows packaged builds caused by native modules (`better-sqlite3`) being under `app.asar.unpacked` while helpers were in `app/node_modules`. `resolveServerNodePath()` now merges both locations with deduplication and existence checks (#1172 — thanks @backryun)
|
||||
|
||||
### 🔧 Internal Improvements
|
||||
|
||||
- **SSE Parser: Responses API Non-Stream Conversion:** Added full `parseSSEToResponsesOutput()` implementation in `sseParser.ts` (255+ lines) — reconstructs complete Responses API objects from SSE event streams, handling `response.output_text.delta/done`, `response.reasoning_summary_text.delta/done`, `response.function_call_arguments.delta/done`, and terminal events. Used by the new chatCore non-stream fallback path for Codex
|
||||
- **Cursor Executor Version Sync:** Updated Cursor client User-Agent to `3.1.0` and centralized version constants (`CURSOR_CLIENT_VERSION`, `CURSOR_USER_AGENT`) for consistent fingerprinting across executor, usage fetcher, and OAuth flows
|
||||
- **Responses API Translator Parity:** `convertResponsesApiFormat()` now accepts credentials and passes them through to the translator, enabling store-aware field propagation. Round-trip preservation of `previous_response_id`, `prompt_cache_key`, `session_id`, and `conversation_id` fields
|
||||
- **Provider Schema Validation:** Added `openaiStoreEnabled` boolean validation to `providerSpecificData` Zod schema
|
||||
- **Combo Error Response Normalization:** Empty combo targets now return 404 (`comboModelNotFoundResponse`) instead of generic 503, improving client-side error differentiation
|
||||
- **Dependency Updates:** Bumps `typescript-eslint` to `8.58.2` (dev), `axios` to `1.15.0` (prod), and `next` to `16.2.2` (prod) (#1224, #1225)
|
||||
|
||||
### ⚠️ Breaking Changes
|
||||
|
||||
- **`DELETE /api/settings/codex-service-tier` removed:** This endpoint no longer exists. Codex Service Tier configuration has moved to per-connection `providerSpecificData.requestDefaults`. Existing connections are migrated automatically on first startup after upgrade. Any external scripts or integrations that call this endpoint should be updated — use `PUT /api/providers/:id` with `providerSpecificData.requestDefaults.serviceTier` instead (#1176).
|
||||
- **CCH signing on CC-compatible providers:** All requests to `anthropic-compatible-cc-*` providers now include an xxHash64 integrity token (`cch=...`) in the billing header. Providers that do not validate CCH will ignore it (no behavioral change), but any custom middleware inspecting the billing header should expect a 5-character hex token instead of the `00000` placeholder
|
||||
|
||||
---
|
||||
|
||||
## [3.6.4] — 2026-04-12
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
225
CLAUDE.md
Normal file
225
CLAUDE.md
Normal 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)
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute-desktop",
|
||||
"version": "3.6.4",
|
||||
"version": "3.6.5",
|
||||
"description": "OmniRoute Desktop Application",
|
||||
"main": "main.js",
|
||||
"author": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || {},
|
||||
};
|
||||
|
||||
70
open-sse/mcp-server/__tests__/dbHealthTool.test.ts
Normal file
70
open-sse/mcp-server/__tests__/dbHealthTool.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,9 @@ export {
|
||||
getSessionSnapshotInput,
|
||||
getSessionSnapshotOutput,
|
||||
getSessionSnapshotTool,
|
||||
dbHealthCheckInput,
|
||||
dbHealthCheckOutput,
|
||||
dbHealthCheckTool,
|
||||
cacheStatsInput,
|
||||
cacheStatsOutput,
|
||||
cacheStatsTool,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* OmniRoute MCP Advanced Tools — 10 intelligence tools that differentiate
|
||||
* OmniRoute MCP Advanced Tools — 11 intelligence tools that differentiate
|
||||
* OmniRoute from all other AI gateways.
|
||||
*
|
||||
* Tools:
|
||||
@@ -12,7 +12,8 @@
|
||||
* 7. omniroute_best_combo_for_task — AI-powered combo recommendation
|
||||
* 8. omniroute_explain_route — Post-hoc routing decision explainer
|
||||
* 9. omniroute_get_session_snapshot — Full session state snapshot
|
||||
* 10. omniroute_sync_pricing — Sync provider pricing from external source
|
||||
* 10. omniroute_db_health_check — Diagnose and repair DB state drift
|
||||
* 11. omniroute_sync_pricing — Sync provider pricing from external source
|
||||
*/
|
||||
|
||||
import { logToolCall } from "../audit.ts";
|
||||
@@ -863,3 +864,33 @@ export async function handleGetSessionSnapshot() {
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDbHealthCheck(args: { autoRepair?: boolean }) {
|
||||
const start = Date.now();
|
||||
const autoRepair = args.autoRepair === true;
|
||||
|
||||
try {
|
||||
const result = toRecord(
|
||||
await apiFetch("/api/v1/db/health", {
|
||||
method: autoRepair ? "POST" : "GET",
|
||||
})
|
||||
);
|
||||
|
||||
await logToolCall(
|
||||
"omniroute_db_health_check",
|
||||
args,
|
||||
{
|
||||
isHealthy: toBoolean(result.isHealthy, false),
|
||||
repairedCount: toNumber(result.repairedCount, 0),
|
||||
},
|
||||
Date.now() - start,
|
||||
true
|
||||
);
|
||||
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await logToolCall("omniroute_db_health_check", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
134
open-sse/services/AGENTS.md
Normal 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 (5–20 targets)
|
||||
- **Rate limit checks**: <1ms (in-memory token bucket)
|
||||
- **Model family fallback**: <5ms (cached family definitions)
|
||||
- **Request routing dispatch**: <2ms (hot path, pre-computed strategy dispatch)
|
||||
- **No blocking I/O** in routing hot path — all async, no awaits on DB queries outside context injection
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- ❌ Synchronous DB calls in `combo.ts` hot path — pre-compute and cache
|
||||
- ❌ Retry logic in handlers; use `retry()` from resilience service
|
||||
- ❌ Direct provider config access; use `providerRegistry` getter functions
|
||||
- ❌ Hardcoded fallback chains; define in `modelFamilyFallback.ts` instead
|
||||
- ❌ State mutations across concurrent requests; use request-scoped context only
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Service
|
||||
|
||||
1. Create `open-sse/services/[serviceName].ts` with clear responsibilities
|
||||
2. Export main handler function and any constants
|
||||
3. Add unit tests in `tests/unit/services/[serviceName].test.mjs`
|
||||
4. Integrate into request pipeline in `handlers/chatCore.ts` (if routing-related) or expose via combo.ts
|
||||
5. Update routing logic in `combo.ts` if service affects target selection or fallback
|
||||
6. Document in this file (table, key decisions section)
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- **Combo-first design**: All routing decisions go through combo engine; fallback strategies are combo targets, not ad-hoc logic
|
||||
- **Service composition**: Small focused modules; combo.ts orchestrates them, not monolithic routing
|
||||
- **Circuit breaker per-target**: Failures isolated to specific provider+account combo; other targets unaffected
|
||||
- **Caching everywhere**: Models, providers, quotas, family fallbacks all pre-cached; invalidated on write
|
||||
- **13 strategies** over hardcoded logic: Strategy pattern allows new routing logic without touching combo.ts core
|
||||
|
||||
---
|
||||
|
||||
## Review Focus
|
||||
|
||||
- New services must not add blocking I/O to routing hot path
|
||||
- Combo target resolution under 10ms (measure with benchmarks)
|
||||
- Circuit breaker state per-target, not global
|
||||
- All fallback chains tested (no infinite loops)
|
||||
- Coverage requirements: See [`CONTRIBUTING.md#running-tests`](../../CONTRIBUTING.md#running-tests) (60% gate enforced in CI)
|
||||
173
open-sse/services/antigravity429Engine.ts
Normal file
173
open-sse/services/antigravity429Engine.ts
Normal 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 };
|
||||
42
open-sse/services/antigravityCredits.ts
Normal file
42
open-sse/services/antigravityCredits.ts
Normal 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);
|
||||
}
|
||||
62
open-sse/services/antigravityHeaderScrub.ts
Normal file
62
open-sse/services/antigravityHeaderScrub.ts
Normal 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;
|
||||
}
|
||||
71
open-sse/services/antigravityHeaders.ts
Normal file
71
open-sse/services/antigravityHeaders.ts
Normal 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 };
|
||||
50
open-sse/services/antigravityObfuscation.ts
Normal file
50
open-sse/services/antigravityObfuscation.ts
Normal 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;
|
||||
}
|
||||
49
open-sse/services/claudeCodeCCH.ts
Normal file
49
open-sse/services/claudeCodeCCH.ts
Normal 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 };
|
||||
@@ -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",
|
||||
|
||||
127
open-sse/services/claudeCodeConstraints.ts
Normal file
127
open-sse/services/claudeCodeConstraints.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
open-sse/services/claudeCodeFingerprint.ts
Normal file
46
open-sse/services/claudeCodeFingerprint.ts
Normal 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 "";
|
||||
}
|
||||
77
open-sse/services/claudeCodeObfuscation.ts
Normal file
77
open-sse/services/claudeCodeObfuscation.ts
Normal 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, "\\$&");
|
||||
}
|
||||
80
open-sse/services/claudeCodeToolRemapper.ts
Normal file
80
open-sse/services/claudeCodeToolRemapper.ts
Normal 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 };
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
44
open-sse/services/qwenThinking.ts
Normal file
44
open-sse/services/qwenThinking.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
124
open-sse/translator/helpers/geminiToolsSanitizer.ts
Normal file
124
open-sse/translator/helpers/geminiToolsSanitizer.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { role: "assistant" },
|
||||
delta: { role: "assistant", content: "" },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
155
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
45
src/app/api/skills/skillssh/install/route.ts
Normal file
45
src/app/api/skills/skillssh/install/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
28
src/app/api/skills/skillssh/route.ts
Normal file
28
src/app/api/skills/skillssh/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
67
src/app/api/upstream-proxy/[providerId]/route.ts
Normal file
67
src/app/api/upstream-proxy/[providerId]/route.ts
Normal 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 });
|
||||
}
|
||||
31
src/app/api/v1/db/health/route.ts
Normal file
31
src/app/api/v1/db/health/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
102
src/lib/db/AGENTS.md
Normal 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 136–162).
|
||||
|
||||
- **Unit tests** mock `getDbInstance()` to return isolated sqlite in-memory instance
|
||||
- **Integration tests** use real SQLite with migrations applied, data cleaned up after each test
|
||||
- **No fixture interdependencies** — each test runs migrations fresh
|
||||
- Test files: `tests/unit/db/*.test.mjs`, `tests/integration/db/*.test.mjs`
|
||||
|
||||
### Anti-Patterns
|
||||
|
||||
- ❌ Raw SQL in routes — always use domain module functions
|
||||
- ❌ Direct `prepare()` statements outside `db/` modules — breaks modularity
|
||||
- ❌ Mixing encryption logic in domain modules — use `encryption.ts` helpers only
|
||||
- ❌ Accessing `provider_connections` table from `combos.ts` — each module owns its tables
|
||||
- ❌ Skipping migrations for schema changes — all changes go through `db/migrations/`
|
||||
|
||||
### Adding a New Domain Module
|
||||
|
||||
1. Create `src/lib/db/[module].ts` with CRUD functions (create, read, update, delete, list)
|
||||
2. Export from `src/lib/db/localDb.ts` (add re-export)
|
||||
3. If new tables required: create migration in `db/migrations/NNN_[description].sql`
|
||||
4. Run migration via `migrationRunner.ts` at startup (automatic)
|
||||
5. Add unit tests in `tests/unit/db/[module].test.mjs`
|
||||
6. Ensure tests meet the coverage requirements in [`CONTRIBUTING.md#running-tests`](../../CONTRIBUTING.md#running-tests)
|
||||
|
||||
### Performance Notes
|
||||
|
||||
- **Read cache** (`readCache.ts`) — Pre-loads frequently accessed data (models, providers) at startup; invalidated on write
|
||||
- **WAL journaling** (`core.ts`) — Enables concurrent reads during writes
|
||||
- **Batch operations** — Use prepared statements with parameter binding to avoid SQL injection
|
||||
- **Connection pooling** — Singleton pattern prevents per-request connection overhead
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- **SQLite over PostgreSQL**: Simpler deployment, no separate database server, encryption at application layer
|
||||
- **Versioned migrations**: Each schema change is tracked, reproducible, reversible with effort
|
||||
- **Domain modules**: Enforces single responsibility, prevents cross-module table access
|
||||
- **Re-export layer**: Convenience for consumers; `localDb.ts` is re-export-only to prevent circular dependencies
|
||||
|
||||
---
|
||||
|
||||
## Review Focus
|
||||
|
||||
- DB module changes must preserve domain boundaries (one module = one table set)
|
||||
- New migrations must be idempotent and run inside transactions
|
||||
- Encryption helpers used for all sensitive fields
|
||||
- Test coverage and PR requirements: see [`CONTRIBUTING.md#running-tests`](../../CONTRIBUTING.md#running-tests) (lines 136–162)
|
||||
- No raw SQL in routes or non-db modules
|
||||
@@ -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
|
||||
|
||||
@@ -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
548
src/lib/db/healthCheck.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
84
src/lib/providers/codexConnectionDefaults.ts
Normal file
84
src/lib/providers/codexConnectionDefaults.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
153
src/lib/providers/requestDefaults.ts
Normal file
153
src/lib/providers/requestDefaults.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
74
src/lib/skills/skillssh.ts
Normal file
74
src/lib/skills/skillssh.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ============
|
||||
|
||||
@@ -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
|
||||
|
||||
43
src/shared/utils/sidebarRouteMatch.ts
Normal file
43
src/shared/utils/sidebarRouteMatch.ts
Normal 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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user