mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
feat(core): add db health checks and provider interoperability
Add automated SQLite health diagnostics with optional auto-repair, startup and scheduled execution, authenticated API routes, an MCP tool, and dashboard visibility for status and repair actions. Improve provider compatibility by adding Cursor usage fetching and v3.1.0 parity headers, introducing a per-connection Codex Responses store opt-in with session fallback, fixing Codex non-stream combo and SSE translation behavior, sanitizing Gemini googleSearch tool payloads and Qwen thinking tool_choice handling, and hardening cleanup and call log storage paths.
This commit is contained in:
19
CHANGELOG.md
19
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).
|
||||
|
||||
@@ -570,9 +570,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
"connect-accept-encoding": "gzip",
|
||||
"connect-protocol-version": "1",
|
||||
"Content-Type": "application/connect+proto",
|
||||
"User-Agent": "connect-es/1.6.1",
|
||||
"User-Agent": "Cursor/3.1.0",
|
||||
},
|
||||
clientVersion: "1.1.3",
|
||||
clientVersion: "3.1.0",
|
||||
models: [
|
||||
{ id: "default", name: "Auto (Server Picks)" },
|
||||
{ id: "claude-4.6-opus-high-thinking", name: "Claude 4.6 Opus High Thinking" },
|
||||
|
||||
@@ -1,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<string, unknown>): unknown {
|
||||
const marker = body._omnirouteResponsesStore;
|
||||
delete body._omnirouteResponsesStore;
|
||||
return marker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex Executor - handles OpenAI Codex API (Responses API format)
|
||||
* Automatically injects default instructions if missing.
|
||||
@@ -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<string, unknown>);
|
||||
|
||||
const nativeCodexPassthrough = body?._nativeCodexPassthrough === true;
|
||||
const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath);
|
||||
const requestDefaults = getCodexRequestDefaults(credentials?.providerSpecificData);
|
||||
const storeEnabled = isOpenAIResponsesStoreEnabled(credentials?.providerSpecificData);
|
||||
const thinkingBudgetConfig = getThinkingBudgetConfig();
|
||||
const allowConnectionReasoningDefaults = thinkingBudgetConfig.mode === ThinkingMode.PASSTHROUGH;
|
||||
const responsesStoreMarker = consumeResponsesStoreMarker(body);
|
||||
|
||||
// Codex /responses rejects stream=false, but /responses/compact rejects the stream field entirely.
|
||||
if (isCompactRequest) {
|
||||
@@ -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`.
|
||||
|
||||
@@ -33,6 +33,9 @@ import crypto from "crypto";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import zlib from "zlib";
|
||||
|
||||
const CURSOR_CLIENT_VERSION = "3.1.0";
|
||||
const CURSOR_USER_AGENT = `Cursor/${CURSOR_CLIENT_VERSION}`;
|
||||
|
||||
// Detect cloud environment
|
||||
const isCloudEnv = () => {
|
||||
if (typeof caches !== "undefined" && typeof caches === "object") return true;
|
||||
@@ -251,11 +254,11 @@ export class CursorExecutor extends BaseExecutor {
|
||||
"connect-accept-encoding": "gzip",
|
||||
"connect-protocol-version": "1",
|
||||
"content-type": "application/connect+proto",
|
||||
"user-agent": "connect-es/1.6.1",
|
||||
"user-agent": CURSOR_USER_AGENT,
|
||||
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`,
|
||||
"x-client-key": crypto.createHash("sha256").update(cleanToken).digest("hex"),
|
||||
"x-cursor-checksum": this.generateChecksum(machineId),
|
||||
"x-cursor-client-version": "2.3.41",
|
||||
"x-cursor-client-version": CURSOR_CLIENT_VERSION,
|
||||
"x-cursor-client-type": "ide",
|
||||
"x-cursor-client-os":
|
||||
process.platform === "win32"
|
||||
@@ -265,6 +268,7 @@ export class CursorExecutor extends BaseExecutor {
|
||||
: "linux",
|
||||
"x-cursor-client-arch": process.arch === "arm64" ? "aarch64" : "x64",
|
||||
"x-cursor-client-device-type": "desktop",
|
||||
"x-cursor-user-agent": CURSOR_USER_AGENT,
|
||||
"x-cursor-config-version": crypto.randomUUID(),
|
||||
"x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||
"x-ghost-mode": ghostMode ? "true" : "false",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../services/claudeCodeCompatible.ts";
|
||||
import { getGigachatAccessToken } from "../services/gigachatAuth.ts";
|
||||
import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts";
|
||||
import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts";
|
||||
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
return (baseUrl || "").trim().replace(/\/$/, "");
|
||||
@@ -203,6 +204,12 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
* "org/model-name") — we must NOT strip path segments. (Fix #493)
|
||||
*/
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
void model;
|
||||
void stream;
|
||||
void credentials;
|
||||
if (this.provider === "qwen" && typeof body === "object" && body !== null) {
|
||||
return sanitizeQwenThinkingToolChoice(body, "QwenExecutor");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type ProviderCredentials,
|
||||
} from "./base.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts";
|
||||
|
||||
function getAuthToken(credentials: ProviderCredentials): string {
|
||||
if (typeof credentials.apiKey === "string" && credentials.apiKey.trim()) {
|
||||
@@ -27,6 +28,15 @@ export class QoderExecutor extends BaseExecutor {
|
||||
super("qoder", PROVIDERS.qoder);
|
||||
}
|
||||
|
||||
transformRequest(model: string, body: unknown): Record<string, unknown> {
|
||||
const payload = {
|
||||
...(typeof body === "object" && body !== null ? body : {}),
|
||||
model,
|
||||
};
|
||||
|
||||
return sanitizeQwenThinkingToolChoice(payload, "QoderExecutor");
|
||||
}
|
||||
|
||||
async execute({ model, body, stream, credentials, signal, upstreamExtraHeaders }: ExecuteInput) {
|
||||
const token = getAuthToken(credentials);
|
||||
|
||||
@@ -90,10 +100,7 @@ export class QoderExecutor extends BaseExecutor {
|
||||
|
||||
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
|
||||
|
||||
const payload = {
|
||||
...(typeof body === "object" && body !== null ? body : {}),
|
||||
model: mappedModel,
|
||||
};
|
||||
const payload = this.transformRequest(mappedModel, body, stream, credentials);
|
||||
|
||||
const bodyStr = JSON.stringify(payload);
|
||||
|
||||
|
||||
@@ -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<string, string> | null
|
||||
)
|
||||
|
||||
@@ -32,6 +32,23 @@ function toNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function hasVisibleMessageContent(content: unknown): boolean {
|
||||
if (typeof content === "string") {
|
||||
return content.trim().length > 0;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) return false;
|
||||
|
||||
return content.some((contentPart) => {
|
||||
const part = toRecord(contentPart);
|
||||
if (!part) return false;
|
||||
if (typeof part.text === "string" && part.text.trim().length > 0) return true;
|
||||
if (typeof part.content === "string" && part.content.trim().length > 0) return true;
|
||||
const partType = toString(part.type);
|
||||
return Boolean(partType && partType !== "thinking" && partType !== "reasoning");
|
||||
});
|
||||
}
|
||||
|
||||
// Matches <think>...</think> blocks and <thinking>...</thinking> (greedy, dotAll)
|
||||
const THINK_TAG_REGEX = /<(?:think|thinking)>([\s\S]*?)<\/(?:think|thinking)>/gi;
|
||||
|
||||
@@ -216,6 +233,13 @@ function sanitizeMessage(msg: unknown): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
// Non-streaming responses should not expose both visible content and reasoning_content.
|
||||
// Some clients drop the visible assistant text or render duplicated panels when both fields
|
||||
// are present in the final payload. Keep reasoning_content only for reasoning-only messages.
|
||||
if (sanitized.reasoning_content !== undefined && hasVisibleMessageContent(sanitized.content)) {
|
||||
delete sanitized.reasoning_content;
|
||||
}
|
||||
|
||||
// Preserve tool_calls
|
||||
if (msgRecord.tool_calls) {
|
||||
sanitized.tool_calls = msgRecord.tool_calls;
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function handleResponsesCore({
|
||||
signal,
|
||||
}) {
|
||||
// Convert Responses API format to Chat Completions format
|
||||
const convertedBody = convertResponsesApiFormat(body);
|
||||
const convertedBody = convertResponsesApiFormat(body, credentials);
|
||||
|
||||
// Ensure stream is enabled
|
||||
convertedBody.stream = true;
|
||||
|
||||
@@ -399,6 +399,132 @@ export function parseSSEToClaudeResponse(rawSSE, fallbackModel) {
|
||||
* Convert Responses API SSE events into a single non-streaming response object.
|
||||
* Expects events such as response.created / response.in_progress / response.completed.
|
||||
*/
|
||||
const RESPONSES_TERMINAL_EVENT_TYPES = new Set([
|
||||
"response.completed",
|
||||
"response.done",
|
||||
"response.cancelled",
|
||||
"response.canceled",
|
||||
"response.failed",
|
||||
"response.incomplete",
|
||||
]);
|
||||
|
||||
function toOutputIndex(value) {
|
||||
if (typeof value === "number" && Number.isInteger(value)) return value;
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isInteger(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cloneResponseItem(item) {
|
||||
const record = toRecord(item);
|
||||
return {
|
||||
...record,
|
||||
...(Array.isArray(record.content)
|
||||
? {
|
||||
content: record.content.map((contentPart) => {
|
||||
const part = toRecord(contentPart);
|
||||
return { ...part };
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
...(Array.isArray(record.summary)
|
||||
? {
|
||||
summary: record.summary.map((summaryPart) => {
|
||||
const part = toRecord(summaryPart);
|
||||
return { ...part };
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureResponsesMessageItem(outputItems, outputIndex) {
|
||||
const existing = outputItems.get(outputIndex);
|
||||
if (existing?.type === "message") return existing;
|
||||
|
||||
const next = {
|
||||
...(existing && typeof existing === "object" ? existing : {}),
|
||||
id: existing?.id || `msg_${Date.now()}_${outputIndex}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: Array.isArray(existing?.content)
|
||||
? existing.content.map((contentPart) => ({ ...toRecord(contentPart) }))
|
||||
: [{ type: "output_text", annotations: [], text: "" }],
|
||||
};
|
||||
|
||||
if (next.content.length === 0) {
|
||||
next.content.push({ type: "output_text", annotations: [], text: "" });
|
||||
}
|
||||
|
||||
outputItems.set(outputIndex, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function ensureResponsesReasoningItem(outputItems, outputIndex, itemId) {
|
||||
const existing = outputItems.get(outputIndex);
|
||||
if (existing?.type === "reasoning") return existing;
|
||||
|
||||
const next = {
|
||||
...(existing && typeof existing === "object" ? existing : {}),
|
||||
id: itemId || existing?.id || `rs_${Date.now()}_${outputIndex}`,
|
||||
type: "reasoning",
|
||||
summary: Array.isArray(existing?.summary)
|
||||
? existing.summary.map((summaryPart) => ({ ...toRecord(summaryPart) }))
|
||||
: [{ type: "summary_text", text: "" }],
|
||||
};
|
||||
|
||||
if (next.summary.length === 0) {
|
||||
next.summary.push({ type: "summary_text", text: "" });
|
||||
}
|
||||
|
||||
outputItems.set(outputIndex, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function ensureResponsesFunctionCallItem(outputItems, outputIndex, itemId, callId, name) {
|
||||
const existing = outputItems.get(outputIndex);
|
||||
if (existing?.type === "function_call") {
|
||||
if (callId && !existing.call_id) existing.call_id = callId;
|
||||
if (name && !existing.name) existing.name = name;
|
||||
if (itemId && !existing.id) existing.id = itemId;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const next = {
|
||||
...(existing && typeof existing === "object" ? existing : {}),
|
||||
id: itemId || existing?.id || `fc_${callId || `${Date.now()}_${outputIndex}`}`,
|
||||
type: "function_call",
|
||||
call_id: callId || existing?.call_id || "",
|
||||
name: name || existing?.name || "",
|
||||
arguments: typeof existing?.arguments === "string" ? existing.arguments : "",
|
||||
};
|
||||
|
||||
outputItems.set(outputIndex, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function mergeResponseItems(existing, incoming) {
|
||||
const next = cloneResponseItem(incoming);
|
||||
if (!existing || typeof existing !== "object") return next;
|
||||
|
||||
return {
|
||||
...existing,
|
||||
...next,
|
||||
...(Array.isArray(next.content)
|
||||
? {
|
||||
content: next.content,
|
||||
}
|
||||
: {}),
|
||||
...(Array.isArray(next.summary)
|
||||
? {
|
||||
summary: next.summary,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSSEToResponsesOutput(rawSSE, fallbackModel) {
|
||||
const lines = String(rawSSE || "").split("\n");
|
||||
const events = [];
|
||||
@@ -409,7 +535,11 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) {
|
||||
const payload = trimmed.slice(5).trim();
|
||||
if (!payload || payload === "[DONE]") continue;
|
||||
try {
|
||||
events.push(JSON.parse(payload));
|
||||
const parsed = JSON.parse(payload);
|
||||
const record = toRecord(parsed);
|
||||
if (Object.keys(record).length > 0) {
|
||||
events.push(record);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed lines and continue best-effort parsing.
|
||||
}
|
||||
@@ -417,12 +547,104 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) {
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
let completed = null;
|
||||
let terminalResponse = null;
|
||||
let terminalEventType = "";
|
||||
let latestResponse = null;
|
||||
const outputItems = new Map();
|
||||
|
||||
for (const evt of events) {
|
||||
if (evt?.type === "response.completed" && evt.response) {
|
||||
completed = evt.response;
|
||||
const eventType = toString(evt?.type);
|
||||
const outputIndex = toOutputIndex(evt?.output_index);
|
||||
const item = toRecord(evt?.item);
|
||||
|
||||
if (outputIndex !== null && eventType === "response.output_item.added") {
|
||||
outputItems.set(outputIndex, cloneResponseItem(item));
|
||||
}
|
||||
|
||||
if (outputIndex !== null && eventType === "response.output_item.done") {
|
||||
const existing = outputItems.get(outputIndex);
|
||||
outputItems.set(outputIndex, mergeResponseItems(existing, item));
|
||||
}
|
||||
|
||||
if (outputIndex !== null && eventType === "response.output_text.delta") {
|
||||
const messageItem = ensureResponsesMessageItem(outputItems, outputIndex);
|
||||
const content = Array.isArray(messageItem.content) ? messageItem.content : [];
|
||||
const firstPart =
|
||||
content.length > 0 ? { ...toRecord(content[0]) } : { type: "output_text", annotations: [] };
|
||||
firstPart.type = firstPart.type || "output_text";
|
||||
firstPart.annotations = Array.isArray(firstPart.annotations) ? firstPart.annotations : [];
|
||||
firstPart.text = `${toString(firstPart.text)}${toString(evt.delta)}`;
|
||||
content[0] = firstPart;
|
||||
messageItem.content = content;
|
||||
}
|
||||
|
||||
if (outputIndex !== null && eventType === "response.output_text.done") {
|
||||
const messageItem = ensureResponsesMessageItem(outputItems, outputIndex);
|
||||
const content = Array.isArray(messageItem.content) ? messageItem.content : [];
|
||||
const firstPart =
|
||||
content.length > 0 ? { ...toRecord(content[0]) } : { type: "output_text", annotations: [] };
|
||||
firstPart.type = firstPart.type || "output_text";
|
||||
firstPart.annotations = Array.isArray(firstPart.annotations) ? firstPart.annotations : [];
|
||||
firstPart.text = toString(evt.text, toString(firstPart.text));
|
||||
content[0] = firstPart;
|
||||
messageItem.content = content;
|
||||
}
|
||||
|
||||
if (outputIndex !== null && eventType === "response.reasoning_summary_text.delta") {
|
||||
const reasoningItem = ensureResponsesReasoningItem(
|
||||
outputItems,
|
||||
outputIndex,
|
||||
toString(evt.item_id)
|
||||
);
|
||||
const summary = Array.isArray(reasoningItem.summary) ? reasoningItem.summary : [];
|
||||
const firstPart =
|
||||
summary.length > 0 ? { ...toRecord(summary[0]) } : { type: "summary_text", text: "" };
|
||||
firstPart.type = firstPart.type || "summary_text";
|
||||
firstPart.text = `${toString(firstPart.text)}${toString(evt.delta)}`;
|
||||
summary[0] = firstPart;
|
||||
reasoningItem.summary = summary;
|
||||
}
|
||||
|
||||
if (outputIndex !== null && eventType === "response.reasoning_summary_text.done") {
|
||||
const reasoningItem = ensureResponsesReasoningItem(
|
||||
outputItems,
|
||||
outputIndex,
|
||||
toString(evt.item_id)
|
||||
);
|
||||
const summary = Array.isArray(reasoningItem.summary) ? reasoningItem.summary : [];
|
||||
const firstPart =
|
||||
summary.length > 0 ? { ...toRecord(summary[0]) } : { type: "summary_text", text: "" };
|
||||
firstPart.type = firstPart.type || "summary_text";
|
||||
firstPart.text = toString(evt.text, toString(firstPart.text));
|
||||
summary[0] = firstPart;
|
||||
reasoningItem.summary = summary;
|
||||
}
|
||||
|
||||
if (outputIndex !== null && eventType === "response.function_call_arguments.delta") {
|
||||
const functionCallItem = ensureResponsesFunctionCallItem(
|
||||
outputItems,
|
||||
outputIndex,
|
||||
toString(evt.item_id),
|
||||
"",
|
||||
""
|
||||
);
|
||||
functionCallItem.arguments = `${toString(functionCallItem.arguments)}${toString(evt.delta)}`;
|
||||
}
|
||||
|
||||
if (outputIndex !== null && eventType === "response.function_call_arguments.done") {
|
||||
const functionCallItem = ensureResponsesFunctionCallItem(
|
||||
outputItems,
|
||||
outputIndex,
|
||||
toString(evt.item_id),
|
||||
"",
|
||||
""
|
||||
);
|
||||
functionCallItem.arguments = toString(evt.arguments, toString(functionCallItem.arguments));
|
||||
}
|
||||
|
||||
if (RESPONSES_TERMINAL_EVENT_TYPES.has(eventType) && evt.response) {
|
||||
terminalResponse = evt.response;
|
||||
terminalEventType = eventType;
|
||||
}
|
||||
if (evt?.response && typeof evt.response === "object") {
|
||||
latestResponse = evt.response;
|
||||
@@ -431,16 +653,33 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) {
|
||||
}
|
||||
}
|
||||
|
||||
const picked = completed || latestResponse;
|
||||
const picked = terminalResponse || latestResponse;
|
||||
if (!picked || typeof picked !== "object") return null;
|
||||
const reconstructedOutput = [...outputItems.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, item]) => item)
|
||||
.filter((item) => item && typeof item === "object");
|
||||
const pickedOutput = Array.isArray(picked.output) ? picked.output : [];
|
||||
const statusFallback =
|
||||
terminalEventType === "response.cancelled"
|
||||
? "cancelled"
|
||||
: terminalEventType === "response.canceled"
|
||||
? "canceled"
|
||||
: terminalEventType === "response.failed"
|
||||
? "failed"
|
||||
: terminalEventType === "response.incomplete"
|
||||
? "incomplete"
|
||||
: terminalResponse
|
||||
? "completed"
|
||||
: "in_progress";
|
||||
|
||||
return {
|
||||
id: picked.id || `resp_${Date.now()}`,
|
||||
object: "response",
|
||||
object: picked.object || "response",
|
||||
model: picked.model || fallbackModel || "unknown",
|
||||
output: Array.isArray(picked.output) ? picked.output : [],
|
||||
output: pickedOutput.length > 0 ? pickedOutput : reconstructedOutput,
|
||||
usage: picked.usage || null,
|
||||
status: picked.status || (completed ? "completed" : "in_progress"),
|
||||
status: picked.status || statusFallback,
|
||||
created_at: picked.created_at || Math.floor(Date.now() / 1000),
|
||||
metadata: picked.metadata || {},
|
||||
};
|
||||
|
||||
70
open-sse/mcp-server/__tests__/dbHealthTool.test.ts
Normal file
70
open-sse/mcp-server/__tests__/dbHealthTool.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { createMcpServer } from "../server.ts";
|
||||
import { MCP_TOOL_MAP, dbHealthCheckInput } from "../schemas/tools.ts";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
vi.mock("../audit.ts", () => ({
|
||||
logToolCall: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe("omniroute_db_health_check MCP tool", () => {
|
||||
let client: Client;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockFetch.mockReset();
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const server = createMcpServer();
|
||||
await server.connect(serverTransport);
|
||||
client = new Client({ name: "test-client", version: "1.0.0" });
|
||||
await client.connect(clientTransport);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("is registered in the MCP tool map", () => {
|
||||
expect(MCP_TOOL_MAP["omniroute_db_health_check"]).toBeDefined();
|
||||
expect(MCP_TOOL_MAP["omniroute_db_health_check"]?.phase).toBe(2);
|
||||
});
|
||||
|
||||
it("validates empty input and explicit autoRepair requests", () => {
|
||||
expect(dbHealthCheckInput.safeParse({}).success).toBe(true);
|
||||
expect(dbHealthCheckInput.safeParse({ autoRepair: true }).success).toBe(true);
|
||||
expect(dbHealthCheckInput.safeParse({ autoRepair: "yes" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("dispatches to /api/v1/db/health using POST when autoRepair=true", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
isHealthy: false,
|
||||
issues: [{ type: "broken_reference", table: "combos", description: "broken", count: 1 }],
|
||||
repairedCount: 1,
|
||||
backupCreated: true,
|
||||
autoRepair: true,
|
||||
checkedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "omniroute_db_health_check",
|
||||
arguments: { autoRepair: true },
|
||||
});
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/db/health"),
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
|
||||
const content = result.content[0] as { type: string; text: string };
|
||||
const payload = JSON.parse(content.text);
|
||||
expect(payload.repairedCount).toBe(1);
|
||||
expect(payload.backupCreated).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,9 @@ export {
|
||||
getSessionSnapshotInput,
|
||||
getSessionSnapshotOutput,
|
||||
getSessionSnapshotTool,
|
||||
dbHealthCheckInput,
|
||||
dbHealthCheckOutput,
|
||||
dbHealthCheckTool,
|
||||
cacheStatsInput,
|
||||
cacheStatsOutput,
|
||||
cacheStatsTool,
|
||||
|
||||
@@ -831,7 +831,51 @@ export const getSessionSnapshotTool: McpToolDefinition<
|
||||
sourceEndpoints: ["/api/usage/analytics", "/api/telemetry/summary"],
|
||||
};
|
||||
|
||||
// --- Tool 18: omniroute_sync_pricing ---
|
||||
// --- Tool 18: omniroute_db_health_check ---
|
||||
export const dbHealthCheckInput = z.object({
|
||||
autoRepair: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("When true, runs the database auto-repair flow before returning the result"),
|
||||
});
|
||||
|
||||
export const dbHealthCheckOutput = z.object({
|
||||
isHealthy: z.boolean(),
|
||||
issues: z.array(
|
||||
z.object({
|
||||
type: z.enum([
|
||||
"integrity_check_failed",
|
||||
"broken_reference",
|
||||
"stale_snapshot",
|
||||
"invalid_state",
|
||||
]),
|
||||
table: z.string(),
|
||||
description: z.string(),
|
||||
count: z.number(),
|
||||
})
|
||||
),
|
||||
repairedCount: z.number(),
|
||||
backupCreated: z.boolean(),
|
||||
autoRepair: z.boolean(),
|
||||
checkedAt: z.string(),
|
||||
});
|
||||
|
||||
export const dbHealthCheckTool: McpToolDefinition<
|
||||
typeof dbHealthCheckInput,
|
||||
typeof dbHealthCheckOutput
|
||||
> = {
|
||||
name: "omniroute_db_health_check",
|
||||
description:
|
||||
"Diagnoses OmniRoute database drift such as orphan quota/domain rows, invalid JSON state, and broken combo references. Set autoRepair=true to repair those rows before returning the report.",
|
||||
inputSchema: dbHealthCheckInput,
|
||||
outputSchema: dbHealthCheckOutput,
|
||||
scopes: ["read:health", "write:resilience"],
|
||||
auditLevel: "full",
|
||||
phase: 2,
|
||||
sourceEndpoints: ["/api/v1/db/health"],
|
||||
};
|
||||
|
||||
// --- Tool 19: omniroute_sync_pricing ---
|
||||
export const syncPricingInput = z.object({
|
||||
sources: z
|
||||
.array(z.string())
|
||||
@@ -959,6 +1003,7 @@ export const MCP_TOOLS = [
|
||||
bestComboForTaskTool,
|
||||
explainRouteTool,
|
||||
getSessionSnapshotTool,
|
||||
dbHealthCheckTool,
|
||||
syncPricingTool,
|
||||
cacheStatsTool,
|
||||
cacheFlushTool,
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
bestComboForTaskInput,
|
||||
explainRouteInput,
|
||||
getSessionSnapshotInput,
|
||||
dbHealthCheckInput,
|
||||
syncPricingInput,
|
||||
} from "./schemas/tools.ts";
|
||||
import { startMcpHeartbeat } from "./runtimeHeartbeat.ts";
|
||||
@@ -59,6 +60,7 @@ import {
|
||||
handleBestComboForTask,
|
||||
handleExplainRoute,
|
||||
handleGetSessionSnapshot,
|
||||
handleDbHealthCheck,
|
||||
handleSyncPricing,
|
||||
} from "./tools/advancedTools.ts";
|
||||
import { memoryTools } from "./tools/memoryTools.ts";
|
||||
@@ -759,6 +761,18 @@ export function createMcpServer(): McpServer {
|
||||
})
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"omniroute_db_health_check",
|
||||
{
|
||||
description:
|
||||
"Diagnoses or repairs OmniRoute database drift, including broken combo references and orphan quota/domain rows",
|
||||
inputSchema: dbHealthCheckInput,
|
||||
},
|
||||
withScopeEnforcement("omniroute_db_health_check", (args) =>
|
||||
handleDbHealthCheck(dbHealthCheckInput.parse(args ?? {}))
|
||||
)
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"omniroute_sync_pricing",
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* OmniRoute MCP Advanced Tools — 10 intelligence tools that differentiate
|
||||
* OmniRoute MCP Advanced Tools — 11 intelligence tools that differentiate
|
||||
* OmniRoute from all other AI gateways.
|
||||
*
|
||||
* Tools:
|
||||
@@ -12,7 +12,8 @@
|
||||
* 7. omniroute_best_combo_for_task — AI-powered combo recommendation
|
||||
* 8. omniroute_explain_route — Post-hoc routing decision explainer
|
||||
* 9. omniroute_get_session_snapshot — Full session state snapshot
|
||||
* 10. omniroute_sync_pricing — Sync provider pricing from external source
|
||||
* 10. omniroute_db_health_check — Diagnose and repair DB state drift
|
||||
* 11. omniroute_sync_pricing — Sync provider pricing from external source
|
||||
*/
|
||||
|
||||
import { logToolCall } from "../audit.ts";
|
||||
@@ -863,3 +864,33 @@ export async function handleGetSessionSnapshot() {
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDbHealthCheck(args: { autoRepair?: boolean }) {
|
||||
const start = Date.now();
|
||||
const autoRepair = args.autoRepair === true;
|
||||
|
||||
try {
|
||||
const result = toRecord(
|
||||
await apiFetch("/api/v1/db/health", {
|
||||
method: autoRepair ? "POST" : "GET",
|
||||
})
|
||||
);
|
||||
|
||||
await logToolCall(
|
||||
"omniroute_db_health_check",
|
||||
args,
|
||||
{
|
||||
isHealthy: toBoolean(result.isHealthy, false),
|
||||
repairedCount: toNumber(result.repairedCount, 0),
|
||||
},
|
||||
Date.now() - start,
|
||||
true
|
||||
);
|
||||
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await logToolCall("omniroute_db_health_check", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
44
open-sse/services/qwenThinking.ts
Normal file
44
open-sse/services/qwenThinking.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export function isQwenThinkingActive(body: JsonRecord): boolean {
|
||||
const thinking = body.thinking;
|
||||
|
||||
if (thinking === true || body.enable_thinking === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
typeof thinking === "object" &&
|
||||
thinking !== null &&
|
||||
!Array.isArray(thinking) &&
|
||||
(thinking as JsonRecord).type === "enabled"
|
||||
);
|
||||
}
|
||||
|
||||
export function isQwenThinkingToolChoiceIncompatible(toolChoice: unknown): boolean {
|
||||
return toolChoice === "required" || (typeof toolChoice === "object" && toolChoice !== null);
|
||||
}
|
||||
|
||||
export function sanitizeQwenThinkingToolChoice(
|
||||
body: JsonRecord,
|
||||
providerLabel = "Qwen"
|
||||
): JsonRecord {
|
||||
if (!isQwenThinkingActive(body)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
const toolChoice = body.tool_choice;
|
||||
if (!isQwenThinkingToolChoiceIncompatible(toolChoice)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
const toolChoiceLabel = typeof toolChoice === "string" ? toolChoice : "object";
|
||||
console.warn(
|
||||
`[${providerLabel}] Neutralizing incompatible tool_choice ${toolChoiceLabel} to "auto" (thinking mode active)`
|
||||
);
|
||||
|
||||
return {
|
||||
...body,
|
||||
tool_choice: "auto",
|
||||
};
|
||||
}
|
||||
@@ -45,6 +45,14 @@ const KIMI_CONFIG = {
|
||||
apiVersion: "2023-06-01",
|
||||
};
|
||||
|
||||
const CURSOR_USAGE_CONFIG = {
|
||||
usageUrl: "https://www.cursor.com/api/usage",
|
||||
userMetaUrl: "https://www.cursor.com/api/auth/me",
|
||||
subscriptionUrl: "https://www.cursor.com/api/subscription",
|
||||
clientVersion: "3.1.0",
|
||||
userAgent: "Cursor/3.1.0",
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type UsageQuota = {
|
||||
used: number;
|
||||
@@ -185,6 +193,8 @@ export async function getUsageForProvider(connection) {
|
||||
return await getIflowUsage(accessToken);
|
||||
case "glm":
|
||||
return await getGlmUsage(apiKey, providerSpecificData);
|
||||
case "cursor":
|
||||
return await getCursorUsage(accessToken);
|
||||
default:
|
||||
return { message: `Usage API not implemented for ${provider}` };
|
||||
}
|
||||
@@ -418,6 +428,178 @@ function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null):
|
||||
return "GitHub Copilot";
|
||||
}
|
||||
|
||||
function buildCursorUsageHeaders(accessToken: string): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": CURSOR_USAGE_CONFIG.userAgent,
|
||||
"x-cursor-client-version": CURSOR_USAGE_CONFIG.clientVersion,
|
||||
"x-cursor-user-agent": CURSOR_USAGE_CONFIG.userAgent,
|
||||
};
|
||||
}
|
||||
|
||||
function getFirstPositiveNumber(...values: unknown[]): number {
|
||||
for (const value of values) {
|
||||
const parsed = toNumber(value, Number.NaN);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getCursorMonthlyRequestLimit(usageData: JsonRecord, subscriptionData: JsonRecord): number {
|
||||
return getFirstPositiveNumber(
|
||||
getFieldValue(subscriptionData, "team_max_monthly_requests", "teamMaxMonthlyRequests"),
|
||||
getFieldValue(usageData, "team_max_request_usage", "teamMaxRequestUsage"),
|
||||
getFieldValue(subscriptionData, "team_max_request_usage", "teamMaxRequestUsage"),
|
||||
getFieldValue(usageData, "hard_limit", "hardLimit"),
|
||||
getFieldValue(subscriptionData, "max_monthly_requests", "maxMonthlyRequests")
|
||||
);
|
||||
}
|
||||
|
||||
function getCursorOnDemandLimit(usageData: JsonRecord, subscriptionData: JsonRecord): number {
|
||||
const onDemand = toRecord(getFieldValue(usageData, "on_demand", "onDemand"));
|
||||
return getFirstPositiveNumber(
|
||||
getFieldValue(onDemand, "max_requests", "maxRequests"),
|
||||
getCursorMonthlyRequestLimit(usageData, subscriptionData)
|
||||
);
|
||||
}
|
||||
|
||||
function formatCursorQuota(
|
||||
usedValue: unknown,
|
||||
totalValue: unknown,
|
||||
resetValue: unknown
|
||||
): UsageQuota {
|
||||
const total = Math.max(0, toNumber(totalValue, 0));
|
||||
const rawUsed = Math.max(0, toNumber(usedValue, 0));
|
||||
const used = total > 0 ? Math.min(rawUsed, total) : rawUsed;
|
||||
const remaining = total > 0 ? Math.max(total - used, 0) : 0;
|
||||
|
||||
return {
|
||||
used,
|
||||
total,
|
||||
remaining,
|
||||
remainingPercentage: total > 0 ? clampPercentage((remaining / total) * 100) : 0,
|
||||
resetAt: parseResetTime(resetValue),
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
function inferCursorPlanName(userMeta: JsonRecord, subscriptionData: JsonRecord): string {
|
||||
const teamInfo = toRecord(getFieldValue(userMeta, "team_info", "teamInfo"));
|
||||
const candidates = [
|
||||
getFieldValue(userMeta, "plan", "plan"),
|
||||
getFieldValue(userMeta, "subscription_type", "subscriptionType"),
|
||||
getFieldValue(subscriptionData, "subscription_type", "subscriptionType"),
|
||||
getFieldValue(subscriptionData, "plan", "plan"),
|
||||
];
|
||||
const planText = candidates.find((value) => typeof value === "string" && value.trim().length > 0);
|
||||
const normalized = typeof planText === "string" ? planText.trim().toLowerCase() : "";
|
||||
|
||||
if (Object.keys(teamInfo).length > 0 || normalized.includes("team")) return "Cursor Team";
|
||||
if (normalized.includes("enterprise")) return "Cursor Enterprise";
|
||||
if (normalized.includes("pro")) return "Cursor Pro";
|
||||
if (normalized.includes("free")) return "Cursor Free";
|
||||
return "Cursor";
|
||||
}
|
||||
|
||||
async function fetchCursorUsageDocument(url: string, accessToken: string) {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: buildCursorUsageHeaders(accessToken),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
data: {} as JsonRecord,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = text ? JSON.parse(text) : {};
|
||||
return {
|
||||
ok: true,
|
||||
status: response.status,
|
||||
data: toRecord(parsed),
|
||||
text,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
data: {} as JsonRecord,
|
||||
text,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getCursorUsage(accessToken: string) {
|
||||
try {
|
||||
if (!accessToken) {
|
||||
return {
|
||||
message: "Cursor token expired or unavailable. Please re-authenticate the connection.",
|
||||
};
|
||||
}
|
||||
|
||||
const [usageSummary, userMeta, subscription] = await Promise.all([
|
||||
fetchCursorUsageDocument(CURSOR_USAGE_CONFIG.usageUrl, accessToken),
|
||||
fetchCursorUsageDocument(CURSOR_USAGE_CONFIG.userMetaUrl, accessToken),
|
||||
fetchCursorUsageDocument(CURSOR_USAGE_CONFIG.subscriptionUrl, accessToken),
|
||||
]);
|
||||
|
||||
const authDenied = [usageSummary, userMeta, subscription].some(
|
||||
(result) => result.status === 401 || result.status === 403
|
||||
);
|
||||
if (authDenied) {
|
||||
return {
|
||||
message:
|
||||
"Cursor token expired or permission denied. Please re-authenticate the connection.",
|
||||
};
|
||||
}
|
||||
|
||||
const usageData = usageSummary.data;
|
||||
const userMetaData = userMeta.data;
|
||||
const subscriptionData = subscription.data;
|
||||
const plan = inferCursorPlanName(userMetaData, subscriptionData);
|
||||
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
const totalUsed = getFieldValue(usageData, "num_requests_total", "numRequestsTotal");
|
||||
const totalLimit = getCursorMonthlyRequestLimit(usageData, subscriptionData);
|
||||
const totalReset =
|
||||
getFieldValue(usageData, "reset_date", "resetDate") ||
|
||||
getFieldValue(subscriptionData, "reset_date", "resetDate");
|
||||
|
||||
if (toNumber(totalUsed, 0) > 0 || totalLimit > 0) {
|
||||
quotas.requests = formatCursorQuota(totalUsed, totalLimit, totalReset);
|
||||
}
|
||||
|
||||
const onDemand = toRecord(getFieldValue(usageData, "on_demand", "onDemand"));
|
||||
const onDemandUsed = getFieldValue(onDemand, "num_requests", "numRequests");
|
||||
const onDemandLimit = getCursorOnDemandLimit(usageData, subscriptionData);
|
||||
const onDemandReset =
|
||||
getFieldValue(onDemand, "reset_date", "resetDate") ||
|
||||
getFieldValue(usageData, "reset_date", "resetDate") ||
|
||||
getFieldValue(subscriptionData, "reset_date", "resetDate");
|
||||
|
||||
if (toNumber(onDemandUsed, 0) > 0 || onDemandLimit > 0) {
|
||||
quotas.on_demand = formatCursorQuota(onDemandUsed, onDemandLimit, onDemandReset);
|
||||
}
|
||||
|
||||
if (Object.keys(quotas).length > 0) {
|
||||
return { plan, quotas };
|
||||
}
|
||||
|
||||
return { plan, message: "Cursor connected. Unable to parse quota data." };
|
||||
} catch (error) {
|
||||
return { message: `Unable to fetch Cursor usage: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gemini CLI subscription info cache ──────────────────────────────────────
|
||||
// Prevents duplicate loadCodeAssist calls within the same quota cycle.
|
||||
// Key: accessToken → { data, fetchedAt }
|
||||
@@ -1352,6 +1534,11 @@ export const __testing = {
|
||||
parseResetTime,
|
||||
formatGitHubQuotaSnapshot,
|
||||
inferGitHubPlanName,
|
||||
buildCursorUsageHeaders,
|
||||
formatCursorQuota,
|
||||
getCursorMonthlyRequestLimit,
|
||||
getCursorOnDemandLimit,
|
||||
inferCursorPlanName,
|
||||
getGeminiCliPlanLabel,
|
||||
getAntigravityPlanLabel,
|
||||
};
|
||||
|
||||
124
open-sse/translator/helpers/geminiToolsSanitizer.ts
Normal file
124
open-sse/translator/helpers/geminiToolsSanitizer.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { cleanJSONSchemaForAntigravity } from "./geminiHelper.ts";
|
||||
|
||||
type GeminiFunctionDeclaration = {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: unknown;
|
||||
};
|
||||
|
||||
type GeminiTool = {
|
||||
functionDeclarations?: GeminiFunctionDeclaration[];
|
||||
googleSearch?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toGeminiGoogleSearchTool(tool: Record<string, unknown>): GeminiTool | null {
|
||||
if (isRecord(tool.googleSearch)) {
|
||||
return { googleSearch: tool.googleSearch };
|
||||
}
|
||||
if (tool.googleSearch !== undefined) {
|
||||
return { googleSearch: {} };
|
||||
}
|
||||
|
||||
if (isRecord(tool.google_search)) {
|
||||
return { googleSearch: tool.google_search };
|
||||
}
|
||||
if (tool.google_search !== undefined) {
|
||||
return { googleSearch: {} };
|
||||
}
|
||||
|
||||
const toolType = typeof tool.type === "string" ? tool.type : "";
|
||||
if (
|
||||
toolType === "googleSearch" ||
|
||||
toolType === "google_search" ||
|
||||
toolType === "web_search" ||
|
||||
toolType === "web_search_preview"
|
||||
) {
|
||||
return { googleSearch: {} };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildGeminiTools(tools: unknown): GeminiTool[] | undefined {
|
||||
if (!Array.isArray(tools) || tools.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const functionDeclarations: GeminiFunctionDeclaration[] = [];
|
||||
let googleSearchTool: GeminiTool | null = null;
|
||||
|
||||
for (const rawTool of tools) {
|
||||
if (!isRecord(rawTool)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedGoogleSearchTool = toGeminiGoogleSearchTool(rawTool);
|
||||
if (normalizedGoogleSearchTool) {
|
||||
googleSearchTool = normalizedGoogleSearchTool;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(rawTool.functionDeclarations)) {
|
||||
for (const fn of rawTool.functionDeclarations) {
|
||||
if (!isRecord(fn) || typeof fn.name !== "string" || !fn.name.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
functionDeclarations.push({
|
||||
name: fn.name,
|
||||
description: typeof fn.description === "string" ? fn.description : "",
|
||||
parameters: cleanJSONSchemaForAntigravity(
|
||||
fn.parameters || { type: "object", properties: {} }
|
||||
),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof rawTool.name === "string" && rawTool.name.trim()) {
|
||||
functionDeclarations.push({
|
||||
name: rawTool.name,
|
||||
description: typeof rawTool.description === "string" ? rawTool.description : "",
|
||||
parameters: cleanJSONSchemaForAntigravity(
|
||||
rawTool.input_schema || { type: "object", properties: {} }
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rawTool.type === "function" && isRecord(rawTool.function)) {
|
||||
const fn = rawTool.function;
|
||||
if (typeof fn.name !== "string" || !fn.name.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
functionDeclarations.push({
|
||||
name: fn.name,
|
||||
description: typeof fn.description === "string" ? fn.description : "",
|
||||
parameters: cleanJSONSchemaForAntigravity(
|
||||
fn.parameters || { type: "object", properties: {} }
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (googleSearchTool && functionDeclarations.length > 0) {
|
||||
console.warn(
|
||||
`[GeminiTools] Removing ${functionDeclarations.length} functionDeclarations because googleSearch cannot be mixed with Gemini function tools`
|
||||
);
|
||||
}
|
||||
|
||||
if (googleSearchTool) {
|
||||
return [googleSearchTool];
|
||||
}
|
||||
|
||||
if (functionDeclarations.length > 0) {
|
||||
return [{ functionDeclarations }];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -4,6 +4,6 @@
|
||||
*/
|
||||
import { openaiResponsesToOpenAIRequest } from "../request/openai-responses.ts";
|
||||
|
||||
export function convertResponsesApiFormat(body) {
|
||||
return openaiResponsesToOpenAIRequest(null, body, null, null);
|
||||
export function convertResponsesApiFormat(body, credentials = null) {
|
||||
return openaiResponsesToOpenAIRequest(null, body, null, credentials);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
cleanJSONSchemaForAntigravity,
|
||||
} from "../helpers/geminiHelper.ts";
|
||||
import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts";
|
||||
import { buildGeminiTools } from "../helpers/geminiToolsSanitizer.ts";
|
||||
|
||||
/**
|
||||
* Direct Claude → Gemini request translator.
|
||||
@@ -168,22 +169,9 @@ export function claudeToGeminiRequest(model, body, stream) {
|
||||
}
|
||||
|
||||
// ── Convert tools ──────────────────────────────────────────────
|
||||
if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) {
|
||||
const functionDeclarations = [];
|
||||
for (const tool of body.tools) {
|
||||
if (tool.name) {
|
||||
functionDeclarations.push({
|
||||
name: tool.name,
|
||||
description: tool.description || "",
|
||||
parameters: cleanJSONSchemaForAntigravity(
|
||||
tool.input_schema || { type: "object", properties: {} }
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (functionDeclarations.length > 0) {
|
||||
result.tools = [{ functionDeclarations }];
|
||||
}
|
||||
const geminiTools = buildGeminiTools(body.tools);
|
||||
if (geminiTools) {
|
||||
result.tools = geminiTools;
|
||||
}
|
||||
|
||||
// ── Thinking config ────────────────────────────────────────────
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
* Responses API uses: { input: [...], instructions: "..." }
|
||||
* Chat API uses: { messages: [...] }
|
||||
*/
|
||||
import { register } from "../registry.ts";
|
||||
import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults";
|
||||
import { FORMATS } from "../formats.ts";
|
||||
import { generateToolCallId } from "../helpers/toolCallHelper.ts";
|
||||
import { register } from "../registry.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
const RESPONSES_STORE_MARKER = "_omnirouteResponsesStore";
|
||||
|
||||
function toRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
@@ -44,6 +46,8 @@ export function openaiResponsesToOpenAIRequest(
|
||||
|
||||
const root = toRecord(body);
|
||||
if (root.input === undefined) return body;
|
||||
const credentialRecord = toRecord(credentials);
|
||||
const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData);
|
||||
|
||||
// Validate tool types — only function tools can be translated to Chat Completions
|
||||
const tools = toArray(root.tools);
|
||||
@@ -271,6 +275,9 @@ export function openaiResponsesToOpenAIRequest(
|
||||
delete result.input;
|
||||
delete result.instructions;
|
||||
delete result.include;
|
||||
if (storeEnabled && root.store !== undefined) {
|
||||
result[RESPONSES_STORE_MARKER] = root.store;
|
||||
}
|
||||
delete result.store;
|
||||
delete result.reasoning;
|
||||
|
||||
@@ -287,15 +294,18 @@ export function openaiToOpenAIResponsesRequest(
|
||||
credentials: unknown
|
||||
): unknown {
|
||||
void stream;
|
||||
void credentials;
|
||||
|
||||
const root = toRecord(body);
|
||||
const credentialRecord = toRecord(credentials);
|
||||
const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData);
|
||||
const result: JsonRecord = {
|
||||
model,
|
||||
input: [],
|
||||
stream: true,
|
||||
store: false,
|
||||
};
|
||||
if (!storeEnabled) {
|
||||
result.store = false;
|
||||
}
|
||||
|
||||
const input = result.input as JsonRecord[];
|
||||
|
||||
@@ -514,10 +524,29 @@ export function openaiToOpenAIResponsesRequest(
|
||||
}
|
||||
|
||||
// Pass through relevant fields
|
||||
if (root.previous_response_id !== undefined) {
|
||||
result.previous_response_id = root.previous_response_id;
|
||||
}
|
||||
if (root.prompt_cache_key !== undefined) {
|
||||
result.prompt_cache_key = root.prompt_cache_key;
|
||||
}
|
||||
if (root.session_id !== undefined) {
|
||||
result.session_id = root.session_id;
|
||||
}
|
||||
if (root.conversation_id !== undefined) {
|
||||
result.conversation_id = root.conversation_id;
|
||||
}
|
||||
if (root.service_tier !== undefined) result.service_tier = root.service_tier;
|
||||
if (root.temperature !== undefined) result.temperature = root.temperature;
|
||||
if (root.max_tokens !== undefined) result.max_tokens = root.max_tokens;
|
||||
if (root.top_p !== undefined) result.top_p = root.top_p;
|
||||
if (storeEnabled) {
|
||||
if (root[RESPONSES_STORE_MARKER] !== undefined) {
|
||||
result.store = root[RESPONSES_STORE_MARKER];
|
||||
} else if (root.store !== undefined) {
|
||||
result.store = root.store;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
generateSessionId,
|
||||
cleanJSONSchemaForAntigravity,
|
||||
} from "../helpers/geminiHelper.ts";
|
||||
import { buildGeminiTools } from "../helpers/geminiToolsSanitizer.ts";
|
||||
|
||||
type GeminiPart = Record<string, unknown>;
|
||||
type GeminiContent = { role: string; parts: GeminiPart[] };
|
||||
@@ -54,7 +55,10 @@ type GeminiRequest = {
|
||||
generationConfig: GeminiGenerationConfig;
|
||||
safetySettings: unknown;
|
||||
systemInstruction?: GeminiContent;
|
||||
tools?: Array<{ functionDeclarations: GeminiFunctionDeclaration[] }>;
|
||||
tools?: Array<{
|
||||
functionDeclarations?: GeminiFunctionDeclaration[];
|
||||
googleSearch?: Record<string, unknown>;
|
||||
}>;
|
||||
cachedContent?: string;
|
||||
};
|
||||
|
||||
@@ -69,7 +73,10 @@ type CloudCodeEnvelope = {
|
||||
contents: GeminiContent[];
|
||||
systemInstruction?: GeminiContent;
|
||||
generationConfig: GeminiGenerationConfig;
|
||||
tools?: Array<{ functionDeclarations: GeminiFunctionDeclaration[] }>;
|
||||
tools?: Array<{
|
||||
functionDeclarations?: GeminiFunctionDeclaration[];
|
||||
googleSearch?: Record<string, unknown>;
|
||||
}>;
|
||||
safetySettings?: unknown;
|
||||
toolConfig?: {
|
||||
functionCallingConfig: { mode: string };
|
||||
@@ -277,35 +284,9 @@ function openaiToGeminiBase(model, body, stream) {
|
||||
}
|
||||
|
||||
// Convert tools
|
||||
if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) {
|
||||
const functionDeclarations = [];
|
||||
for (const t of body.tools) {
|
||||
// Check if already in Anthropic/Claude format (no type field, direct name/description/input_schema)
|
||||
if (t.name && t.input_schema) {
|
||||
functionDeclarations.push({
|
||||
name: t.name,
|
||||
description: t.description || "",
|
||||
parameters: cleanJSONSchemaForAntigravity(
|
||||
t.input_schema || { type: "object", properties: {} }
|
||||
),
|
||||
});
|
||||
}
|
||||
// OpenAI format
|
||||
else if (t.type === "function" && t.function) {
|
||||
const fn = t.function;
|
||||
functionDeclarations.push({
|
||||
name: fn.name,
|
||||
description: fn.description || "",
|
||||
parameters: cleanJSONSchemaForAntigravity(
|
||||
fn.parameters || { type: "object", properties: {} }
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (functionDeclarations.length > 0) {
|
||||
result.tools = [{ functionDeclarations }];
|
||||
}
|
||||
const geminiTools = buildGeminiTools(body.tools);
|
||||
if (geminiTools) {
|
||||
result.tools = geminiTools;
|
||||
}
|
||||
|
||||
// Convert response_format to Gemini's responseMimeType/responseSchema
|
||||
@@ -437,7 +418,7 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
|
||||
}
|
||||
|
||||
// Add toolConfig for Antigravity
|
||||
if (geminiCLI.tools?.length > 0) {
|
||||
if (geminiCLI.tools?.some((tool) => Array.isArray(tool.functionDeclarations))) {
|
||||
envelope.request.toolConfig = {
|
||||
functionCallingConfig: { mode: "VALIDATED" },
|
||||
};
|
||||
@@ -534,19 +515,9 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu
|
||||
|
||||
// Convert Claude tools to Gemini functionDeclarations
|
||||
if (claudeRequest.tools && Array.isArray(claudeRequest.tools)) {
|
||||
const functionDeclarations = [];
|
||||
for (const tool of claudeRequest.tools) {
|
||||
if (tool.name && tool.input_schema) {
|
||||
const cleanedSchema = cleanJSONSchemaForAntigravity(tool.input_schema);
|
||||
functionDeclarations.push({
|
||||
name: tool.name,
|
||||
description: tool.description || "",
|
||||
parameters: cleanedSchema,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (functionDeclarations.length > 0) {
|
||||
envelope.request.tools = [{ functionDeclarations }];
|
||||
const geminiTools = buildGeminiTools(claudeRequest.tools);
|
||||
if (geminiTools) {
|
||||
envelope.request.tools = geminiTools;
|
||||
envelope.request.toolConfig = {
|
||||
functionCallingConfig: { mode: "VALIDATED" },
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,7 +11,10 @@ import Input from "@/shared/components/Input";
|
||||
import Modal from "@/shared/components/Modal";
|
||||
import Toggle from "@/shared/components/Toggle";
|
||||
import Tooltip from "@/shared/components/Tooltip";
|
||||
import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { pickDisplayValue } from "@/shared/utils/maskEmail";
|
||||
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { ROUTING_STRATEGIES } from "@/shared/constants/routingStrategies";
|
||||
import {
|
||||
@@ -467,10 +470,12 @@ function formatComboEntryDisplay(
|
||||
providerNodes = [],
|
||||
builderProviders = [],
|
||||
includeConnection = false,
|
||||
showFullEmails = true,
|
||||
}: {
|
||||
providerNodes?: any[];
|
||||
builderProviders?: any[];
|
||||
includeConnection?: boolean;
|
||||
showFullEmails?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const normalizedEntry = normalizeModelEntry(entry);
|
||||
@@ -493,11 +498,14 @@ function formatComboEntryDisplay(
|
||||
}
|
||||
|
||||
const connectionId = normalizedEntry.connectionId || null;
|
||||
const connectionLabel =
|
||||
const rawConnectionLabel =
|
||||
(connectionId &&
|
||||
builderProvider?.connections?.find((connection) => connection.id === connectionId)?.label) ||
|
||||
normalizedEntry.label ||
|
||||
null;
|
||||
const connectionLabel = rawConnectionLabel
|
||||
? pickDisplayValue([rawConnectionLabel], showFullEmails, rawConnectionLabel)
|
||||
: null;
|
||||
|
||||
if (connectionId) {
|
||||
return `${providerLabel}/${modelLabel} · ${connectionLabel || `acct ${connectionId.slice(0, 8)}`}`;
|
||||
@@ -516,6 +524,7 @@ function formatComboEntryDisplay(
|
||||
export default function CombosPage() {
|
||||
const t = useTranslations("combos");
|
||||
const tc = useTranslations("common");
|
||||
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [combos, setCombos] = useState([]);
|
||||
@@ -848,12 +857,38 @@ export default function CombosPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t("title")}</h1>
|
||||
<p className="text-sm text-text-muted mt-1">{t("description")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex items-center gap-2 rounded-lg border border-black/8 dark:border-white/8 bg-black/[0.02] dark:bg-white/[0.02] px-2.5 py-1.5">
|
||||
<span className="hidden lg:inline text-xs text-text-muted">
|
||||
{getI18nOrFallback(
|
||||
t,
|
||||
"emailVisibilityHint",
|
||||
"Account emails here follow the global privacy toggle."
|
||||
)}
|
||||
</span>
|
||||
<Tooltip
|
||||
position="bottom"
|
||||
content={getI18nOrFallback(
|
||||
t,
|
||||
"emailVisibilityTooltip",
|
||||
"Use the eye icon to reveal or hide account emails globally across combos, providers and quota screens."
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex">
|
||||
<EmailPrivacyToggle size="md" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{emailsVisible
|
||||
? getI18nOrFallback(t, "emailVisibilityStateOn", "Emails visible globally")
|
||||
: getI18nOrFallback(t, "emailVisibilityStateOff", "Emails masked globally")}
|
||||
</span>
|
||||
</div>
|
||||
{!showUsageGuide && (
|
||||
<Button size="sm" variant="ghost" onClick={handleShowUsageGuide}>
|
||||
{getI18nOrFallback(t, "usageGuideShow", "Show guide")}
|
||||
@@ -1416,6 +1451,7 @@ function ComboCard({
|
||||
const isDisabled = combo.isActive === false;
|
||||
const t = useTranslations("combos");
|
||||
const tc = useTranslations("common");
|
||||
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
|
||||
const strategyDescription = getStrategyDescription(t, strategy);
|
||||
|
||||
return (
|
||||
@@ -1501,6 +1537,7 @@ function ComboCard({
|
||||
{formatComboEntryDisplay(entry, {
|
||||
providerNodes,
|
||||
includeConnection: true,
|
||||
showFullEmails: emailsVisible,
|
||||
})}
|
||||
{strategy === "weighted" && weight > 0 ? ` (${weight}%)` : ""}
|
||||
</code>
|
||||
@@ -1708,6 +1745,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
|
||||
const t = useTranslations("combos");
|
||||
const tc = useTranslations("common");
|
||||
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
|
||||
const notify = useNotificationStore();
|
||||
const createDraftStateRef = useRef<CreateDraftSnapshot>(getEmptyCreateDraftSnapshot());
|
||||
const [name, setName] = useState(combo?.name || "");
|
||||
@@ -2314,9 +2352,10 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
providerNodes,
|
||||
builderProviders,
|
||||
includeConnection: true,
|
||||
showFullEmails: emailsVisible,
|
||||
});
|
||||
},
|
||||
[builderProviders, providerNodes]
|
||||
[builderProviders, emailsVisible, providerNodes]
|
||||
);
|
||||
|
||||
const handleMoveUp = (index) => {
|
||||
@@ -2760,7 +2799,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
</option>
|
||||
{selectedBuilderConnections.map((connection) => (
|
||||
<option key={connection.id} value={connection.id}>
|
||||
{connection.label}
|
||||
{pickDisplayValue([connection.label], emailsVisible, connection.label)}
|
||||
{connection.status !== "active" ? ` · ${connection.status}` : ""}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -43,6 +43,8 @@ export default function HealthPage() {
|
||||
const tc = useTranslations("common");
|
||||
const tp = useTranslations("providers");
|
||||
const [data, setData] = useState(null);
|
||||
const [dbHealth, setDbHealth] = useState(null);
|
||||
const [dbHealthError, setDbHealthError] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [lastRefresh, setLastRefresh] = useState(null);
|
||||
const [telemetry, setTelemetry] = useState(null);
|
||||
@@ -50,6 +52,7 @@ export default function HealthPage() {
|
||||
const [signatureCache, setSignatureCache] = useState(null);
|
||||
const [degradation, setDegradation] = useState(null);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [repairingDb, setRepairingDb] = useState(false);
|
||||
|
||||
const fetchHealth = useCallback(async () => {
|
||||
try {
|
||||
@@ -64,6 +67,18 @@ export default function HealthPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchDbHealth = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/db/health");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
setDbHealth(json);
|
||||
setDbHealthError(null);
|
||||
} catch (err) {
|
||||
setDbHealthError(err.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch telemetry, cache, and signature cache stats
|
||||
const fetchExtras = useCallback(async () => {
|
||||
const results = await Promise.allSettled([
|
||||
@@ -83,12 +98,14 @@ export default function HealthPage() {
|
||||
useEffect(() => {
|
||||
fetchHealth();
|
||||
fetchExtras();
|
||||
fetchDbHealth();
|
||||
const interval = setInterval(() => {
|
||||
fetchHealth();
|
||||
fetchExtras();
|
||||
fetchDbHealth();
|
||||
}, 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchHealth, fetchExtras]);
|
||||
}, [fetchHealth, fetchExtras, fetchDbHealth]);
|
||||
|
||||
const handleResetHealth = async () => {
|
||||
if (!confirm(t("resetConfirm"))) return;
|
||||
@@ -106,6 +123,24 @@ export default function HealthPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRepairDb = async () => {
|
||||
setRepairingDb(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/db/health", { method: "POST" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
setDbHealth(json);
|
||||
setDbHealthError(null);
|
||||
await fetchHealth();
|
||||
await fetchExtras();
|
||||
} catch (err) {
|
||||
console.error("Failed to repair database health:", err);
|
||||
setDbHealthError(err.message);
|
||||
} finally {
|
||||
setRepairingDb(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fmtMs = (ms) =>
|
||||
ms != null ? t("millisecondsShort", { value: Math.round(ms) }) : t("notAvailable");
|
||||
|
||||
@@ -167,6 +202,7 @@ export default function HealthPage() {
|
||||
onClick={() => {
|
||||
fetchHealth();
|
||||
fetchExtras();
|
||||
fetchDbHealth();
|
||||
}}
|
||||
className="p-2 rounded-lg bg-surface hover:bg-surface/80 text-text-muted hover:text-text-main transition-colors"
|
||||
title={tc("refresh")}
|
||||
@@ -198,6 +234,87 @@ export default function HealthPage() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Card className="p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div
|
||||
className={`flex items-center justify-center size-9 rounded-lg ${
|
||||
dbHealth?.isHealthy
|
||||
? "bg-green-500/10 text-green-500"
|
||||
: "bg-amber-500/10 text-amber-500"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">database</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-main">Database Health</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
Diagnose and repair stale quota/domain rows and broken combo references.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-4">
|
||||
<div className="rounded-xl border border-border bg-surface/50 p-3">
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">Status</p>
|
||||
<p
|
||||
className={`mt-1 text-sm font-medium ${
|
||||
dbHealth?.isHealthy ? "text-green-400" : "text-amber-400"
|
||||
}`}
|
||||
>
|
||||
{dbHealth?.isHealthy ? "Healthy" : "Attention needed"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface/50 p-3">
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">Issues</p>
|
||||
<p className="mt-1 text-sm font-medium text-text-main">
|
||||
{dbHealth?.issues?.length ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface/50 p-3">
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">Repairs</p>
|
||||
<p className="mt-1 text-sm font-medium text-text-main">
|
||||
{dbHealth?.repairedCount ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-stretch gap-2 min-w-[180px]">
|
||||
<button
|
||||
onClick={handleRepairDb}
|
||||
disabled={repairingDb}
|
||||
className="px-4 py-2 rounded-lg bg-primary/10 text-primary text-sm hover:bg-primary/20 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{repairingDb ? "Repairing..." : "Run Auto-Repair"}
|
||||
</button>
|
||||
{dbHealth?.backupCreated && (
|
||||
<p className="text-xs text-text-muted">
|
||||
A repair backup was created before mutating.
|
||||
</p>
|
||||
)}
|
||||
{dbHealthError && <p className="text-xs text-red-400">{dbHealthError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{Array.isArray(dbHealth?.issues) && dbHealth.issues.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{dbHealth.issues.map((issue, index) => (
|
||||
<div
|
||||
key={`${issue.table}-${issue.type}-${index}`}
|
||||
className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-text-main">{issue.description}</p>
|
||||
<span className="text-xs text-amber-400">{issue.count}</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{issue.table} · {issue.type}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* System Info Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
<Toggle
|
||||
checked={formData.codexOpenaiStoreEnabled}
|
||||
onChange={(checked) => setFormData({ ...formData, codexOpenaiStoreEnabled: checked })}
|
||||
label="OpenAI Responses Store"
|
||||
description="Preserves `store`, `previous_response_id`, and adds a stable fallback `session_id` for long Codex sessions. Enable only when the upstream account accepts stored Responses."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isOAuth && connection.email && (
|
||||
|
||||
31
src/app/api/v1/db/health/route.ts
Normal file
31
src/app/api/v1/db/health/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runManagedDbHealthCheck } from "@/lib/db/core";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
return NextResponse.json(runManagedDbHealthCheck({ autoRepair: false }));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error("[API] DB health diagnosis failed:", message);
|
||||
return NextResponse.json({ error: { message } }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
return NextResponse.json(runManagedDbHealthCheck({ autoRepair: true }));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error("[API] DB health repair failed:", message);
|
||||
return NextResponse.json({ error: { message } }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -554,6 +554,8 @@ export async function deleteApiKey(id: string) {
|
||||
|
||||
if (result.changes === 0) return false;
|
||||
|
||||
db.prepare("DELETE FROM domain_budgets WHERE api_key_id = ?").run(id);
|
||||
db.prepare("DELETE FROM domain_cost_history WHERE api_key_id = ?").run(id);
|
||||
setNoLog(id, false);
|
||||
|
||||
// Invalidate caches since a key was removed
|
||||
|
||||
@@ -9,6 +9,7 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
import { resolveDataDir, getLegacyDotDataDir } from "../dataPaths";
|
||||
import { runMigrations } from "./migrationRunner";
|
||||
import { runDbHealthCheck } from "./healthCheck";
|
||||
|
||||
type SqliteDatabase = import("better-sqlite3").Database;
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
@@ -455,6 +456,96 @@ function hasColumn(db: SqliteDatabase, tableName: string, columnName: string): b
|
||||
return rows.some((row) => row.name === columnName);
|
||||
}
|
||||
|
||||
function isAutomatedTestProcess(): boolean {
|
||||
return (
|
||||
typeof process !== "undefined" &&
|
||||
(process.env.NODE_ENV === "test" ||
|
||||
process.env.VITEST !== undefined ||
|
||||
process.argv.some((arg) => arg.includes("test")))
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRunStartupDbHealthCheck(): boolean {
|
||||
if (process.env.OMNIROUTE_FORCE_DB_HEALTHCHECK === "1") return true;
|
||||
return !isAutomatedTestProcess();
|
||||
}
|
||||
|
||||
function createHealthCheckBackup(db: SqliteDatabase): boolean {
|
||||
const isTest = isAutomatedTestProcess();
|
||||
if (isTest) return false;
|
||||
|
||||
try {
|
||||
const backupDir = DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups");
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backupPath = path.join(backupDir, `db_${timestamp}_health-check-repair.sqlite`);
|
||||
const escapedBackupPath = backupPath.replace(/'/g, "''");
|
||||
|
||||
db.exec(`VACUUM INTO '${escapedBackupPath}'`);
|
||||
console.log(`[DB] Health-check backup created: ${backupPath}`);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn("[DB] Failed to create health-check backup:", message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let dbHealthCheckTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function getDbHealthCheckIntervalMs(): number {
|
||||
const rawValue = process.env.OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS;
|
||||
if (typeof rawValue === "string" && rawValue.trim().length > 0) {
|
||||
const parsed = Number(rawValue);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return 6 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function clearDbHealthCheckScheduler() {
|
||||
if (dbHealthCheckTimer) {
|
||||
clearInterval(dbHealthCheckTimer);
|
||||
dbHealthCheckTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startDbHealthCheckScheduler(db: SqliteDatabase) {
|
||||
clearDbHealthCheckScheduler();
|
||||
if (isCloud || isBuildPhase || isAutomatedTestProcess()) return;
|
||||
|
||||
const intervalMs = getDbHealthCheckIntervalMs();
|
||||
if (intervalMs <= 0) return;
|
||||
|
||||
dbHealthCheckTimer = setInterval(() => {
|
||||
try {
|
||||
if (!db.open) return;
|
||||
runDbHealthCheck(db, {
|
||||
autoRepair: true,
|
||||
expectedSchemaVersion: "1",
|
||||
createBackupBeforeRepair: () => createHealthCheckBackup(db),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn("[DB] Periodic health-check failed:", message);
|
||||
}
|
||||
}, intervalMs);
|
||||
dbHealthCheckTimer.unref?.();
|
||||
}
|
||||
|
||||
export function runManagedDbHealthCheck(options?: { autoRepair?: boolean }) {
|
||||
const db = getDbInstance();
|
||||
return runDbHealthCheck(db, {
|
||||
autoRepair: options?.autoRepair === true,
|
||||
expectedSchemaVersion: "1",
|
||||
createBackupBeforeRepair: () => createHealthCheckBackup(db),
|
||||
});
|
||||
}
|
||||
|
||||
export function getDbInstance(): SqliteDatabase {
|
||||
const existing = getDb();
|
||||
if (existing) return existing;
|
||||
@@ -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;
|
||||
|
||||
|
||||
519
src/lib/db/healthCheck.ts
Normal file
519
src/lib/db/healthCheck.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
type SqliteDatabase = import("better-sqlite3").Database;
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export type DbHealthIssueType =
|
||||
| "integrity_check_failed"
|
||||
| "broken_reference"
|
||||
| "stale_snapshot"
|
||||
| "invalid_state";
|
||||
|
||||
export interface DbHealthIssue {
|
||||
type: DbHealthIssueType;
|
||||
table: string;
|
||||
description: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DbHealthCheckResult {
|
||||
isHealthy: boolean;
|
||||
issues: DbHealthIssue[];
|
||||
repairedCount: number;
|
||||
backupCreated: boolean;
|
||||
autoRepair: boolean;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
interface RunDbHealthCheckOptions {
|
||||
autoRepair?: boolean;
|
||||
createBackupBeforeRepair?: () => boolean;
|
||||
expectedSchemaVersion?: string;
|
||||
}
|
||||
|
||||
interface ComboRow {
|
||||
id: string;
|
||||
name: string;
|
||||
data: string;
|
||||
sort_order?: number | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
interface ComboRepairResult {
|
||||
issueCount: number;
|
||||
repairedCount: number;
|
||||
}
|
||||
|
||||
interface QuotaSnapshotRow {
|
||||
id?: number;
|
||||
provider?: string | null;
|
||||
connection_id?: string | null;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): JsonRecord {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function toTrimmedString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function parseJsonRecord(value: string): JsonRecord | null {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): boolean {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function hasRows(db: SqliteDatabase, table: string): boolean {
|
||||
const row = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(table) as { name?: string } | undefined;
|
||||
return row?.name === table;
|
||||
}
|
||||
|
||||
function hasProviderConnection(db: SqliteDatabase, connectionId: string): boolean {
|
||||
const row = db
|
||||
.prepare("SELECT 1 AS ok FROM provider_connections WHERE id = ? LIMIT 1")
|
||||
.get(connectionId) as { ok?: number } | undefined;
|
||||
return row?.ok === 1;
|
||||
}
|
||||
|
||||
function isValidIsoTimestamp(value: unknown): boolean {
|
||||
if (typeof value !== "string" || value.trim().length === 0) return false;
|
||||
return !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
||||
function buildRepairNote(message: string, checkedAt: string): string {
|
||||
return `[db-health:${checkedAt}] ${message}`;
|
||||
}
|
||||
|
||||
function buildDisabledCombo(row: ComboRow, checkedAt: string): JsonRecord {
|
||||
const now = checkedAt;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
version: 2,
|
||||
strategy: "priority",
|
||||
models: [],
|
||||
config: {},
|
||||
isActive: false,
|
||||
isHidden: false,
|
||||
sortOrder: typeof row.sort_order === "number" ? row.sort_order : 0,
|
||||
createdAt: row.created_at || now,
|
||||
updatedAt: now,
|
||||
repairNote: buildRepairNote("Combo payload was rebuilt after invalid JSON was detected.", now),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeComboModels(models: unknown): unknown[] {
|
||||
return Array.isArray(models) ? models : [];
|
||||
}
|
||||
|
||||
function repairComboRows(
|
||||
db: SqliteDatabase,
|
||||
rows: ComboRow[],
|
||||
checkedAt: string,
|
||||
options: { autoRepair: boolean }
|
||||
): ComboRepairResult {
|
||||
if (rows.length === 0) return { issueCount: 0, repairedCount: 0 };
|
||||
|
||||
const existingComboNames = new Set(rows.map((row) => row.name));
|
||||
let issueCount = 0;
|
||||
let repairedCount = 0;
|
||||
|
||||
const updateComboStmt = db.prepare("UPDATE combos SET data = ?, updated_at = ? WHERE id = ?");
|
||||
|
||||
for (const row of rows) {
|
||||
const parsed = parseJsonRecord(row.data);
|
||||
if (!parsed) {
|
||||
issueCount += 1;
|
||||
if (options.autoRepair) {
|
||||
const repaired = buildDisabledCombo(row, checkedAt);
|
||||
updateComboStmt.run(JSON.stringify(repaired), checkedAt, row.id);
|
||||
repairedCount += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentModels = normalizeComboModels(parsed.models);
|
||||
if (currentModels.length === 0) continue;
|
||||
|
||||
const nextModels: unknown[] = [];
|
||||
let removedSteps = 0;
|
||||
let clearedConnectionPins = 0;
|
||||
|
||||
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<number>();
|
||||
const rows = db
|
||||
.prepare("SELECT id, provider, connection_id, created_at FROM quota_snapshots")
|
||||
.all() as QuotaSnapshotRow[];
|
||||
|
||||
for (const row of rows) {
|
||||
const connectionId = toTrimmedString(row.connection_id);
|
||||
const missingConnection = !!connectionId && !hasProviderConnection(db, connectionId);
|
||||
const invalidTimestamp = !isValidIsoTimestamp(row.created_at);
|
||||
if ((missingConnection || invalidTimestamp) && typeof row.id === "number") {
|
||||
brokenRowIds.add(row.id);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(brokenRowIds);
|
||||
}
|
||||
|
||||
function countOrphanQuotaSnapshots(db: SqliteDatabase): number {
|
||||
return getBrokenQuotaSnapshotRowIds(db).length;
|
||||
}
|
||||
|
||||
function repairQuotaSnapshots(db: SqliteDatabase): number {
|
||||
if (!hasRows(db, "quota_snapshots")) return 0;
|
||||
const brokenRowIds = getBrokenQuotaSnapshotRowIds(db);
|
||||
if (brokenRowIds.length === 0) return 0;
|
||||
|
||||
const deleteByRowId = db.prepare("DELETE FROM quota_snapshots WHERE id = ?");
|
||||
let repaired = 0;
|
||||
for (const rowId of brokenRowIds) {
|
||||
repaired += deleteByRowId.run(rowId).changes;
|
||||
}
|
||||
return repaired;
|
||||
}
|
||||
|
||||
function countOrphanDomainRows(
|
||||
db: SqliteDatabase,
|
||||
table: "domain_budgets" | "domain_cost_history"
|
||||
) {
|
||||
if (!hasRows(db, table)) return 0;
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM ${table}
|
||||
WHERE api_key_id NOT IN (SELECT id FROM api_keys)`
|
||||
)
|
||||
.get() as { count?: number } | undefined;
|
||||
return row?.count || 0;
|
||||
}
|
||||
|
||||
function repairOrphanDomainRows(
|
||||
db: SqliteDatabase,
|
||||
table: "domain_budgets" | "domain_cost_history"
|
||||
): number {
|
||||
if (!hasRows(db, table)) return 0;
|
||||
return db.prepare(`DELETE FROM ${table} WHERE api_key_id NOT IN (SELECT id FROM api_keys)`).run()
|
||||
.changes;
|
||||
}
|
||||
|
||||
function countInvalidJsonRows(
|
||||
db: SqliteDatabase,
|
||||
table: "domain_fallback_chains" | "domain_lockout_state" | "domain_circuit_breakers",
|
||||
column: "chain" | "attempts" | "options"
|
||||
): number {
|
||||
if (!hasRows(db, table)) return 0;
|
||||
const rows = db.prepare(`SELECT ${column} FROM ${table}`).all() as Array<Record<string, unknown>>;
|
||||
let invalid = 0;
|
||||
for (const row of rows) {
|
||||
const raw = row[column];
|
||||
if (raw == null && column === "options") continue;
|
||||
if (typeof raw !== "string") {
|
||||
invalid += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
JSON.parse(raw);
|
||||
} catch {
|
||||
invalid += 1;
|
||||
}
|
||||
}
|
||||
return invalid;
|
||||
}
|
||||
|
||||
function repairInvalidJsonRows(
|
||||
db: SqliteDatabase,
|
||||
table: "domain_fallback_chains" | "domain_lockout_state" | "domain_circuit_breakers",
|
||||
column: "chain" | "attempts" | "options"
|
||||
): number {
|
||||
if (!hasRows(db, table)) return 0;
|
||||
|
||||
const rows = db.prepare(`SELECT rowid, ${column} FROM ${table}`).all() as Array<{
|
||||
rowid: number;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
|
||||
const deleteByRowId = db.prepare(`DELETE FROM ${table} WHERE rowid = ?`);
|
||||
const clearOptionsByRowId = db.prepare(
|
||||
"UPDATE domain_circuit_breakers SET options = NULL WHERE rowid = ?"
|
||||
);
|
||||
let repaired = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const raw = row[column];
|
||||
if (raw == null && table === "domain_circuit_breakers") {
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== "string") {
|
||||
if (table === "domain_circuit_breakers") {
|
||||
repaired += clearOptionsByRowId.run(row.rowid).changes;
|
||||
continue;
|
||||
}
|
||||
deleteByRowId.run(row.rowid);
|
||||
repaired += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
JSON.parse(raw);
|
||||
} catch {
|
||||
if (table === "domain_circuit_breakers") {
|
||||
repaired += clearOptionsByRowId.run(row.rowid).changes;
|
||||
continue;
|
||||
}
|
||||
deleteByRowId.run(row.rowid);
|
||||
repaired += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return repaired;
|
||||
}
|
||||
|
||||
function getSchemaVersionIssueCount(db: SqliteDatabase, expectedSchemaVersion: string): number {
|
||||
if (!hasRows(db, "db_meta")) return 0;
|
||||
const row = db.prepare("SELECT value FROM db_meta WHERE key = 'schema_version'").get() as
|
||||
| { value?: string | null }
|
||||
| undefined;
|
||||
const current = typeof row?.value === "string" ? row.value : null;
|
||||
return current === expectedSchemaVersion ? 0 : 1;
|
||||
}
|
||||
|
||||
function repairSchemaVersion(db: SqliteDatabase, expectedSchemaVersion: string): number {
|
||||
if (!hasRows(db, "db_meta")) return 0;
|
||||
return db
|
||||
.prepare("INSERT OR REPLACE INTO db_meta (key, value) VALUES ('schema_version', ?)")
|
||||
.run(expectedSchemaVersion).changes;
|
||||
}
|
||||
|
||||
export function runDbHealthCheck(
|
||||
db: SqliteDatabase,
|
||||
options: RunDbHealthCheckOptions = {}
|
||||
): DbHealthCheckResult {
|
||||
const autoRepair = options.autoRepair === true;
|
||||
const expectedSchemaVersion = options.expectedSchemaVersion || "1";
|
||||
const checkedAt = new Date().toISOString();
|
||||
const issues: DbHealthIssue[] = [];
|
||||
let repairedCount = 0;
|
||||
let backupCreated = false;
|
||||
let backupAttempted = false;
|
||||
|
||||
const ensureBackupBeforeRepair = () => {
|
||||
if (!autoRepair || backupAttempted || typeof options.createBackupBeforeRepair !== "function") {
|
||||
return;
|
||||
}
|
||||
backupAttempted = true;
|
||||
backupCreated = options.createBackupBeforeRepair();
|
||||
};
|
||||
|
||||
const integrityCheck = db.pragma("integrity_check") as Array<{ integrity_check?: string }>;
|
||||
if (integrityCheck[0]?.integrity_check !== "ok") {
|
||||
issues.push({
|
||||
type: "integrity_check_failed",
|
||||
table: "sqlite",
|
||||
description: "SQLite integrity_check returned a non-ok status.",
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
sessionId: unknown
|
||||
): Record<string, unknown> {
|
||||
const explicitSessionId = body.session_id;
|
||||
const explicitConversationId = body.conversation_id;
|
||||
const promptCacheKey = body.prompt_cache_key ?? body.promptCacheKey;
|
||||
|
||||
if (
|
||||
hasNonEmptyString(explicitSessionId) ||
|
||||
hasNonEmptyString(explicitConversationId) ||
|
||||
hasNonEmptyString(promptCacheKey)
|
||||
) {
|
||||
return body;
|
||||
}
|
||||
|
||||
const fallbackSessionId = buildOpenAIStoreSessionId(sessionId);
|
||||
if (!fallbackSessionId) return body;
|
||||
|
||||
return {
|
||||
...body,
|
||||
session_id: fallbackSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function getProviderRequestDefaults(
|
||||
provider: string | null | undefined,
|
||||
providerSpecificData: unknown
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export const MCP_TOOL_SCOPES: Record<string, readonly McpScope[]> = {
|
||||
omniroute_best_combo_for_task: ["read:combos", "read:health"],
|
||||
omniroute_explain_route: ["read:health", "read:usage"],
|
||||
omniroute_get_session_snapshot: ["read:usage"],
|
||||
omniroute_db_health_check: ["read:health", "write:resilience"],
|
||||
} as const;
|
||||
|
||||
// ============ Scope Groups ============
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
254
tests/unit/codex-stream-false.test.mjs
Normal file
254
tests/unit/codex-stream-false.test.mjs
Normal file
@@ -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"
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
314
tests/unit/db-health-check.test.mjs
Normal file
314
tests/unit/db-health-check.test.mjs
Normal file
@@ -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
|
||||
);
|
||||
});
|
||||
97
tests/unit/db-health-route.test.mjs
Normal file
97
tests/unit/db-health-route.test.mjs
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" };
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
43
tests/unit/provider-specific-data-schema.test.mjs
Normal file
43
tests/unit/provider-specific-data-schema.test.mjs
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
40
tests/unit/request-defaults-store-session.test.mjs
Normal file
40
tests/unit/request-defaults-store-session.test.mjs
Normal file
@@ -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");
|
||||
});
|
||||
@@ -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: "<think>discard me</think>Visible text",
|
||||
content: "<think>discard me</think>",
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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+$/);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user