From a3d1dc6cf9ac3ad9221389cb8ab7c41153618cf7 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 17 Apr 2026 18:45:32 -0300 Subject: [PATCH 1/7] fix(api): support image-only models and authless search providers Allow image generation requests to omit prompts for models that only accept image input, and validate required inputs from model metadata instead of enforcing a text prompt for every request. Treat authless search providers as executable with built-in defaults so SearXNG can run without stored credentials, including during provider auto-selection. Also align runtime support with Node.js 24 LTS, harden thinking tag compression and proxy wildcard matching, and update tests for the new route and runtime behavior. --- bin/nodeRuntimeSupport.mjs | 16 +-- open-sse/config/imageRegistry.ts | 28 +++++ open-sse/executors/perplexity-web.ts | 4 - open-sse/services/claudeCodeCompatible.ts | 15 ++- open-sse/services/contextManager.ts | 21 +++- open-sse/utils/proxyFetch.ts | 7 +- src/app/api/v1/images/generations/route.ts | 36 ++++++ src/app/api/v1/search/route.ts | 32 +++--- src/lib/usage/callLogArtifacts.ts | 3 +- src/shared/components/OAuthModal.tsx | 8 +- src/shared/components/PricingModal.tsx | 2 +- src/shared/validation/schemas.ts | 2 +- tests/unit/bailian-quota-fetcher.test.ts | 8 +- tests/unit/db-core-init.test.ts | 2 +- tests/unit/db-migration-runner.test.ts | 26 +++-- tests/unit/image-generation-route.test.ts | 113 +++++++++++++++++++ tests/unit/node-runtime-support.test.ts | 35 ++++-- tests/unit/search-route.test.ts | 93 +++++++++++++++ tests/unit/usage-fetcher-antigravity.test.ts | 4 +- tests/unit/usage-service-hardening.test.ts | 9 +- 20 files changed, 393 insertions(+), 71 deletions(-) create mode 100644 tests/unit/image-generation-route.test.ts diff --git a/bin/nodeRuntimeSupport.mjs b/bin/nodeRuntimeSupport.mjs index ea6c66d8b54..8c203f07229 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 f5df635ec53..f33f8e2955a 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 fd270fe055d..a167136ee23 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 f2b5b44773f..df9e4e0a9d2 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 d9bd126f7ec..4a1e53da74e 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 ad1122c6b4f..86adc6a639e 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 0283a06409a..3a37b9e5055 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 f8820e11e20..3b4a415f692 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 42f10cf07ff..198336ce011 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 ed84ba346b8..d36515bce8c 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 9217c355816..ae120f3da7d 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 fc422ec5358..472abfa4385 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 cc88ab1da6b..f953a635c29 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 d69d0b9e38f..3e171051afa 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 00000000000..164eb93680b --- /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 e57c573e2f7..cf13684a5e0 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 0a947fe9cbd..98fd1e41d26 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 5d34e266e04..d8d1cfe9e79 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 f09cd8f147a..5adda83709e 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: { From 0afd3049496828274feb4ca379ed9531bc1aa7ea Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 17 Apr 2026 19:10:49 -0300 Subject: [PATCH 2/7] fix(routes): require prompts for media generation requests Restore prompt validation for v1 music and video generation endpoints so empty or missing prompts fail fast with a 400 response. Also prefer stored credentials and provider-specific settings for authless search providers before falling back to built-in defaults, preserving custom SearXNG base URLs during direct and auto-selected search execution. Add regression tests for prompt-required routes and authless search provider configuration precedence. --- src/app/api/v1/music/generations/route.ts | 4 ++ src/app/api/v1/search/route.ts | 8 ++- src/app/api/v1/videos/generations/route.ts | 4 ++ src/lib/dataPaths.js | 63 +++++++++++----------- tests/unit/prompt-required-routes.test.ts | 49 +++++++++++++++++ tests/unit/search-route.test.ts | 55 +++++++++++++++++++ 6 files changed, 145 insertions(+), 38 deletions(-) create mode 100644 tests/unit/prompt-required-routes.test.ts diff --git a/src/app/api/v1/music/generations/route.ts b/src/app/api/v1/music/generations/route.ts index 7e88643dd0b..3dce64136b3 100644 --- a/src/app/api/v1/music/generations/route.ts +++ b/src/app/api/v1/music/generations/route.ts @@ -72,6 +72,10 @@ export async function POST(request) { } const body = validation.data; + if (typeof body.prompt !== "string" || body.prompt.trim().length === 0) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Prompt is required"); + } + // Optional API key validation if (process.env.REQUIRE_API_KEY === "true") { const apiKey = extractApiKey(request); diff --git a/src/app/api/v1/search/route.ts b/src/app/api/v1/search/route.ts index 3b4a415f692..8bd8ac87c11 100644 --- a/src/app/api/v1/search/route.ts +++ b/src/app/api/v1/search/route.ts @@ -69,11 +69,9 @@ async function resolveSearchExecutionCredentials(providerConfig: { id: string; authType: string; }): Promise | null> { - if (providerConfig.authType === "none") { - return {}; - } - - return resolveSearchCredentials(providerConfig.id); + const credentials = await resolveSearchCredentials(providerConfig.id); + if (credentials) return credentials; + return providerConfig.authType === "none" ? {} : null; } // Helper: build domain filter array from filters object diff --git a/src/app/api/v1/videos/generations/route.ts b/src/app/api/v1/videos/generations/route.ts index 702012e793d..b406bd629cd 100644 --- a/src/app/api/v1/videos/generations/route.ts +++ b/src/app/api/v1/videos/generations/route.ts @@ -72,6 +72,10 @@ export async function POST(request) { } const body = validation.data; + if (typeof body.prompt !== "string" || body.prompt.trim().length === 0) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Prompt is required"); + } + // Optional API key validation if (process.env.REQUIRE_API_KEY === "true") { const apiKey = extractApiKey(request); diff --git a/src/lib/dataPaths.js b/src/lib/dataPaths.js index 6f9b0b626f8..5078bbe9e6b 100644 --- a/src/lib/dataPaths.js +++ b/src/lib/dataPaths.js @@ -1,69 +1,66 @@ -import os from "os"; -import path from "path"; - -export const APP_NAME = "omniroute"; - +"use strict"; +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.APP_NAME = void 0; +exports.getLegacyDotDataDir = getLegacyDotDataDir; +exports.getDefaultDataDir = getDefaultDataDir; +exports.resolveDataDir = resolveDataDir; +exports.isSamePath = isSamePath; +const path_1 = __importDefault(require("path")); +const os_1 = __importDefault(require("os")); +exports.APP_NAME = "omniroute"; function fallbackHomeDir() { const envHome = process.env.HOME || process.env.USERPROFILE; if (typeof envHome === "string" && envHome.trim().length > 0) { - return path.resolve(envHome); + return path_1.default.resolve(envHome); } - - return os.tmpdir(); + return os_1.default.tmpdir(); } - function safeHomeDir() { try { - return os.homedir(); + return os_1.default.homedir(); } catch { return fallbackHomeDir(); } } - function normalizeConfiguredPath(dir) { if (typeof dir !== "string") return null; const trimmed = dir.trim(); if (!trimmed) return null; - return path.resolve(trimmed); + return path_1.default.resolve(trimmed); } - -export function getLegacyDotDataDir() { - return path.join(safeHomeDir(), `.${APP_NAME}`); +function getLegacyDotDataDir() { + return path_1.default.join(safeHomeDir(), `.${exports.APP_NAME}`); } - -export function getDefaultDataDir() { +function getDefaultDataDir() { const homeDir = safeHomeDir(); - if (process.platform === "win32") { - const appData = process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"); - return path.join(appData, APP_NAME); + const appData = process.env.APPDATA || path_1.default.join(homeDir, "AppData", "Roaming"); + return path_1.default.join(appData, exports.APP_NAME); } - + // Support XDG on Linux/macOS when explicitly configured. const xdgConfigHome = normalizeConfiguredPath(process.env.XDG_CONFIG_HOME); if (xdgConfigHome) { - return path.join(xdgConfigHome, APP_NAME); + return path_1.default.join(xdgConfigHome, exports.APP_NAME); } - return getLegacyDotDataDir(); } - -export function resolveDataDir({ isCloud = false } = {}) { +function resolveDataDir({ isCloud = false } = {}) { if (isCloud) return "/tmp"; - const configured = normalizeConfiguredPath(process.env.DATA_DIR); if (configured) return configured; - return getDefaultDataDir(); } - -export function isSamePath(a, b) { +function isSamePath(a, b) { if (!a || !b) return false; - const normalizedA = path.resolve(a); - const normalizedB = path.resolve(b); - + const normalizedA = path_1.default.resolve(a); + const normalizedB = path_1.default.resolve(b); if (process.platform === "win32") { return normalizedA.toLowerCase() === normalizedB.toLowerCase(); } - return normalizedA === normalizedB; } diff --git a/tests/unit/prompt-required-routes.test.ts b/tests/unit/prompt-required-routes.test.ts new file mode 100644 index 00000000000..cd5e5c979e3 --- /dev/null +++ b/tests/unit/prompt-required-routes.test.ts @@ -0,0 +1,49 @@ +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-prompt-required-routes-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const musicRoute = await import("../../src/app/api/v1/music/generations/route.ts"); +const videoRoute = await import("../../src/app/api/v1/videos/generations/route.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("v1 video generation POST rejects requests without a prompt", async () => { + const response = await videoRoute.POST( + new Request("http://localhost/api/v1/videos/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "comfyui/animatediff", + }), + }) + ); + const body = await response.json(); + + assert.equal(response.status, 400); + assert.match(body.error.message, /Prompt is required/); +}); + +test("v1 music generation POST rejects requests without a prompt", async () => { + const response = await musicRoute.POST( + new Request("http://localhost/api/v1/music/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "comfyui/musicgen-medium", + }), + }) + ); + const body = await response.json(); + + assert.equal(response.status, 400); + assert.match(body.error.message, /Prompt is required/); +}); diff --git a/tests/unit/search-route.test.ts b/tests/unit/search-route.test.ts index 98fd1e41d26..05ecfc49116 100644 --- a/tests/unit/search-route.test.ts +++ b/tests/unit/search-route.test.ts @@ -224,6 +224,61 @@ test("v1 search POST accepts authless SearXNG with the built-in default base URL } }); +test("v1 search POST preserves stored SearXNG baseUrl for authless providers", async () => { + await seedConnection("searxng-search", { + apiKey: null, + authType: "none", + providerSpecificData: { + baseUrl: "http://127.0.0.1:9090/custom-search", + }, + }); + + const originalFetch = globalThis.fetch; + let capturedUrl = ""; + + globalThis.fetch = async (url) => { + capturedUrl = String(url); + return new Response( + JSON.stringify({ + results: [ + { + title: "Stored SearXNG result", + url: "https://searx.example/stored", + content: "Stored 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: "stored self hosted meta search", + provider: "searxng-search", + search_type: "web", + }), + }) + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal( + capturedUrl, + "http://127.0.0.1:9090/custom-search/search?q=stored+self+hosted+meta+search&format=json&categories=general" + ); + assert.equal(body.provider, "searxng-search"); + assert.equal(body.results[0].title, "Stored 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 = ""; From 447c13592f21c03f2cb2d0a678618899e5b811cc Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 17 Apr 2026 19:23:58 -0300 Subject: [PATCH 3/7] refactor(audit): align audit dashboard with compliance log entries Switch the audit API and dashboard viewer to consume the compliance audit log shape instead of the older config diff format. This updates summary responses to return entry counts, adds total results for paginated audit queries, and replaces source-based filters with actor and date-based parameters. The dashboard copy and columns now reflect broader administrative and security events rather than only configuration changes. --- .../dashboard/audit/ConfigAuditViewer.tsx | 177 ++++++------------ src/app/(dashboard)/dashboard/audit/page.tsx | 5 +- src/app/api/audit/route.ts | 29 ++- 3 files changed, 70 insertions(+), 141 deletions(-) diff --git a/src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx b/src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx index 728897e1a72..298c09b60a1 100644 --- a/src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx +++ b/src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx @@ -3,25 +3,18 @@ import { useState, useEffect } from "react"; import { useTranslations } from "next-intl"; -interface ConfigDiff { - added: string[]; - removed: string[]; - changed: Array<{ key: string; from: any; to: any }>; - isEmpty: boolean; -} - interface AuditEntry { - id: string; + id: number; timestamp: string; action: string; - target: string; - targetId: string; - targetName: string; - source: string; - before: any; - after: any; - diff: ConfigDiff; - note: string | null; + actor: string; + target?: string; + resource_type?: string; + ip_address?: string; + status?: string; + request_id?: string; + details?: any; + metadata?: any; } export default function ConfigAuditViewer() { @@ -48,16 +41,17 @@ export default function ConfigAuditViewer() { }; const getActionColor = (action: string) => { - switch (action) { - case "create": - return "text-green-400 bg-green-400/10 border-green-500/20"; - case "update": - return "text-blue-400 bg-blue-400/10 border-blue-500/20"; - case "delete": - return "text-red-400 bg-red-400/10 border-red-500/20"; - default: - return "text-gray-400 bg-gray-400/10 border-gray-500/20"; + const act = action.toLowerCase(); + if (act.includes("success") || act.includes("create")) { + return "text-green-400 bg-green-400/10 border-green-500/20"; + } + if (act.includes("update") || act.includes("modify")) { + return "text-blue-400 bg-blue-400/10 border-blue-500/20"; + } + if (act.includes("failed") || act.includes("delete")) { + return "text-red-400 bg-red-400/10 border-red-500/20"; } + return "text-gray-400 bg-gray-400/10 border-gray-500/20"; }; if (loading) { @@ -98,8 +92,8 @@ export default function ConfigAuditViewer() { Timestamp Action Target - Resource - Source + Actor + Resource/IP Details @@ -120,20 +114,20 @@ export default function ConfigAuditViewer() { - {entry.target} - - - {entry.targetName} + {entry.target || "-"} - {entry.source} + {entry.actor} + + + {entry.resource_type || entry.ip_address || "-"} @@ -149,10 +143,10 @@ export default function ConfigAuditViewer() {

- {selectedEntry.action} {selectedEntry.target} + {selectedEntry.action}

- ID: {selectedEntry.targetId} • {selectedEntry.targetName} + Actor: {selectedEntry.actor} • Target: {selectedEntry.target || "N/A"}