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.
This commit is contained in:
@@ -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.`;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,8 +33,6 @@ const CITATION_RE = /\[\d+\]/g;
|
||||
const GROK_TAG_RE = /<grok:[^>]*>.*?<\/grok:[^>]*>/gs;
|
||||
const GROK_SELF_RE = /<grok:[^>]*\/>/g;
|
||||
const XML_DECL_RE = /<[?]xml[^?]*[?]>/g;
|
||||
const SCRIPT_RE = /<script\b[^>]*>.*?<\/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");
|
||||
|
||||
@@ -437,11 +437,16 @@ function buildClaudeCodeCompatibleMessages(messages: MessageLike[]) {
|
||||
.filter(
|
||||
(
|
||||
message
|
||||
): message is { role: "user" | "assistant"; content: Array<Record<string, unknown>> } =>
|
||||
!!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<Record<string, unknown>> }> = [];
|
||||
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<string, unknown> => !!message);
|
||||
.filter((message): message is Record<string, unknown> & { content: unknown } => !!message);
|
||||
}
|
||||
|
||||
function normalizeClaudeToolInput(tools: unknown) {
|
||||
|
||||
@@ -216,10 +216,23 @@ function compressThinking(messages: Record<string, unknown>[]) {
|
||||
|
||||
// Remove thinking XML tags from string content
|
||||
if (typeof msg.content === "string") {
|
||||
const cleaned = msg.content
|
||||
.replace(/<thinking>.*?<\/thinking>/gs, "")
|
||||
.replace(/<antThinking>.*?<\/antThinking>/gs, "")
|
||||
.trim();
|
||||
let cleaned = msg.content;
|
||||
for (const [start, end] of [
|
||||
["<thinking>", "</thinking>"],
|
||||
["<antThinking>", "</antThinking>"],
|
||||
]) {
|
||||
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]" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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;
|
||||
|
||||
@@ -65,6 +65,17 @@ async function resolveSearchCredentials(providerId: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveSearchExecutionCredentials(providerConfig: {
|
||||
id: string;
|
||||
authType: string;
|
||||
}): Promise<Record<string, any> | 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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -648,15 +648,15 @@ export default function OAuthModal({
|
||||
</span>
|
||||
<strong>
|
||||
{t.rich("googleOAuthWarning", {
|
||||
code: (chunks) => <code>{chunks}</code>,
|
||||
a: (chunks) => (
|
||||
code: (c) => <code className="font-mono">{c}</code>,
|
||||
a: (c) => (
|
||||
<a
|
||||
href="https://github.com/diegosouzapw/OmniRoute#oauth-on-a-remote-server"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{chunks}
|
||||
{c}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
@@ -692,7 +692,7 @@ export default function OAuthModal({
|
||||
<p className="text-sm font-medium mb-2">{t("step2PasteCallback")}</p>
|
||||
<p className="text-xs text-text-muted mb-2">
|
||||
{t.rich("step2Hint", {
|
||||
code: (chunks) => <code>{chunks}</code>,
|
||||
code: (c) => <code className="font-mono">{c}</code>,
|
||||
})}
|
||||
</p>
|
||||
<Input
|
||||
|
||||
@@ -120,7 +120,7 @@ export default function PricingModal({ isOpen, onClose, onSave }) {
|
||||
<p className="font-medium mb-1">{t("pricingRatesFormat")}</p>
|
||||
<p className="text-text-muted">
|
||||
{t.rich("ratesDescription", {
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
strong: (c) => <strong className="font-semibold">{c}</strong>,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -491,7 +491,7 @@ export const v1EmbeddingsSchema = z
|
||||
export const v1ImageGenerationSchema = z
|
||||
.object({
|
||||
model: modelIdSchema,
|
||||
prompt: nonEmptyStringSchema,
|
||||
prompt: nonEmptyStringSchema.optional(),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user