diff --git a/.env.example b/.env.example index 252ee8d853..d482763231 100644 --- a/.env.example +++ b/.env.example @@ -585,6 +585,11 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann # Trae OAuth token override. Used by: open-sse/executors/trae.ts. # TRAE_TOKEN= +# ── The Old LLM (theoldllm) ── +# Playwright navigation timeout (ms) for the browser-backed token capture. +# Used by: open-sse/executors/theoldllm.ts. Default: 30000 (30s). +# THEOLDLLM_NAV_TIMEOUT_MS=30000 + # ── Gemini / Gemini CLI / Antigravity / Windsurf (all Google-based) ── # These providers ship public OAuth client_id/secret values (or Firebase Web # keys) embedded in their public CLIs/binaries. Defaults are baked into the diff --git a/CHANGELOG.md b/CHANGELOG.md index e04cd1f272..76dc95a9b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,7 @@ --- -## [3.8.11] — Unreleased - -_Development cycle in progress — entries are added as work merges into `release/v3.8.11` and finalized by the release flow._ +## [3.8.11] — 2026-06-05 ### ✨ New Features @@ -44,6 +42,7 @@ _Development cycle in progress — entries are added as work merges into `releas - **build:** finish the build-output-isolation cleanup — `assembleStandalone.mjs` now derives both its async (`syncStandalone*`) and sync copy paths from a single `NATIVE_ASSET_ENTRIES`/`EXTRA_MODULE_ENTRIES` source of truth (previously two hand-maintained lists that could silently drift), guarded by a new parity test; and the `Dockerfile` drops 5 redundant per-module `COPY` overrides (`@swc/helpers`, `pino-abstract-transport`, `pino-pretty`, `split2`, `migrations`) now that `assembleStandalone` bundles them into the standalone regardless of NFT/Turbopack tracing (validated with a real Turbopack `docker build` + boot → `/api/monitoring/health` 200; `better-sqlite3` stays explicit since only its native `build/` is synced) (#3187 — thanks @diegosouzapw) - **combo:** add a regression guard asserting the same-provider cascade is short-circuited by the connection-cooldown layer (#3200 — thanks @diegosouzapw) +- **repo:** housekeeping — ignore the generated `coverage/` output dir and prune deprecated `.agents/skills/*` SKILL definitions superseded by the current workflow skills (thanks @diegosouzapw) ### 🙌 Contributors diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 67e2b6156a..5b7ecced97 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -249,6 +249,7 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | `OMNIROUTE_PUBLIC_BASE_URL` | _(unset)_ | `open-sse/executors/chatgpt-web.ts` | Browser-facing OmniRoute origin used for image URLs in API responses (e.g., `/v1/chatgpt-web/image/`). Set this when OpenWebUI or another relay reaches OmniRoute by an internal URL but the user's browser must fetch images from a LAN, tunnel, or public origin. Do **not** include `/v1`. | | `OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS` | `180000` (3 min) | `open-sse/executors/chatgpt-web.ts` | Max wait time for an async chatgpt-web image to land via the celsius WebSocket. Increase during upstream queue-deep windows. | | `OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB` | `256` | `open-sse/services/chatgptImageCache.ts` | Total in-memory byte budget (MB) for the chatgpt-web image cache serving `/v1/chatgpt-web/image/`. Lower on memory-constrained hosts; raise if image generation is heavy and clients race the 30-minute TTL. | +| `THEOLDLLM_NAV_TIMEOUT_MS` | `30000` (30s) | `open-sse/executors/theoldllm.ts` | Playwright navigation timeout (ms) for the browser-backed token capture used by the The Old LLM (theoldllm) free provider. Raise on slow networks if the relay page is slow to settle. | | `KIE_CALLBACK_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Public callback URL for asynchronous kie.ai jobs. Highest-priority override before `OMNIROUTE_KIE_CALLBACK_URL` and `OMNIROUTE_PUBLIC_URL`. | | `OMNIROUTE_KIE_CALLBACK_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Alternate spelling of `KIE_CALLBACK_URL`. Falls back when the primary variable is unset. | | `OMNIROUTE_PUBLIC_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Public origin used to compose async callback URLs. Lowest-priority fallback for kie.ai callbacks; also used as a generic public URL for other relays. | diff --git a/tests/unit/i18n-nest-dotted-keys.test.ts b/tests/unit/i18n-nest-dotted-keys.test.ts index 40878e2680..c4d1ab33a8 100644 --- a/tests/unit/i18n-nest-dotted-keys.test.ts +++ b/tests/unit/i18n-nest-dotted-keys.test.ts @@ -1,7 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { nestDottedKeys } from "@/i18n/request"; +// The shipped helper is `normalizeComplianceEventTypes` (#3185); it nests dotted +// keys under `compliance.eventTypes` and is a no-op for messages without that path. +import { normalizeComplianceEventTypes as nestDottedKeys } from "@/i18n/request"; test("nestDottedKeys expands flat compliance.eventTypes keys into nested objects", () => { const input = { diff --git a/tests/unit/model-capabilities-registry.test.ts b/tests/unit/model-capabilities-registry.test.ts index bf0adc4e63..559df1eafd 100644 --- a/tests/unit/model-capabilities-registry.test.ts +++ b/tests/unit/model-capabilities-registry.test.ts @@ -68,9 +68,10 @@ test("canonical model capability resolver lets exact synced metadata override gl }), }, antigravity: { - // The resolver returns "gemini-3.1-pro-high" unchanged (ANTIGRAVITY_MODEL_ALIASES only maps - // the public-facing alias → internal, not the reverse). Save under the canonical resolved key. - "gemini-3.1-pro-high": buildCapability({ + // Since #3229, ANTIGRAVITY_MODEL_ALIASES maps both "gemini-3.1-pro-high" and + // "gemini-3.1-pro-low" → "gemini-3.1-pro", so the capability resolver looks + // synced metadata up under the canonical "gemini-3.1-pro" key. Save it there. + "gemini-3.1-pro": buildCapability({ tool_call: false, reasoning: false, modalities_input: JSON.stringify(["text"]), diff --git a/tests/unit/nvidia-nim-validator.test.ts b/tests/unit/nvidia-nim-validator.test.ts index 1150e26aa7..d20eb112dc 100644 --- a/tests/unit/nvidia-nim-validator.test.ts +++ b/tests/unit/nvidia-nim-validator.test.ts @@ -1,99 +1,107 @@ import test from "node:test"; import assert from "node:assert/strict"; +import http from "node:http"; // #2463 — NVIDIA NIM validation must not crash with `e.startsWith is not a function` // when providerSpecificData has malformed shapes; and the validation must use a // direct chat probe instead of the /models probe. +// +// #3226 — the validator now probes via `directHttpsRequest` → `safeOutboundFetch` +// with `bypassProxyPatch: true`, which uses the ORIGINAL (un-patched) native fetch +// captured at module load. Patching `globalThis.fetch` in the test no longer +// intercepts it, so we point `baseUrl` at a real local HTTP server instead (the +// outbound guard is "none" for validation, so 127.0.0.1 is reachable). This +// exercises the true code path end-to-end without a real upstream call. +// +// IMPORTANT: import the validator at FILE LOAD (top-level), not lazily inside a +// test. `proxyFetch` captures the un-patched `globalThis.fetch` on its first +// import; if the first import happened inside a test that had already patched +// `globalThis.fetch`, `getOriginalFetch()` would forever return that test's mock +// and poison every later bypass call. Loading here pins the real native fetch. +const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts"); + +async function withMockServer( + handler: (req: http.IncomingMessage, res: http.ServerResponse) => void, + fn: (baseUrl: string) => Promise +): Promise { + const server = http.createServer(handler); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + const baseUrl = `http://127.0.0.1:${port}/v1`; + try { + await fn(baseUrl); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} test("normalizeBaseUrl tolerates non-string baseUrl without throwing", async () => { - // Indirect probe — call validation entrypoint with a non-string baseUrl in PSD; - // the function should return a normal Validation result (not throw a TypeError - // such as `e.startsWith is not a function` after minification — see #2463). - const origFetch = globalThis.fetch; - globalThis.fetch = (async () => - new Response(JSON.stringify({}), { - status: 400, - headers: { "content-type": "application/json" }, - })) as typeof fetch; - - try { - const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts"); - const result = await validateProviderApiKey({ - provider: "nvidia", - apiKey: "nv-test-key", - // Malformed: baseUrl is an object instead of a string. Pre-fix this would - // crash inside normalizeBaseUrl with `.trim is not a function`. - providerSpecificData: { baseUrl: { not: "a string" } as any }, - }); - assert.equal(typeof result, "object"); - assert.equal(typeof result.valid, "boolean"); - // Whether the call succeeds (with default baseUrl) or fails gracefully with - // an outbound URL guard error is fine — the contract is "no TypeError leak". - if (!result.valid && typeof result.error === "string") { - assert.ok( - !result.error.includes("startsWith"), - `error must not mention startsWith TypeError, got: ${result.error}` - ); - assert.ok( - !result.error.includes("is not a function"), - `error must not mention TypeError, got: ${result.error}` - ); - } - } finally { - globalThis.fetch = origFetch; + // Call validation entrypoint with a non-string baseUrl in PSD; the function + // should return a normal Validation result (not throw a TypeError such as + // `e.startsWith is not a function` after minification — see #2463). A malformed + // baseUrl normalizes to "" and yields an invalid relative probe URL, which the + // outbound layer rejects gracefully — so no real upstream call is made. + const result = await validateProviderApiKey({ + provider: "nvidia", + apiKey: "nv-test-key", + providerSpecificData: { baseUrl: { not: "a string" } as any }, + }); + assert.equal(typeof result, "object"); + assert.equal(typeof result.valid, "boolean"); + if (!result.valid && typeof result.error === "string") { + assert.ok( + !result.error.includes("startsWith"), + `error must not mention startsWith TypeError, got: ${result.error}` + ); + assert.ok( + !result.error.includes("is not a function"), + `error must not mention TypeError, got: ${result.error}` + ); } }); test("nvidia specialty validator returns Invalid API key on 401", async () => { - const origFetch = globalThis.fetch; - globalThis.fetch = (async () => - new Response(JSON.stringify({ error: "unauthorized" }), { - status: 401, - headers: { "content-type": "application/json" }, - })) as typeof fetch; - - try { - const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts"); - const result = await validateProviderApiKey({ - provider: "nvidia", - apiKey: "nv-badkey", - providerSpecificData: {}, - }); - assert.equal(result.valid, false); - assert.equal(result.error, "Invalid API key"); - } finally { - globalThis.fetch = origFetch; - } + await withMockServer( + (_req, res) => { + res.writeHead(401, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "unauthorized" })); + }, + async (baseUrl) => { + const result = await validateProviderApiKey({ + provider: "nvidia", + apiKey: "nv-badkey", + providerSpecificData: { baseUrl }, + }); + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid API key"); + } + ); }); test("nvidia specialty validator skips /models probe entirely", async () => { const calls: string[] = []; - const origFetch = globalThis.fetch; - globalThis.fetch = (async (url: any) => { - calls.push(String(url)); - return new Response(JSON.stringify({}), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }) as typeof fetch; - - try { - const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts"); - const result = await validateProviderApiKey({ - provider: "nvidia", - apiKey: "nv-key", - providerSpecificData: {}, - }); - assert.equal(result.valid, true); - assert.ok( - calls.every((u) => !u.endsWith("/v1/models")), - `should not call /v1/models, called: ${JSON.stringify(calls)}` - ); - assert.ok( - calls.some((u) => u.endsWith("/chat/completions")), - `should call /chat/completions, called: ${JSON.stringify(calls)}` - ); - } finally { - globalThis.fetch = origFetch; - } + await withMockServer( + (req, res) => { + calls.push(String(req.url)); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({})); + }, + async (baseUrl) => { + const result = await validateProviderApiKey({ + provider: "nvidia", + apiKey: "nv-key", + providerSpecificData: { baseUrl }, + }); + assert.equal(result.valid, true); + assert.ok( + calls.every((u) => !u.endsWith("/v1/models")), + `should not call /v1/models, called: ${JSON.stringify(calls)}` + ); + assert.ok( + calls.some((u) => u.endsWith("/chat/completions")), + `should call /chat/completions, called: ${JSON.stringify(calls)}` + ); + } + ); }); diff --git a/tests/unit/synced-model-delete-persist-3199.test.ts b/tests/unit/synced-model-delete-persist-3199.test.ts index db32ee1f42..7e9271da2d 100644 --- a/tests/unit/synced-model-delete-persist-3199.test.ts +++ b/tests/unit/synced-model-delete-persist-3199.test.ts @@ -9,13 +9,22 @@ */ import test, { before, after } from "node:test"; import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; -import { - replaceSyncedAvailableModelsForConnection, - getSyncedAvailableModels, - mergeModelCompatOverride, -} from "../../src/lib/localDb.ts"; -import { resetDbInstance } from "../../src/lib/db/core.ts"; +// Hermetic DB: this test marks a model hidden (an override persisted in the +// `modelCompatOverrides` key_value namespace). Without an isolated DATA_DIR it +// would write that override into the shared dev/CI database and never clean it +// up, so the SECOND run would see `model-del` already hidden and the first-sync +// precondition would fail. Point DATA_DIR at a throwaway dir before any import +// that opens the SQLite connection. +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-synced-del-")); +process.env.DATA_DIR = tmpDir; + +const { replaceSyncedAvailableModelsForConnection, getSyncedAvailableModels, mergeModelCompatOverride } = + await import("../../src/lib/localDb.ts"); +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); before(() => { resetDbInstance(); @@ -23,6 +32,7 @@ before(() => { after(() => { resetDbInstance(); + fs.rmSync(tmpDir, { recursive: true, force: true }); }); test("a hidden (deleted) synced model is not re-added on re-import", async () => { diff --git a/tests/unit/web-session-credentials.test.ts b/tests/unit/web-session-credentials.test.ts index 8f715d0088..9f83f8910f 100644 --- a/tests/unit/web-session-credentials.test.ts +++ b/tests/unit/web-session-credentials.test.ts @@ -15,10 +15,11 @@ test("web session credential metadata covers every web-cookie provider", () => { }); test("web session credential metadata identifies cookie, token, and no-auth providers", () => { + // Grok needs BOTH sso and sso-rw cookies (#3180) assert.deepEqual(webSessionCredentials.getWebSessionCredentialRequirement("grok-web"), { kind: "cookie", - credentialName: "sso", - placeholder: "sso=...", + credentialName: "sso + sso-rw", + placeholder: "sso=...; sso-rw=...", acceptsFullCookieHeader: true, }); assert.deepEqual(webSessionCredentials.getWebSessionCredentialRequirement("copilot-web"), {