diff --git a/CHANGELOG.md b/CHANGELOG.md index 32c49f2189..c58f9b0bc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,23 +4,40 @@ --- -## [3.6.5] — 2026-04-12 +## [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, 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` - **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 - **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 +- **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 NODE_PATH Resolution (Windows):** 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 + ### ⚠️ 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). diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 38816c9559..9de66365e2 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -570,9 +570,9 @@ export const REGISTRY: Record = { "connect-accept-encoding": "gzip", "connect-protocol-version": "1", "Content-Type": "application/connect+proto", - "User-Agent": "connect-es/1.6.1", + "User-Agent": "Cursor/3.1.0", }, - clientVersion: "1.1.3", + clientVersion: "3.1.0", models: [ { id: "default", name: "Auto (Server Picks)" }, { id: "claude-4.6-opus-high-thinking", name: "Claude 4.6 Opus High Thinking" }, diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index db67c0932a..967cbc990d 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -1,9 +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"; -import { getCodexRequestDefaults } from "@/lib/providers/requestDefaults"; // ─── T09: Codex vs Spark Scope-Aware Rate Limiting ──────────────────────── // Codex has two independent quota pools: "codex" (standard) and "spark" (premium). @@ -321,6 +324,12 @@ function normalizeEffortValue(value: unknown): string | undefined { return normalized || undefined; } +function consumeResponsesStoreMarker(body: Record): unknown { + const marker = body._omnirouteResponsesStore; + delete body._omnirouteResponsesStore; + return marker; +} + /** * Codex Executor - handles OpenAI Codex API (Responses API format) * Automatically injects default instructions if missing. @@ -395,11 +404,18 @@ export class CodexExecutor extends BaseExecutor { * Transform request before sending - inject default instructions if missing */ transformRequest(model, body, stream, credentials) { + // Do not mutate the caller's payload in place. Combo quality checks and + // other post-execute paths still inspect the original request body. + body = + body && typeof body === "object" ? structuredClone(body) : ({} as Record); + const nativeCodexPassthrough = body?._nativeCodexPassthrough === true; const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath); const requestDefaults = getCodexRequestDefaults(credentials?.providerSpecificData); + const storeEnabled = isOpenAIResponsesStoreEnabled(credentials?.providerSpecificData); const thinkingBudgetConfig = getThinkingBudgetConfig(); const allowConnectionReasoningDefaults = thinkingBudgetConfig.mode === ThinkingMode.PASSTHROUGH; + const responsesStoreMarker = consumeResponsesStoreMarker(body); // Codex /responses rejects stream=false, but /responses/compact rejects the stream field entirely. if (isCompactRequest) { @@ -424,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`. diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index 23e045dacc..2f4f7ed2a9 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -33,6 +33,9 @@ import crypto from "crypto"; import { v5 as uuidv5 } from "uuid"; import zlib from "zlib"; +const CURSOR_CLIENT_VERSION = "3.1.0"; +const CURSOR_USER_AGENT = `Cursor/${CURSOR_CLIENT_VERSION}`; + // Detect cloud environment const isCloudEnv = () => { if (typeof caches !== "undefined" && typeof caches === "object") return true; @@ -251,11 +254,11 @@ export class CursorExecutor extends BaseExecutor { "connect-accept-encoding": "gzip", "connect-protocol-version": "1", "content-type": "application/connect+proto", - "user-agent": "connect-es/1.6.1", + "user-agent": CURSOR_USER_AGENT, "x-amzn-trace-id": `Root=${crypto.randomUUID()}`, "x-client-key": crypto.createHash("sha256").update(cleanToken).digest("hex"), "x-cursor-checksum": this.generateChecksum(machineId), - "x-cursor-client-version": "2.3.41", + "x-cursor-client-version": CURSOR_CLIENT_VERSION, "x-cursor-client-type": "ide", "x-cursor-client-os": process.platform === "win32" @@ -265,6 +268,7 @@ export class CursorExecutor extends BaseExecutor { : "linux", "x-cursor-client-arch": process.arch === "arm64" ? "aarch64" : "x64", "x-cursor-client-device-type": "desktop", + "x-cursor-user-agent": CURSOR_USER_AGENT, "x-cursor-config-version": crypto.randomUUID(), "x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", "x-ghost-mode": ghostMode ? "true" : "false", diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 15e2c0ff64..2e2f76b99a 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -9,6 +9,7 @@ import { } from "../services/claudeCodeCompatible.ts"; import { getGigachatAccessToken } from "../services/gigachatAuth.ts"; import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts"; +import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts"; function normalizeBaseUrl(baseUrl) { return (baseUrl || "").trim().replace(/\/$/, ""); @@ -203,6 +204,12 @@ export class DefaultExecutor extends BaseExecutor { * "org/model-name") — we must NOT strip path segments. (Fix #493) */ transformRequest(model, body, stream, credentials) { + void model; + void stream; + void credentials; + if (this.provider === "qwen" && typeof body === "object" && body !== null) { + return sanitizeQwenThinkingToolChoice(body, "QwenExecutor"); + } return body; } diff --git a/open-sse/executors/qoder.ts b/open-sse/executors/qoder.ts index 26e1889245..322c1fdeb4 100644 --- a/open-sse/executors/qoder.ts +++ b/open-sse/executors/qoder.ts @@ -5,6 +5,7 @@ import { type ProviderCredentials, } from "./base.ts"; import { PROVIDERS } from "../config/constants.ts"; +import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts"; function getAuthToken(credentials: ProviderCredentials): string { if (typeof credentials.apiKey === "string" && credentials.apiKey.trim()) { @@ -27,6 +28,15 @@ export class QoderExecutor extends BaseExecutor { super("qoder", PROVIDERS.qoder); } + transformRequest(model: string, body: unknown): Record { + const payload = { + ...(typeof body === "object" && body !== null ? body : {}), + model, + }; + + return sanitizeQwenThinkingToolChoice(payload, "QoderExecutor"); + } + async execute({ model, body, stream, credentials, signal, upstreamExtraHeaders }: ExecuteInput) { const token = getAuthToken(credentials); @@ -90,10 +100,7 @@ export class QoderExecutor extends BaseExecutor { mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); - const payload = { - ...(typeof body === "object" && body !== null ? body : {}), - model: mappedModel, - }; + const payload = this.transformRequest(mappedModel, body, stream, credentials); const bodyStr = JSON.stringify(payload); diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 645c6de690..1f37789219 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -2009,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 = @@ -2016,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); @@ -2211,10 +2221,10 @@ export async function handleChatCore({ // Translate response to client's expected format (usually OpenAI) // Pass toolNameMap so Claude OAuth proxy_ prefix is stripped in tool_use blocks (#605) - let translatedResponse = needsTranslation(targetFormat, clientResponseFormat) + let translatedResponse = needsTranslation(responseFormatForTranslation, clientResponseFormat) ? translateNonStreamingResponse( responseBody, - targetFormat, + responseFormatForTranslation, clientResponseFormat, toolNameMap as Map | null ) diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 24e3614831..5fe7dfeded 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -32,6 +32,23 @@ function toNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function hasVisibleMessageContent(content: unknown): boolean { + if (typeof content === "string") { + return content.trim().length > 0; + } + + if (!Array.isArray(content)) return false; + + return content.some((contentPart) => { + const part = toRecord(contentPart); + if (!part) return false; + if (typeof part.text === "string" && part.text.trim().length > 0) return true; + if (typeof part.content === "string" && part.content.trim().length > 0) return true; + const partType = toString(part.type); + return Boolean(partType && partType !== "thinking" && partType !== "reasoning"); + }); +} + // Matches ... blocks and ... (greedy, dotAll) const THINK_TAG_REGEX = /<(?:think|thinking)>([\s\S]*?)<\/(?:think|thinking)>/gi; @@ -216,6 +233,13 @@ function sanitizeMessage(msg: unknown): unknown { } } + // Non-streaming responses should not expose both visible content and reasoning_content. + // Some clients drop the visible assistant text or render duplicated panels when both fields + // are present in the final payload. Keep reasoning_content only for reasoning-only messages. + if (sanitized.reasoning_content !== undefined && hasVisibleMessageContent(sanitized.content)) { + delete sanitized.reasoning_content; + } + // Preserve tool_calls if (msgRecord.tool_calls) { sanitized.tool_calls = msgRecord.tool_calls; diff --git a/open-sse/handlers/responsesHandler.ts b/open-sse/handlers/responsesHandler.ts index 1fab9d04fb..dfd9662c8e 100644 --- a/open-sse/handlers/responsesHandler.ts +++ b/open-sse/handlers/responsesHandler.ts @@ -35,7 +35,7 @@ export async function handleResponsesCore({ signal, }) { // Convert Responses API format to Chat Completions format - const convertedBody = convertResponsesApiFormat(body); + const convertedBody = convertResponsesApiFormat(body, credentials); // Ensure stream is enabled convertedBody.stream = true; diff --git a/open-sse/handlers/sseParser.ts b/open-sse/handlers/sseParser.ts index f21f780cc1..4ab94cfbbc 100644 --- a/open-sse/handlers/sseParser.ts +++ b/open-sse/handlers/sseParser.ts @@ -399,6 +399,132 @@ export function parseSSEToClaudeResponse(rawSSE, fallbackModel) { * Convert Responses API SSE events into a single non-streaming response object. * Expects events such as response.created / response.in_progress / response.completed. */ +const RESPONSES_TERMINAL_EVENT_TYPES = new Set([ + "response.completed", + "response.done", + "response.cancelled", + "response.canceled", + "response.failed", + "response.incomplete", +]); + +function toOutputIndex(value) { + if (typeof value === "number" && Number.isInteger(value)) return value; + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number(value); + if (Number.isInteger(parsed)) return parsed; + } + return null; +} + +function cloneResponseItem(item) { + const record = toRecord(item); + return { + ...record, + ...(Array.isArray(record.content) + ? { + content: record.content.map((contentPart) => { + const part = toRecord(contentPart); + return { ...part }; + }), + } + : {}), + ...(Array.isArray(record.summary) + ? { + summary: record.summary.map((summaryPart) => { + const part = toRecord(summaryPart); + return { ...part }; + }), + } + : {}), + }; +} + +function ensureResponsesMessageItem(outputItems, outputIndex) { + const existing = outputItems.get(outputIndex); + if (existing?.type === "message") return existing; + + const next = { + ...(existing && typeof existing === "object" ? existing : {}), + id: existing?.id || `msg_${Date.now()}_${outputIndex}`, + type: "message", + role: "assistant", + content: Array.isArray(existing?.content) + ? existing.content.map((contentPart) => ({ ...toRecord(contentPart) })) + : [{ type: "output_text", annotations: [], text: "" }], + }; + + if (next.content.length === 0) { + next.content.push({ type: "output_text", annotations: [], text: "" }); + } + + outputItems.set(outputIndex, next); + return next; +} + +function ensureResponsesReasoningItem(outputItems, outputIndex, itemId) { + const existing = outputItems.get(outputIndex); + if (existing?.type === "reasoning") return existing; + + const next = { + ...(existing && typeof existing === "object" ? existing : {}), + id: itemId || existing?.id || `rs_${Date.now()}_${outputIndex}`, + type: "reasoning", + summary: Array.isArray(existing?.summary) + ? existing.summary.map((summaryPart) => ({ ...toRecord(summaryPart) })) + : [{ type: "summary_text", text: "" }], + }; + + if (next.summary.length === 0) { + next.summary.push({ type: "summary_text", text: "" }); + } + + outputItems.set(outputIndex, next); + return next; +} + +function ensureResponsesFunctionCallItem(outputItems, outputIndex, itemId, callId, name) { + const existing = outputItems.get(outputIndex); + if (existing?.type === "function_call") { + if (callId && !existing.call_id) existing.call_id = callId; + if (name && !existing.name) existing.name = name; + if (itemId && !existing.id) existing.id = itemId; + return existing; + } + + const next = { + ...(existing && typeof existing === "object" ? existing : {}), + id: itemId || existing?.id || `fc_${callId || `${Date.now()}_${outputIndex}`}`, + type: "function_call", + call_id: callId || existing?.call_id || "", + name: name || existing?.name || "", + arguments: typeof existing?.arguments === "string" ? existing.arguments : "", + }; + + outputItems.set(outputIndex, next); + return next; +} + +function mergeResponseItems(existing, incoming) { + const next = cloneResponseItem(incoming); + if (!existing || typeof existing !== "object") return next; + + return { + ...existing, + ...next, + ...(Array.isArray(next.content) + ? { + content: next.content, + } + : {}), + ...(Array.isArray(next.summary) + ? { + summary: next.summary, + } + : {}), + }; +} + export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { const lines = String(rawSSE || "").split("\n"); const events = []; @@ -409,7 +535,11 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { const payload = trimmed.slice(5).trim(); if (!payload || payload === "[DONE]") continue; try { - events.push(JSON.parse(payload)); + const parsed = JSON.parse(payload); + const record = toRecord(parsed); + if (Object.keys(record).length > 0) { + events.push(record); + } } catch { // Ignore malformed lines and continue best-effort parsing. } @@ -417,12 +547,104 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { if (events.length === 0) return null; - let completed = null; + let terminalResponse = null; + let terminalEventType = ""; let latestResponse = null; + const outputItems = new Map(); for (const evt of events) { - if (evt?.type === "response.completed" && evt.response) { - completed = evt.response; + const eventType = toString(evt?.type); + const outputIndex = toOutputIndex(evt?.output_index); + const item = toRecord(evt?.item); + + if (outputIndex !== null && eventType === "response.output_item.added") { + outputItems.set(outputIndex, cloneResponseItem(item)); + } + + if (outputIndex !== null && eventType === "response.output_item.done") { + const existing = outputItems.get(outputIndex); + outputItems.set(outputIndex, mergeResponseItems(existing, item)); + } + + if (outputIndex !== null && eventType === "response.output_text.delta") { + const messageItem = ensureResponsesMessageItem(outputItems, outputIndex); + const content = Array.isArray(messageItem.content) ? messageItem.content : []; + const firstPart = + content.length > 0 ? { ...toRecord(content[0]) } : { type: "output_text", annotations: [] }; + firstPart.type = firstPart.type || "output_text"; + firstPart.annotations = Array.isArray(firstPart.annotations) ? firstPart.annotations : []; + firstPart.text = `${toString(firstPart.text)}${toString(evt.delta)}`; + content[0] = firstPart; + messageItem.content = content; + } + + if (outputIndex !== null && eventType === "response.output_text.done") { + const messageItem = ensureResponsesMessageItem(outputItems, outputIndex); + const content = Array.isArray(messageItem.content) ? messageItem.content : []; + const firstPart = + content.length > 0 ? { ...toRecord(content[0]) } : { type: "output_text", annotations: [] }; + firstPart.type = firstPart.type || "output_text"; + firstPart.annotations = Array.isArray(firstPart.annotations) ? firstPart.annotations : []; + firstPart.text = toString(evt.text, toString(firstPart.text)); + content[0] = firstPart; + messageItem.content = content; + } + + if (outputIndex !== null && eventType === "response.reasoning_summary_text.delta") { + const reasoningItem = ensureResponsesReasoningItem( + outputItems, + outputIndex, + toString(evt.item_id) + ); + const summary = Array.isArray(reasoningItem.summary) ? reasoningItem.summary : []; + const firstPart = + summary.length > 0 ? { ...toRecord(summary[0]) } : { type: "summary_text", text: "" }; + firstPart.type = firstPart.type || "summary_text"; + firstPart.text = `${toString(firstPart.text)}${toString(evt.delta)}`; + summary[0] = firstPart; + reasoningItem.summary = summary; + } + + if (outputIndex !== null && eventType === "response.reasoning_summary_text.done") { + const reasoningItem = ensureResponsesReasoningItem( + outputItems, + outputIndex, + toString(evt.item_id) + ); + const summary = Array.isArray(reasoningItem.summary) ? reasoningItem.summary : []; + const firstPart = + summary.length > 0 ? { ...toRecord(summary[0]) } : { type: "summary_text", text: "" }; + firstPart.type = firstPart.type || "summary_text"; + firstPart.text = toString(evt.text, toString(firstPart.text)); + summary[0] = firstPart; + reasoningItem.summary = summary; + } + + if (outputIndex !== null && eventType === "response.function_call_arguments.delta") { + const functionCallItem = ensureResponsesFunctionCallItem( + outputItems, + outputIndex, + toString(evt.item_id), + "", + "" + ); + functionCallItem.arguments = `${toString(functionCallItem.arguments)}${toString(evt.delta)}`; + } + + if (outputIndex !== null && eventType === "response.function_call_arguments.done") { + const functionCallItem = ensureResponsesFunctionCallItem( + outputItems, + outputIndex, + toString(evt.item_id), + "", + "" + ); + functionCallItem.arguments = toString(evt.arguments, toString(functionCallItem.arguments)); + } + + if (RESPONSES_TERMINAL_EVENT_TYPES.has(eventType) && evt.response) { + terminalResponse = evt.response; + terminalEventType = eventType; } if (evt?.response && typeof evt.response === "object") { latestResponse = evt.response; @@ -431,16 +653,33 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { } } - const picked = completed || latestResponse; + const picked = terminalResponse || latestResponse; if (!picked || typeof picked !== "object") return null; + const reconstructedOutput = [...outputItems.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([, item]) => item) + .filter((item) => item && typeof item === "object"); + const pickedOutput = Array.isArray(picked.output) ? picked.output : []; + const statusFallback = + terminalEventType === "response.cancelled" + ? "cancelled" + : terminalEventType === "response.canceled" + ? "canceled" + : terminalEventType === "response.failed" + ? "failed" + : terminalEventType === "response.incomplete" + ? "incomplete" + : terminalResponse + ? "completed" + : "in_progress"; return { id: picked.id || `resp_${Date.now()}`, - object: "response", + object: picked.object || "response", model: picked.model || fallbackModel || "unknown", - output: Array.isArray(picked.output) ? picked.output : [], + output: pickedOutput.length > 0 ? pickedOutput : reconstructedOutput, usage: picked.usage || null, - status: picked.status || (completed ? "completed" : "in_progress"), + status: picked.status || statusFallback, created_at: picked.created_at || Math.floor(Date.now() / 1000), metadata: picked.metadata || {}, }; diff --git a/open-sse/mcp-server/__tests__/dbHealthTool.test.ts b/open-sse/mcp-server/__tests__/dbHealthTool.test.ts new file mode 100644 index 0000000000..9aa7565ca4 --- /dev/null +++ b/open-sse/mcp-server/__tests__/dbHealthTool.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { createMcpServer } from "../server.ts"; +import { MCP_TOOL_MAP, dbHealthCheckInput } from "../schemas/tools.ts"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +vi.mock("../audit.ts", () => ({ + logToolCall: vi.fn().mockResolvedValue(undefined), +})); + +describe("omniroute_db_health_check MCP tool", () => { + let client: Client; + + beforeEach(async () => { + mockFetch.mockReset(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(); + await server.connect(serverTransport); + client = new Client({ name: "test-client", version: "1.0.0" }); + await client.connect(clientTransport); + }); + + afterEach(async () => { + await client.close(); + }); + + it("is registered in the MCP tool map", () => { + expect(MCP_TOOL_MAP["omniroute_db_health_check"]).toBeDefined(); + expect(MCP_TOOL_MAP["omniroute_db_health_check"]?.phase).toBe(2); + }); + + it("validates empty input and explicit autoRepair requests", () => { + expect(dbHealthCheckInput.safeParse({}).success).toBe(true); + expect(dbHealthCheckInput.safeParse({ autoRepair: true }).success).toBe(true); + expect(dbHealthCheckInput.safeParse({ autoRepair: "yes" }).success).toBe(false); + }); + + it("dispatches to /api/v1/db/health using POST when autoRepair=true", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + isHealthy: false, + issues: [{ type: "broken_reference", table: "combos", description: "broken", count: 1 }], + repairedCount: 1, + backupCreated: true, + autoRepair: true, + checkedAt: new Date().toISOString(), + }), + }); + + const result = await client.callTool({ + name: "omniroute_db_health_check", + arguments: { autoRepair: true }, + }); + + expect(result.isError).toBeFalsy(); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/db/health"), + expect.objectContaining({ method: "POST" }) + ); + + const content = result.content[0] as { type: string; text: string }; + const payload = JSON.parse(content.text); + expect(payload.repairedCount).toBe(1); + expect(payload.backupCreated).toBe(true); + }); +}); diff --git a/open-sse/mcp-server/schemas/index.ts b/open-sse/mcp-server/schemas/index.ts index ed1a5c9ba6..e233dd03ba 100644 --- a/open-sse/mcp-server/schemas/index.ts +++ b/open-sse/mcp-server/schemas/index.ts @@ -60,6 +60,9 @@ export { getSessionSnapshotInput, getSessionSnapshotOutput, getSessionSnapshotTool, + dbHealthCheckInput, + dbHealthCheckOutput, + dbHealthCheckTool, cacheStatsInput, cacheStatsOutput, cacheStatsTool, diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 8a65141ab5..9138b39abb 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -831,7 +831,51 @@ export const getSessionSnapshotTool: McpToolDefinition< sourceEndpoints: ["/api/usage/analytics", "/api/telemetry/summary"], }; -// --- Tool 18: omniroute_sync_pricing --- +// --- Tool 18: omniroute_db_health_check --- +export const dbHealthCheckInput = z.object({ + autoRepair: z + .boolean() + .optional() + .describe("When true, runs the database auto-repair flow before returning the result"), +}); + +export const dbHealthCheckOutput = z.object({ + isHealthy: z.boolean(), + issues: z.array( + z.object({ + type: z.enum([ + "integrity_check_failed", + "broken_reference", + "stale_snapshot", + "invalid_state", + ]), + table: z.string(), + description: z.string(), + count: z.number(), + }) + ), + repairedCount: z.number(), + backupCreated: z.boolean(), + autoRepair: z.boolean(), + checkedAt: z.string(), +}); + +export const dbHealthCheckTool: McpToolDefinition< + typeof dbHealthCheckInput, + typeof dbHealthCheckOutput +> = { + name: "omniroute_db_health_check", + description: + "Diagnoses OmniRoute database drift such as orphan quota/domain rows, invalid JSON state, and broken combo references. Set autoRepair=true to repair those rows before returning the report.", + inputSchema: dbHealthCheckInput, + outputSchema: dbHealthCheckOutput, + scopes: ["read:health", "write:resilience"], + auditLevel: "full", + phase: 2, + sourceEndpoints: ["/api/v1/db/health"], +}; + +// --- Tool 19: omniroute_sync_pricing --- export const syncPricingInput = z.object({ sources: z .array(z.string()) @@ -959,6 +1003,7 @@ export const MCP_TOOLS = [ bestComboForTaskTool, explainRouteTool, getSessionSnapshotTool, + dbHealthCheckTool, syncPricingTool, cacheStatsTool, cacheFlushTool, diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 01fbfdaaff..3cc53ad4ea 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -38,6 +38,7 @@ import { bestComboForTaskInput, explainRouteInput, getSessionSnapshotInput, + dbHealthCheckInput, syncPricingInput, } from "./schemas/tools.ts"; import { startMcpHeartbeat } from "./runtimeHeartbeat.ts"; @@ -59,6 +60,7 @@ import { handleBestComboForTask, handleExplainRoute, handleGetSessionSnapshot, + handleDbHealthCheck, handleSyncPricing, } from "./tools/advancedTools.ts"; import { memoryTools } from "./tools/memoryTools.ts"; @@ -759,6 +761,18 @@ export function createMcpServer(): McpServer { }) ); + server.registerTool( + "omniroute_db_health_check", + { + description: + "Diagnoses or repairs OmniRoute database drift, including broken combo references and orphan quota/domain rows", + inputSchema: dbHealthCheckInput, + }, + withScopeEnforcement("omniroute_db_health_check", (args) => + handleDbHealthCheck(dbHealthCheckInput.parse(args ?? {})) + ) + ); + server.registerTool( "omniroute_sync_pricing", { diff --git a/open-sse/mcp-server/tools/advancedTools.ts b/open-sse/mcp-server/tools/advancedTools.ts index b6d713c811..271ee70e8e 100644 --- a/open-sse/mcp-server/tools/advancedTools.ts +++ b/open-sse/mcp-server/tools/advancedTools.ts @@ -1,5 +1,5 @@ /** - * OmniRoute MCP Advanced Tools — 10 intelligence tools that differentiate + * OmniRoute MCP Advanced Tools — 11 intelligence tools that differentiate * OmniRoute from all other AI gateways. * * Tools: @@ -12,7 +12,8 @@ * 7. omniroute_best_combo_for_task — AI-powered combo recommendation * 8. omniroute_explain_route — Post-hoc routing decision explainer * 9. omniroute_get_session_snapshot — Full session state snapshot - * 10. omniroute_sync_pricing — Sync provider pricing from external source + * 10. omniroute_db_health_check — Diagnose and repair DB state drift + * 11. omniroute_sync_pricing — Sync provider pricing from external source */ import { logToolCall } from "../audit.ts"; @@ -863,3 +864,33 @@ export async function handleGetSessionSnapshot() { return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } } + +export async function handleDbHealthCheck(args: { autoRepair?: boolean }) { + const start = Date.now(); + const autoRepair = args.autoRepair === true; + + try { + const result = toRecord( + await apiFetch("/api/v1/db/health", { + method: autoRepair ? "POST" : "GET", + }) + ); + + await logToolCall( + "omniroute_db_health_check", + args, + { + isHealthy: toBoolean(result.isHealthy, false), + repairedCount: toNumber(result.repairedCount, 0), + }, + Date.now() - start, + true + ); + + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_db_health_check", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 2b194227a2..061ccb9de5 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -5,7 +5,7 @@ */ import { checkFallbackError, formatRetryAfter, getProviderProfile } from "./accountFallback.ts"; -import { unavailableResponse } from "../utils/error.ts"; +import { errorResponse, unavailableResponse } from "../utils/error.ts"; import { recordComboIntent, recordComboRequest, getComboMetrics } from "./comboMetrics.ts"; import { resolveComboConfig, getDefaultComboConfig } from "./comboConfig.ts"; import { maybeGenerateHandoff, resolveContextRelayConfig } from "./contextHandoff.ts"; @@ -45,6 +45,10 @@ const COMBO_BAD_REQUEST_FALLBACK_PATTERNS = [ const MAX_COMBO_DEPTH = 3; +function comboModelNotFoundResponse(message: string) { + return errorResponse(404, message); +} + // Bootstrap defaults from ClawRouter benchmark (used when no local latency history exists yet) const DEFAULT_MODEL_P95_MS = { "grok-4-fast-non-reasoning": 1143, @@ -913,6 +917,7 @@ export async function handleComboChat({ if (pinnedModel) { log.info("COMBO", `[#401] Context caching: pinned model=${pinnedModel}`); } + const clientRequestedStream = body?.stream === true; // Wrap handleSingleModel to inject context caching tag on response (#401) const handleSingleModelWrapped = combo.context_cache_protection ? async (b, modelStr, target) => { @@ -1287,7 +1292,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 +1349,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 +1620,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 +1628,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 +1703,7 @@ async function handleRoundRobinCombo({ // Success — validate response quality before returning if (result.ok) { - const quality = await validateResponseQuality(result, !!body.stream, log); + const quality = await validateResponseQuality(result, clientRequestedStream, log); if (!quality.valid) { log.warn( "COMBO-RR", diff --git a/open-sse/services/qwenThinking.ts b/open-sse/services/qwenThinking.ts new file mode 100644 index 0000000000..074534b08b --- /dev/null +++ b/open-sse/services/qwenThinking.ts @@ -0,0 +1,44 @@ +type JsonRecord = Record; + +export function isQwenThinkingActive(body: JsonRecord): boolean { + const thinking = body.thinking; + + if (thinking === true || body.enable_thinking === true) { + return true; + } + + return ( + typeof thinking === "object" && + thinking !== null && + !Array.isArray(thinking) && + (thinking as JsonRecord).type === "enabled" + ); +} + +export function isQwenThinkingToolChoiceIncompatible(toolChoice: unknown): boolean { + return toolChoice === "required" || (typeof toolChoice === "object" && toolChoice !== null); +} + +export function sanitizeQwenThinkingToolChoice( + body: JsonRecord, + providerLabel = "Qwen" +): JsonRecord { + if (!isQwenThinkingActive(body)) { + return body; + } + + const toolChoice = body.tool_choice; + if (!isQwenThinkingToolChoiceIncompatible(toolChoice)) { + return body; + } + + const toolChoiceLabel = typeof toolChoice === "string" ? toolChoice : "object"; + console.warn( + `[${providerLabel}] Neutralizing incompatible tool_choice ${toolChoiceLabel} to "auto" (thinking mode active)` + ); + + return { + ...body, + tool_choice: "auto", + }; +} diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 392de29d2e..8e7e4afdb9 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -45,6 +45,14 @@ const KIMI_CONFIG = { apiVersion: "2023-06-01", }; +const CURSOR_USAGE_CONFIG = { + usageUrl: "https://www.cursor.com/api/usage", + userMetaUrl: "https://www.cursor.com/api/auth/me", + subscriptionUrl: "https://www.cursor.com/api/subscription", + clientVersion: "3.1.0", + userAgent: "Cursor/3.1.0", +}; + type JsonRecord = Record; type UsageQuota = { used: number; @@ -185,6 +193,8 @@ export async function getUsageForProvider(connection) { return await getIflowUsage(accessToken); case "glm": return await getGlmUsage(apiKey, providerSpecificData); + case "cursor": + return await getCursorUsage(accessToken); default: return { message: `Usage API not implemented for ${provider}` }; } @@ -418,6 +428,178 @@ function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null): return "GitHub Copilot"; } +function buildCursorUsageHeaders(accessToken: string): Record { + return { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + "User-Agent": CURSOR_USAGE_CONFIG.userAgent, + "x-cursor-client-version": CURSOR_USAGE_CONFIG.clientVersion, + "x-cursor-user-agent": CURSOR_USAGE_CONFIG.userAgent, + }; +} + +function getFirstPositiveNumber(...values: unknown[]): number { + for (const value of values) { + const parsed = toNumber(value, Number.NaN); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return 0; +} + +function getCursorMonthlyRequestLimit(usageData: JsonRecord, subscriptionData: JsonRecord): number { + return getFirstPositiveNumber( + getFieldValue(subscriptionData, "team_max_monthly_requests", "teamMaxMonthlyRequests"), + getFieldValue(usageData, "team_max_request_usage", "teamMaxRequestUsage"), + getFieldValue(subscriptionData, "team_max_request_usage", "teamMaxRequestUsage"), + getFieldValue(usageData, "hard_limit", "hardLimit"), + getFieldValue(subscriptionData, "max_monthly_requests", "maxMonthlyRequests") + ); +} + +function getCursorOnDemandLimit(usageData: JsonRecord, subscriptionData: JsonRecord): number { + const onDemand = toRecord(getFieldValue(usageData, "on_demand", "onDemand")); + return getFirstPositiveNumber( + getFieldValue(onDemand, "max_requests", "maxRequests"), + getCursorMonthlyRequestLimit(usageData, subscriptionData) + ); +} + +function formatCursorQuota( + usedValue: unknown, + totalValue: unknown, + resetValue: unknown +): UsageQuota { + const total = Math.max(0, toNumber(totalValue, 0)); + const rawUsed = Math.max(0, toNumber(usedValue, 0)); + const used = total > 0 ? Math.min(rawUsed, total) : rawUsed; + const remaining = total > 0 ? Math.max(total - used, 0) : 0; + + return { + used, + total, + remaining, + remainingPercentage: total > 0 ? clampPercentage((remaining / total) * 100) : 0, + resetAt: parseResetTime(resetValue), + unlimited: false, + }; +} + +function inferCursorPlanName(userMeta: JsonRecord, subscriptionData: JsonRecord): string { + const teamInfo = toRecord(getFieldValue(userMeta, "team_info", "teamInfo")); + const candidates = [ + getFieldValue(userMeta, "plan", "plan"), + getFieldValue(userMeta, "subscription_type", "subscriptionType"), + getFieldValue(subscriptionData, "subscription_type", "subscriptionType"), + getFieldValue(subscriptionData, "plan", "plan"), + ]; + const planText = candidates.find((value) => typeof value === "string" && value.trim().length > 0); + const normalized = typeof planText === "string" ? planText.trim().toLowerCase() : ""; + + if (Object.keys(teamInfo).length > 0 || normalized.includes("team")) return "Cursor Team"; + if (normalized.includes("enterprise")) return "Cursor Enterprise"; + if (normalized.includes("pro")) return "Cursor Pro"; + if (normalized.includes("free")) return "Cursor Free"; + return "Cursor"; +} + +async function fetchCursorUsageDocument(url: string, accessToken: string) { + const response = await fetch(url, { + method: "GET", + headers: buildCursorUsageHeaders(accessToken), + }); + + const text = await response.text(); + if (!response.ok) { + return { + ok: false, + status: response.status, + data: {} as JsonRecord, + text, + }; + } + + try { + const parsed = text ? JSON.parse(text) : {}; + return { + ok: true, + status: response.status, + data: toRecord(parsed), + text, + }; + } catch { + return { + ok: false, + status: response.status, + data: {} as JsonRecord, + text, + }; + } +} + +async function getCursorUsage(accessToken: string) { + try { + if (!accessToken) { + return { + message: "Cursor token expired or unavailable. Please re-authenticate the connection.", + }; + } + + const [usageSummary, userMeta, subscription] = await Promise.all([ + fetchCursorUsageDocument(CURSOR_USAGE_CONFIG.usageUrl, accessToken), + fetchCursorUsageDocument(CURSOR_USAGE_CONFIG.userMetaUrl, accessToken), + fetchCursorUsageDocument(CURSOR_USAGE_CONFIG.subscriptionUrl, accessToken), + ]); + + const authDenied = [usageSummary, userMeta, subscription].some( + (result) => result.status === 401 || result.status === 403 + ); + if (authDenied) { + return { + message: + "Cursor token expired or permission denied. Please re-authenticate the connection.", + }; + } + + const usageData = usageSummary.data; + const userMetaData = userMeta.data; + const subscriptionData = subscription.data; + const plan = inferCursorPlanName(userMetaData, subscriptionData); + + const quotas: Record = {}; + const totalUsed = getFieldValue(usageData, "num_requests_total", "numRequestsTotal"); + const totalLimit = getCursorMonthlyRequestLimit(usageData, subscriptionData); + const totalReset = + getFieldValue(usageData, "reset_date", "resetDate") || + getFieldValue(subscriptionData, "reset_date", "resetDate"); + + if (toNumber(totalUsed, 0) > 0 || totalLimit > 0) { + quotas.requests = formatCursorQuota(totalUsed, totalLimit, totalReset); + } + + const onDemand = toRecord(getFieldValue(usageData, "on_demand", "onDemand")); + const onDemandUsed = getFieldValue(onDemand, "num_requests", "numRequests"); + const onDemandLimit = getCursorOnDemandLimit(usageData, subscriptionData); + const onDemandReset = + getFieldValue(onDemand, "reset_date", "resetDate") || + getFieldValue(usageData, "reset_date", "resetDate") || + getFieldValue(subscriptionData, "reset_date", "resetDate"); + + if (toNumber(onDemandUsed, 0) > 0 || onDemandLimit > 0) { + quotas.on_demand = formatCursorQuota(onDemandUsed, onDemandLimit, onDemandReset); + } + + if (Object.keys(quotas).length > 0) { + return { plan, quotas }; + } + + return { plan, message: "Cursor connected. Unable to parse quota data." }; + } catch (error) { + return { message: `Unable to fetch Cursor usage: ${(error as Error).message}` }; + } +} + // ── Gemini CLI subscription info cache ────────────────────────────────────── // Prevents duplicate loadCodeAssist calls within the same quota cycle. // Key: accessToken → { data, fetchedAt } @@ -1352,6 +1534,11 @@ export const __testing = { parseResetTime, formatGitHubQuotaSnapshot, inferGitHubPlanName, + buildCursorUsageHeaders, + formatCursorQuota, + getCursorMonthlyRequestLimit, + getCursorOnDemandLimit, + inferCursorPlanName, getGeminiCliPlanLabel, getAntigravityPlanLabel, }; diff --git a/open-sse/translator/helpers/geminiToolsSanitizer.ts b/open-sse/translator/helpers/geminiToolsSanitizer.ts new file mode 100644 index 0000000000..b8628c6e38 --- /dev/null +++ b/open-sse/translator/helpers/geminiToolsSanitizer.ts @@ -0,0 +1,124 @@ +import { cleanJSONSchemaForAntigravity } from "./geminiHelper.ts"; + +type GeminiFunctionDeclaration = { + name: string; + description: string; + parameters: unknown; +}; + +type GeminiTool = { + functionDeclarations?: GeminiFunctionDeclaration[]; + googleSearch?: Record; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function toGeminiGoogleSearchTool(tool: Record): GeminiTool | null { + if (isRecord(tool.googleSearch)) { + return { googleSearch: tool.googleSearch }; + } + if (tool.googleSearch !== undefined) { + return { googleSearch: {} }; + } + + if (isRecord(tool.google_search)) { + return { googleSearch: tool.google_search }; + } + if (tool.google_search !== undefined) { + return { googleSearch: {} }; + } + + const toolType = typeof tool.type === "string" ? tool.type : ""; + if ( + toolType === "googleSearch" || + toolType === "google_search" || + toolType === "web_search" || + toolType === "web_search_preview" + ) { + return { googleSearch: {} }; + } + + return null; +} + +export function buildGeminiTools(tools: unknown): GeminiTool[] | undefined { + if (!Array.isArray(tools) || tools.length === 0) { + return undefined; + } + + const functionDeclarations: GeminiFunctionDeclaration[] = []; + let googleSearchTool: GeminiTool | null = null; + + for (const rawTool of tools) { + if (!isRecord(rawTool)) { + continue; + } + + const normalizedGoogleSearchTool = toGeminiGoogleSearchTool(rawTool); + if (normalizedGoogleSearchTool) { + googleSearchTool = normalizedGoogleSearchTool; + continue; + } + + if (Array.isArray(rawTool.functionDeclarations)) { + for (const fn of rawTool.functionDeclarations) { + if (!isRecord(fn) || typeof fn.name !== "string" || !fn.name.trim()) { + continue; + } + + functionDeclarations.push({ + name: fn.name, + description: typeof fn.description === "string" ? fn.description : "", + parameters: cleanJSONSchemaForAntigravity( + fn.parameters || { type: "object", properties: {} } + ), + }); + } + continue; + } + + if (typeof rawTool.name === "string" && rawTool.name.trim()) { + functionDeclarations.push({ + name: rawTool.name, + description: typeof rawTool.description === "string" ? rawTool.description : "", + parameters: cleanJSONSchemaForAntigravity( + rawTool.input_schema || { type: "object", properties: {} } + ), + }); + continue; + } + + if (rawTool.type === "function" && isRecord(rawTool.function)) { + const fn = rawTool.function; + if (typeof fn.name !== "string" || !fn.name.trim()) { + continue; + } + + functionDeclarations.push({ + name: fn.name, + description: typeof fn.description === "string" ? fn.description : "", + parameters: cleanJSONSchemaForAntigravity( + fn.parameters || { type: "object", properties: {} } + ), + }); + } + } + + if (googleSearchTool && functionDeclarations.length > 0) { + console.warn( + `[GeminiTools] Removing ${functionDeclarations.length} functionDeclarations because googleSearch cannot be mixed with Gemini function tools` + ); + } + + if (googleSearchTool) { + return [googleSearchTool]; + } + + if (functionDeclarations.length > 0) { + return [{ functionDeclarations }]; + } + + return undefined; +} diff --git a/open-sse/translator/helpers/responsesApiHelper.ts b/open-sse/translator/helpers/responsesApiHelper.ts index 49ab141f55..32d971835b 100644 --- a/open-sse/translator/helpers/responsesApiHelper.ts +++ b/open-sse/translator/helpers/responsesApiHelper.ts @@ -4,6 +4,6 @@ */ import { openaiResponsesToOpenAIRequest } from "../request/openai-responses.ts"; -export function convertResponsesApiFormat(body) { - return openaiResponsesToOpenAIRequest(null, body, null, null); +export function convertResponsesApiFormat(body, credentials = null) { + return openaiResponsesToOpenAIRequest(null, body, null, credentials); } diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index 2cee5d98f7..b36e064b2c 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -6,6 +6,7 @@ import { cleanJSONSchemaForAntigravity, } from "../helpers/geminiHelper.ts"; import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; +import { buildGeminiTools } from "../helpers/geminiToolsSanitizer.ts"; /** * Direct Claude → Gemini request translator. @@ -168,22 +169,9 @@ export function claudeToGeminiRequest(model, body, stream) { } // ── Convert tools ────────────────────────────────────────────── - if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) { - const functionDeclarations = []; - for (const tool of body.tools) { - if (tool.name) { - functionDeclarations.push({ - name: tool.name, - description: tool.description || "", - parameters: cleanJSONSchemaForAntigravity( - tool.input_schema || { type: "object", properties: {} } - ), - }); - } - } - if (functionDeclarations.length > 0) { - result.tools = [{ functionDeclarations }]; - } + const geminiTools = buildGeminiTools(body.tools); + if (geminiTools) { + result.tools = geminiTools; } // ── Thinking config ──────────────────────────────────────────── diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 6bc09b829f..bbeaff2e5a 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -4,11 +4,13 @@ * Responses API uses: { input: [...], instructions: "..." } * Chat API uses: { messages: [...] } */ -import { register } from "../registry.ts"; +import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults"; import { FORMATS } from "../formats.ts"; import { generateToolCallId } from "../helpers/toolCallHelper.ts"; +import { register } from "../registry.ts"; type JsonRecord = Record; +const RESPONSES_STORE_MARKER = "_omnirouteResponsesStore"; function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; @@ -44,6 +46,8 @@ export function openaiResponsesToOpenAIRequest( const root = toRecord(body); if (root.input === undefined) return body; + const credentialRecord = toRecord(credentials); + const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData); // Validate tool types — only function tools can be translated to Chat Completions const tools = toArray(root.tools); @@ -271,6 +275,9 @@ export function openaiResponsesToOpenAIRequest( delete result.input; delete result.instructions; delete result.include; + if (storeEnabled && root.store !== undefined) { + result[RESPONSES_STORE_MARKER] = root.store; + } delete result.store; delete result.reasoning; @@ -287,15 +294,18 @@ export function openaiToOpenAIResponsesRequest( credentials: unknown ): unknown { void stream; - void credentials; const root = toRecord(body); + const credentialRecord = toRecord(credentials); + const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData); const result: JsonRecord = { model, input: [], stream: true, - store: false, }; + if (!storeEnabled) { + result.store = false; + } const input = result.input as JsonRecord[]; @@ -514,10 +524,29 @@ export function openaiToOpenAIResponsesRequest( } // Pass through relevant fields + if (root.previous_response_id !== undefined) { + result.previous_response_id = root.previous_response_id; + } + if (root.prompt_cache_key !== undefined) { + result.prompt_cache_key = root.prompt_cache_key; + } + if (root.session_id !== undefined) { + result.session_id = root.session_id; + } + if (root.conversation_id !== undefined) { + result.conversation_id = root.conversation_id; + } if (root.service_tier !== undefined) result.service_tier = root.service_tier; if (root.temperature !== undefined) result.temperature = root.temperature; if (root.max_tokens !== undefined) result.max_tokens = root.max_tokens; if (root.top_p !== undefined) result.top_p = root.top_p; + if (storeEnabled) { + if (root[RESPONSES_STORE_MARKER] !== undefined) { + result.store = root[RESPONSES_STORE_MARKER]; + } else if (root.store !== undefined) { + result.store = root.store; + } + } return result; } diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index ba299fe2c3..bb904ed247 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -25,6 +25,7 @@ import { generateSessionId, cleanJSONSchemaForAntigravity, } from "../helpers/geminiHelper.ts"; +import { buildGeminiTools } from "../helpers/geminiToolsSanitizer.ts"; type GeminiPart = Record; type GeminiContent = { role: string; parts: GeminiPart[] }; @@ -54,7 +55,10 @@ type GeminiRequest = { generationConfig: GeminiGenerationConfig; safetySettings: unknown; systemInstruction?: GeminiContent; - tools?: Array<{ functionDeclarations: GeminiFunctionDeclaration[] }>; + tools?: Array<{ + functionDeclarations?: GeminiFunctionDeclaration[]; + googleSearch?: Record; + }>; cachedContent?: string; }; @@ -69,7 +73,10 @@ type CloudCodeEnvelope = { contents: GeminiContent[]; systemInstruction?: GeminiContent; generationConfig: GeminiGenerationConfig; - tools?: Array<{ functionDeclarations: GeminiFunctionDeclaration[] }>; + tools?: Array<{ + functionDeclarations?: GeminiFunctionDeclaration[]; + googleSearch?: Record; + }>; safetySettings?: unknown; toolConfig?: { functionCallingConfig: { mode: string }; @@ -277,35 +284,9 @@ function openaiToGeminiBase(model, body, stream) { } // Convert tools - if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) { - const functionDeclarations = []; - for (const t of body.tools) { - // Check if already in Anthropic/Claude format (no type field, direct name/description/input_schema) - if (t.name && t.input_schema) { - functionDeclarations.push({ - name: t.name, - description: t.description || "", - parameters: cleanJSONSchemaForAntigravity( - t.input_schema || { type: "object", properties: {} } - ), - }); - } - // OpenAI format - else if (t.type === "function" && t.function) { - const fn = t.function; - functionDeclarations.push({ - name: fn.name, - description: fn.description || "", - parameters: cleanJSONSchemaForAntigravity( - fn.parameters || { type: "object", properties: {} } - ), - }); - } - } - - if (functionDeclarations.length > 0) { - result.tools = [{ functionDeclarations }]; - } + const geminiTools = buildGeminiTools(body.tools); + if (geminiTools) { + result.tools = geminiTools; } // Convert response_format to Gemini's responseMimeType/responseSchema @@ -437,7 +418,7 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra } // Add toolConfig for Antigravity - if (geminiCLI.tools?.length > 0) { + if (geminiCLI.tools?.some((tool) => Array.isArray(tool.functionDeclarations))) { envelope.request.toolConfig = { functionCallingConfig: { mode: "VALIDATED" }, }; @@ -534,19 +515,9 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu // Convert Claude tools to Gemini functionDeclarations if (claudeRequest.tools && Array.isArray(claudeRequest.tools)) { - const functionDeclarations = []; - for (const tool of claudeRequest.tools) { - if (tool.name && tool.input_schema) { - const cleanedSchema = cleanJSONSchemaForAntigravity(tool.input_schema); - functionDeclarations.push({ - name: tool.name, - description: tool.description || "", - parameters: cleanedSchema, - }); - } - } - if (functionDeclarations.length > 0) { - envelope.request.tools = [{ functionDeclarations }]; + const geminiTools = buildGeminiTools(claudeRequest.tools); + if (geminiTools) { + envelope.request.tools = geminiTools; envelope.request.toolConfig = { functionCallingConfig: { mode: "VALIDATED" }, }; diff --git a/open-sse/utils/cursorChecksum.ts b/open-sse/utils/cursorChecksum.ts index 8b6bbe5c8c..4892224416 100644 --- a/open-sse/utils/cursorChecksum.ts +++ b/open-sse/utils/cursorChecksum.ts @@ -8,6 +8,9 @@ import crypto from "crypto"; import { v5 as uuidv5 } from "uuid"; +const CURSOR_CLIENT_VERSION = "3.1.0"; +const CURSOR_USER_AGENT = `Cursor/${CURSOR_CLIENT_VERSION}`; + /** * Generate SHA-256 hash like generateHashed64Hex * @param {string} input - Input string @@ -112,11 +115,12 @@ export function buildCursorHeaders(accessToken, machineId = null, ghostMode = tr "connect-accept-encoding": "gzip", "connect-protocol-version": "1", "Content-Type": "application/connect+proto", - "User-Agent": "connect-es/1.6.1", + "User-Agent": CURSOR_USER_AGENT, "x-amzn-trace-id": `Root=${crypto.randomUUID()}`, "x-client-key": clientKey, "x-cursor-checksum": checksum, - "x-cursor-client-version": "1.1.3", + "x-cursor-client-version": CURSOR_CLIENT_VERSION, + "x-cursor-user-agent": CURSOR_USER_AGENT, "x-cursor-config-version": crypto.randomUUID(), "x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", "x-ghost-mode": ghostMode ? "true" : "false", diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 9e46f4d163..5425f80f88 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -11,7 +11,10 @@ import Input from "@/shared/components/Input"; import Modal from "@/shared/components/Modal"; import Toggle from "@/shared/components/Toggle"; import Tooltip from "@/shared/components/Tooltip"; +import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { pickDisplayValue } from "@/shared/utils/maskEmail"; +import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import { useNotificationStore } from "@/store/notificationStore"; import { ROUTING_STRATEGIES } from "@/shared/constants/routingStrategies"; import { @@ -467,10 +470,12 @@ function formatComboEntryDisplay( providerNodes = [], builderProviders = [], includeConnection = false, + showFullEmails = true, }: { providerNodes?: any[]; builderProviders?: any[]; includeConnection?: boolean; + showFullEmails?: boolean; } = {} ) { const normalizedEntry = normalizeModelEntry(entry); @@ -493,11 +498,14 @@ function formatComboEntryDisplay( } const connectionId = normalizedEntry.connectionId || null; - const connectionLabel = + const rawConnectionLabel = (connectionId && builderProvider?.connections?.find((connection) => connection.id === connectionId)?.label) || normalizedEntry.label || null; + const connectionLabel = rawConnectionLabel + ? pickDisplayValue([rawConnectionLabel], showFullEmails, rawConnectionLabel) + : null; if (connectionId) { return `${providerLabel}/${modelLabel} · ${connectionLabel || `acct ${connectionId.slice(0, 8)}`}`; @@ -516,6 +524,7 @@ function formatComboEntryDisplay( export default function CombosPage() { const t = useTranslations("combos"); const tc = useTranslations("common"); + const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); const router = useRouter(); const searchParams = useSearchParams(); const [combos, setCombos] = useState([]); @@ -848,12 +857,38 @@ export default function CombosPage() { return (
{/* Header */} -
+

{t("title")}

{t("description")}

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

Database Health

+

+ Diagnose and repair stale quota/domain rows and broken combo references. +

+
+
+
+
+

Status

+

+ {dbHealth?.isHealthy ? "Healthy" : "Attention needed"} +

+
+
+

Issues

+

+ {dbHealth?.issues?.length ?? 0} +

+
+
+

Repairs

+

+ {dbHealth?.repairedCount ?? 0} +

+
+
+
+
+ + {dbHealth?.backupCreated && ( +

+ A repair backup was created before mutating. +

+ )} + {dbHealthError &&

{dbHealthError}

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

{issue.description}

+ {issue.count} +
+

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

+
+ ))} +
+ )} +
+ {/* System Info Cards */}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 4a1fe36567..ed730d519c 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -5457,6 +5457,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec accountId: "", codexReasoningEffort: "medium", codexFastServiceTier: false, + codexOpenaiStoreEnabled: false, }); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); @@ -5504,6 +5505,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec 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; @@ -5659,6 +5661,8 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec reasoningEffort: formData.codexReasoningEffort, ...(formData.codexFastServiceTier ? { serviceTier: "priority" } : {}), }; + updates.providerSpecificData.openaiStoreEnabled = + formData.codexOpenaiStoreEnabled === true; } } const error = (await onSave(updates)) as void | unknown; @@ -5712,6 +5716,12 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec label="Codex Fast Service Tier" description="When enabled, injects `service_tier=priority` for this connection if the client leaves the tier unset." /> + 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." + />
)} {isOAuth && connection.email && ( diff --git a/src/app/api/v1/db/health/route.ts b/src/app/api/v1/db/health/route.ts new file mode 100644 index 0000000000..dfe84dda6c --- /dev/null +++ b/src/app/api/v1/db/health/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { runManagedDbHealthCheck } from "@/lib/db/core"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; + +export async function GET(request: Request) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 }); + } + + try { + return NextResponse.json(runManagedDbHealthCheck({ autoRepair: false })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error("[API] DB health diagnosis failed:", message); + return NextResponse.json({ error: { message } }, { status: 500 }); + } +} + +export async function POST(request: Request) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 }); + } + + try { + return NextResponse.json(runManagedDbHealthCheck({ autoRepair: true })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error("[API] DB health repair failed:", message); + return NextResponse.json({ error: { message } }, { status: 500 }); + } +} diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 0b588cbcd7..34d7d3c5f1 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -554,6 +554,8 @@ export async function deleteApiKey(id: string) { if (result.changes === 0) return false; + db.prepare("DELETE FROM domain_budgets WHERE api_key_id = ?").run(id); + db.prepare("DELETE FROM domain_cost_history WHERE api_key_id = ?").run(id); setNoLog(id, false); // Invalidate caches since a key was removed diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index 1d80647695..ee090c6141 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -9,6 +9,7 @@ import path from "path"; import fs from "fs"; import { resolveDataDir, getLegacyDotDataDir } from "../dataPaths"; import { runMigrations } from "./migrationRunner"; +import { runDbHealthCheck } from "./healthCheck"; type SqliteDatabase = import("better-sqlite3").Database; type JsonRecord = Record; @@ -455,6 +456,96 @@ function hasColumn(db: SqliteDatabase, tableName: string, columnName: string): b return rows.some((row) => row.name === columnName); } +function isAutomatedTestProcess(): boolean { + return ( + typeof process !== "undefined" && + (process.env.NODE_ENV === "test" || + process.env.VITEST !== undefined || + process.argv.some((arg) => arg.includes("test"))) + ); +} + +function shouldRunStartupDbHealthCheck(): boolean { + if (process.env.OMNIROUTE_FORCE_DB_HEALTHCHECK === "1") return true; + return !isAutomatedTestProcess(); +} + +function createHealthCheckBackup(db: SqliteDatabase): boolean { + const isTest = isAutomatedTestProcess(); + if (isTest) return false; + + try { + const backupDir = DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups"); + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backupPath = path.join(backupDir, `db_${timestamp}_health-check-repair.sqlite`); + const escapedBackupPath = backupPath.replace(/'/g, "''"); + + db.exec(`VACUUM INTO '${escapedBackupPath}'`); + console.log(`[DB] Health-check backup created: ${backupPath}`); + return true; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.warn("[DB] Failed to create health-check backup:", message); + return false; + } +} + +let dbHealthCheckTimer: NodeJS.Timeout | null = null; + +function getDbHealthCheckIntervalMs(): number { + const rawValue = process.env.OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS; + if (typeof rawValue === "string" && rawValue.trim().length > 0) { + const parsed = Number(rawValue); + if (Number.isFinite(parsed) && parsed >= 0) { + return parsed; + } + } + return 6 * 60 * 60 * 1000; +} + +function clearDbHealthCheckScheduler() { + if (dbHealthCheckTimer) { + clearInterval(dbHealthCheckTimer); + dbHealthCheckTimer = null; + } +} + +function startDbHealthCheckScheduler(db: SqliteDatabase) { + clearDbHealthCheckScheduler(); + if (isCloud || isBuildPhase || isAutomatedTestProcess()) return; + + const intervalMs = getDbHealthCheckIntervalMs(); + if (intervalMs <= 0) return; + + dbHealthCheckTimer = setInterval(() => { + try { + if (!db.open) return; + runDbHealthCheck(db, { + autoRepair: true, + expectedSchemaVersion: "1", + createBackupBeforeRepair: () => createHealthCheckBackup(db), + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.warn("[DB] Periodic health-check failed:", message); + } + }, intervalMs); + dbHealthCheckTimer.unref?.(); +} + +export function runManagedDbHealthCheck(options?: { autoRepair?: boolean }) { + const db = getDbInstance(); + return runDbHealthCheck(db, { + autoRepair: options?.autoRepair === true, + expectedSchemaVersion: "1", + createBackupBeforeRepair: () => createHealthCheckBackup(db), + }); +} + export function getDbInstance(): SqliteDatabase { const existing = getDb(); if (existing) return existing; @@ -615,13 +706,22 @@ export function getDbInstance(): SqliteDatabase { "INSERT OR REPLACE INTO db_meta (key, value) VALUES ('schema_version', '1')" ); versionStmt.run(); + if (shouldRunStartupDbHealthCheck()) { + runDbHealthCheck(db, { + autoRepair: true, + expectedSchemaVersion: "1", + createBackupBeforeRepair: () => createHealthCheckBackup(db), + }); + } setDb(db); + startDbHealthCheckScheduler(db); console.log(`[DB] SQLite database ready: ${sqliteFile}`); return db; } export function closeDbInstance(options?: { checkpointMode?: CheckpointMode | null }): boolean { + clearDbHealthCheckScheduler(); const db = getDb(); if (!db) return false; diff --git a/src/lib/db/healthCheck.ts b/src/lib/db/healthCheck.ts new file mode 100644 index 0000000000..fa952086b6 --- /dev/null +++ b/src/lib/db/healthCheck.ts @@ -0,0 +1,519 @@ +type SqliteDatabase = import("better-sqlite3").Database; +type JsonRecord = Record; + +export type DbHealthIssueType = + | "integrity_check_failed" + | "broken_reference" + | "stale_snapshot" + | "invalid_state"; + +export interface DbHealthIssue { + type: DbHealthIssueType; + table: string; + description: string; + count: number; +} + +export interface DbHealthCheckResult { + isHealthy: boolean; + issues: DbHealthIssue[]; + repairedCount: number; + backupCreated: boolean; + autoRepair: boolean; + checkedAt: string; +} + +interface RunDbHealthCheckOptions { + autoRepair?: boolean; + createBackupBeforeRepair?: () => boolean; + expectedSchemaVersion?: string; +} + +interface ComboRow { + id: string; + name: string; + data: string; + sort_order?: number | null; + created_at?: string | null; + updated_at?: string | null; +} + +interface ComboRepairResult { + issueCount: number; + repairedCount: number; +} + +interface QuotaSnapshotRow { + id?: number; + provider?: string | null; + connection_id?: string | null; + created_at?: string | null; +} + +function isRecord(value: unknown): value is JsonRecord { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function toRecord(value: unknown): JsonRecord { + return isRecord(value) ? value : {}; +} + +function toTrimmedString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function parseJsonRecord(value: string): JsonRecord | null { + try { + const parsed = JSON.parse(value); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function isFiniteNumber(value: unknown): boolean { + return typeof value === "number" && Number.isFinite(value); +} + +function hasRows(db: SqliteDatabase, table: string): boolean { + const row = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(table) as { name?: string } | undefined; + return row?.name === table; +} + +function hasProviderConnection(db: SqliteDatabase, connectionId: string): boolean { + const row = db + .prepare("SELECT 1 AS ok FROM provider_connections WHERE id = ? LIMIT 1") + .get(connectionId) as { ok?: number } | undefined; + return row?.ok === 1; +} + +function isValidIsoTimestamp(value: unknown): boolean { + if (typeof value !== "string" || value.trim().length === 0) return false; + return !Number.isNaN(Date.parse(value)); +} + +function buildRepairNote(message: string, checkedAt: string): string { + return `[db-health:${checkedAt}] ${message}`; +} + +function buildDisabledCombo(row: ComboRow, checkedAt: string): JsonRecord { + const now = checkedAt; + return { + id: row.id, + name: row.name, + version: 2, + strategy: "priority", + models: [], + config: {}, + isActive: false, + isHidden: false, + sortOrder: typeof row.sort_order === "number" ? row.sort_order : 0, + createdAt: row.created_at || now, + updatedAt: now, + repairNote: buildRepairNote("Combo payload was rebuilt after invalid JSON was detected.", now), + }; +} + +function normalizeComboModels(models: unknown): unknown[] { + return Array.isArray(models) ? models : []; +} + +function repairComboRows( + db: SqliteDatabase, + rows: ComboRow[], + checkedAt: string, + options: { autoRepair: boolean } +): ComboRepairResult { + if (rows.length === 0) return { issueCount: 0, repairedCount: 0 }; + + const existingComboNames = new Set(rows.map((row) => row.name)); + let issueCount = 0; + let repairedCount = 0; + + const updateComboStmt = db.prepare("UPDATE combos SET data = ?, updated_at = ? WHERE id = ?"); + + for (const row of rows) { + const parsed = parseJsonRecord(row.data); + if (!parsed) { + issueCount += 1; + if (options.autoRepair) { + const repaired = buildDisabledCombo(row, checkedAt); + updateComboStmt.run(JSON.stringify(repaired), checkedAt, row.id); + repairedCount += 1; + } + continue; + } + + const currentModels = normalizeComboModels(parsed.models); + if (currentModels.length === 0) continue; + + const nextModels: unknown[] = []; + let removedSteps = 0; + let clearedConnectionPins = 0; + + for (const rawStep of currentModels) { + if (!isRecord(rawStep)) { + 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) { + continue; + } + + issueCount += removedSteps + clearedConnectionPins; + 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, + ] + .filter(Boolean) + .join(" "), + checkedAt + ), + ...(nextModels.length === 0 ? { isActive: false } : {}), + }; + + updateComboStmt.run(JSON.stringify(nextCombo), checkedAt, row.id); + repairedCount += removedSteps + clearedConnectionPins; + } + + return { issueCount, repairedCount }; +} + +function getBrokenQuotaSnapshotRowIds(db: SqliteDatabase): number[] { + if (!hasRows(db, "quota_snapshots")) return []; + + const brokenRowIds = new Set(); + const rows = db + .prepare("SELECT id, provider, connection_id, created_at FROM quota_snapshots") + .all() as QuotaSnapshotRow[]; + + for (const row of rows) { + const connectionId = toTrimmedString(row.connection_id); + const missingConnection = !!connectionId && !hasProviderConnection(db, connectionId); + const invalidTimestamp = !isValidIsoTimestamp(row.created_at); + if ((missingConnection || invalidTimestamp) && typeof row.id === "number") { + brokenRowIds.add(row.id); + } + } + + return Array.from(brokenRowIds); +} + +function countOrphanQuotaSnapshots(db: SqliteDatabase): number { + return getBrokenQuotaSnapshotRowIds(db).length; +} + +function repairQuotaSnapshots(db: SqliteDatabase): number { + if (!hasRows(db, "quota_snapshots")) return 0; + const brokenRowIds = getBrokenQuotaSnapshotRowIds(db); + if (brokenRowIds.length === 0) return 0; + + const deleteByRowId = db.prepare("DELETE FROM quota_snapshots WHERE id = ?"); + let repaired = 0; + for (const rowId of brokenRowIds) { + repaired += deleteByRowId.run(rowId).changes; + } + return repaired; +} + +function countOrphanDomainRows( + db: SqliteDatabase, + table: "domain_budgets" | "domain_cost_history" +) { + if (!hasRows(db, table)) return 0; + const row = db + .prepare( + `SELECT COUNT(*) AS count + FROM ${table} + WHERE api_key_id NOT IN (SELECT id FROM api_keys)` + ) + .get() as { count?: number } | undefined; + return row?.count || 0; +} + +function repairOrphanDomainRows( + db: SqliteDatabase, + table: "domain_budgets" | "domain_cost_history" +): number { + if (!hasRows(db, table)) return 0; + return db.prepare(`DELETE FROM ${table} WHERE api_key_id NOT IN (SELECT id FROM api_keys)`).run() + .changes; +} + +function countInvalidJsonRows( + db: SqliteDatabase, + table: "domain_fallback_chains" | "domain_lockout_state" | "domain_circuit_breakers", + column: "chain" | "attempts" | "options" +): number { + if (!hasRows(db, table)) return 0; + const rows = db.prepare(`SELECT ${column} FROM ${table}`).all() as Array>; + let invalid = 0; + for (const row of rows) { + const raw = row[column]; + if (raw == null && column === "options") continue; + if (typeof raw !== "string") { + invalid += 1; + continue; + } + try { + JSON.parse(raw); + } catch { + invalid += 1; + } + } + return invalid; +} + +function repairInvalidJsonRows( + db: SqliteDatabase, + table: "domain_fallback_chains" | "domain_lockout_state" | "domain_circuit_breakers", + column: "chain" | "attempts" | "options" +): number { + if (!hasRows(db, table)) return 0; + + const rows = db.prepare(`SELECT rowid, ${column} FROM ${table}`).all() as Array<{ + rowid: number; + [key: string]: unknown; + }>; + + const deleteByRowId = db.prepare(`DELETE FROM ${table} WHERE rowid = ?`); + const clearOptionsByRowId = db.prepare( + "UPDATE domain_circuit_breakers SET options = NULL WHERE rowid = ?" + ); + let repaired = 0; + + for (const row of rows) { + const raw = row[column]; + if (raw == null && table === "domain_circuit_breakers") { + continue; + } + if (typeof raw !== "string") { + if (table === "domain_circuit_breakers") { + repaired += clearOptionsByRowId.run(row.rowid).changes; + continue; + } + deleteByRowId.run(row.rowid); + repaired += 1; + continue; + } + try { + JSON.parse(raw); + } catch { + if (table === "domain_circuit_breakers") { + repaired += clearOptionsByRowId.run(row.rowid).changes; + continue; + } + deleteByRowId.run(row.rowid); + repaired += 1; + } + } + + return repaired; +} + +function getSchemaVersionIssueCount(db: SqliteDatabase, expectedSchemaVersion: string): number { + if (!hasRows(db, "db_meta")) return 0; + const row = db.prepare("SELECT value FROM db_meta WHERE key = 'schema_version'").get() as + | { value?: string | null } + | undefined; + const current = typeof row?.value === "string" ? row.value : null; + return current === expectedSchemaVersion ? 0 : 1; +} + +function repairSchemaVersion(db: SqliteDatabase, expectedSchemaVersion: string): number { + if (!hasRows(db, "db_meta")) return 0; + return db + .prepare("INSERT OR REPLACE INTO db_meta (key, value) VALUES ('schema_version', ?)") + .run(expectedSchemaVersion).changes; +} + +export function runDbHealthCheck( + db: SqliteDatabase, + options: RunDbHealthCheckOptions = {} +): DbHealthCheckResult { + const autoRepair = options.autoRepair === true; + const expectedSchemaVersion = options.expectedSchemaVersion || "1"; + const checkedAt = new Date().toISOString(); + const issues: DbHealthIssue[] = []; + let repairedCount = 0; + let backupCreated = false; + let backupAttempted = false; + + const ensureBackupBeforeRepair = () => { + if (!autoRepair || backupAttempted || typeof options.createBackupBeforeRepair !== "function") { + return; + } + backupAttempted = true; + backupCreated = options.createBackupBeforeRepair(); + }; + + const integrityCheck = db.pragma("integrity_check") as Array<{ integrity_check?: string }>; + if (integrityCheck[0]?.integrity_check !== "ok") { + issues.push({ + type: "integrity_check_failed", + table: "sqlite", + description: "SQLite integrity_check returned a non-ok status.", + count: 1, + }); + } + + 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, invalid JSON, or pinned connections that no longer exist.", + count: comboRepair.issueCount, + }); + ensureBackupBeforeRepair(); + repairedCount += comboRepair.repairedCount; + } + + const orphanQuotaCount = countOrphanQuotaSnapshots(db); + if (orphanQuotaCount > 0) { + issues.push({ + type: "stale_snapshot", + table: "quota_snapshots", + description: + "Quota snapshots referenced missing connections or contained invalid timestamps.", + count: orphanQuotaCount, + }); + if (autoRepair) { + ensureBackupBeforeRepair(); + repairedCount += repairQuotaSnapshots(db); + } + } + + const orphanBudgets = countOrphanDomainRows(db, "domain_budgets"); + if (orphanBudgets > 0) { + issues.push({ + type: "broken_reference", + table: "domain_budgets", + description: "Domain budgets referenced API keys that no longer exist.", + count: orphanBudgets, + }); + if (autoRepair) { + ensureBackupBeforeRepair(); + repairedCount += repairOrphanDomainRows(db, "domain_budgets"); + } + } + + const orphanCostHistory = countOrphanDomainRows(db, "domain_cost_history"); + if (orphanCostHistory > 0) { + issues.push({ + type: "broken_reference", + table: "domain_cost_history", + description: "Domain cost history referenced API keys that no longer exist.", + count: orphanCostHistory, + }); + if (autoRepair) { + ensureBackupBeforeRepair(); + repairedCount += repairOrphanDomainRows(db, "domain_cost_history"); + } + } + + const invalidFallbackChains = countInvalidJsonRows(db, "domain_fallback_chains", "chain"); + if (invalidFallbackChains > 0) { + issues.push({ + type: "invalid_state", + table: "domain_fallback_chains", + description: "Fallback chain rows contained invalid JSON payloads.", + count: invalidFallbackChains, + }); + if (autoRepair) { + ensureBackupBeforeRepair(); + repairedCount += repairInvalidJsonRows(db, "domain_fallback_chains", "chain"); + } + } + + const invalidLockoutState = countInvalidJsonRows(db, "domain_lockout_state", "attempts"); + if (invalidLockoutState > 0) { + issues.push({ + type: "invalid_state", + table: "domain_lockout_state", + description: "Lockout state rows contained invalid JSON payloads.", + count: invalidLockoutState, + }); + if (autoRepair) { + ensureBackupBeforeRepair(); + repairedCount += repairInvalidJsonRows(db, "domain_lockout_state", "attempts"); + } + } + + const invalidBreakerOptions = countInvalidJsonRows(db, "domain_circuit_breakers", "options"); + if (invalidBreakerOptions > 0) { + issues.push({ + type: "invalid_state", + table: "domain_circuit_breakers", + description: "Circuit breaker option payloads were invalid JSON.", + count: invalidBreakerOptions, + }); + if (autoRepair) { + ensureBackupBeforeRepair(); + repairedCount += repairInvalidJsonRows(db, "domain_circuit_breakers", "options"); + } + } + + const schemaVersionIssues = getSchemaVersionIssueCount(db, expectedSchemaVersion); + if (schemaVersionIssues > 0) { + issues.push({ + type: "invalid_state", + table: "db_meta", + description: `db_meta.schema_version did not match expected version ${expectedSchemaVersion}.`, + count: schemaVersionIssues, + }); + if (autoRepair) { + ensureBackupBeforeRepair(); + repairedCount += repairSchemaVersion(db, expectedSchemaVersion); + } + } + + return { + isHealthy: issues.length === 0, + issues, + repairedCount, + backupCreated, + autoRepair, + checkedAt, + }; +} diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 75a9a09709..51dbcf03d6 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -386,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 = @@ -400,6 +401,22 @@ export async function deleteProviderConnection(id: string) { export async function deleteProviderConnectionsByProvider(providerId: string) { const db = getDbInstance() as unknown as DbLike; + const connectionIds = db + .prepare("SELECT id FROM provider_connections WHERE provider = ?") + .all(providerId) + .map((row) => { + const record = toRecord(row); + return typeof record.id === "string" ? record.id : null; + }) + .filter((id): id is string => id !== null); + + if (connectionIds.length > 0) { + const deleteSnapshots = db.prepare("DELETE FROM quota_snapshots WHERE connection_id = ?"); + for (const connectionId of connectionIds) { + deleteSnapshots.run(connectionId); + } + } + const result = db.prepare("DELETE FROM provider_connections WHERE provider = ?").run(providerId); backupDbFile("pre-write"); return result.changes; diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 868a73b869..7b7200146a 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -131,7 +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", + 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"}`, @@ -206,7 +207,7 @@ export const CURSOR_CONFIG = { agentEndpoint: "https://agent.api5.cursor.sh", // Privacy mode agentNonPrivacyEndpoint: "https://agentn.api5.cursor.sh", // Non-privacy mode // Client metadata - clientVersion: "0.48.6", + clientVersion: "3.1.0", clientType: "ide", // Token storage locations (for user reference) tokenStoragePaths: { diff --git a/src/lib/oauth/services/cursor.ts b/src/lib/oauth/services/cursor.ts index 82b4639b74..c2e95fb560 100644 --- a/src/lib/oauth/services/cursor.ts +++ b/src/lib/oauth/services/cursor.ts @@ -51,11 +51,13 @@ export class CursorService { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/connect+proto", "Connect-Protocol-Version": "1", + "User-Agent": `Cursor/${this.config.clientVersion}`, "x-cursor-client-version": this.config.clientVersion, "x-cursor-client-type": this.config.clientType, "x-cursor-client-os": this.detectOS(), "x-cursor-client-arch": this.detectArch(), "x-cursor-client-device-type": "desktop", + "x-cursor-user-agent": `Cursor/${this.config.clientVersion}`, "x-cursor-checksum": checksum, "x-ghost-mode": ghostMode ? "true" : "false", }; diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts index bfa6af7540..d49d1021e7 100644 --- a/src/lib/providers/requestDefaults.ts +++ b/src/lib/providers/requestDefaults.ts @@ -16,6 +16,10 @@ function normalizeString(value: unknown): string | undefined { 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)) { @@ -77,9 +81,57 @@ export function normalizeProviderSpecificData( } } + if ("openaiStoreEnabled" in normalized && typeof normalized.openaiStoreEnabled !== "boolean") { + delete normalized.openaiStoreEnabled; + } + return Object.keys(normalized).length > 0 ? normalized : undefined; } +export function isOpenAIResponsesStoreEnabled(providerSpecificData: unknown): boolean { + return asRecord(providerSpecificData).openaiStoreEnabled === true; +} + +export function buildOpenAIStoreSessionId(sessionId: unknown): string | undefined { + if (!hasNonEmptyString(sessionId)) return undefined; + + const normalized = String(sessionId) + .trim() + .replace(/^ext:/i, "") + .replace(/[^a-zA-Z0-9._:-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 96); + + if (!normalized) return undefined; + return `omniroute-session-${normalized}`; +} + +export function ensureOpenAIStoreSessionFallback( + body: Record, + sessionId: unknown +): Record { + const explicitSessionId = body.session_id; + const explicitConversationId = body.conversation_id; + const promptCacheKey = body.prompt_cache_key ?? body.promptCacheKey; + + if ( + hasNonEmptyString(explicitSessionId) || + hasNonEmptyString(explicitConversationId) || + hasNonEmptyString(promptCacheKey) + ) { + return body; + } + + const fallbackSessionId = buildOpenAIStoreSessionId(sessionId); + if (!fallbackSessionId) return body; + + return { + ...body, + session_id: fallbackSessionId, + }; +} + export function getProviderRequestDefaults( provider: string | null | undefined, providerSpecificData: unknown diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 800f86c356..fa57df9a53 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -68,6 +68,8 @@ type CallLogArtifact = { pipeline?: RequestPipelinePayloads; }; +const CALL_LOG_INLINE_BODY_LIMIT = 256 * 1024; + function asRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } @@ -427,8 +429,8 @@ export async function saveCallLog(entry: any) { comboStepId: toStringOrNull(entry.comboStepId), comboExecutionKey: toStringOrNull(entry.comboExecutionKey) || toStringOrNull(entry.comboStepId), - requestBody: serializePayloadForStorage(protectedRequestBody, 8192), - responseBody: serializePayloadForStorage(protectedResponseBody, 8192), + requestBody: serializePayloadForStorage(protectedRequestBody, CALL_LOG_INLINE_BODY_LIMIT), + responseBody: serializePayloadForStorage(protectedResponseBody, CALL_LOG_INLINE_BODY_LIMIT), error: toStoredErrorString(protectedError), }; diff --git a/src/shared/constants/mcpScopes.ts b/src/shared/constants/mcpScopes.ts index 45378b048a..7a55ec9de5 100644 --- a/src/shared/constants/mcpScopes.ts +++ b/src/shared/constants/mcpScopes.ts @@ -47,6 +47,7 @@ export const MCP_TOOL_SCOPES: Record = { 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 ============ diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 691e021908..f99fb200e2 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -42,6 +42,15 @@ function validateProviderSpecificData( }); } + 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)) { diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 1ef674b49b..8e279ac970 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -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" && diff --git a/tests/integration/integration-wiring.test.mjs b/tests/integration/integration-wiring.test.mjs index a7b164ab61..1ccaa9bf55 100644 --- a/tests/integration/integration-wiring.test.mjs +++ b/tests/integration/integration-wiring.test.mjs @@ -316,4 +316,11 @@ describe("Page Integration — combos page empty state", () => { assert.match(src, /pricingCoverage/); assert.match(src, /warningCostOptimizedPartialPricing/); }); + + it("should wire combo account labels to the global email privacy toggle", () => { + assert.match(src, /EmailPrivacyToggle/); + assert.match(src, /useEmailPrivacyStore/); + assert.match(src, /pickDisplayValue/); + assert.match(src, /emailVisibilityTooltip/); + }); }); diff --git a/tests/unit/call-log-cap.test.mjs b/tests/unit/call-log-cap.test.mjs index 5335cc212f..80ef62f9cb 100644 --- a/tests/unit/call-log-cap.test.mjs +++ b/tests/unit/call-log-cap.test.mjs @@ -443,6 +443,64 @@ test("getCallLogById returns legacy pipeline details even when no legacy disk ar assert.equal(detail?.hasPipelineDetails, true); }); +test("saveCallLog keeps payloads below 256KB inline in sqlite", async () => { + const requestBody = { payload: "x".repeat(64 * 1024) }; + const responseBody = { payload: "y".repeat(96 * 1024) }; + + await callLogs.saveCallLog({ + id: "inline-payload-limit", + timestamp: "2026-03-31T09:00:00.000Z", + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "openai/gpt-4.1", + provider: "openai", + duration: 5, + requestBody, + responseBody, + }); + + const db = core.getDbInstance(); + const row = db + .prepare("SELECT request_body, response_body FROM call_logs WHERE id = ?") + .get("inline-payload-limit"); + const storedRequest = JSON.parse(row.request_body); + const storedResponse = JSON.parse(row.response_body); + + assert.equal(storedRequest._truncated, undefined); + assert.equal(storedResponse._truncated, undefined); + + const detail = await callLogs.getCallLogById("inline-payload-limit"); + assert.equal(detail?.requestBody?.payload?.length, requestBody.payload.length); + assert.equal(detail?.responseBody?.payload?.length, responseBody.payload.length); +}); + +test("saveCallLog still truncates oversized inline sqlite payloads above 256KB", async () => { + const requestBody = { payload: "x".repeat(320 * 1024) }; + + await callLogs.saveCallLog({ + id: "truncated-inline-payload-limit", + timestamp: "2026-03-31T09:05:00.000Z", + method: "POST", + path: "/v1/chat/completions", + status: 500, + model: "openai/gpt-4.1", + provider: "openai", + duration: 7, + requestBody, + }); + + const db = core.getDbInstance(); + const row = db + .prepare("SELECT request_body FROM call_logs WHERE id = ?") + .get("truncated-inline-payload-limit"); + const storedRequest = JSON.parse(row.request_body); + + assert.equal(storedRequest._truncated, true); + assert.equal(storedRequest._preview.length, 256 * 1024); + assert.ok(storedRequest._originalSize > storedRequest._preview.length); +}); + test("saveCallLog logs and returns when sqlite persistence throws unexpectedly", async () => { const db = core.getDbInstance(); const originalPrepare = db.prepare; diff --git a/tests/unit/codex-connection-defaults.test.mjs b/tests/unit/codex-connection-defaults.test.mjs index 76cfaf218f..b9bd0ab5ce 100644 --- a/tests/unit/codex-connection-defaults.test.mjs +++ b/tests/unit/codex-connection-defaults.test.mjs @@ -99,6 +99,7 @@ test("provider connection persistence normalizes request defaults without droppi authType: "oauth", email: "normalize@example.com", providerSpecificData: { + openaiStoreEnabled: true, workspaceId: "ws-normalize", tag: "team-z", requestDefaults: { @@ -114,6 +115,7 @@ test("provider connection persistence normalizes request defaults without droppi serviceTier: "priority", customFlag: "keep-me", }); + assert.equal(created.providerSpecificData.openaiStoreEnabled, true); assert.equal(created.providerSpecificData.workspaceId, "ws-normalize"); assert.equal(created.providerSpecificData.tag, "team-z"); @@ -127,6 +129,7 @@ test("provider connection persistence normalizes request defaults without droppi assert.deepEqual(updated.providerSpecificData.requestDefaults, { reasoningEffort: "medium", }); + assert.equal(updated.providerSpecificData.openaiStoreEnabled, true); assert.equal(updated.providerSpecificData.workspaceId, "ws-normalize"); assert.equal(updated.providerSpecificData.tag, "team-z"); }); diff --git a/tests/unit/codex-stream-false.test.mjs b/tests/unit/codex-stream-false.test.mjs new file mode 100644 index 0000000000..714b082112 --- /dev/null +++ b/tests/unit/codex-stream-false.test.mjs @@ -0,0 +1,254 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-stream-false-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts"); +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const { CodexExecutor } = await import("../../open-sse/executors/codex.ts"); + +const originalFetch = globalThis.fetch; + +function noopLog() { + return { + debug() {}, + info() {}, + warn() {}, + error() {}, + }; +} + +function createComboLog() { + const entries = []; + return { + info: (tag, msg) => entries.push({ level: "info", tag, msg }), + warn: (tag, msg) => entries.push({ level: "warn", tag, msg }), + error: (tag, msg) => entries.push({ level: "error", tag, msg }), + entries, + }; +} + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function buildResponsesSse(text = "Brasilia") { + return new Response( + [ + "event: response.created", + 'data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5.3-codex","status":"in_progress","output":[]}}', + "", + "event: response.output_text.delta", + `data: ${JSON.stringify({ + type: "response.output_text.delta", + output_index: 0, + delta: text, + })}`, + "", + "event: response.completed", + `data: ${JSON.stringify({ + type: "response.completed", + response: { + id: "resp_1", + object: "response", + model: "gpt-5.3-codex", + status: "completed", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text }], + }, + ], + usage: { input_tokens: 6, output_tokens: 1 }, + }, + })}`, + "", + "data: [DONE]", + "", + ].join("\n"), + { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + } + ); +} + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function waitForAsyncSideEffects() { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setTimeout(resolve, 10)); +} + +async function invokeChatCore({ + body, + provider = "openai", + model = "gpt-4o-mini", + endpoint = "/v1/chat/completions", + accept = "application/json", + responseFactory, +} = {}) { + const calls = []; + + globalThis.fetch = async (url, init = {}) => { + const headers = + init.headers instanceof Headers + ? Object.fromEntries(init.headers.entries()) + : init.headers || {}; + const call = { + url: String(url), + method: init.method || "GET", + headers, + body: init.body ? JSON.parse(String(init.body)) : null, + }; + calls.push(call); + return responseFactory(call); + }; + + try { + const requestBody = structuredClone(body); + const result = await handleChatCore({ + body: requestBody, + modelInfo: { provider, model, extendedContext: false }, + credentials: { + apiKey: "sk-test", + accessToken: "codex-token", + providerSpecificData: {}, + }, + log: noopLog(), + clientRawRequest: { + endpoint, + body: structuredClone(body), + headers: new Headers({ accept }), + }, + userAgent: "unit-test", + }); + await waitForAsyncSideEffects(); + return { result, calls, call: calls.at(-1) }; + } finally { + globalThis.fetch = originalFetch; + } +} + +test.beforeEach(async () => { + globalThis.fetch = originalFetch; + await resetStorage(); +}); + +test.after(async () => { + globalThis.fetch = originalFetch; + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("CodexExecutor.transformRequest clones the request body before forcing stream=true", () => { + const executor = new CodexExecutor(); + const body = { + model: "gpt-5.4", + input: [{ role: "user", content: [{ type: "input_text", text: "Oi" }] }], + stream: false, + reasoning: { effort: "low" }, + }; + const original = structuredClone(body); + + const transformed = executor.transformRequest("gpt-5.4", body, false, { + requestEndpointPath: "/responses", + }); + + assert.notStrictEqual(transformed, body); + assert.deepEqual(body, original); + assert.equal(transformed.stream, true); +}); + +test("chatCore converts Responses-style SSE fallback into JSON when stream=false", async () => { + const { result, call } = await invokeChatCore({ + body: { + model: "gpt-4o-mini", + stream: false, + messages: [{ role: "user", content: "Qual a capital do Brasil?" }], + }, + provider: "openai", + model: "gpt-4o-mini", + responseFactory: () => buildResponsesSse("Brasilia"), + }); + + const payload = await result.response.json(); + + assert.equal(result.success, true); + assert.equal(call.headers.Accept || call.headers.accept, "application/json"); + assert.equal(payload.object, "chat.completion"); + assert.equal(payload.choices[0].message.content, "Brasilia"); + assert.ok(payload.usage.total_tokens >= 7); + assert.ok(payload.usage.prompt_tokens > 0); + assert.ok(payload.usage.completion_tokens > 0); +}); + +test("handleComboChat validates non-stream quality using the original client stream intent", async () => { + const combo = { + name: "codex-stream-false-quality", + models: ["codex/gpt-5.4", "openai/gpt-4o-mini"], + }; + const log = createComboLog(); + const seenModels = []; + + const result = await handleComboChat({ + body: { + stream: false, + messages: [{ role: "user", content: "Qual a capital do Brasil?" }], + }, + combo, + handleSingleModel: async (requestBody, modelStr) => { + seenModels.push(modelStr); + if (modelStr === "codex/gpt-5.4") { + requestBody.stream = true; + return jsonResponse({ + choices: [ + { index: 0, message: { role: "assistant", content: "" }, finish_reason: "stop" }, + ], + }); + } + + return jsonResponse({ + choices: [ + { + index: 0, + message: { role: "assistant", content: "Brasilia" }, + finish_reason: "stop", + }, + ], + }); + }, + isModelAvailable: async () => true, + log, + settings: null, + allCombos: null, + }); + + const payload = await result.json(); + + assert.equal(result.ok, true); + assert.deepEqual(seenModels, ["codex/gpt-5.4", "openai/gpt-4o-mini"]); + assert.equal(payload.choices[0].message.content, "Brasilia"); + assert.ok( + log.entries.some( + (entry) => + entry.level === "warn" && + String(entry.msg).includes( + "failed quality check: empty content and no tool_calls in response" + ) + ) + ); +}); diff --git a/tests/unit/combo-routing-engine.test.mjs b/tests/unit/combo-routing-engine.test.mjs index 08bdaac5b3..0c3c46ef2b 100644 --- a/tests/unit/combo-routing-engine.test.mjs +++ b/tests/unit/combo-routing-engine.test.mjs @@ -866,7 +866,36 @@ test("handleComboChat returns the earliest retry-after when all priority targets assert.ok(Number(result.headers.get("Retry-After")) >= 1); }); -test("handleComboChat round-robin returns 503 when no models are configured", async () => { +test("handleComboChat returns 404 model_not_found when a combo has no executable targets", async () => { + const result = await handleComboChat({ + body: {}, + combo: { + name: "empty-priority", + strategy: "priority", + models: [], + }, + handleSingleModel: async () => { + throw new Error("handleSingleModel should not run for empty combos"); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: { + comboDefaults: { + maxRetries: 0, + retryDelayMs: 1, + }, + }, + allCombos: null, + }); + + const payload = await result.json(); + + assert.equal(result.status, 404); + assert.equal(payload.error.code, "model_not_found"); + assert.match(payload.error.message, /Combo has no executable targets/); +}); + +test("handleComboChat round-robin returns 404 when no models are configured", async () => { const result = await handleComboChat({ body: {}, combo: { @@ -890,8 +919,11 @@ test("handleComboChat round-robin returns 503 when no models are configured", as allCombos: null, }); - assert.equal(result.status, 503); - assert.match((await result.json()).error.message, /Round-robin combo has no executable targets/); + const payload = await result.json(); + + assert.equal(result.status, 404); + assert.equal(payload.error.code, "model_not_found"); + assert.match(payload.error.message, /Round-robin combo has no executable targets/); }); test("handleComboChat round-robin falls through semaphore timeouts and malformed success payloads", async () => { diff --git a/tests/unit/db-health-check.test.mjs b/tests/unit/db-health-check.test.mjs new file mode 100644 index 0000000000..cf05e68845 --- /dev/null +++ b/tests/unit/db-health-check.test.mjs @@ -0,0 +1,314 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-health-check-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "task-303-api-key-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const healthCheckDb = await import("../../src/lib/db/healthCheck.ts"); + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function insertBrokenRows(db) { + db.prepare( + `INSERT INTO quota_snapshots + (provider, connection_id, window_key, remaining_percentage, is_exhausted, created_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).run("openai", "missing-conn", "monthly", 75, 0, new Date().toISOString()); + + db.prepare( + `INSERT INTO quota_snapshots + (provider, connection_id, window_key, remaining_percentage, is_exhausted, created_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).run("openai", "missing-conn-2", "monthly", 55, 0, "not-a-timestamp"); + + db.prepare( + "INSERT INTO domain_budgets (api_key_id, daily_limit_usd, monthly_limit_usd, warning_threshold) VALUES (?, ?, ?, ?)" + ).run("missing-key", 10, 100, 0.8); + db.prepare("INSERT INTO domain_cost_history (api_key_id, cost, timestamp) VALUES (?, ?, ?)").run( + "missing-key", + 1.5, + Date.now() + ); + db.prepare("INSERT INTO domain_fallback_chains (model, chain) VALUES (?, ?)").run( + "broken-model", + "{invalid" + ); + db.prepare( + "INSERT INTO domain_lockout_state (identifier, attempts, locked_until) VALUES (?, ?, ?)" + ).run("broken-lockout", "{invalid", null); + db.prepare( + "INSERT INTO domain_circuit_breakers (name, state, failure_count, last_failure_time, options) VALUES (?, ?, ?, ?, ?)" + ).run("broken-breaker", "OPEN", 3, Date.now(), "{invalid"); +} + +test("runDbHealthCheck reports issues without mutating when autoRepair is disabled", async () => { + const db = core.getDbInstance(); + insertBrokenRows(db); + + const result = healthCheckDb.runDbHealthCheck(db, { autoRepair: false }); + + assert.equal(result.isHealthy, false); + assert.equal(result.repairedCount, 0); + assert.equal(result.issues.length, 6); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots").get().count, 2); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get().count, 1); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_fallback_chains").get().count, 1); +}); + +test("runDbHealthCheck auto-repairs orphan rows and invalid JSON payloads", async () => { + const db = core.getDbInstance(); + insertBrokenRows(db); + + const result = healthCheckDb.runDbHealthCheck(db, { + autoRepair: true, + createBackupBeforeRepair: () => true, + }); + + assert.equal(result.isHealthy, false); + assert.equal(result.backupCreated, true); + assert.equal(result.repairedCount, 7); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots").get().count, 0); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get().count, 0); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_cost_history").get().count, 0); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_fallback_chains").get().count, 0); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_lockout_state").get().count, 0); + assert.equal( + db.prepare("SELECT options FROM domain_circuit_breakers WHERE name = ?").get("broken-breaker") + .options, + null + ); +}); + +test("runDbHealthCheck repairs broken combo payloads, combo refs and stale connection pins", async () => { + const db = core.getDbInstance(); + const now = new Date().toISOString(); + const activeConnection = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "Healthy Connection", + apiKey: "sk-healthy", + }); + + db.prepare( + "INSERT INTO combos (id, name, data, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" + ).run("combo-invalid", "combo-invalid", "{invalid", 1, now, now); + + db.prepare( + "INSERT INTO combos (id, name, data, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" + ).run( + "combo-broken", + "combo-broken", + JSON.stringify({ + id: "combo-broken", + name: "combo-broken", + strategy: "priority", + models: [ + { id: "ref-missing", kind: "combo-ref", comboName: "missing-child", weight: 0 }, + { + id: "model-pinned", + kind: "model", + providerId: "openai", + model: "openai/gpt-4o-mini", + connectionId: "missing-connection", + weight: 0, + }, + { + id: "model-healthy", + kind: "model", + providerId: "openai", + model: "openai/gpt-4o-mini", + connectionId: activeConnection.id, + weight: 0, + }, + ], + config: {}, + isActive: true, + createdAt: now, + updatedAt: now, + }), + 2, + now, + now + ); + + const result = healthCheckDb.runDbHealthCheck(db, { + autoRepair: true, + createBackupBeforeRepair: () => false, + }); + const invalidCombo = JSON.parse( + db.prepare("SELECT data FROM combos WHERE id = ?").get("combo-invalid").data + ); + const repairedCombo = JSON.parse( + db.prepare("SELECT data FROM combos WHERE id = ?").get("combo-broken").data + ); + + assert.equal( + result.issues.some((issue) => issue.table === "combos"), + true + ); + assert.equal(result.repairedCount, 3); + assert.equal(invalidCombo.isActive, false); + assert.match(invalidCombo.repairNote, /invalid JSON/i); + assert.equal(repairedCombo.models.length, 2); + assert.equal( + repairedCombo.models.some((step) => step.kind === "combo-ref"), + false + ); + assert.equal("connectionId" in repairedCombo.models[0], false); + assert.equal(repairedCombo.models[1].connectionId, activeConnection.id); + assert.match(repairedCombo.repairNote, /broken combo step/i); +}); + +test("getDbInstance can auto-repair persisted broken rows when startup repair is forced", async () => { + let db = core.getDbInstance(); + insertBrokenRows(db); + core.resetDbInstance(); + + const previousForce = process.env.OMNIROUTE_FORCE_DB_HEALTHCHECK; + process.env.OMNIROUTE_FORCE_DB_HEALTHCHECK = "1"; + try { + db = core.getDbInstance(); + } finally { + if (previousForce === undefined) { + delete process.env.OMNIROUTE_FORCE_DB_HEALTHCHECK; + } else { + process.env.OMNIROUTE_FORCE_DB_HEALTHCHECK = previousForce; + } + } + + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots").get().count, 0); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get().count, 0); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_cost_history").get().count, 0); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_fallback_chains").get().count, 0); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_lockout_state").get().count, 0); + assert.equal( + db.prepare("SELECT options FROM domain_circuit_breakers WHERE name = ?").get("broken-breaker") + .options, + null + ); +}); + +test("getDbInstance skips automatic startup repair during tests unless forced", async () => { + let db = core.getDbInstance(); + insertBrokenRows(db); + core.resetDbInstance(); + + db = core.getDbInstance(); + + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots").get().count, 2); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get().count, 1); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_cost_history").get().count, 1); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_fallback_chains").get().count, 1); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_lockout_state").get().count, 1); +}); + +test("runDbHealthCheck repairs a drifted db_meta schema version", async () => { + const db = core.getDbInstance(); + db.prepare("UPDATE db_meta SET value = ? WHERE key = 'schema_version'").run("0"); + + const result = healthCheckDb.runDbHealthCheck(db, { + autoRepair: true, + createBackupBeforeRepair: () => false, + }); + + assert.equal( + result.issues.some((issue) => issue.table === "db_meta"), + true + ); + assert.equal( + db.prepare("SELECT value FROM db_meta WHERE key = 'schema_version'").get().value, + "1" + ); +}); + +test("deleteApiKey removes domain budget and cost history rows for that key", async () => { + const created = await apiKeysDb.createApiKey("Cleanup Key", "machine-health"); + const db = core.getDbInstance(); + + db.prepare( + "INSERT INTO domain_budgets (api_key_id, daily_limit_usd, monthly_limit_usd, warning_threshold) VALUES (?, ?, ?, ?)" + ).run(created.id, 5, 50, 0.9); + db.prepare("INSERT INTO domain_cost_history (api_key_id, cost, timestamp) VALUES (?, ?, ?)").run( + created.id, + 0.5, + Date.now() + ); + + assert.equal(await apiKeysDb.deleteApiKey(created.id), true); + assert.equal( + db.prepare("SELECT COUNT(*) AS count FROM domain_budgets WHERE api_key_id = ?").get(created.id) + .count, + 0 + ); + assert.equal( + db + .prepare("SELECT COUNT(*) AS count FROM domain_cost_history WHERE api_key_id = ?") + .get(created.id).count, + 0 + ); +}); + +test("deleteProviderConnection and bulk delete remove related quota snapshots", async () => { + const first = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "Primary", + apiKey: "sk-primary", + }); + const second = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "Secondary", + apiKey: "sk-secondary", + }); + const db = core.getDbInstance(); + + db.prepare( + `INSERT INTO quota_snapshots + (provider, connection_id, window_key, remaining_percentage, is_exhausted, created_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).run("openai", first.id, "monthly", 80, 0, new Date().toISOString()); + db.prepare( + `INSERT INTO quota_snapshots + (provider, connection_id, window_key, remaining_percentage, is_exhausted, created_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).run("openai", second.id, "monthly", 40, 0, new Date().toISOString()); + + assert.equal(await providersDb.deleteProviderConnection(first.id), true); + assert.equal( + db + .prepare("SELECT COUNT(*) AS count FROM quota_snapshots WHERE connection_id = ?") + .get(first.id).count, + 0 + ); + + await providersDb.deleteProviderConnectionsByProvider("openai"); + assert.equal( + db + .prepare("SELECT COUNT(*) AS count FROM quota_snapshots WHERE connection_id = ?") + .get(second.id).count, + 0 + ); +}); diff --git a/tests/unit/db-health-route.test.mjs b/tests/unit/db-health-route.test.mjs new file mode 100644 index 0000000000..91eb01b9d5 --- /dev/null +++ b/tests/unit/db-health-route.test.mjs @@ -0,0 +1,97 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-health-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "task-303-api-key-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const routeModule = await import("../../src/app/api/v1/db/health/route.ts"); + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function makeRequest(method, token) { + return new Request("http://localhost/api/v1/db/health", { + method, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); +} + +function insertBrokenRows(db) { + db.prepare( + `INSERT INTO quota_snapshots + (provider, connection_id, window_key, remaining_percentage, is_exhausted, created_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).run("openai", "missing-conn", "monthly", 75, 0, new Date().toISOString()); + db.prepare( + "INSERT INTO domain_budgets (api_key_id, daily_limit_usd, monthly_limit_usd, warning_threshold) VALUES (?, ?, ?, ?)" + ).run("missing-key", 10, 100, 0.8); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("GET /api/v1/db/health requires authentication", async () => { + const previousInitialPassword = process.env.INITIAL_PASSWORD; + process.env.INITIAL_PASSWORD = "route-health-auth"; + + try { + const response = await routeModule.GET(makeRequest("GET")); + const body = await response.json(); + + assert.equal(response.status, 401); + assert.equal(body.error.message, "Authentication required"); + } finally { + if (previousInitialPassword === undefined) { + delete process.env.INITIAL_PASSWORD; + } else { + process.env.INITIAL_PASSWORD = previousInitialPassword; + } + } +}); + +test("GET /api/v1/db/health diagnoses without mutating database rows", async () => { + const authKey = await apiKeysDb.createApiKey("Health Route", "machine-route-health"); + const db = core.getDbInstance(); + insertBrokenRows(db); + + const response = await routeModule.GET(makeRequest("GET", authKey.key)); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.isHealthy, false); + assert.equal(body.repairedCount, 0); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots").get().count, 1); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get().count, 1); +}); + +test("POST /api/v1/db/health repairs broken rows for authenticated callers", async () => { + const authKey = await apiKeysDb.createApiKey("Health Route", "machine-route-health"); + const db = core.getDbInstance(); + insertBrokenRows(db); + + const response = await routeModule.POST(makeRequest("POST", authKey.key)); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.isHealthy, false); + assert.equal(body.repairedCount, 2); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots").get().count, 0); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get().count, 0); +}); diff --git a/tests/unit/executor-codex.test.mjs b/tests/unit/executor-codex.test.mjs index 96865046d2..35523ae50d 100644 --- a/tests/unit/executor-codex.test.mjs +++ b/tests/unit/executor-codex.test.mjs @@ -130,6 +130,29 @@ test("CodexExecutor.transformRequest preserves compact requests and native passt assert.equal(result.instructions, "keep this"); }); +test("CodexExecutor.transformRequest preserves store-enabled responses state when explicitly enabled", () => { + const executor = new CodexExecutor(); + const body = { + _nativeCodexPassthrough: true, + _omnirouteResponsesStore: true, + instructions: "keep this", + previous_response_id: "resp_prev_123", + stream: false, + }; + + const result = executor.transformRequest("gpt-5.3-codex", body, false, { + requestEndpointPath: "/responses/compact", + providerSpecificData: { + openaiStoreEnabled: true, + requestDefaults: { serviceTier: "priority" }, + }, + }); + + assert.equal(result._omnirouteResponsesStore, undefined); + assert.equal(result.store, true); + assert.equal(result.previous_response_id, "resp_prev_123"); +}); + test("CodexExecutor.transformRequest applies per-connection reasoning and service tier defaults", () => { const executor = new CodexExecutor(); const result = executor.transformRequest( diff --git a/tests/unit/executor-cursor-extended.test.mjs b/tests/unit/executor-cursor-extended.test.mjs index ccd326a0a6..d793c4f4e3 100644 --- a/tests/unit/executor-cursor-extended.test.mjs +++ b/tests/unit/executor-cursor-extended.test.mjs @@ -9,6 +9,7 @@ import { wrapConnectRPCFrame, } from "../../open-sse/utils/cursorProtobuf.ts"; import { + buildCursorHeaders, generateCursorChecksum, generateHashed64Hex, generateSessionId, @@ -100,6 +101,9 @@ test("CursorExecutor.buildHeaders strips token prefixes and derives checksum/ses assert.equal(headers["x-client-key"], generateHashed64Hex("real-token")); assert.equal(headers["x-session-id"], generateSessionId("real-token")); assert.equal(headers["x-cursor-checksum"], generateCursorChecksum("machine-1")); + assert.equal(headers["x-cursor-client-version"], "3.1.0"); + assert.equal(headers["x-cursor-user-agent"], "Cursor/3.1.0"); + assert.equal(headers["user-agent"], "Cursor/3.1.0"); assert.equal(headers["x-ghost-mode"], "false"); assert.equal(headers["connect-protocol-version"], "1"); assert.match(headers["x-amzn-trace-id"], /^Root=/); @@ -109,6 +113,16 @@ test("CursorExecutor.buildHeaders strips token prefixes and derives checksum/ses } }); +test("buildCursorHeaders utility stays aligned with Cursor Composer 2 versioned headers", () => { + const headers = buildCursorHeaders("prefix::real-token", "machine-1", false); + + assert.equal(headers.Authorization, "Bearer real-token"); + assert.equal(headers["x-cursor-client-version"], "3.1.0"); + assert.equal(headers["x-cursor-user-agent"], "Cursor/3.1.0"); + assert.equal(headers["User-Agent"], "Cursor/3.1.0"); + assert.equal(headers["x-ghost-mode"], "false"); +}); + test("CursorExecutor.buildHeaders requires a machine ID", () => { const executor = new CursorExecutor(); assert.throws( diff --git a/tests/unit/executor-default-base.test.mjs b/tests/unit/executor-default-base.test.mjs index 087445a2ce..9b374a8e64 100644 --- a/tests/unit/executor-default-base.test.mjs +++ b/tests/unit/executor-default-base.test.mjs @@ -328,6 +328,19 @@ test("DefaultExecutor.transformRequest is a passthrough and preserves model ids assert.equal(result.model, "zai-org/GLM-5-FP8"); }); +test("DefaultExecutor.transformRequest neutralizes incompatible tool_choice for Qwen thinking", () => { + const executor = new DefaultExecutor("qwen"); + const body = { + messages: [{ role: "user", content: "hi" }], + thinking: { type: "enabled" }, + tool_choice: { type: "function", function: { name: "pwd" } }, + }; + const result = executor.transformRequest("qwen3-coder-plus", body, true, {}); + + assert.notEqual(result, body); + assert.equal(result.tool_choice, "auto"); +}); + test("BaseExecutor helpers manage custom user agents and upstream extra headers", () => { const headers = { "user-agent": "old", Authorization: "Bearer old" }; diff --git a/tests/unit/mask-email.test.mjs b/tests/unit/mask-email.test.mjs index 6fa1db5e4d..92873ea474 100644 --- a/tests/unit/mask-email.test.mjs +++ b/tests/unit/mask-email.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { maskEmail, maskEmailLikeValue, + pickDisplayValue, pickMaskedDisplayValue, } from "../../src/shared/utils/maskEmail.ts"; @@ -63,4 +64,16 @@ describe("maskEmail", () => { ); assert.equal(pickMaskedDisplayValue([null, "Workspace"], "fallback"), "Workspace"); }); + + it("respects the global visibility toggle when picking display values", () => { + assert.equal( + pickDisplayValue(["person@example.com", "Workspace"], false, "fallback"), + "per***@********com" + ); + assert.equal( + pickDisplayValue(["person@example.com", "Workspace"], true, "fallback"), + "person@example.com" + ); + assert.equal(pickDisplayValue([null, "Workspace"], false, "fallback"), "Workspace"); + }); }); diff --git a/tests/unit/provider-specific-data-schema.test.mjs b/tests/unit/provider-specific-data-schema.test.mjs new file mode 100644 index 0000000000..879238ffdb --- /dev/null +++ b/tests/unit/provider-specific-data-schema.test.mjs @@ -0,0 +1,43 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { createProviderSchema, updateProviderConnectionSchema } = + await import("../../src/shared/validation/schemas.ts"); + +test("provider schemas accept boolean openaiStoreEnabled in providerSpecificData", () => { + const created = createProviderSchema.safeParse({ + provider: "codex", + apiKey: "token", + name: "Codex", + providerSpecificData: { + openaiStoreEnabled: true, + }, + }); + const updated = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { + openaiStoreEnabled: false, + }, + }); + + assert.equal(created.success, true); + assert.equal(updated.success, true); +}); + +test("provider schemas reject non-boolean openaiStoreEnabled values", () => { + const created = createProviderSchema.safeParse({ + provider: "codex", + apiKey: "token", + name: "Codex", + providerSpecificData: { + openaiStoreEnabled: "yes", + }, + }); + const updated = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { + openaiStoreEnabled: "no", + }, + }); + + assert.equal(created.success, false); + assert.equal(updated.success, false); +}); diff --git a/tests/unit/qoder-executor.test.mjs b/tests/unit/qoder-executor.test.mjs index e13f879852..b7157284d6 100644 --- a/tests/unit/qoder-executor.test.mjs +++ b/tests/unit/qoder-executor.test.mjs @@ -236,3 +236,27 @@ test("QoderExecutor: stream calls pass through successful SSE responses", async globalThis.fetch = originalFetch; } }); + +test("QoderExecutor: neutralizes incompatible tool_choice when Qwen thinking is active", () => { + const executor = new QoderExecutor(); + const result = executor.transformRequest("qwen3-coder-plus", { + messages: [{ role: "user", content: "hi" }], + thinking: true, + tool_choice: "required", + }); + + assert.equal(result.model, "qwen3-coder-plus"); + assert.equal(result.tool_choice, "auto"); +}); + +test("QoderExecutor: preserves tool_choice when thinking is inactive", () => { + const executor = new QoderExecutor(); + const forcedTool = { type: "function", function: { name: "pwd" } }; + const result = executor.transformRequest("qwen3-coder-plus", { + messages: [{ role: "user", content: "hi" }], + tool_choice: forcedTool, + }); + + assert.equal(result.model, "qwen3-coder-plus"); + assert.deepEqual(result.tool_choice, forcedTool); +}); diff --git a/tests/unit/request-defaults-store-session.test.mjs b/tests/unit/request-defaults-store-session.test.mjs new file mode 100644 index 0000000000..0fdf54a5fd --- /dev/null +++ b/tests/unit/request-defaults-store-session.test.mjs @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildOpenAIStoreSessionId, ensureOpenAIStoreSessionFallback } = + await import("../../src/lib/providers/requestDefaults.ts"); + +test("buildOpenAIStoreSessionId normalizes external and generated session ids", () => { + assert.equal( + buildOpenAIStoreSessionId("ext:client session/abc"), + "omniroute-session-client-session-abc" + ); + assert.equal( + buildOpenAIStoreSessionId(" internal:session "), + "omniroute-session-internal:session" + ); + assert.equal(buildOpenAIStoreSessionId(""), undefined); +}); + +test("ensureOpenAIStoreSessionFallback injects session_id only when no stable cache key exists", () => { + const injected = ensureOpenAIStoreSessionFallback({ model: "gpt-5.3-codex" }, "ext:session-1"); + assert.equal(injected.session_id, "omniroute-session-session-1"); + + const withPromptCacheKey = ensureOpenAIStoreSessionFallback( + { model: "gpt-5.3-codex", prompt_cache_key: "cache-123" }, + "ext:session-2" + ); + assert.equal(withPromptCacheKey.session_id, undefined); + + const withConversation = ensureOpenAIStoreSessionFallback( + { model: "gpt-5.3-codex", conversation_id: "conv-1" }, + "ext:session-3" + ); + assert.equal(withConversation.session_id, undefined); + + const withExplicitSession = ensureOpenAIStoreSessionFallback( + { model: "gpt-5.3-codex", session_id: "existing-session" }, + "ext:session-4" + ); + assert.equal(withExplicitSession.session_id, "existing-session"); +}); diff --git a/tests/unit/response-sanitizer.test.mjs b/tests/unit/response-sanitizer.test.mjs index baf4e659e4..b4fc2dc11e 100644 --- a/tests/unit/response-sanitizer.test.mjs +++ b/tests/unit/response-sanitizer.test.mjs @@ -33,7 +33,7 @@ test("sanitizeOpenAIResponse strips non-standard fields and preserves required t }); }); -test("sanitizeOpenAIResponse extracts thinking, collapses newlines, and preserves tool calls", () => { +test("sanitizeOpenAIResponse extracts thinking, collapses newlines, strips final reasoning_content, and preserves tool calls", () => { const sanitized = sanitizeOpenAIResponse({ id: "chatcmpl_test", model: "gpt-4.1", @@ -54,26 +54,26 @@ test("sanitizeOpenAIResponse extracts thinking, collapses newlines, and preserve assert.equal(sanitized.choices[0].index, 2); assert.equal(sanitized.choices[0].finish_reason, "tool_calls"); assert.equal(sanitized.choices[0].message.content, "Hello\n\nworld"); - assert.equal(sanitized.choices[0].message.reasoning_content, "internal chain"); + assert.equal(sanitized.choices[0].message.reasoning_content, undefined); assert.deepEqual(sanitized.choices[0].message.tool_calls, [{ id: "call_1" }]); assert.deepEqual(sanitized.choices[0].message.function_call, { name: "legacy" }); }); -test("sanitizeOpenAIResponse preserves native reasoning_content over extracted think tags", () => { +test("sanitizeOpenAIResponse preserves native reasoning_content when no visible content remains", () => { const sanitized = sanitizeOpenAIResponse({ model: "gpt-4.1", choices: [ { message: { role: "assistant", - content: "discard meVisible text", + content: "discard me", reasoning_content: "provider reasoning", }, }, ], }); - assert.equal(sanitized.choices[0].message.content, "Visible text"); + assert.equal(sanitized.choices[0].message.content, ""); assert.equal(sanitized.choices[0].message.reasoning_content, "provider reasoning"); }); @@ -96,7 +96,7 @@ test("sanitizeOpenAIResponse maps Claude-style usage fields and strips extras", }); }); -test("sanitizeOpenAIResponse normalizes reasoning_details arrays into reasoning_content", () => { +test("sanitizeOpenAIResponse strips reasoning_details-derived reasoning_content when visible text exists", () => { const sanitized = sanitizeOpenAIResponse({ model: "openrouter/model", choices: [ @@ -114,6 +114,26 @@ test("sanitizeOpenAIResponse normalizes reasoning_details arrays into reasoning_ ], }); + assert.equal(sanitized.choices[0].message.reasoning_content, undefined); +}); + +test("sanitizeOpenAIResponse keeps reasoning_details-derived reasoning_content for reasoning-only messages", () => { + const sanitized = sanitizeOpenAIResponse({ + model: "openrouter/model", + choices: [ + { + message: { + role: "assistant", + content: "", + reasoning_details: [ + { type: "reasoning.text", text: "first " }, + { type: "thinking", content: "second" }, + ], + }, + }, + ], + }); + assert.equal(sanitized.choices[0].message.reasoning_content, "first second"); }); diff --git a/tests/unit/responses-handler.test.mjs b/tests/unit/responses-handler.test.mjs index 36f02b62d0..ce47c61e5c 100644 --- a/tests/unit/responses-handler.test.mjs +++ b/tests/unit/responses-handler.test.mjs @@ -175,6 +175,30 @@ test("handleResponsesCore preserves previous_response_id and handles empty input assert.equal(call.body.stream, true); }); +test("handleResponsesCore preserves store for Codex responses when connection opt-in is enabled", async () => { + const { call, result } = await invokeResponsesCore({ + body: { + model: "gpt-5.3-codex", + input: [], + previous_response_id: "resp_prev_store", + store: true, + }, + provider: "codex", + model: "gpt-5.3-codex", + credentials: { + accessToken: "codex-token", + providerSpecificData: { + openaiStoreEnabled: true, + }, + }, + }); + + assert.equal(result.success, true); + assert.equal(call.body.previous_response_id, "resp_prev_store"); + assert.equal(call.body.store, true); + assert.equal(call.body.stream, true); +}); + test("handleResponsesCore transforms upstream OpenAI SSE into Responses API SSE", async () => { const { result } = await invokeResponsesCore({ body: { diff --git a/tests/unit/sse-parser.test.mjs b/tests/unit/sse-parser.test.mjs index 55e164115a..f1dacf02d7 100644 --- a/tests/unit/sse-parser.test.mjs +++ b/tests/unit/sse-parser.test.mjs @@ -155,3 +155,47 @@ test("parseSSEToResponsesOutput handles large payloads without truncation", () = assert.equal(parsed.output[0].content[0].text.length, 10_000); }); + +test("parseSSEToResponsesOutput treats response.cancelled as terminal and reconstructs output from deltas", () => { + const rawSSE = [ + "event: response.created", + 'data: {"type":"response.created","response":{"id":"resp_cancelled","model":"gpt-5.3-codex","status":"in_progress","output":[]}}', + "", + "event: response.output_item.added", + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"output_text","text":""}]}}', + "", + "event: response.output_text.delta", + 'data: {"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hel"}', + "", + "event: response.output_text.delta", + 'data: {"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"lo"}', + "", + "event: response.cancelled", + 'data: {"type":"response.cancelled","response":{"id":"resp_cancelled","model":"gpt-5.3-codex","status":"cancelled","output":[],"usage":{"input_tokens":3}}}', + "", + "data: [DONE]", + ].join("\n"); + + const parsed = parseSSEToResponsesOutput(rawSSE, "fallback-model"); + + assert.equal(parsed.id, "resp_cancelled"); + assert.equal(parsed.status, "cancelled"); + assert.equal(parsed.output[0].type, "message"); + assert.equal(parsed.output[0].content[0].text, "Hello"); + assert.deepEqual(parsed.usage, { input_tokens: 3 }); +}); + +test("parseSSEToResponsesOutput treats response.canceled as terminal and reconstructs message text without added item", () => { + const rawSSE = [ + 'data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"Bye"}', + 'data: {"type":"response.canceled","response":{"id":"resp_canceled","model":"gpt-5.3-codex","output":[]}}', + "data: [DONE]", + ].join("\n"); + + const parsed = parseSSEToResponsesOutput(rawSSE, "fallback-model"); + + assert.equal(parsed.id, "resp_canceled"); + assert.equal(parsed.status, "canceled"); + assert.equal(parsed.output[0].type, "message"); + assert.equal(parsed.output[0].content[0].text, "Bye"); +}); diff --git a/tests/unit/translator-openai-responses-req.test.mjs b/tests/unit/translator-openai-responses-req.test.mjs index 572d8051bd..77dbec4e7a 100644 --- a/tests/unit/translator-openai-responses-req.test.mjs +++ b/tests/unit/translator-openai-responses-req.test.mjs @@ -179,6 +179,7 @@ test("Chat -> Responses converts messages, tool calls, tool outputs, tools and p }, ], tool_choice: { type: "function", function: { name: "read_file" } }, + previous_response_id: "resp_prev_123", temperature: 0.2, max_tokens: 100, top_p: 0.9, @@ -190,6 +191,7 @@ test("Chat -> Responses converts messages, tool calls, tool outputs, tools and p assert.equal(result.instructions, "Rules"); assert.equal(result.stream, true); assert.equal(result.store, false); + assert.equal(result.previous_response_id, "resp_prev_123"); assert.deepEqual(result.input, [ { type: "message", @@ -232,6 +234,51 @@ test("Chat -> Responses converts messages, tool calls, tool outputs, tools and p assert.equal(result.top_p, 0.9); }); +test("Responses round-trip preserves store and previous_response_id when opt-in is enabled", () => { + const credentials = { + providerSpecificData: { + openaiStoreEnabled: true, + }, + }; + + const chatBody = openaiResponsesToOpenAIRequest( + "gpt-4o", + { + instructions: "Rules", + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Hello" }] }], + previous_response_id: "resp_prev_store", + store: true, + }, + false, + credentials + ); + + const result = openaiToOpenAIResponsesRequest("gpt-4o", chatBody, false, credentials); + + assert.equal(result.previous_response_id, "resp_prev_store"); + assert.equal(result.store, true); + assert.equal(result.instructions, "Rules"); +}); + +test("Chat -> Responses preserves prompt_cache_key and session affinity fields", () => { + const result = openaiToOpenAIResponsesRequest( + "gpt-5.3-codex", + { + messages: [{ role: "user", content: "Hello" }], + prompt_cache_key: "cache-key-1", + session_id: "omniroute-session-abc", + conversation_id: "conv-123", + }, + false, + { providerSpecificData: { openaiStoreEnabled: true } } + ); + + assert.equal(result.prompt_cache_key, "cache-key-1"); + assert.equal(result.session_id, "omniroute-session-abc"); + assert.equal(result.conversation_id, "conv-123"); + assert.equal(result.store, undefined); +}); + test("Chat -> Responses filters orphan function_call_output items and leaves empty instructions when absent", () => { const result = openaiToOpenAIResponsesRequest( "gpt-4o", diff --git a/tests/unit/translator-openai-to-gemini.test.mjs b/tests/unit/translator-openai-to-gemini.test.mjs index 403c398b9c..b3e8f685c5 100644 --- a/tests/unit/translator-openai-to-gemini.test.mjs +++ b/tests/unit/translator-openai-to-gemini.test.mjs @@ -270,6 +270,53 @@ test("OpenAI -> Gemini CLI adds thinking config and normalizes namespaced tool n assert.equal(responseTurn.parts[0].functionResponse.name, "weather"); }); +test("OpenAI -> Gemini request gives googleSearch precedence over function tools", () => { + const result = openaiToGeminiRequest( + "gemini-2.5-pro", + { + messages: [{ role: "user", content: "Search the web" }], + tools: [ + { + type: "function", + function: { + name: "weather", + description: "Fetch weather", + parameters: { type: "object", properties: {} }, + }, + }, + { type: "web_search" }, + ], + }, + false + ); + + assert.deepEqual(result.tools, [{ googleSearch: {} }]); +}); + +test("OpenAI -> Antigravity keeps googleSearch without function calling config", () => { + const result = openaiToAntigravityRequest( + "gemini-2.5-pro", + { + messages: [{ role: "user", content: "Search the web" }], + tools: [ + { + type: "function", + function: { + name: "weather", + parameters: { type: "object", properties: {} }, + }, + }, + { type: "web_search_preview" }, + ], + }, + false, + { projectId: "proj-search" } + ); + + assert.deepEqual(result.request.tools, [{ googleSearch: {} }]); + assert.equal(result.request.toolConfig, undefined); +}); + test("OpenAI -> Gemini helper IDs and JSON parsing stay in the expected format", () => { assert.match(generateRequestId(), /^agent-/); assert.match(generateSessionId(), /^-\d+$/); diff --git a/tests/unit/usage-service-hardening.test.mjs b/tests/unit/usage-service-hardening.test.mjs index b92f9799f2..e8e8d7a8b2 100644 --- a/tests/unit/usage-service-hardening.test.mjs +++ b/tests/unit/usage-service-hardening.test.mjs @@ -757,6 +757,68 @@ test("usage service covers Qwen, Qoder and GLM branches", async () => { ); }); +test("usage service parses Cursor team quotas and clamps on-demand ratio", async () => { + const calls = []; + globalThis.fetch = async (url, init = {}) => { + calls.push({ url: String(url), init }); + + if (String(url).endsWith("/api/usage")) { + return new Response( + JSON.stringify({ + numRequestsTotal: 450, + hard_limit: 100, + teamMaxRequestUsage: 500, + onDemand: { + numRequests: 600, + }, + }), + { status: 200 } + ); + } + + if (String(url).endsWith("/api/auth/me")) { + return new Response( + JSON.stringify({ + plan: "team", + teamInfo: { id: "team-1", name: "Core Team" }, + }), + { status: 200 } + ); + } + + if (String(url).endsWith("/api/subscription")) { + return new Response( + JSON.stringify({ + teamMaxMonthlyRequests: 500, + }), + { status: 200 } + ); + } + + throw new Error(`unexpected fetch: ${url}`); + }; + + const usage = await usageService.getUsageForProvider({ + provider: "cursor", + accessToken: "cursor-token", + }); + + assert.equal(calls.length, 3); + for (const call of calls) { + assert.equal(call.init.headers.Authorization, "Bearer cursor-token"); + assert.equal(call.init.headers["User-Agent"], "Cursor/3.1.0"); + assert.equal(call.init.headers["x-cursor-client-version"], "3.1.0"); + } + + assert.equal(usage.plan, "Cursor Team"); + assert.equal(usage.quotas.requests.total, 500); + assert.equal(usage.quotas.requests.used, 450); + assert.equal(usage.quotas.requests.remainingPercentage, 10); + assert.equal(usage.quotas.on_demand.total, 500); + assert.equal(usage.quotas.on_demand.used, 500); + assert.equal(usage.quotas.on_demand.remainingPercentage, 0); +}); + test("usage helper branches cover reset parsing, GitHub quota math, and plan inference fallbacks", () => { const fixedDate = new Date("2026-01-02T03:04:05.000Z"); @@ -855,6 +917,32 @@ test("usage helper branches cover reset parsing, GitHub quota math, and plan inf "Copilot Student" ); assert.equal(__testing.inferGitHubPlanName({}, null), "GitHub Copilot"); + + assert.deepEqual(__testing.buildCursorUsageHeaders("cursor-token"), { + Authorization: "Bearer cursor-token", + Accept: "application/json", + "User-Agent": "Cursor/3.1.0", + "x-cursor-client-version": "3.1.0", + "x-cursor-user-agent": "Cursor/3.1.0", + }); + assert.equal( + __testing.getCursorMonthlyRequestLimit( + { hard_limit: 100, teamMaxRequestUsage: 400 }, + { teamMaxMonthlyRequests: 500 } + ), + 500 + ); + assert.equal(__testing.getCursorOnDemandLimit({ onDemand: { maxRequests: 120 } }, {}), 120); + assert.deepEqual(__testing.formatCursorQuota(150, 100, null), { + used: 100, + total: 100, + remaining: 0, + remainingPercentage: 0, + resetAt: null, + unlimited: false, + }); + assert.equal(__testing.inferCursorPlanName({ teamInfo: { id: "team-1" } }, {}), "Cursor Team"); + assert.equal(__testing.inferCursorPlanName({ plan: "pro" }, {}), "Cursor Pro"); }); test("usage helper branches cover Gemini CLI and Antigravity plan label fallbacks", () => {