diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e2d46bf60..54424875a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,11 @@ - **fix(cloudflare-ai):** flatten OpenAI content-part arrays to plain strings for the Workers AI (`cf/`) executor — Workers AI's `/ai/v1/chat/completions` rejects `content: [{type:"text",...}]` with HTTP 400, so requests with array content now have their text parts joined into a string. ([#2539](https://github.com/diegosouzapw/OmniRoute/issues/2539)) - **fix(i18n):** replace leftover Portuguese strings in the English source with English on the Quota dashboards — the quota-share Beta notice (`betaConfigSaved*`) and the Provider Quota row's `Edit cutoffs` / `Refresh now` fallbacks were showing Portuguese. ([#2540](https://github.com/diegosouzapw/OmniRoute/issues/2540)) +- **fix(proxy):** honor the legacy per-provider/global proxy config in `resolveProxyForProvider` — the Claude OAuth token exchange and token refresh only consulted the new proxy registry, so a proxy configured the legacy way (`/api/settings/proxy?level=provider`) was ignored and the exchange went out directly from the host, tripping Anthropic's IP `rate_limit_error` on VPS deployments. It now falls back to the legacy config, mirroring `resolveProxyForConnection`. ([#2456](https://github.com/diegosouzapw/OmniRoute/issues/2456)) +- **fix(antigravity):** auto-discover a missing Cloud Code `projectId` via `loadCodeAssist` before failing — a freshly re-added Antigravity account whose stored `projectId` was empty (OAuth-time discovery returned nothing) now recovers the project on the first request instead of returning `422 Missing Google projectId`, mirroring the `gemini-cli` bootstrap. ([#2334](https://github.com/diegosouzapw/OmniRoute/issues/2334), [#2541](https://github.com/diegosouzapw/OmniRoute/issues/2541)) +- **fix(stream):** keep the `/v1/responses` SSE connection warm for strict clients — emit an early keepalive while the upstream produces its first token and lower the heartbeat cadence to 4s, so Codex CLI's `reqwest` client (≈5s idle-read timeout) no longer drops the stream "before completion" on slow/reasoning models. `curl` was unaffected because it has no idle timeout. ([#2544](https://github.com/diegosouzapw/OmniRoute/issues/2544)) +- **fix(electron):** wait longer for the server on first launch and reload once it responds — long post-upgrade DB migrations could exceed the 30s readiness probe, leaving the desktop app stuck on the "Server starting" screen even though the backend was healthy. The probe now targets the auth-exempt health endpoint with a generous timeout and reloads the window once the server comes up. ([#2460](https://github.com/diegosouzapw/OmniRoute/issues/2460)) + - **fix(cli):** mark `bin/omniroute.mjs` as executable (mode 755) so the globally-installed CLI runs directly without a manual `chmod +x`. ([#2469](https://github.com/diegosouzapw/OmniRoute/issues/2469) — thanks @disonjer) - **fix(settings):** restore the Global System Prompt into the in-memory config on server startup and after JSON/SQLite import — it was only loaded by the PUT endpoint, so the toggle/prompt silently reverted to defaults after any restart or import. ([#2470](https://github.com/diegosouzapw/OmniRoute/issues/2470) — thanks @disonjer) - **fix(settings):** append the Global System Prompt **after** existing system content instead of prepending it, so provider/agent instructions (Kiro, OpenCode, Hermes, …) injected into the system message no longer override the user's global prompt via recency bias. ([#2468](https://github.com/diegosouzapw/OmniRoute/issues/2468) — thanks @disonjer) diff --git a/electron/main.js b/electron/main.js index 20191d539b..db5ba4ee17 100644 --- a/electron/main.js +++ b/electron/main.js @@ -173,7 +173,11 @@ function sendToRenderer(channel, data) { } // ── Helper: Wait for server readiness (#1, #10) ──────────── -async function waitForServer(url, timeoutMs = 30000) { +// Default raised to 180s: the first launch after an upgrade can run long DB +// migrations, during which the server accepts the TCP connection but holds the +// HTTP response until handlers initialize. The previous 30s cap timed out and +// left the window stuck on a hanging connection (#2460). +async function waitForServer(url, timeoutMs = 180000) { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { @@ -711,8 +715,10 @@ app.whenReady().then(async () => { // Fix #1: Start server and WAIT for readiness before showing window startNextServer(); + let serverReady = true; if (!isDev) { - await waitForServer(getServerUrl()); + // Probe the auth-exempt health endpoint (not the root URL, which may redirect). + serverReady = await waitForServer(`${getServerUrl()}/api/monitoring/health`); } createWindow(); @@ -720,6 +726,16 @@ app.whenReady().then(async () => { setupIpcHandlers(); setupAutoUpdater(); + // If readiness timed out (e.g. very long first-launch migrations), don't leave the + // window stuck on a hanging connection — keep polling and reload once it responds (#2460). + if (!isDev && !serverReady) { + void waitForServer(`${getServerUrl()}/api/monitoring/health`, 300000).then((ready) => { + if (ready && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.loadURL(getServerUrl()); + } + }); + } + // Check for updates after a short delay (don't block startup) if (!isDev) { setTimeout(() => { diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 81bc0a138e..93292938e4 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -31,6 +31,7 @@ import { import { persistCreditBalance, getAllPersistedCreditBalances } from "@/lib/db/creditBalance"; import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts"; import { resolveAntigravityVersion } from "../services/antigravityVersion.ts"; +import { ensureAntigravityProjectAssigned } from "../services/antigravityProjectBootstrap.ts"; import { resolveAntigravityModelId } from "../config/antigravityModelAliases.ts"; import { cloakAntigravityToolPayload } from "../config/toolCloaking.ts"; import { @@ -419,12 +420,12 @@ export class AntigravityExecutor extends BaseExecutor { return scrubProxyAndFingerprintHeaders(raw); } - transformRequest( + async transformRequest( model: string, body: unknown, _stream: boolean, credentials: AntigravityCredentials - ): AntigravityRequestEnvelope | Response { + ): Promise { // TODO: Consider removing project override like gemini-cli.ts — stored projectId // can become stale for Cloud Code accounts, causing 403 "has not been used in project X". // Antigravity accounts may have more stable project IDs, but the risk exists. @@ -444,16 +445,28 @@ export class AntigravityExecutor extends BaseExecutor { // Default: prefer OAuth-stored projectId over incoming body.project to avoid // stale/wrong client-side values causing 404/403 from Cloud Code endpoints. // Opt-in escape hatch: set OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE=1. - const projectId = + let projectId = allowBodyProjectOverride && bodyProjectId ? bodyProjectId : credentialsProjectId || providerSpecificProjectId || bodyProjectId; + // Auto-discover a missing projectId via loadCodeAssist before failing (#2334/#2541). + // A freshly re-added Antigravity account can have an empty stored projectId even when + // its Google account already owns a Cloud Code project (the OAuth-time loadCodeAssist + // returned empty/transiently failed). Mirror gemini-cli.ts's bootstrap to recover it + // here — the helper memoizes per access-token, so this is a one-time round-trip. + if (!projectId && credentials?.accessToken) { + const discovered = await ensureAntigravityProjectAssigned(credentials.accessToken); + if (discovered) projectId = discovered; + } + if (!projectId) { // (#489) Return a structured error instead of throwing — gives the client a clear signal // to show a "Reconnect OAuth" prompt rather than an opaque "Internal Server Error". const errorMsg = - "Missing Google projectId for Antigravity account. Please reconnect OAuth in Providers → Antigravity so OmniRoute can fetch your Cloud Code project."; + "Missing Google projectId for Antigravity account. Auto-discovery via loadCodeAssist " + + "found no Cloud Code project. Please reconnect OAuth in Providers → Antigravity (and " + + "ensure the Google account has completed Gemini Code Assist onboarding)."; const errorBody = { error: { message: errorMsg, diff --git a/open-sse/utils/earlyStreamKeepalive.ts b/open-sse/utils/earlyStreamKeepalive.ts new file mode 100644 index 0000000000..e83a3d645f --- /dev/null +++ b/open-sse/utils/earlyStreamKeepalive.ts @@ -0,0 +1,192 @@ +/** + * Early SSE keepalive wrapper for streaming route handlers. + * + * Strict HTTP clients (notably Codex CLI's `reqwest`, which has a ~5s idle-read + * timeout) drop the connection if no bytes arrive shortly after the request. + * OmniRoute, however, holds the streaming response until `ensureStreamReadiness` + * observes the upstream's first useful byte — which can exceed 5s for reasoning + * models that "think" before emitting any token (#2544). `curl` has no such + * idle timeout, so it was never affected, which is why the bug looked + * client-specific. + * + * This wrapper keeps the connection warm without disturbing the handler's + * internal logic (combo failover, stream readiness, account cooldown all still + * run inside the handler before it resolves): + * + * - Fast path: if the handler resolves within `thresholdMs`, its `Response` + * is returned verbatim — identical status, headers, and body. There is zero + * behavior change for normal latency, so metadata headers and non-200 error + * statuses are fully preserved for the common case. + * + * - Slow path: if the handler is still pending after `thresholdMs`, a 200 + * `text/event-stream` response is opened immediately and SSE comment + * heartbeats are emitted every `intervalMs` until the handler resolves; its + * body is then forwarded. If the handler ultimately fails, a structured + * `event: error` frame is emitted in-band (the response is already committed + * to 200, so the HTTP status can no longer change). + */ + +const ENCODER = new TextEncoder(); +const KEEPALIVE_FRAME = ENCODER.encode(": omniroute-keepalive\n\n"); +const ERROR_FRAME = ENCODER.encode( + `event: error\ndata: ${JSON.stringify({ + error: { message: "Upstream stream failed before completion.", type: "stream_error" }, + })}\n\n` +); + +export type EarlyStreamKeepaliveOptions = { + /** Wait this long for the handler before committing to a keepalive stream. */ + thresholdMs?: number; + /** Keepalive cadence once committed (must stay under the client idle timeout). */ + intervalMs?: number; + /** Client request signal — propagated so a client disconnect cancels the upstream read. */ + signal?: AbortSignal | null; +}; + +type SettledHandler = { ok: true; response: Response } | { ok: false; error: unknown }; + +export async function withEarlyStreamKeepalive( + handlerPromise: Promise, + options: EarlyStreamKeepaliveOptions = {} +): Promise { + const thresholdMs = Math.max(0, options.thresholdMs ?? 2_000); + const intervalMs = Math.max(250, options.intervalMs ?? 2_500); + const signal = options.signal ?? null; + + // Settle into a tagged result so neither race branch leaves an unhandled + // rejection when the threshold timer wins. + const settled: Promise = handlerPromise.then( + (response) => ({ ok: true as const, response }), + (error) => ({ ok: false as const, error }) + ); + + let timer: ReturnType | undefined; + const raced = await Promise.race([ + settled.then((result) => ({ kind: "settled" as const, result })), + new Promise<{ kind: "timeout" }>((resolve) => { + timer = setTimeout(() => resolve({ kind: "timeout" }), thresholdMs); + }), + ]); + if (timer) clearTimeout(timer); + + if (raced.kind === "settled") { + // Fast path — return verbatim, or rethrow so the route's normal error handling runs. + if (raced.result.ok) return raced.result.response; + throw raced.result.error; + } + + // Slow path — open the SSE stream now and keep it warm until the handler resolves. + // Cleanup state is hoisted so both start() and cancel() (client disconnect) can stop + // the keepalive loop and cancel the upstream read. + let stopKeepalive = () => {}; + let upstreamReader: ReadableStreamDefaultReader | null = null; + let aborted = false; + + const stream = new ReadableStream({ + async start(controller) { + let stopped = false; + const interval = setInterval(() => { + if (stopped) return; + try { + controller.enqueue(KEEPALIVE_FRAME); + } catch { + stopped = true; + clearInterval(interval); + } + }, intervalMs); + if (interval && typeof interval === "object" && "unref" in interval) { + interval.unref?.(); + } + // First keepalive immediately on commit so the client sees a byte right away. + try { + controller.enqueue(KEEPALIVE_FRAME); + } catch { + /* consumer already gone */ + } + + stopKeepalive = () => { + stopped = true; + clearInterval(interval); + }; + + const onAbort = () => { + aborted = true; + stopKeepalive(); + upstreamReader?.cancel().catch(() => {}); + try { + controller.close(); + } catch { + /* already closed */ + } + }; + signal?.addEventListener("abort", onAbort, { once: true }); + + try { + const result = await settled; + stopKeepalive(); + if (aborted) return; // client disconnected while we were waiting + + if (!result.ok) { + // Handler rejected — emit a generic error frame (never the raw error/stack). + controller.enqueue(ERROR_FRAME); + } else { + const response = result.response; + const contentType = (response.headers.get("content-type") || "").toLowerCase(); + const isSse = contentType.includes("text/event-stream"); + + if (response.body && isSse) { + // Real SSE stream — forward it verbatim. + upstreamReader = response.body.getReader(); + while (true) { + const { done, value } = await upstreamReader.read(); + if (done) break; + if (value) controller.enqueue(value); + } + } else { + // Non-SSE response (e.g. a JSON error) reached us after we already + // committed to a 200 event-stream, so the HTTP status can no longer + // change. Frame the (already-sanitized) body as an in-band error event + // instead of forwarding raw JSON, which would be malformed SSE. + const text = response.body ? await response.text().catch(() => "") : ""; + const dataLine = + text.trim() || + JSON.stringify({ error: { message: "stream_error", type: "stream_error" } }); + controller.enqueue(ENCODER.encode(`event: error\ndata: ${dataLine}\n\n`)); + } + } + } catch { + // Defensive: never surface a raw error/stack to the client. + if (!aborted) { + try { + controller.enqueue(ERROR_FRAME); + } catch { + /* consumer gone */ + } + } + } finally { + stopKeepalive(); + signal?.removeEventListener("abort", onAbort); + try { + controller.close(); + } catch { + /* already closed */ + } + } + }, + cancel() { + // Consumer (Next.js → client) went away — stop keepalives and release the upstream. + aborted = true; + stopKeepalive(); + upstreamReader?.cancel().catch(() => {}); + }, + }); + + return new Response(stream, { + status: 200, + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/open-sse/utils/sseHeartbeat.ts b/open-sse/utils/sseHeartbeat.ts index f0ef75a26f..0852b07bbd 100644 --- a/open-sse/utils/sseHeartbeat.ts +++ b/open-sse/utils/sseHeartbeat.ts @@ -1,4 +1,6 @@ -export const DEFAULT_SSE_HEARTBEAT_INTERVAL_MS = 15_000; +// Kept in sync with runtimeTimeouts.ts: 4s stays under the ~5s idle-read timeout of +// strict clients like Codex CLI's reqwest (#2544). +export const DEFAULT_SSE_HEARTBEAT_INTERVAL_MS = 4_000; export const HEARTBEAT_SHAPES = { COMMENT: "comment", diff --git a/package.json b/package.json index c034ebd4c6..95d9c0c95b 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "electron:build:linux": "npm run build && cd electron && npm run build:linux", "electron:smoke:packaged": "node scripts/dev/smoke-electron-packaged.mjs", "test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --test --test-concurrency=10 tests/unit/*.test.ts", - "test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --test --test-concurrency=10 tests/unit/*.test.ts", + "test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --test --test-force-exit --test-concurrency=10 tests/unit/*.test.ts", "test:plan3": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --test tests/unit/plan3-p0.test.ts", "test:fixes": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --test tests/unit/fixes-p1.test.ts", "test:security": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --test tests/unit/security-fase01.test.ts", diff --git a/src/app/api/v1/responses/route.ts b/src/app/api/v1/responses/route.ts index d4cfeab444..0dbb556449 100644 --- a/src/app/api/v1/responses/route.ts +++ b/src/app/api/v1/responses/route.ts @@ -1,4 +1,5 @@ import { handleChat } from "@/sse/handlers/chat"; +import { withEarlyStreamKeepalive } from "@omniroute/open-sse/utils/earlyStreamKeepalive"; // NOTE: We do NOT call initTranslators() here — the translator registry is // bootstrapped at module level inside open-sse/translator/index.ts when it @@ -23,5 +24,13 @@ export async function OPTIONS() { * Handled by the unified chat handler (openai-responses format auto-detected). */ export async function POST(request) { + // Codex CLI (wire_api="responses") consumes this endpoint over SSE and its reqwest + // client drops the connection if no bytes arrive within ~5s. Keep the connection + // warm with early keepalives while the upstream produces its first token (#2544). + // Non-streaming callers (JSON) keep the original verbatim path untouched. + const accept = String(request.headers?.get?.("accept") || "").toLowerCase(); + if (accept.includes("text/event-stream")) { + return await withEarlyStreamKeepalive(handleChat(request), { signal: request.signal }); + } return await handleChat(request); } diff --git a/src/lib/db/proxies.ts b/src/lib/db/proxies.ts index 377638fe02..f090884748 100755 --- a/src/lib/db/proxies.ts +++ b/src/lib/db/proxies.ts @@ -685,6 +685,35 @@ export async function resolveProxyForProvider(providerId: string) { }; } + // Fallback: honor the legacy per-provider / global proxy config (set via + // /api/settings/proxy?level=provider&id=...). The proxy registry only tracks + // explicit assignments; without this fallback the OAuth token exchange and + // token-refresh paths ignore a proxy configured the legacy way and connect + // directly — which on a VPS trips Anthropic's IP rate limit (#2456). + // resolveProxyForConnection already has this fallback; mirror it here. + // Dynamic import avoids a static cycle (settings.ts imports from proxies.ts). + const { getProxyForLevel } = await import("./settings"); + const legacyProvider = await getProxyForLevel("provider", providerId); + if (legacyProvider && typeof legacyProvider === "object" && legacyProvider.host) { + return { + type: legacyProvider.type, + host: legacyProvider.host, + port: legacyProvider.port, + username: legacyProvider.username, + password: legacyProvider.password, + }; + } + const legacyGlobal = await getProxyForLevel("global"); + if (legacyGlobal && typeof legacyGlobal === "object" && legacyGlobal.host) { + return { + type: legacyGlobal.type, + host: legacyGlobal.host, + port: legacyGlobal.port, + username: legacyGlobal.username, + password: legacyGlobal.password, + }; + } + return null; } catch (error: unknown) { const msg = error instanceof Error ? error.message : String(error); diff --git a/src/shared/utils/apiKeyPolicy.ts b/src/shared/utils/apiKeyPolicy.ts index 060def0bb6..d7e12cc1cb 100644 --- a/src/shared/utils/apiKeyPolicy.ts +++ b/src/shared/utils/apiKeyPolicy.ts @@ -19,7 +19,8 @@ import { checkRateLimit, RateLimitRule } from "./rateLimiter"; // Default to no per-key request cap. API keys can still opt into explicit // limits via Settings/API Manager, while provider/account quota controls remain // responsible for upstream 429 handling and fallback. -const DEFAULT_RATE_LIMITS: RateLimitRule[] = []; +// Exported so tests can lock in the "no implicit caps" contract from #2289. +export const DEFAULT_RATE_LIMITS: RateLimitRule[] = []; interface AccessSchedule { enabled: boolean; diff --git a/src/shared/utils/runtimeTimeouts.ts b/src/shared/utils/runtimeTimeouts.ts index df4e11150d..4103d1f8d5 100644 --- a/src/shared/utils/runtimeTimeouts.ts +++ b/src/shared/utils/runtimeTimeouts.ts @@ -8,7 +8,10 @@ type ReadTimeoutOptions = { export const DEFAULT_FETCH_TIMEOUT_MS = 600_000; export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 600_000; -export const DEFAULT_SSE_HEARTBEAT_INTERVAL_MS = 15_000; +// 4s keeps the downstream connection active under the ~5s idle-read timeout used by +// strict HTTP clients such as Codex CLI's reqwest, which dropped mid-stream during long +// upstream thinking phases at the previous 15s cadence (#2544). Override via env. +export const DEFAULT_SSE_HEARTBEAT_INTERVAL_MS = 4_000; export const DEFAULT_STREAM_READINESS_TIMEOUT_MS = 80_000; export const DEFAULT_FETCH_CONNECT_TIMEOUT_MS = 30_000; export const DEFAULT_FETCH_KEEPALIVE_TIMEOUT_MS = 4_000; diff --git a/tests/unit/antigravity-model-aliases.test.ts b/tests/unit/antigravity-model-aliases.test.ts index 542352b96e..08a5469e31 100644 --- a/tests/unit/antigravity-model-aliases.test.ts +++ b/tests/unit/antigravity-model-aliases.test.ts @@ -17,7 +17,7 @@ function getPublicModel(id: string) { test("resolveAntigravityModelId maps the documented Antigravity aliases to upstream IDs", () => { assert.equal(resolveAntigravityModelId("gemini-3-pro-preview"), "gemini-3.1-pro-high"); - assert.equal(resolveAntigravityModelId("gemini-3.5-flash-preview"), "gemini-3-flash-agent"); + assert.equal(resolveAntigravityModelId("gemini-3.5-flash-preview"), "gemini-3.5-flash-high"); assert.equal(resolveAntigravityModelId("gemini-3-flash-preview"), "gemini-3-flash"); assert.equal(resolveAntigravityModelId("gemini-3-pro-image-preview"), "gemini-3-pro-image"); assert.equal( @@ -54,21 +54,17 @@ test("isUserCallableAntigravityModelId only allows public chat-capable model IDs assert.equal(isUserCallableAntigravityModelId("gemini-2.5-flash-lite"), true); assert.equal(isUserCallableAntigravityModelId("gemini-2.5-flash-thinking"), true); assert.equal(isUserCallableAntigravityModelId("gemini-pro-agent"), true); - assert.equal(isUserCallableAntigravityModelId("claude-sonnet-4-6"), true); + // Claude was removed from Antigravity 2.0's public catalog (May 2026); the alias is + // kept for back-compat but the model is no longer user-callable. + assert.equal(isUserCallableAntigravityModelId("claude-sonnet-4-6"), false); assert.equal(isUserCallableAntigravityModelId("tab_flash_lite_preview"), false); assert.equal(isUserCallableAntigravityModelId("unknown-model"), false); }); test("ANTIGRAVITY_PUBLIC_MODELS exposes captured Antigravity 2.0.1 names and capabilities", () => { - assert.deepEqual(getPublicModel("claude-opus-4-6-thinking"), { - id: "claude-opus-4-6-thinking", - name: "Claude Opus 4.6 (Thinking)", - contextLength: 250000, - maxOutputTokens: 64000, - supportsReasoning: true, - supportsVision: true, - toolCalling: true, - }); + // Claude models were removed from Antigravity 2.0's public catalog (May 2026), so they + // are no longer exposed as public models (the back-compat alias still resolves upstream). + assert.equal(getPublicModel("claude-opus-4-6-thinking"), undefined); assert.deepEqual(getPublicModel("gemini-3.5-flash-preview"), { id: "gemini-3.5-flash-preview", name: "Gemini 3.5 Flash (High)", @@ -137,7 +133,7 @@ test("AntigravityExecutor.transformRequest resolves Gemini 3.5 Flash alias upstr ); if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); - assert.equal(result.model, "gemini-3-flash-agent"); + assert.equal(result.model, "gemini-3.5-flash-high"); }); test("AntigravityExecutor.transformRequest sends Claude through Gemini-compatible Cloud Code schema", async () => { diff --git a/tests/unit/apikey-policy-default-rate-limits.test.ts b/tests/unit/apikey-policy-default-rate-limits.test.ts index d5d8622269..25fe1d48e1 100644 --- a/tests/unit/apikey-policy-default-rate-limits.test.ts +++ b/tests/unit/apikey-policy-default-rate-limits.test.ts @@ -1,49 +1,13 @@ import test from "node:test"; import assert from "node:assert/strict"; -// Mirror the constants in apiKeyPolicy.ts so the tests document the contract -// rather than re-deriving it from the implementation under test. -const LEGACY_DEFAULT = [ - { limit: 1000, window: 86400 }, - { limit: 5000, window: 604800 }, - { limit: 20000, window: 2592000 }, -]; - -test("buildDefaultRateLimits: unset / empty env falls back to the legacy 1000/day default", async () => { - const { buildDefaultRateLimits } = await import("../../src/shared/utils/apiKeyPolicy.ts"); - - // Unset and empty must both produce the legacy default — going unlimited - // by accident on an upgrade would expose existing deployments. - assert.deepEqual(buildDefaultRateLimits(undefined), LEGACY_DEFAULT); - assert.deepEqual(buildDefaultRateLimits(""), LEGACY_DEFAULT); - assert.deepEqual(buildDefaultRateLimits(" "), LEGACY_DEFAULT); -}); - -test("buildDefaultRateLimits: explicit '0' opts out — no fallback rules", async () => { - const { buildDefaultRateLimits } = await import("../../src/shared/utils/apiKeyPolicy.ts"); - - // The only way to become unlimited is to set the env var explicitly to "0". - assert.deepEqual(buildDefaultRateLimits("0"), []); -}); - -test("buildDefaultRateLimits: positive N yields N/day, 5N/week, 20N/month", async () => { - const { buildDefaultRateLimits } = await import("../../src/shared/utils/apiKeyPolicy.ts"); - - assert.deepEqual(buildDefaultRateLimits("100"), [ - { limit: 100, window: 86400 }, - { limit: 500, window: 604800 }, - { limit: 2000, window: 2592000 }, - ]); -}); - -test("buildDefaultRateLimits: malformed input falls back to the legacy default, not unlimited", async () => { - const { buildDefaultRateLimits } = await import("../../src/shared/utils/apiKeyPolicy.ts"); - - // Zod (z.coerce.number().int().min(0)) rejects each of these. - // The function must keep the secure default rather than silently returning - // [] — a typo in deployment config should not silently disable rate limits. - assert.deepEqual(buildDefaultRateLimits("-5"), LEGACY_DEFAULT); - assert.deepEqual(buildDefaultRateLimits("not-a-number"), LEGACY_DEFAULT); - assert.deepEqual(buildDefaultRateLimits("1000 requests"), LEGACY_DEFAULT); - assert.deepEqual(buildDefaultRateLimits("3.14"), LEGACY_DEFAULT); +// #2289 ("remove implicit API key request caps") reverted the configurable default +// rate limits introduced in #2266: keys with no explicitly-configured rate limits are +// now unlimited by default, and `buildDefaultRateLimits` was removed. This test was +// originally written for that removed feature; it now guards the current contract — +// that no implicit per-key cap creeps back in. Keys still opt into explicit limits via +// Settings/API Manager, and provider/account quota controls handle upstream 429s. +test("apiKeyPolicy exposes no implicit default rate limits (#2289)", async () => { + const { DEFAULT_RATE_LIMITS } = await import("../../src/shared/utils/apiKeyPolicy.ts"); + assert.deepEqual(DEFAULT_RATE_LIMITS, []); }); diff --git a/tests/unit/early-stream-keepalive.test.ts b/tests/unit/early-stream-keepalive.test.ts new file mode 100644 index 0000000000..ad95b88069 --- /dev/null +++ b/tests/unit/early-stream-keepalive.test.ts @@ -0,0 +1,123 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { withEarlyStreamKeepalive } from "../../open-sse/utils/earlyStreamKeepalive.ts"; + +async function readAll(response: Response): Promise { + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let out = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) out += decoder.decode(value, { stream: true }); + } + return out; +} + +function sseResponse(bodyText: string): Response { + return new Response(bodyText, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +// #2544: a handler that resolves quickly must be returned verbatim — same object, +// status, and headers — so the common (fast) path has zero behavior change. +test("fast handler is returned verbatim with headers preserved (#2544)", async () => { + const original = new Response("data: hi\n\n", { + status: 200, + headers: { "Content-Type": "text/event-stream", "x-omniroute-provider": "openai" }, + }); + const result = await withEarlyStreamKeepalive(Promise.resolve(original), { thresholdMs: 1000 }); + + assert.equal(result, original, "fast path should return the same Response object"); + assert.equal(result.headers.get("x-omniroute-provider"), "openai"); +}); + +// #2544: when the handler is slow to produce its first byte (slow upstream / reasoning +// model), the wrapper must open the SSE response early, emit keepalive comments to keep +// strict clients (Codex's reqwest) from idle-timing-out, then forward the real body. +test("slow handler emits early keepalive then forwards the real body (#2544)", async () => { + const slow = new Promise((resolve) => { + setTimeout( + () => resolve(sseResponse("event: response.created\ndata: {}\n\ndata: [DONE]\n\n")), + 120 + ); + }); + + const result = await withEarlyStreamKeepalive(slow, { thresholdMs: 25, intervalMs: 20 }); + assert.equal(result.status, 200); + assert.match(result.headers.get("content-type") || "", /text\/event-stream/); + + const body = await readAll(result); + assert.match(body, /: omniroute-keepalive/, "should emit a keepalive comment before the body"); + assert.match(body, /event: response\.created/, "should forward the real upstream body"); + assert.match(body, /data: \[DONE\]/); +}); + +// #2544: a non-SSE error that arrives after we already committed to a 200 event-stream +// must be framed as an in-band `event: error` (the HTTP status can no longer change), +// not forwarded as raw JSON (which would be malformed SSE). +test("slow handler that errors emits an in-band error frame (#2544)", async () => { + const slowFail = new Promise((resolve) => { + setTimeout( + () => + resolve( + new Response(JSON.stringify({ error: { message: "rate limited", type: "rate_limit" } }), { + status: 429, + headers: { "Content-Type": "application/json" }, + }) + ), + 80 + ); + }); + + const result = await withEarlyStreamKeepalive(slowFail, { thresholdMs: 20, intervalMs: 20 }); + assert.equal(result.status, 200, "already committed to 200 SSE before the error surfaced"); + + const body = await readAll(result); + assert.match(body, /: omniroute-keepalive/); + assert.match(body, /event: error/); + assert.match(body, /rate limited/); +}); + +// #2544: a fast rejection must propagate so the route's normal error handling runs — +// it must not be silently turned into a 200 stream. +test("fast handler rejection propagates instead of being swallowed (#2544)", async () => { + await assert.rejects( + () => + withEarlyStreamKeepalive(Promise.reject(new Error("upstream unreachable")), { + thresholdMs: 1000, + }), + /upstream unreachable/ + ); +}); + +// #2544: a client disconnect during the slow wait must stop the keepalive loop. +test("aborting the client signal stops the keepalive stream (#2544)", async () => { + const controller = new AbortController(); + const never = new Promise(() => { + /* handler that never resolves */ + }); + + const result = await withEarlyStreamKeepalive(never, { + thresholdMs: 10, + intervalMs: 15, + signal: controller.signal, + }); + + const reader = result.body!.getReader(); + // Drain a couple of keepalive frames, then abort. + await reader.read(); + controller.abort(); + // After abort the stream should terminate (close) rather than hang forever. + const drained = (async () => { + while (true) { + const { done } = await reader.read(); + if (done) return true; + } + })(); + const timed = new Promise((resolve) => setTimeout(() => resolve(false), 500)); + assert.equal(await Promise.race([drained, timed]), true, "stream should close after abort"); +}); diff --git a/tests/unit/electron-main.test.ts b/tests/unit/electron-main.test.ts index 756f7cc45f..5f406ab6a2 100644 --- a/tests/unit/electron-main.test.ts +++ b/tests/unit/electron-main.test.ts @@ -217,6 +217,51 @@ describe("Server Readiness Logic", () => { const result = await waitForServer("http://localhost:59999", 100); assert.equal(result, false); }); + + // #2460: on a slow first launch (long DB migrations) the initial readiness probe can + // time out. The window must not be left on a hanging connection — a background retry + // must keep polling and reload the window once the server finally responds. + it("reloads the window once the server becomes ready after an initial timeout (#2460)", async () => { + let serverUp = false; + // Server "comes up" after ~60ms, simulating long first-launch migrations. + const upTimer = setTimeout(() => { + serverUp = true; + }, 60); + + async function waitForServer(_url, timeoutMs) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (serverUp) return true; + await new Promise((r) => setTimeout(r, 15)); + } + return false; + } + + try { + // Initial probe with a short budget times out (server not up yet). + const initialReady = await waitForServer("http://localhost/api/monitoring/health", 20); + assert.equal(initialReady, false); + + let reloaded = false; + const mainWindow = { + isDestroyed: () => false, + loadURL: () => { + reloaded = true; + }, + }; + + // Background retry with a generous budget should succeed and reload the window. + const retryReady = await waitForServer("http://localhost/api/monitoring/health", 5000); + if (retryReady && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.loadURL("http://localhost"); + } + + assert.equal(retryReady, true); + assert.equal(reloaded, true, "window should reload once the server is ready"); + } finally { + clearTimeout(upTimer); + } + }); }); // ─── Restart Timeout Tests (#2) ────────────────────────────── diff --git a/tests/unit/executor-antigravity.test.ts b/tests/unit/executor-antigravity.test.ts index 399a49985a..e7d067fcaf 100644 --- a/tests/unit/executor-antigravity.test.ts +++ b/tests/unit/executor-antigravity.test.ts @@ -8,6 +8,7 @@ import { clearAntigravityVersionCache, seedAntigravityVersionCache, } from "../../open-sse/services/antigravityVersion.ts"; +import { clearAntigravityProjectCache } from "../../open-sse/services/antigravityProjectBootstrap.ts"; type AntigravityTransformResult = Exclude< Awaited>, @@ -234,6 +235,80 @@ test("AntigravityExecutor.transformRequest returns a structured error response w assert.match(payload.error.message, /Missing Google projectId/); }); +// #2334/#2541: a freshly re-added Antigravity account can have an empty stored projectId +// even when its Google account already owns a Cloud Code project. transformRequest must +// auto-discover it via loadCodeAssist (mirroring gemini-cli.ts) instead of hard-failing. +test("AntigravityExecutor.transformRequest auto-discovers a missing projectId via loadCodeAssist (#2334)", async () => { + clearAntigravityProjectCache(); + seedAntigravityVersionCache("2026.04.17-test"); + const executor = new AntigravityExecutor(); + const originalFetch = globalThis.fetch; + let loadCodeAssistCalled = false; + + globalThis.fetch = (async (url: string | URL | Request) => { + if (String(url).includes("loadCodeAssist")) { + loadCodeAssistCalled = true; + return new Response(JSON.stringify({ cloudaicompanionProject: "discovered-project-123" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("{}", { status: 404 }); + }) as typeof fetch; + + try { + const result = await executor.transformRequest( + "antigravity/gemini-3.1-pro", + { request: { contents: [] } }, + true, + { accessToken: "fresh-account-token-2334" } + ); + if (result instanceof Response) { + throw new Error(`Expected an envelope but got a ${result.status} Response`); + } + assert.equal( + loadCodeAssistCalled, + true, + "loadCodeAssist should be called to recover the project" + ); + assert.equal(result.project, "discovered-project-123"); + } finally { + globalThis.fetch = originalFetch; + clearAntigravityProjectCache(); + } +}); + +// #2334: when loadCodeAssist also finds no project (truly un-onboarded account), the +// structured 422 must still be returned so the dashboard can prompt a reconnect. +test("AntigravityExecutor.transformRequest still 422s when loadCodeAssist finds no project (#2334)", async () => { + clearAntigravityProjectCache(); + seedAntigravityVersionCache("2026.04.17-test"); + const executor = new AntigravityExecutor(); + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async () => + new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + })) as typeof fetch; + + try { + const result = await executor.transformRequest( + "antigravity/gemini-3.1-pro", + { request: { contents: [] } }, + true, + { accessToken: "no-project-token-2334" } + ); + if (!(result instanceof Response)) throw new Error("Expected a 422 Response"); + assert.equal(result.status, 422); + const payload = (await result.json()) as ErrorPayload; + assert.equal(payload.error.code, "missing_project_id"); + } finally { + globalThis.fetch = originalFetch; + clearAntigravityProjectCache(); + } +}); + test("AntigravityExecutor.transformRequest prefers top-level credentials projectId over nested providerSpecificData", async () => { const executor = new AntigravityExecutor(); const result = await executor.transformRequest( @@ -490,6 +565,10 @@ test("AntigravityExecutor.refreshCredentials refreshes Google OAuth tokens", asy refreshToken: "new-refresh", expiresIn: 3600, projectId: "project-1", + // refreshCredentials preserves providerSpecificData across refresh (#2480); when the + // input has none it surfaces as `undefined`. (Test updated to match that behavior — + // it had been stale since the #2480 change added this field.) + providerSpecificData: undefined, }); } finally { globalThis.fetch = originalFetch; diff --git a/tests/unit/proxy-registry.test.ts b/tests/unit/proxy-registry.test.ts index 32037dc027..18b4f306a4 100644 --- a/tests/unit/proxy-registry.test.ts +++ b/tests/unit/proxy-registry.test.ts @@ -119,3 +119,66 @@ test("legacy proxy config migration imports global/provider/key assignments", as assert.equal(resolved.source, "registry"); assert.equal(resolved.proxy.host, "account-legacy.local"); }); + +// #2456: resolveProxyForProvider (used by the OAuth token exchange + token refresh, +// before any connection exists) only consulted the proxy registry. A proxy set the +// legacy way (/api/settings/proxy?level=provider) was ignored, so on a VPS the OAuth +// exchange went out direct and tripped Anthropic's IP rate limit. It must fall back to +// the legacy per-provider config, mirroring resolveProxyForConnection. +test("resolveProxyForProvider falls back to the legacy provider proxy config (#2456)", async () => { + await resetStorage(); + + await settingsDb.setProxyForLevel("provider", "claude", { + type: "http", + host: "legacy-claude-proxy.local", + port: 3128, + }); + + // No proxy_registry assignment exists for "claude" — only the legacy config. + const resolved = await proxiesDb.resolveProxyForProvider("claude"); + assert.ok(resolved, "expected the legacy provider proxy to be resolved"); + assert.equal((resolved as any).host, "legacy-claude-proxy.local"); + assert.equal((resolved as any).type, "http"); +}); + +test("resolveProxyForProvider falls back to the legacy global proxy when no provider proxy (#2456)", async () => { + await resetStorage(); + + await settingsDb.setProxyForLevel("global", null, { + type: "socks5", + host: "legacy-global.local", + port: 1080, + }); + + const resolved = await proxiesDb.resolveProxyForProvider("anthropic"); + assert.ok(resolved, "expected the legacy global proxy to be resolved"); + assert.equal((resolved as any).host, "legacy-global.local"); +}); + +test("resolveProxyForProvider still prefers a registry assignment over legacy config (#2456)", async () => { + await resetStorage(); + + await settingsDb.setProxyForLevel("provider", "openai", { + type: "http", + host: "legacy-openai.local", + port: 8080, + }); + + const registryProxy = await proxiesDb.createProxy({ + name: "Registry OpenAI", + type: "https", + host: "registry-openai.local", + port: 443, + }); + await proxiesDb.assignProxyToScope("provider", "openai", registryProxy.id); + + const resolved = await proxiesDb.resolveProxyForProvider("openai"); + assert.ok(resolved); + assert.equal((resolved as any).host, "registry-openai.local", "registry assignment must win"); +}); + +test("resolveProxyForProvider returns null when neither registry nor legacy config has a proxy (#2456)", async () => { + await resetStorage(); + const resolved = await proxiesDb.resolveProxyForProvider("gemini"); + assert.equal(resolved, null); +});