diff --git a/bin/nodeRuntimeSupport.mjs b/bin/nodeRuntimeSupport.mjs index ea6c66d8..8c203f07 100644 --- a/bin/nodeRuntimeSupport.mjs +++ b/bin/nodeRuntimeSupport.mjs @@ -3,11 +3,13 @@ export const SECURE_NODE_LINES = Object.freeze([ Object.freeze({ major: 20, minor: 20, patch: 2 }), Object.freeze({ major: 22, minor: 22, patch: 2 }), + Object.freeze({ major: 24, minor: 0, patch: 0 }), ]); -export const RECOMMENDED_NODE_VERSION = "22.22.2"; -export const SUPPORTED_NODE_RANGE = ">=20.20.2 <21 || >=22.22.2 <23"; -export const SUPPORTED_NODE_DISPLAY = "Node.js 20.20.2+ (20.x LTS) or 22.22.2+ (22.x LTS)"; +export const RECOMMENDED_NODE_VERSION = "24.14.1"; +export const SUPPORTED_NODE_RANGE = ">=20.20.2 <21 || >=22.22.2 <23 || >=24.0.0 <25"; +export const SUPPORTED_NODE_DISPLAY = + "Node.js 20.20.2+ (20.x LTS), 22.22.2+ (22.x LTS), or 24.0.0+ (24.x LTS)"; function formatVersion(version) { return `${version.major}.${version.minor}.${version.patch}`; @@ -50,8 +52,8 @@ export function getNodeRuntimeSupport(version = process.versions.node) { reason = "supported"; } else if (secureFloor) { reason = "below-security-floor"; - } else if (parsed.major >= 24) { - reason = "native-addon-incompatible"; + } else if (parsed.major >= 25) { + reason = "unreleased-major"; } return { @@ -73,8 +75,8 @@ export function getNodeRuntimeWarning(version = process.versions.node) { return `Node.js ${support.nodeVersion} is below the patched minimum ${support.minimumSecureVersion} for this LTS line.`; } - if (support.reason === "native-addon-incompatible") { - return `Node.js ${support.nodeVersion} is outside the supported LTS lines and may fail at runtime because better-sqlite3 does not support Node.js 24+ here.`; + if (support.reason === "unreleased-major") { + return `Node.js ${support.nodeVersion} is outside the supported LTS lines. OmniRoute currently supports Node.js 20.x, 22.x, and 24.x.`; } return `Node.js ${support.nodeVersion} is outside OmniRoute's approved secure runtime policy.`; diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index f5df635e..f33f8e29 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -493,3 +493,31 @@ export function getAllImageModels() { export function getImageModelAliases() { return IMAGE_MODEL_ALIASES; } + +export function getImageModelEntry(modelStr) { + if (!modelStr) return null; + + const alias = IMAGE_MODEL_ALIASES[modelStr]; + if (alias) { + const modelConfig = findImageModelConfig(alias.provider, alias.model); + return { + provider: alias.provider, + model: alias.model, + inputModalities: alias.inputModalities || modelConfig?.inputModalities || ["text"], + description: alias.description || modelConfig?.description || undefined, + }; + } + + const { provider, model } = parseImageModel(modelStr); + if (!provider || !model) return null; + + const modelConfig = findImageModelConfig(provider, model); + if (!modelConfig) return null; + + return { + provider, + model, + inputModalities: modelConfig.inputModalities || ["text"], + description: modelConfig.description || undefined, + }; +} diff --git a/open-sse/executors/perplexity-web.ts b/open-sse/executors/perplexity-web.ts index fd270fe0..a167136e 100644 --- a/open-sse/executors/perplexity-web.ts +++ b/open-sse/executors/perplexity-web.ts @@ -33,8 +33,6 @@ const CITATION_RE = /\[\d+\]/g; const GROK_TAG_RE = /]*>.*?<\/grok:[^>]*>/gs; const GROK_SELF_RE = /]*\/>/g; const XML_DECL_RE = /<[?]xml[^?]*[?]>/g; -const SCRIPT_RE = /]*>.*?<\/script>/gis; -const SCRIPT_TAG_RE = /<\/?script\b[^>]*>/gi; const RESPONSE_TAG_RE = /<\/?response\b[^>]*>/gi; const MULTI_SPACE = / {2,}/g; const MULTI_NL = /\n{3,}/g; @@ -109,8 +107,6 @@ function cleanResponse(text: string, strip = true): string { t = t.replace(GROK_TAG_RE, ""); t = t.replace(GROK_SELF_RE, ""); t = t.replace(RESPONSE_TAG_RE, ""); - t = t.replace(SCRIPT_RE, ""); // lgtm[js/incomplete-multi-character-sanitization] - t = t.replace(SCRIPT_TAG_RE, ""); // lgtm[js/incomplete-multi-character-sanitization] if (strip) { t = t.replace(MULTI_SPACE, " "); t = t.replace(MULTI_NL, "\n\n"); diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index f2b5b447..df9e4e0a 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -437,11 +437,16 @@ function buildClaudeCodeCompatibleMessages(messages: MessageLike[]) { .filter( ( message - ): message is { role: "user" | "assistant"; content: Array> } => - !!message && message.content.length > 0 + ): message is { + role: "user" | "assistant"; + content: Array<{ type: string; text: string }>; + } => !!message && message.content.length > 0 ); - const merged: Array<{ role: "user" | "assistant"; content: Array> }> = []; + const merged: Array<{ + role: "user" | "assistant"; + content: Array<{ type: string; text: string }>; + }> = []; for (const message of converted) { const last = merged[merged.length - 1]; @@ -575,7 +580,7 @@ function buildClaudeCodeCompatibleSystemBlocks({ for (const systemBlock of customSystemBlocks) { const preparedBlock = { ...systemBlock }; if (!preserveCacheControl) { - delete preparedBlock.cache_control; + delete preparedBlock["cache_control"]; } blocks.push(preparedBlock); } @@ -735,7 +740,7 @@ function normalizeClaudeMessageInput(messages: unknown) { content: normalizeClaudeContentInput(record.content), }; }) - .filter((message): message is Record => !!message); + .filter((message): message is Record & { content: unknown } => !!message); } function normalizeClaudeToolInput(tools: unknown) { diff --git a/open-sse/services/contextManager.ts b/open-sse/services/contextManager.ts index d9bd126f..4a1e53da 100644 --- a/open-sse/services/contextManager.ts +++ b/open-sse/services/contextManager.ts @@ -216,10 +216,23 @@ function compressThinking(messages: Record[]) { // Remove thinking XML tags from string content if (typeof msg.content === "string") { - const cleaned = msg.content - .replace(/.*?<\/thinking>/gs, "") - .replace(/.*?<\/antThinking>/gs, "") - .trim(); + let cleaned = msg.content; + for (const [start, end] of [ + ["", ""], + ["", ""], + ]) { + while (true) { + const s = cleaned.indexOf(start); + if (s === -1) break; + const e = cleaned.indexOf(end, s + start.length); + if (e === -1) { + cleaned = cleaned.slice(0, s); + break; + } + cleaned = cleaned.slice(0, s) + cleaned.slice(e + end.length); + } + } + cleaned = cleaned.trim(); return { ...msg, content: cleaned || "[thinking compressed]" }; } diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index ad1122c6..86adc6a6 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -82,7 +82,12 @@ function noProxyMatch(targetUrl) { // Support wildcard matching (e.g. 192.168.* or *.local) if (patternHost.includes("*")) { const regexStr = - "^" + patternHost.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*") + "$"; + "^" + + patternHost + .split("*") + .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join(".*") + + "$"; if (new RegExp(regexStr).test(hostname)) return true; } diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index 0283a064..3a37b9e5 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -10,6 +10,7 @@ import { parseImageModel, getAllImageModels, getImageProvider, + getImageModelEntry, } from "@omniroute/open-sse/config/imageRegistry.ts"; import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; @@ -85,6 +86,21 @@ export async function GET() { /** * POST /v1/images/generations — generate images */ +function hasImageGenerationInput(body: Record) { + if (typeof body.image_url === "string" && body.image_url.trim()) return true; + if (typeof body.image === "string" && body.image.trim()) return true; + if (Array.isArray(body.imageUrls) && body.imageUrls.some((value) => typeof value === "string")) { + return true; + } + if ( + Array.isArray(body.image_urls) && + body.image_urls.some((value) => typeof value === "string") + ) { + return true; + } + return false; +} + export async function POST(request) { let rawBody; try { @@ -150,6 +166,26 @@ export async function POST(request) { // Check provider config for auth bypass const providerConfig = getImageProvider(provider); + const imageModelEntry = getImageModelEntry(body.model); + const inputModalities = imageModelEntry?.inputModalities || ["text"]; + const requiresPrompt = inputModalities.includes("text"); + const requiresImageInput = inputModalities.includes("image"); + const hasPrompt = typeof body.prompt === "string" && body.prompt.trim().length > 0; + const hasImageInput = hasImageGenerationInput(body); + + if (requiresPrompt && !hasPrompt) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `Prompt is required for image model: ${body.model}` + ); + } + + if (requiresImageInput && !hasImageInput) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `Image input is required for image model: ${body.model}` + ); + } // Get credentials — skip for local providers (authType: "none") let credentials = null; diff --git a/src/app/api/v1/search/route.ts b/src/app/api/v1/search/route.ts index f8820e11..3b4a415f 100644 --- a/src/app/api/v1/search/route.ts +++ b/src/app/api/v1/search/route.ts @@ -65,6 +65,17 @@ async function resolveSearchCredentials(providerId: string) { return null; } +async function resolveSearchExecutionCredentials(providerConfig: { + id: string; + authType: string; +}): Promise | null> { + if (providerConfig.authType === "none") { + return {}; + } + + return resolveSearchCredentials(providerConfig.id); +} + // Helper: build domain filter array from filters object function buildDomainFilter(filters?: { include_domains?: string[]; @@ -139,26 +150,16 @@ export async function POST(request: Request) { if (body.provider) { // Explicit provider — single credential lookup (with fallback) - credentials = await resolveSearchCredentials(providerConfig.id); - if ( - !credentials && - providerConfig.authType === "none" && - typeof body.provider_options?.baseUrl === "string" && - body.provider_options.baseUrl.trim().length > 0 - ) { - credentials = { providerSpecificData: { baseUrl: body.provider_options.baseUrl.trim() } }; - } + credentials = await resolveSearchExecutionCredentials(providerConfig); if (!credentials) { return errorResponse( HTTP_STATUS.BAD_REQUEST, - providerConfig.authType === "none" - ? `Search provider ${providerConfig.id} is not configured. Set its base URL in the dashboard or pass provider_options.baseUrl.` - : `No credentials configured for search provider: ${providerConfig.id}. Add an API key for "${providerConfig.id}" in the dashboard.` + `No credentials configured for search provider: ${providerConfig.id}. Add an API key for "${providerConfig.id}" in the dashboard.` ); } } else { // Auto-select — try the resolved provider first, then iterate others by cost - credentials = await resolveSearchCredentials(providerConfig.id); + credentials = await resolveSearchExecutionCredentials(providerConfig); if (!credentials) { // Sort by cost to find cheapest with credentials @@ -170,7 +171,7 @@ export async function POST(request: Request) { for (const pid of sortedIds) { if (pid === providerConfig.id) continue; const altConfig = getSearchProvider(pid); - const altCreds = await resolveSearchCredentials(pid); + const altCreds = altConfig ? await resolveSearchExecutionCredentials(altConfig) : null; if (altConfig && altCreds) { providerConfig = altConfig; credentials = altCreds; @@ -194,7 +195,8 @@ export async function POST(request: Request) { .filter((id) => id !== providerConfig.id); for (const pid of otherIds) { - const creds = await resolveSearchCredentials(pid); + const altConfig = getSearchProvider(pid); + const creds = altConfig ? await resolveSearchExecutionCredentials(altConfig) : null; if (creds) { alternateProviderId = pid; alternateCredentials = creds; diff --git a/src/lib/usage/callLogArtifacts.ts b/src/lib/usage/callLogArtifacts.ts index 42f10cf0..198336ce 100644 --- a/src/lib/usage/callLogArtifacts.ts +++ b/src/lib/usage/callLogArtifacts.ts @@ -75,7 +75,8 @@ export function writeCallArtifact( try { const serialized = JSON.stringify(artifact, null, 2); const sizeBytes = Buffer.byteLength(serialized); - const artifactHash = crypto.createHash("sha256").update(serialized).digest("hex"); // lgtm[js/insufficient-password-hash] + // codeql[js/insufficient-password-hash] - This is a file checksum, not a password hash + const artifactHash = crypto.createHash("sha256").update(serialized).digest("hex"); fs.mkdirSync(path.dirname(absPath), { recursive: true }); fs.writeFileSync(tmpPath, serialized); diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index ed84ba34..d36515bc 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -648,15 +648,15 @@ export default function OAuthModal({ {t.rich("googleOAuthWarning", { - code: (chunks) => {chunks}, - a: (chunks) => ( + code: (c) => {c}, + a: (c) => ( - {chunks} + {c} ), })} @@ -692,7 +692,7 @@ export default function OAuthModal({

{t("step2PasteCallback")}

{t.rich("step2Hint", { - code: (chunks) => {chunks}, + code: (c) => {c}, })}

{t("pricingRatesFormat")}

{t.rich("ratesDescription", { - strong: (chunks) => {chunks}, + strong: (c) => {c}, })}

diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 9217c355..ae120f3d 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -491,7 +491,7 @@ export const v1EmbeddingsSchema = z export const v1ImageGenerationSchema = z .object({ model: modelIdSchema, - prompt: nonEmptyStringSchema, + prompt: nonEmptyStringSchema.optional(), }) .catchall(z.unknown()); diff --git a/tests/unit/bailian-quota-fetcher.test.ts b/tests/unit/bailian-quota-fetcher.test.ts index fc422ec5..472abfa4 100644 --- a/tests/unit/bailian-quota-fetcher.test.ts +++ b/tests/unit/bailian-quota-fetcher.test.ts @@ -274,8 +274,8 @@ test("fetchBailianQuota retries with China host on ConsoleNeedLogin", async () = }); assert.equal(calls.length, 2); - assert.ok(calls[0].url.includes("modelstudio.console.alibabacloud.com")); // lgtm[js/incomplete-url-substring-sanitization] - assert.ok(calls[1].url.includes("bailian.console.aliyun.com")); // lgtm[js/incomplete-url-substring-sanitization] + assert.ok(calls[0].url.includes("://modelstudio.console.alibabacloud.com/")); + assert.ok(calls[1].url.includes("://bailian.console.aliyun.com/")); assert.equal(quota?.percentUsed, 0.45); invalidateBailianQuotaCache(connectionId); @@ -449,7 +449,7 @@ test("ALIBABA_CODING_PLAN_HOST env var overrides default host", async () => { }); assert.equal(calls.length, 1); - assert.ok(calls[0].url.includes("custom.bailian.aliyun.com")); // lgtm[js/incomplete-url-substring-sanitization] + assert.ok(calls[0].url.includes("://custom.bailian.aliyun.com/")); assert.equal(quota?.percentUsed, 0.55); process.env.ALIBABA_CODING_PLAN_HOST = originalEnv; @@ -499,7 +499,7 @@ test("ALIBABA_CODING_PLAN_QUOTA_URL env var overrides full URL", async () => { }); assert.equal(calls.length, 1); - assert.ok(calls[0].url.includes("override.example.com")); // lgtm[js/incomplete-url-substring-sanitization] + assert.ok(calls[0].url.includes("://override.example.com/")); assert.equal(quota?.percentUsed, 0.2); process.env.ALIBABA_CODING_PLAN_QUOTA_URL = originalEnv; diff --git a/tests/unit/db-core-init.test.ts b/tests/unit/db-core-init.test.ts index cc88ab1d..f953a635 100644 --- a/tests/unit/db-core-init.test.ts +++ b/tests/unit/db-core-init.test.ts @@ -67,7 +67,7 @@ async function withEnv(overrides, fn) { if (value === undefined) { delete process.env[key]; } else { - process.env[key] = value; + process.env[key] = value as string; } } } diff --git a/tests/unit/db-migration-runner.test.ts b/tests/unit/db-migration-runner.test.ts index d69d0b9e..3e171051 100644 --- a/tests/unit/db-migration-runner.test.ts +++ b/tests/unit/db-migration-runner.test.ts @@ -31,13 +31,13 @@ function withMockedMigrationFs(files, fn) { return originalExistsSync(target); }; - fs.readdirSync = (target, options) => { + fs.readdirSync = ((target: string, options?: any) => { if (files && isMigrationDir(target)) { return Object.keys(files); } return originalReaddirSync(target, options); - }; + }) as any; fs.readFileSync = (target, options) => { const fileName = path.basename(String(target)); @@ -183,13 +183,19 @@ test("runMigrations skips versions that are already tracked as applied", serial, assert.equal(secondRun, 0); assert.equal( - db.prepare("SELECT COUNT(*) AS count FROM _omniroute_migrations WHERE version = ?").get("001") - .count, + ( + db + .prepare("SELECT COUNT(*) AS count FROM _omniroute_migrations WHERE version = ?") + .get("001") as any + ).count, 1 ); assert.equal( - db.prepare("SELECT COUNT(*) AS count FROM _omniroute_migrations WHERE version = ?").get("002") - .count, + ( + db + .prepare("SELECT COUNT(*) AS count FROM _omniroute_migrations WHERE version = ?") + .get("002") as any + ).count, 1 ); } finally { @@ -269,9 +275,11 @@ test( undefined ); assert.equal( - db - .prepare("SELECT COUNT(*) AS count FROM _omniroute_migrations WHERE version = ?") - .get("002").count, + ( + db + .prepare("SELECT COUNT(*) AS count FROM _omniroute_migrations WHERE version = ?") + .get("002") as any + ).count, 0 ); } finally { diff --git a/tests/unit/image-generation-route.test.ts b/tests/unit/image-generation-route.test.ts new file mode 100644 index 00000000..164eb936 --- /dev/null +++ b/tests/unit/image-generation-route.test.ts @@ -0,0 +1,113 @@ +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-image-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const imageRoute = await import("../../src/app/api/v1/images/generations/route.ts"); + +const originalFetch = globalThis.fetch; + +async function resetStorage() { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function seedConnection(provider: string, overrides: { apiKey?: string | null } = {}) { + return providersDb.createProviderConnection({ + provider, + authType: "apikey", + name: `${provider}-${Math.random().toString(16).slice(2, 8)}`, + apiKey: overrides.apiKey ?? "test-key", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("v1 image models GET exposes image-only modalities for image-only models", async () => { + const response = await imageRoute.GET(); + const body = await response.json(); + const byId = new Map(body.data.map((item: { id: string }) => [item.id, item])); + + assert.equal(response.status, 200); + assert.deepEqual(byId.get("topaz/topaz-enhance")?.input_modalities, ["image"]); + assert.deepEqual(byId.get("stability-ai/remove-background")?.input_modalities, ["image"]); + assert.deepEqual(byId.get("stability-ai/fast")?.input_modalities, ["image"]); +}); + +test("v1 image generation POST accepts promptless requests for image-only models", async () => { + await seedConnection("topaz", { apiKey: "topaz-key" }); + + globalThis.fetch = async (url, options = {}) => { + const stringUrl = String(url); + if (stringUrl === "https://example.com/topaz-input.png") { + return new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + } + + if (stringUrl === "https://api.topazlabs.com/image/v1/enhance") { + const formData = options.body as FormData; + assert.ok(formData.get("image") instanceof File); + return new Response(new Uint8Array([7, 7, 7]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }); + } + + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + const response = await imageRoute.POST( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "topaz/topaz-enhance", + image_url: "https://example.com/topaz-input.png", + size: "2048x2048", + response_format: "b64_json", + }), + }) + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.data[0].b64_json, "BwcH"); +}); + +test("v1 image generation POST still requires prompts for text-input models", async () => { + const response = await imageRoute.POST( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "openai/dall-e-3", + image_url: "https://example.com/source.png", + }), + }) + ); + const body = await response.json(); + + assert.equal(response.status, 400); + assert.match(body.error.message, /Prompt is required for image model: openai\/dall-e-3/); +}); diff --git a/tests/unit/node-runtime-support.test.ts b/tests/unit/node-runtime-support.test.ts index e57c573e..cf13684a 100644 --- a/tests/unit/node-runtime-support.test.ts +++ b/tests/unit/node-runtime-support.test.ts @@ -7,6 +7,10 @@ import { getNodeRuntimeWarning, parseNodeVersion, } from "../../src/shared/utils/nodeRuntimeSupport.ts"; +import { + getNodeRuntimeSupport as getCliNodeRuntimeSupport, + getNodeRuntimeWarning as getCliNodeRuntimeWarning, +} from "../../bin/nodeRuntimeSupport.mjs"; test("parseNodeVersion normalizes v-prefixed versions", () => { assert.deepEqual(parseNodeVersion("v22.22.2"), { @@ -18,18 +22,27 @@ test("parseNodeVersion normalizes v-prefixed versions", () => { }); }); -test("getNodeRuntimeSupport accepts patched Node 22 and 20 LTS lines", () => { +test("getNodeRuntimeSupport accepts patched Node 24, 22 and 20 LTS lines", () => { assert.deepEqual(getNodeRuntimeSupport("22.22.2"), { nodeVersion: "v22.22.2", nodeCompatible: true, reason: "supported", supportedRange: SUPPORTED_NODE_RANGE, - supportedDisplay: "Node.js 20.20.2+ (20.x LTS) or 22.22.2+ (22.x LTS)", - recommendedVersion: "v22.22.2", + supportedDisplay: "Node.js 20.20.2+ (20.x LTS), 22.22.2+ (22.x LTS), or 24.0.0+ (24.x LTS)", + recommendedVersion: "v24.14.1", minimumSecureVersion: "v22.22.2", }); assert.equal(getNodeRuntimeSupport("20.20.2").nodeCompatible, true); + assert.deepEqual(getNodeRuntimeSupport("24.1.0"), { + nodeVersion: "v24.1.0", + nodeCompatible: true, + reason: "supported", + supportedRange: SUPPORTED_NODE_RANGE, + supportedDisplay: "Node.js 20.20.2+ (20.x LTS), 22.22.2+ (22.x LTS), or 24.0.0+ (24.x LTS)", + recommendedVersion: "v24.14.1", + minimumSecureVersion: "v24.0.0", + }); }); test("getNodeRuntimeSupport rejects versions below the secure floor in a supported line", () => { @@ -43,16 +56,22 @@ test("getNodeRuntimeSupport rejects versions below the secure floor in a support test("getNodeRuntimeSupport rejects unsupported major lines", () => { const node18 = getNodeRuntimeSupport("18.20.8"); - const node24 = getNodeRuntimeSupport("24.1.0"); + const node25 = getNodeRuntimeSupport("25.1.0"); assert.equal(node18.nodeCompatible, false); assert.equal(node18.reason, "unsupported-major"); assert.match(getNodeRuntimeWarning("18.20.8") || "", /outside OmniRoute's approved secure/i); - assert.equal(node24.nodeCompatible, false); - assert.equal(node24.reason, "native-addon-incompatible"); + assert.equal(node25.nodeCompatible, false); + assert.equal(node25.reason, "unreleased-major"); assert.match( - getNodeRuntimeWarning("24.1.0") || "", - /better-sqlite3 does not support Node\.js 24\+/i + getNodeRuntimeWarning("25.1.0") || "", + /currently supports Node\.js 20\.x, 22\.x, and 24\.x/i ); }); + +test("CLI runtime support stays aligned with the shared runtime policy", () => { + assert.deepEqual(getCliNodeRuntimeSupport("24.1.0"), getNodeRuntimeSupport("24.1.0")); + assert.deepEqual(getCliNodeRuntimeSupport("22.22.2"), getNodeRuntimeSupport("22.22.2")); + assert.equal(getCliNodeRuntimeWarning("25.1.0"), getNodeRuntimeWarning("25.1.0")); +}); diff --git a/tests/unit/search-route.test.ts b/tests/unit/search-route.test.ts index 0a947fe9..98fd1e41 100644 --- a/tests/unit/search-route.test.ts +++ b/tests/unit/search-route.test.ts @@ -176,3 +176,96 @@ test("v1 search POST accepts authless SearXNG with provider_options baseUrl", as globalThis.fetch = originalFetch; } }); + +test("v1 search POST accepts authless SearXNG with the built-in default base URL", async () => { + const originalFetch = globalThis.fetch; + let capturedUrl = ""; + + globalThis.fetch = async (url) => { + capturedUrl = String(url); + return new Response( + JSON.stringify({ + results: [ + { + title: "Default SearXNG result", + url: "https://searx.example/default", + content: "Default self-hosted response", + engines: ["duckduckgo"], + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const response = await searchRoute.POST( + new Request("http://localhost/api/v1/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: "default self hosted meta search", + provider: "searxng-search", + search_type: "web", + }), + }) + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal( + capturedUrl, + "http://localhost:8888/search?q=default+self+hosted+meta+search&format=json&categories=general" + ); + assert.equal(body.provider, "searxng-search"); + assert.equal(body.results[0].title, "Default SearXNG result"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("v1 search POST auto-select uses authless SearXNG when no API-key providers are configured", async () => { + const originalFetch = globalThis.fetch; + let capturedUrl = ""; + + globalThis.fetch = async (url) => { + capturedUrl = String(url); + return new Response( + JSON.stringify({ + results: [ + { + title: "Auto-selected SearXNG result", + url: "https://searx.example/auto", + content: "Auto-selected self-hosted response", + engines: ["duckduckgo"], + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const response = await searchRoute.POST( + new Request("http://localhost/api/v1/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: "auto select self hosted search", + search_type: "web", + }), + }) + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal( + capturedUrl, + "http://localhost:8888/search?q=auto+select+self+hosted+search&format=json&categories=general" + ); + assert.equal(body.provider, "searxng-search"); + assert.equal(body.results[0].title, "Auto-selected SearXNG result"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/usage-fetcher-antigravity.test.ts b/tests/unit/usage-fetcher-antigravity.test.ts index 5d34e266..d8d1cfe9 100644 --- a/tests/unit/usage-fetcher-antigravity.test.ts +++ b/tests/unit/usage-fetcher-antigravity.test.ts @@ -16,8 +16,8 @@ test("usage fetcher retries Antigravity quota discovery across shared fallback U calls.push({ url: String(url), init }); // Mock the first two to fail with 503 - if (String(url).includes("cloudcode-pa.googleapis.com") && !String(url).includes("sandbox")) { - // lgtm[js/incomplete-url-substring-sanitization] + const urlStr = String(url); + if (urlStr.includes("://cloudcode-pa.googleapis.com/") && !urlStr.includes("sandbox")) { return new Response("unavailable", { status: 503 }); } diff --git a/tests/unit/usage-service-hardening.test.ts b/tests/unit/usage-service-hardening.test.ts index f09cd8f1..5adda837 100644 --- a/tests/unit/usage-service-hardening.test.ts +++ b/tests/unit/usage-service-hardening.test.ts @@ -247,7 +247,7 @@ test("usage service covers Gemini CLI tier-label fallbacks and fetch error handl if (String(_url).includes("loadCodeAssist")) { return new Response(JSON.stringify({ currentTier: { id: "tier_pro" } }), { status: 200 }); } - assert.ok(String(init.body).includes("project-throw")); + assert.ok(String((init as any).body).includes("project-throw")); throw new Error("quota endpoint offline"); }; const fetchError: any = await usageService.getUsageForProvider({ @@ -365,7 +365,8 @@ test("usage service retries Antigravity fetchAvailableModels across the shared f return new Response("bad gateway", { status: 502 }); } - if (String(url).startsWith("https://daily-cloudcode-pa.googleapis.com/")) { + const urlStr = String(url); + if (urlStr.includes("://daily-cloudcode-pa.googleapis.com/")) { return new Response("bad gateway", { status: 502 }); } @@ -552,7 +553,7 @@ test("usage service covers Claude default-plan fallback, legacy org denial and f test("usage service covers Codex, Kiro and Kimi usage parsing and error branches", async () => { globalThis.fetch = async (url, init = {}) => { if (String(url).includes("/backend-api/wham/usage")) { - assert.equal(init.headers["chatgpt-account-id"], "workspace-123"); + assert.equal((init as any).headers["chatgpt-account-id"], "workspace-123"); return new Response( JSON.stringify({ plan_type: "plus", @@ -796,7 +797,7 @@ test("usage service covers Qwen, Qoder, GLM and GLMT branches", async () => { globalThis.fetch = async (url, init = {}) => { if (String(url).includes("/api/monitor/usage/quota/limit")) { - assert.equal(init.headers.Authorization, "Bearer glm-key"); + assert.equal((init as any).headers.Authorization, "Bearer glm-key"); return new Response( JSON.stringify({ data: {