feat(providers): optional AI Horde API key and live image catalog (#10542)

* feat(providers): optional AI Horde API key and live image catalog

Allow a registered Horde key on the no-auth connection and send it for
chat and image jobs. List only image models that currently have workers,
and generate through Horde's native async API.

# Conflicts:
#	open-sse/config/imageRegistry.ts
#	src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx
#	src/shared/constants/providers.ts
#	src/sse/services/auth.ts

* fix(providers): validate AI Horde keys against find_user

The OpenAI-compatible /v1/models probe returns 200 for any Bearer token
on oai.aihorde.net, so Check always succeeded. Use Horde's /v2/find_user
lookup instead; an empty key still counts as the optional anonymous path.

* chore(changelog): name the AI Horde fragment for #10542

* fix(images): harden AI Horde optional-key selection and outbound fetches

- Optional-key selection now honors connection health (rate-limit cooldown
  and terminal/unavailable test status) before handing a stored key back,
  rotating to the next healthy key or falling back to the anonymous no-auth
  path instead of using an unhealthy stored key.
- Route the Horde submit/check/status/cancel and catalog calls through the
  repository's bounded outbound-fetch helper (timeout, no more bare fetch())
  and route R2 image downloads through the established bounded remote-image
  fetch (SSRF host guard, DNS-rebinding pin, streaming byte cap, redirect
  limit) instead of an unbounded fetch().
- Extend the generation deadline to cover the full request lifecycle
  (catalog freshness check, submit, polling, and image download), and add a
  regression test proving that exceeding the deadline issues a DELETE
  cancel to Horde's API rather than only timing out locally.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: pqr <pqr@soraka.ititti.es>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
pageragatz
2026-08-18 08:51:57 -05:00
committed by GitHub
parent f92075bb63
commit b9cd5ed138
20 changed files with 1525 additions and 26 deletions

View File

@@ -0,0 +1,2 @@
- **feat(providers):** AI Horde accepts an optional registered API key and advertises only live image models that currently have workers ([#10542](https://github.com/diegosouzapw/OmniRoute/pull/10542))
- **fix(providers):** AI Horde Check validates keys via `/v2/find_user` instead of the unauthenticated OpenAI models list ([#10542](https://github.com/diegosouzapw/OmniRoute/pull/10542))

View File

@@ -16,6 +16,7 @@ import {
ADOBE_FIREFLY_IMAGE_ROUTING_ALIASES,
toRegistryImageModels,
} from "../services/adobeFireflyModels.ts";
import { AI_HORDE_IMAGE_PROVIDER } from "./providers/registry/aihorde/imageModels.ts";
interface ImageModelEntry {
id: string;
@@ -841,6 +842,7 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
// still pass supported 4K dimensions through the permissive request schema.
supportedSizes: ["1024x1024", "2048x2048"],
},
aihorde: AI_HORDE_IMAGE_PROVIDER,
};
/**

View File

@@ -0,0 +1,26 @@
/**
* AI Horde image-generation provider entry.
*
* Chat still goes through oai.aihorde.net. Image jobs use the native Horde
* async API (`/v2/generate/async`). `models` is a live getter so
* imageRegistry stays under the file-size cap and zero-worker names are
* never advertised.
*/
import { getCachedAiHordeImageCatalogEntries } from "../../../../services/aihordeImageCatalog.ts";
export const AI_HORDE_IMAGE_PROVIDER = {
id: "aihorde",
alias: "horde",
baseUrl: "https://aihorde.net/api",
authType: "apikey",
authHeader: "apikey",
format: "aihorde",
get models() {
return getCachedAiHordeImageCatalogEntries().map((entry) => ({
id: entry.id.startsWith("aihorde/") ? entry.id.slice("aihorde/".length) : entry.id,
name: entry.name,
inputModalities: entry.inputModalities,
}));
},
supportedSizes: ["512x512", "768x768", "1024x1024", "1024x768", "768x1024"],
};

View File

@@ -17,9 +17,14 @@ import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
* the free catalog registers it as `recurring-uncapped` (never summed into
* the token headline) rather than inventing an RPM/RPD figure.
*
* Model list changes as workers come and go, so the live catalog is fetched via
* passthrough; the entries below are the ones that have carried steady worker
* threads and only serve as a fallback when discovery fails.
* Chat model list changes as workers come and go, so the live chat catalog is
* fetched via passthrough; the entries below are the ones that have carried
* steady worker threads and only serve as a fallback when discovery fails.
*
* Image models are a separate native Horde API (`/v2/generate/async`). They
* are discovered by polling `/v2/status/models?type=image` and only advertised
* while `count > 0`. An optional registered API key is stored as a normal
* connection and sent as the Horde `apikey` header for both chat and images.
*/
export const aihordeProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
id: "aihorde",

View File

@@ -1,20 +1,5 @@
import { randomUUID } from "crypto";
/**
* Image Generation Handler
*
* Handles POST /v1/images/generations requests.
* Proxies to upstream image generation providers using OpenAI-compatible format.
*
* Request format (OpenAI-compatible):
* {
* "model": "openai/gpt-image-2",
* "prompt": "a beautiful sunset over mountains",
* "n": 1,
* "size": "1024x1024",
* "quality": "standard", // optional: "standard" | "hd"
* "response_format": "url" // optional: "url" | "b64_json"
* }
*/
/** Image generation handler for POST /v1/images/generations (OpenAI-compatible). */
import { getImageProvider, parseImageModel } from "../config/imageRegistry.ts";
import { HTTP_STATUS } from "../config/constants.ts";
@@ -51,10 +36,6 @@ import {
} from "@/shared/utils/fetchTimeout";
import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "../utils/error.ts";
// --- Per-provider handlers (extracted to co-located files in PR-#4582-batch) ---
// Imported locally so internal callers (handleImageGeneration / handleImageEdit)
// resolve to a real binding. extractMarkdownImageUrls + CHATGPT_WEB_IMAGE_ID_RE
// are still used by handleImageEdit below, so they are imported (not re-defined).
import { handleSDWebUIImageGeneration } from "./imageGeneration/providers/sdWebUI.ts";
import { handleHyperbolicImageGeneration } from "./imageGeneration/providers/hyperbolic.ts";
import { handleHuggingFaceImageGeneration } from "./imageGeneration/providers/huggingface.ts";
@@ -76,6 +57,7 @@ import { handleDesignerWebImageGeneration } from "./imageGeneration/providers/de
import { handleMinimaxImageGeneration } from "./imageGeneration/providers/minimax.ts";
import { handleAdobeFireflyImageGeneration } from "./imageGeneration/providers/adobeFirefly.ts";
import { handleAlibabaImageGeneration } from "./imageGeneration/providers/alibabaImage.ts";
import { handleAiHordeImageGeneration } from "./imageGeneration/providers/aihorde.ts";
import {
applyPollinationsAnonymousFallback,
reportPollinationsAnonOutcome,
@@ -373,6 +355,18 @@ export async function handleImageGeneration({
});
}
if (providerConfig.format === "aihorde") {
return handleAiHordeImageGeneration({
model,
provider,
providerConfig,
body,
credentials,
log,
signal,
});
}
if (providerConfig.format === "gemini-image") {
return handleGeminiImageGeneration({ model, providerConfig, body, credentials, log });
}

View File

@@ -0,0 +1,325 @@
import { saveCallLog } from "@/lib/usageDb";
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";
import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
import { sleep } from "../../../utils/sleep.ts";
import { sanitizeErrorMessage } from "../../../utils/error.ts";
import {
AI_HORDE_ANONYMOUS_KEY,
AI_HORDE_API_BASE,
AI_HORDE_CATALOG_FETCH_TIMEOUT_MS,
AI_HORDE_CLIENT_AGENT,
aiHordeImageCatalog,
} from "../../../services/aihordeImageCatalog.ts";
import {
extractHordeSourceB64,
mapHordeGenerateRequest,
stripHordeModelPrefix,
} from "./aihordeMapRequest.ts";
const GENERATE_TIMEOUT_MS = 600_000;
const POLL_INTERVAL_MS = 1_000;
// Per-call bound for the Horde API's own submit/check/status/cancel calls
// (a fixed, trusted host — no SSRF guard needed, just a hard timeout so a
// hung upstream cannot stall a request indefinitely). Individual calls are
// additionally capped to whatever remains of the overall generation deadline.
const HORDE_API_CALL_TIMEOUT_MS = 30_000;
// R2 image downloads point at a URL Horde's response supplies, not a fixed
// OmniRoute-controlled host, so they get the SSRF host guard too.
const HORDE_IMAGE_DOWNLOAD_TIMEOUT_MS = 60_000;
const MAX_HORDE_IMAGE_BYTES = 25 * 1024 * 1024;
function hordeHeaders(apiKey: string): Record<string, string> {
return {
apikey: apiKey,
"Client-Agent": AI_HORDE_CLIENT_AGENT,
Accept: "application/json",
"Content-Type": "application/json",
};
}
/** Bound to whatever is left of the overall request deadline, floored so a
* near-expired deadline still gets one last bounded attempt instead of a
* zero/negative timeout. */
function boundedTimeoutMs(deadline: number, cap: number): number {
return Math.max(1_000, Math.min(cap, deadline - Date.now()));
}
function hordeMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === "object") {
const message = (payload as { message?: unknown }).message;
if (typeof message === "string" && message.trim()) return message;
}
return fallback;
}
async function safeJson(response: Response): Promise<unknown> {
try {
return await response.json();
} catch {
return null;
}
}
function mapUpstreamStatus(status: number): number {
if (
status === 400 ||
status === 401 ||
status === 403 ||
status === 404 ||
status === 429 ||
status === 503
) {
return status;
}
return 502;
}
function resolveHordeApiKey(credentials: { apiKey?: unknown } | null | undefined): string {
const raw = credentials?.apiKey;
return typeof raw === "string" && raw.trim() ? raw.trim() : AI_HORDE_ANONYMOUS_KEY;
}
async function cancelHordeJob(jobId: string, apiKey: string): Promise<void> {
try {
// Best-effort cancel — deliberately not tied to the caller's (already
// expired/aborted) signal, and given its own short timeout so a hung
// cancel-DELETE cannot itself hang the cleanup path.
await safeOutboundFetch(`${AI_HORDE_API_BASE}/v2/generate/status/${jobId}`, {
method: "DELETE",
headers: hordeHeaders(apiKey),
guard: "none",
timeoutMs: HORDE_API_CALL_TIMEOUT_MS,
});
} catch {
// Best-effort cancel after timeout or client disconnect.
}
}
async function fetchHordeImageBytes(
img: string,
options: { signal?: AbortSignal | null; timeoutMs: number }
): Promise<string> {
const value = img.trim();
if (value.startsWith("http://") || value.startsWith("https://")) {
// Horde's response supplies this URL (a signed R2 storage link), not a
// fixed OmniRoute-controlled host — route it through the repository's
// established bounded remote-image fetch (SSRF host guard + DNS-rebinding
// pin, streaming byte cap, redirect limit, abort-aware timeout) instead of
// a bare fetch(). Same helper `imageGeneration.ts` already uses for other
// providers' remote image URLs.
const remote = await fetchRemoteImage(value, {
timeoutMs: options.timeoutMs,
signal: options.signal ?? undefined,
maxBytes: MAX_HORDE_IMAGE_BYTES,
});
if (remote.buffer.length === 0) throw new Error("Horde R2 download returned an empty image");
return remote.buffer.toString("base64");
}
return value;
}
export async function handleAiHordeImageGeneration({
model,
provider,
body,
credentials,
log,
signal = null,
timeoutMs = GENERATE_TIMEOUT_MS,
}: {
model: string;
provider: string;
providerConfig?: { baseUrl?: string };
body: Record<string, unknown>;
credentials?: { apiKey?: unknown } | null;
log?: {
info: (scope: string, message: string) => void;
error: (scope: string, message: string) => void;
} | null;
signal?: AbortSignal | null;
/** Overridable for tests; production callers should rely on the default. */
timeoutMs?: number;
}) {
const startTime = Date.now();
const hordeModel = stripHordeModelPrefix(model);
const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? "");
const apiKey = resolveHordeApiKey(credentials);
const logRequestBody = {
model: hordeModel,
prompt: prompt.slice(0, 200),
size: body.size || "1024x1024",
n: body.n || 1,
};
// Deadline covers the FULL request lifecycle — catalog freshness check,
// job submission, polling, and image download — not just the polling
// loop. Every bounded fetch below is capped to whatever remains of it.
const deadline = startTime + timeoutMs;
if (log) {
log.info("IMAGE", `${provider}/${hordeModel} (aihorde) | prompt: "${prompt.slice(0, 60)}..."`);
}
try {
await aiHordeImageCatalog.ensureFresh(undefined, {
signal: signal ?? undefined,
timeoutMs: boundedTimeoutMs(deadline, AI_HORDE_CATALOG_FETCH_TIMEOUT_MS),
});
if (aiHordeImageCatalog.hasSnapshot() && !aiHordeImageCatalog.isServed(hordeModel)) {
const error = `No Horde workers are currently serving ${hordeModel}`;
saveCallLog({
method: "POST",
path: "/v1/images/generations",
status: 400,
model: `${provider}/${hordeModel}`,
provider,
duration: Date.now() - startTime,
error,
requestBody: logRequestBody,
}).catch(() => {});
return { success: false, status: 400, error };
}
const sourceImage = extractHordeSourceB64(body);
const payload = mapHordeGenerateRequest(body, { sourceImage });
const submit = await safeOutboundFetch(`${AI_HORDE_API_BASE}/v2/generate/async`, {
method: "POST",
headers: hordeHeaders(apiKey),
body: JSON.stringify(payload),
signal: signal ?? undefined,
guard: "none",
timeoutMs: boundedTimeoutMs(deadline, HORDE_API_CALL_TIMEOUT_MS),
});
const submitBody = await safeJson(submit);
if (submit.status !== 200 && submit.status !== 202) {
const error = hordeMessage(submitBody, `Horde submit failed (${submit.status})`);
saveCallLog({
method: "POST",
path: "/v1/images/generations",
status: mapUpstreamStatus(submit.status),
model: `${provider}/${hordeModel}`,
provider,
duration: Date.now() - startTime,
error,
requestBody: logRequestBody,
}).catch(() => {});
return { success: false, status: mapUpstreamStatus(submit.status), error };
}
const jobId =
submitBody && typeof submitBody === "object" ? (submitBody as { id?: unknown }).id : null;
if (typeof jobId !== "string" || !jobId) {
return { success: false, status: 502, error: "Horde submit did not return a job id" };
}
let completed = false;
try {
while (true) {
if (signal?.aborted) throw new Error("Horde image generation cancelled");
if (Date.now() >= deadline) {
throw Object.assign(new Error("Horde image generation timed out"), { status: 504 });
}
await sleep(POLL_INTERVAL_MS);
const checkRes = await safeOutboundFetch(`${AI_HORDE_API_BASE}/v2/generate/check/${jobId}`, {
headers: hordeHeaders(apiKey),
signal: signal ?? undefined,
guard: "none",
timeoutMs: boundedTimeoutMs(deadline, HORDE_API_CALL_TIMEOUT_MS),
});
const check = await safeJson(checkRes);
if (!checkRes.ok || !check || typeof check !== "object") {
throw Object.assign(
new Error(hordeMessage(check, `Horde check failed (${checkRes.status})`)),
{ status: mapUpstreamStatus(checkRes.status) }
);
}
const checkObj = check as Record<string, unknown>;
if (checkObj.faulted) throw new Error("Horde marked the job as faulted");
if (checkObj.is_possible === false) {
throw Object.assign(new Error("No Horde workers can currently fulfill this request"), {
status: 503,
});
}
if (!checkObj.done) continue;
const statusRes = await safeOutboundFetch(`${AI_HORDE_API_BASE}/v2/generate/status/${jobId}`, {
headers: hordeHeaders(apiKey),
signal: signal ?? undefined,
guard: "none",
timeoutMs: boundedTimeoutMs(deadline, HORDE_API_CALL_TIMEOUT_MS),
});
const status = await safeJson(statusRes);
if (!statusRes.ok || !status || typeof status !== "object") {
throw Object.assign(
new Error(hordeMessage(status, `Horde status failed (${statusRes.status})`)),
{ status: mapUpstreamStatus(statusRes.status) }
);
}
const generations = (status as { generations?: unknown }).generations;
if (!Array.isArray(generations) || generations.length === 0) {
throw new Error("Horde status contained no generations");
}
const images: Array<{ b64_json: string; revised_prompt: string }> = [];
for (const item of generations) {
if (!item || typeof item !== "object") continue;
const img = (item as { img?: unknown }).img;
if (typeof img !== "string" || !img) continue;
// The polling loop's deadline check only runs once per iteration
// before the poll fetches — re-check here so a deadline that
// expires during (or immediately after) polling still aborts
// before an unbounded amount of image-download work starts, and
// so the job gets cancelled via the `finally` below rather than
// silently completing over-budget.
if (Date.now() >= deadline) {
throw Object.assign(new Error("Horde image generation timed out"), { status: 504 });
}
images.push({
b64_json: await fetchHordeImageBytes(img, {
signal,
timeoutMs: boundedTimeoutMs(deadline, HORDE_IMAGE_DOWNLOAD_TIMEOUT_MS),
}),
revised_prompt: prompt,
});
}
if (images.length === 0) throw new Error("Horde status contained no image payloads");
completed = true;
saveCallLog({
method: "POST",
path: "/v1/images/generations",
status: 200,
model: `${provider}/${hordeModel}`,
provider,
duration: Date.now() - startTime,
requestBody: logRequestBody,
responseBody: { images_count: images.length },
}).catch(() => {});
return {
success: true,
data: { created: Math.floor(Date.now() / 1000), data: images },
};
}
} finally {
if (!completed) await cancelHordeJob(jobId, apiKey);
}
} catch (err) {
const status =
err &&
typeof err === "object" &&
"status" in err &&
typeof (err as { status: unknown }).status === "number"
? (err as { status: number }).status
: 502;
const raw = err instanceof Error ? err.message : "Horde image generation failed";
const error = sanitizeErrorMessage(raw);
if (log) log.error("IMAGE", `aihorde error: ${String(error).slice(0, 200)}`);
saveCallLog({
method: "POST",
path: "/v1/images/generations",
status,
model: `${provider}/${hordeModel}`,
provider,
duration: Date.now() - startTime,
error: String(error).slice(0, 500),
requestBody: logRequestBody,
}).catch(() => {});
return { success: false, status, error };
}
}

View File

@@ -0,0 +1,124 @@
/**
* Map OpenAI image request bodies onto AI Horde generate payloads.
*/
const MAX_N = 4;
const MIN_DIM = 64;
const MAX_DIM = 3072;
const DIM_STEP = 64;
const DEFAULT_WIDTH = 1024;
const DEFAULT_HEIGHT = 1024;
const DEFAULT_DENOISING = 0.75;
const DEFAULT_STEPS = 20;
const SIZE_RE = /^\s*(\d+)\s*x\s*(\d+)\s*$/i;
const DATA_URL_RE = /^data:([^;,]+)?(?:;[^,]*)?;base64,(.+)$/i;
export function stripHordeModelPrefix(model: string): string {
const name = model.trim();
const lower = name.toLowerCase();
if (lower.startsWith("aihorde/")) return name.slice("aihorde/".length);
if (lower.startsWith("horde/")) return name.slice("horde/".length);
return name;
}
export function snapHordeDim(value: number): number {
const snapped = Math.round(value / DIM_STEP) * DIM_STEP;
return Math.max(MIN_DIM, Math.min(MAX_DIM, snapped));
}
export function parseHordeSize(size: string | null | undefined): { width: number; height: number } {
if (!size) return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT };
const match = SIZE_RE.exec(size);
if (!match) {
throw new Error(`size must look like WIDTHxHEIGHT, got ${JSON.stringify(size)}`);
}
return { width: snapHordeDim(Number(match[1])), height: snapHordeDim(Number(match[2])) };
}
export function capHordeN(n: unknown): number {
if (n === null || n === undefined) return 1;
const value = Number(n);
if (!Number.isFinite(value)) {
throw new Error("n must be an integer");
}
if (value < 1) {
throw new Error("n must be at least 1");
}
return Math.min(Math.trunc(value), MAX_N);
}
export function extractHordeSourceB64(body: Record<string, unknown>): string | null {
const images = body.images;
if (Array.isArray(images) && images.length > 0) {
return coerceHordeImage(images[0]);
}
if (body.image !== undefined) return coerceHordeImage(body.image);
if (typeof body.image_url === "string" && body.image_url.trim()) {
return coerceHordeImage(body.image_url);
}
return null;
}
function coerceHordeImage(value: unknown): string {
if (value && typeof value === "object") {
const obj = value as Record<string, unknown>;
for (const key of ["image_url", "url", "b64_json", "image"]) {
const inner = obj[key];
if (typeof inner === "string" && inner.trim()) return stripDataUrl(inner);
}
throw new Error("image object is missing image_url, url, b64_json, or image");
}
if (typeof value === "string" && value.trim()) return stripDataUrl(value);
throw new Error("image must be a data URL, raw base64 string, or image object");
}
function stripDataUrl(value: string): string {
const match = DATA_URL_RE.exec(value.trim());
return match ? match[2].trim() : value.trim();
}
export function mapHordeGenerateRequest(
body: Record<string, unknown>,
options: { sourceImage?: string | null; steps?: number } = {}
): Record<string, unknown> {
const prompt = body.prompt;
if (typeof prompt !== "string" || !prompt.trim()) {
throw new Error("prompt is required");
}
const model = body.model;
if (typeof model !== "string" || !model.trim()) {
throw new Error("model is required");
}
const hordeModel = stripHordeModelPrefix(model);
if (!hordeModel) {
throw new Error("model is empty after stripping aihorde/horde prefix");
}
const size = typeof body.size === "string" ? body.size : null;
const { width, height } = parseHordeSize(size);
const payload: Record<string, unknown> = {
prompt,
models: [hordeModel],
nsfw: false,
censor_nsfw: true,
r2: true,
shared: false,
validated_backends: true,
slow_workers: true,
allow_downgrade: true,
params: {
n: capHordeN(body.n),
width,
height,
steps: options.steps ?? DEFAULT_STEPS,
},
};
if (options.sourceImage) {
payload.source_image = options.sourceImage;
payload.source_processing = "img2img";
(payload.params as Record<string, unknown>).denoising_strength = DEFAULT_DENOISING;
}
return payload;
}

View File

@@ -0,0 +1,220 @@
/**
* Live AI Horde image-model detector.
*
* Horde workers appear and disappear. A static IMAGE_PROVIDERS list goes stale.
* This module polls `GET /v2/status/models?type=image` and keeps only models
* with at least one worker (`count > 0`). Names are the exact Horde strings
* (do not slugify). On poll failure the last good snapshot is kept.
*/
import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
export const AI_HORDE_API_BASE = "https://aihorde.net/api";
export const AI_HORDE_ANONYMOUS_KEY = "0000000000";
export const AI_HORDE_CLIENT_AGENT = "OmniRoute:3.8.49:https://github.com/diegosouzapw/OmniRoute";
export const AI_HORDE_CATALOG_POLL_MS = 30_000;
// The catalog endpoint is a fixed, trusted OmniRoute-controlled URL (not
// user-supplied), so it does not need SSRF host validation — but it still
// needs a hard bound so a hung upstream cannot block a request indefinitely.
export const AI_HORDE_CATALOG_FETCH_TIMEOUT_MS = 15_000;
export interface HordeImageCatalogModel {
name: string;
count: number;
queued: number | null;
eta: number | null;
performance: number | null;
jobs: number | null;
}
export interface HordeImageCatalogSnapshot {
models: HordeImageCatalogModel[];
updatedAt: number | null;
lastError: string | null;
}
type HordeFetchInit = RequestInit & { timeoutMs?: number };
type HordeFetch = (input: string, init?: HordeFetchInit) => Promise<Response>;
// Bounded default transport: fixed trusted host (guard "none"), abort-aware
// timeout. Callers that inject a custom `fetchImpl` (tests, alternate
// transports) opt out of this bound deliberately.
const defaultHordeFetch: HordeFetch = (input, init) => {
const { timeoutMs, ...rest } = init || {};
return safeOutboundFetch(input, {
guard: "none",
timeoutMs: timeoutMs ?? AI_HORDE_CATALOG_FETCH_TIMEOUT_MS,
...rest,
});
};
function asNumber(value: unknown): number | null {
if (value === null || value === undefined) return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function asInt(value: unknown): number | null {
const parsed = asNumber(value);
return parsed === null ? null : Math.trunc(parsed);
}
/**
* Keep image models that currently have at least one worker.
* @throws {Error} when the payload is not a JSON array
*/
export function parseHordeImageModels(payload: unknown): HordeImageCatalogModel[] {
if (!Array.isArray(payload)) {
throw new Error("Horde model catalog must be a JSON array");
}
const models: HordeImageCatalogModel[] = [];
for (const item of payload) {
if (!item || typeof item !== "object") continue;
const row = item as Record<string, unknown>;
const name = row.name;
if (typeof name !== "string" || !name.trim()) continue;
const modelType = row.type ?? "image";
if (modelType !== null && modelType !== "image") continue;
const count = asInt(row.count ?? 0) ?? 0;
if (count <= 0) continue;
models.push({
name,
count,
queued: asNumber(row.queued),
eta: asInt(row.eta),
performance: asNumber(row.performance),
jobs: asNumber(row.jobs),
});
}
models.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
return models;
}
export class HordeImageCatalog {
pollMs: number;
private models = new Map<string, HordeImageCatalogModel>();
private updatedAt: number | null = null;
private lastError: string | null = null;
private inflight: Promise<void> | null = null;
private fetchImpl: HordeFetch;
constructor(options: { pollMs?: number; fetchImpl?: HordeFetch } = {}) {
this.pollMs = Math.max(5_000, options.pollMs ?? AI_HORDE_CATALOG_POLL_MS);
this.fetchImpl = options.fetchImpl ?? defaultHordeFetch;
}
get snapshot(): HordeImageCatalogSnapshot {
return {
models: this.listModels(),
updatedAt: this.updatedAt,
lastError: this.lastError,
};
}
get stale(): boolean {
return this.lastError !== null && this.updatedAt !== null;
}
listModels(): HordeImageCatalogModel[] {
return [...this.models.values()].sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
);
}
get(name: string): HordeImageCatalogModel | undefined {
return this.models.get(name);
}
isServed(name: string): boolean {
const model = this.models.get(name);
return Boolean(model && model.count > 0);
}
hasSnapshot(): boolean {
return this.updatedAt !== null;
}
replace(models: HordeImageCatalogModel[], error: string | null = null): void {
this.models = new Map(models.map((model) => [model.name, model]));
if (error === null) {
this.updatedAt = Date.now();
this.lastError = null;
} else {
this.lastError = error;
}
}
/** Drop the snapshot so the next `ensureFresh` must hit Horde. */
clear(): void {
this.models = new Map();
this.updatedAt = null;
this.lastError = null;
}
setFetch(fetchImpl: HordeFetch): void {
this.fetchImpl = fetchImpl;
}
async refresh(options: { timeoutMs?: number; signal?: AbortSignal } = {}): Promise<void> {
if (this.inflight) return this.inflight;
this.inflight = this.refreshOnce(options).finally(() => {
this.inflight = null;
});
return this.inflight;
}
async ensureFresh(
maxAgeMs = this.pollMs,
options: { timeoutMs?: number; signal?: AbortSignal } = {}
): Promise<void> {
if (this.updatedAt !== null && Date.now() - this.updatedAt < maxAgeMs && !this.lastError) {
return;
}
await this.refresh(options);
}
private async refreshOnce(options: { timeoutMs?: number; signal?: AbortSignal } = {}): Promise<void> {
try {
const url = `${AI_HORDE_API_BASE}/v2/status/models?type=image`;
const response = await this.fetchImpl(url, {
method: "GET",
headers: { Accept: "application/json", "Client-Agent": AI_HORDE_CLIENT_AGENT },
signal: options.signal,
timeoutMs: options.timeoutMs,
});
if (!response.ok) {
throw new Error(`Horde catalog HTTP ${response.status}`);
}
const models = parseHordeImageModels(await response.json());
this.replace(models);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.lastError = message;
}
}
}
export const aiHordeImageCatalog = new HordeImageCatalog();
export function resetAiHordeImageCatalog(): void {
aiHordeImageCatalog.clear();
}
export function getCachedAiHordeImageCatalogEntries(): Array<{
id: string;
name: string;
provider: string;
supportedSizes: string[];
inputModalities: string[];
description?: string;
}> {
return aiHordeImageCatalog.listModels().map((model) => ({
id: `aihorde/${model.name}`,
name: `${model.name} (AI Horde)`,
provider: "aihorde",
supportedSizes: ["512x512", "768x768", "1024x1024", "1024x768", "768x1024"],
inputModalities: ["text", "image"],
description: `${model.count} worker${model.count === 1 ? "" : "s"} online`,
}));
}

View File

@@ -551,7 +551,7 @@ export default function ProviderDetailPageClient() {
providerName={providerInfo?.name || providerId}
/>
)}
{!isUpstreamProxyProvider && !isFreeNoAuth && (
{!isUpstreamProxyProvider && (!isFreeNoAuth || providerSupportsPat) && (
<Card>
<ProviderAccountRoutingCard
providerKey={providerId}

View File

@@ -22,6 +22,7 @@ import {
getAllImageModels,
isRegisteredImageModel,
} from "@omniroute/open-sse/config/imageRegistry";
import { aiHordeImageCatalog } from "@omniroute/open-sse/services/aihordeImageCatalog";
import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry";
import { getAllAudioModels } from "@omniroute/open-sse/config/audioRegistry";
import { getAllModerationModels } from "@omniroute/open-sse/config/moderationRegistry";
@@ -1163,7 +1164,15 @@ async function buildUnifiedModelsResponseCore(
});
}
// Add image models (filtered by active providers)
// Add image models (filtered by active providers).
// AI Horde image workers come and go — refresh the live detector first.
if (isProviderActive("aihorde")) {
try {
await aiHordeImageCatalog.ensureFresh();
} catch {
// Keep the last good snapshot (or none) if Horde is unreachable.
}
}
for (const imgModel of getAllImageModels()) {
if (!isProviderActive(imgModel.provider)) continue;
const rawModelId = getSpecialtyModelRelativeId(imgModel.id, imgModel.provider);

View File

@@ -106,6 +106,7 @@ import {
bytezValidationResultFromStatus,
validateBytezProvider,
} from "./validation/webCookie";
import { validateAiHordeProvider } from "./validation/aihorde";
import {
validateV0VercelProvider,
validateAuggieProvider,
@@ -182,6 +183,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
// for parity with the "jules" cloud-agent entry above — see #6142.
devin: validateDevinCloudAgentProvider,
auggie: validateAuggieProvider,
aihorde: validateAiHordeProvider,
qoder: validateQoderProvider,
kiro: validateKiroProvider,
"command-code": validateCommandCodeProvider,

View File

@@ -0,0 +1,63 @@
/**
* AI Horde key check. The OpenAI facade at oai.aihorde.net answers /v1/models
* with 200 for any Bearer token, so the generic OpenAI-like probe always
* reports a junk key as valid. Horde's own `GET /v2/find_user` is the
* documented key lookup and returns 401/404 for unknown keys.
*/
import {
AI_HORDE_ANONYMOUS_KEY,
AI_HORDE_API_BASE,
AI_HORDE_CLIENT_AGENT,
} from "@omniroute/open-sse/services/aihordeImageCatalog.ts";
import { toValidationErrorResult, validationRead } from "./transport";
type HordeFetch = typeof validationRead;
export async function validateAiHordeProvider({
apiKey,
fetchImpl = validationRead,
}: {
apiKey?: unknown;
fetchImpl?: HordeFetch;
}) {
const key = typeof apiKey === "string" ? apiKey.trim() : "";
if (!key) {
return { valid: true, error: null, method: "aihorde_anonymous" };
}
try {
const response = await fetchImpl(`${AI_HORDE_API_BASE}/v2/find_user`, {
method: "GET",
headers: {
apikey: key,
Accept: "application/json",
"Client-Agent": AI_HORDE_CLIENT_AGENT,
},
});
if (response.status === 401 || response.status === 403 || response.status === 404) {
return { valid: false, error: "Invalid API key" };
}
if (!response.ok) {
return { valid: false, error: `Horde validation failed (${response.status})` };
}
const body = await response.json().catch(() => null);
if (!body || typeof body !== "object") {
return { valid: false, error: "Horde validation returned an unexpected body" };
}
const username = (body as { username?: unknown }).username;
if (typeof username !== "string" || !username.trim()) {
return { valid: false, error: "Invalid API key" };
}
return {
valid: true,
error: null,
method: key === AI_HORDE_ANONYMOUS_KEY ? "aihorde_anonymous" : "aihorde_find_user",
};
} catch (error) {
return toValidationErrorResult(error);
}
}

View File

@@ -39,6 +39,10 @@ export const FREE_APIKEY_PROVIDER_IDS = new Set([
"auggie",
// zcode is a local app-server backend; auth stays in the ZCode profile.
"zcode",
// AI Horde works anonymously (`0000000000`) and also accepts a free registered
// key for higher queue priority. The no-auth page still enables the provider;
// this flag admits an optional apikey connection so that stored key is used.
"aihorde",
]);
export function supportsApiKeyOnFreeProvider(providerId: unknown): boolean {

View File

@@ -173,7 +173,7 @@ export const NOAUTH_PROVIDERS = {
freeNote:
"Crowdsourced inference from volunteer GPUs. Throughput is a shared queue, not a quota: there is no RPM/RPD cap, but waits grow when the network is busy.",
notice: {
text: "AI Horde routes to volunteer-run workers, so responses can take minutes and tool calling is unavailable. Model availability changes as workers come and go.",
text: "AI Horde routes to volunteer-run workers, so chat and image jobs can take minutes and tool calling is unavailable. Chat models come from the live oai.aihorde.net catalog. Image models are listed only while Horde reports at least one worker. An optional aihorde.net API key raises queue priority (kudos).",
},
},
};

View File

@@ -98,6 +98,7 @@ import {
} from "./noAuthProviderSettings";
import { resolveAccountProxiesFromRegistry } from "./noAuthProxyResolution";
import { getNoAuthHydrationProviderIds } from "./noAuthProviderSiblings";
import { loadOptionalNoAuthApiKeyCredentials } from "./noAuthOptionalApiKey";
import { getResource404Bypass } from "./requestResourceHealth";
import { isVertexConnectionWidePermissionDenied } from "./vertexErrorClassifier";
import * as log from "../utils/logger";
@@ -1096,6 +1097,15 @@ export async function getProviderCredentials(
excludeConnectionId,
options.excludeConnectionIds
);
const optionalKey = await loadOptionalNoAuthApiKeyCredentials(resolvedId, excludedForNoAuth);
if (
optionalKey &&
(!allowedConnections ||
allowedConnections.length === 0 ||
allowedConnections.includes(optionalKey.connectionId))
) {
return optionalKey;
}
// #9057: when allowedConnections is set, the synthetic "noauth" connection
// is never in the explicit allowlist, so we must NOT return it — fall through
// to the normal connection-selection path so the connection allowlist is

View File

@@ -0,0 +1,127 @@
/**
* Optional API keys on no-auth providers (AI Horde).
*
* `getProviderCredentials` short-circuits no-auth providers to a synthetic
* `connectionId: "noauth"` row so they work with nothing configured. That
* skipped stored connections, so a registered Horde key could be saved and
* still never sent. When a no-auth provider also accepts an optional key
* (`anonymousApiKey` and/or FREE_APIKEY), prefer an active connection that
* actually has a key, then fall back to the synthetic anonymous path.
*/
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
import { isAccountUnavailable } from "@omniroute/open-sse/services/accountFallback.ts";
import { createLazyConnectionView } from "@/lib/db/providers/lazyConnectionView";
import type { ProviderConnectionView } from "@/lib/db/providers/lazyConnectionView";
import { getCachedRawProviderConnections } from "@/lib/db/readCache";
import { supportsApiKeyOnFreeProvider } from "@/shared/constants/providers";
export function noAuthProviderAcceptsOptionalApiKey(providerId: string): boolean {
if (supportsApiKeyOnFreeProvider(providerId)) return true;
const entry = REGISTRY[providerId] as { anonymousApiKey?: string } | undefined;
return Boolean(entry?.anonymousApiKey);
}
function hasUsableApiKey(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
// Terminal statuses stay unavailable until credentials/settings change — an
// operator reset, not a cooldown expiry, clears them (see auth.ts's
// isTerminalConnectionStatus, which this mirrors for the optional-key path).
const TERMINAL_TEST_STATUSES = new Set(["credits_exhausted", "banned", "expired"]);
/**
* A stored optional key is only usable when it passes the same connection
* health checks the normal credential-selection path enforces: not in an
* active rate-limit/cooldown window (`rateLimitedUntil`), and not parked in
* a terminal or transient-unavailable `testStatus`. Without this, a
* rate-limited or banned stored Horde key could get selected here — bypassing
* cooldown entirely — instead of falling back to the anonymous no-auth path
* or rotating to the next healthy key.
*/
function isConnectionHealthy(connection: ProviderConnectionView): boolean {
if (isAccountUnavailable(connection.rateLimitedUntil)) return false;
const status = (connection.testStatus || "").trim().toLowerCase();
if (TERMINAL_TEST_STATUSES.has(status)) return false;
if (status === "unavailable") return false;
return true;
}
export async function loadOptionalNoAuthApiKeyCredentials(
providerId: string,
excludedConnectionIds: Set<string>
): Promise<{
apiKey: string;
accessToken: null;
refreshToken: null;
expiresAt: null;
projectId: null;
defaultModel: string | null;
copilotToken: null;
providerSpecificData: Record<string, unknown>;
id: string;
provider: string;
connectionId: string;
testStatus: string | null;
lastError: null;
lastErrorType: null;
lastErrorSource: null;
errorCode: null;
rateLimitedUntil: null;
maxConcurrent: null;
} | null> {
if (!noAuthProviderAcceptsOptionalApiKey(providerId)) return null;
let connectionsRaw: unknown;
try {
connectionsRaw = await getCachedRawProviderConnections({
provider: providerId,
isActive: true,
});
} catch {
return null;
}
const connections = (Array.isArray(connectionsRaw) ? connectionsRaw : [])
.map(createLazyConnectionView)
.filter(
(conn) =>
conn.id.length > 0 &&
!excludedConnectionIds.has(conn.id) &&
conn.isActive !== false &&
hasUsableApiKey(conn.apiKey)
)
.sort((a, b) => (a.priority || 999) - (b.priority || 999));
// Rotate past unhealthy (cooling-down/terminal) stored keys instead of
// handing one back regardless of health. If every candidate is unhealthy,
// fall through to the caller's anonymous/synthetic no-auth fallback.
const connection = connections.find(isConnectionHealthy);
if (!connection || !hasUsableApiKey(connection.apiKey)) return null;
const providerSpecificData =
connection.providerSpecificData && typeof connection.providerSpecificData === "object"
? (connection.providerSpecificData as Record<string, unknown>)
: {};
return {
apiKey: connection.apiKey.trim(),
accessToken: null,
refreshToken: null,
expiresAt: null,
projectId: null,
defaultModel: connection.defaultModel || null,
copilotToken: null,
providerSpecificData,
id: connection.id,
provider: connection.provider || providerId,
connectionId: connection.id,
testStatus: connection.testStatus ?? "active",
lastError: null,
lastErrorType: null,
lastErrorSource: null,
errorCode: null,
rateLimitedUntil: null,
maxConcurrent: null,
};
}

View File

@@ -0,0 +1,104 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
HordeImageCatalog,
parseHordeImageModels,
resetAiHordeImageCatalog,
getCachedAiHordeImageCatalogEntries,
aiHordeImageCatalog,
} from "../../open-sse/services/aihordeImageCatalog.ts";
import {
parseImageModel,
getImageProvider,
getAllImageModels,
} from "../../open-sse/config/imageRegistry.ts";
const HORDE_MODELS = [
{
name: "FLUX.1-schnell",
count: 6,
queued: 2,
eta: 12,
performance: 18.5,
type: "image",
},
{
name: "AlbedoBase XL (SDXL)",
count: 1,
queued: 0,
eta: 4,
type: "image",
},
{ name: "DeadModel", count: 0, type: "image" },
{ name: "koboldcpp/Gemma", count: 3, type: "text" },
];
test.afterEach(() => {
resetAiHordeImageCatalog();
});
test("parseHordeImageModels drops zero-worker and text models", () => {
const models = parseHordeImageModels(HORDE_MODELS);
assert.deepEqual(
models.map((model) => model.name),
["AlbedoBase XL (SDXL)", "FLUX.1-schnell"]
);
assert.equal(models[1].count, 6);
});
test("parseHordeImageModels rejects a non-array payload", () => {
assert.throws(() => parseHordeImageModels({ name: "nope" }), /JSON array/);
});
test("refresh keeps the last good snapshot when Horde is down", async () => {
let calls = 0;
const catalog = new HordeImageCatalog({
pollMs: 5_000,
fetchImpl: async () => {
calls += 1;
if (calls === 1) {
return new Response(JSON.stringify(HORDE_MODELS), { status: 200 });
}
return new Response(JSON.stringify({ message: "maintenance" }), { status: 503 });
},
});
await catalog.refresh();
assert.equal(catalog.isServed("FLUX.1-schnell"), true);
assert.equal(catalog.snapshot.lastError, null);
await catalog.refresh();
assert.equal(catalog.isServed("FLUX.1-schnell"), true);
assert.equal(catalog.stale, true);
assert.ok(catalog.snapshot.lastError);
assert.equal(
catalog.listModels().some((model) => model.name === "DeadModel"),
false
);
});
test("live catalog entries use exact Horde names and the aihorde prefix", async () => {
aiHordeImageCatalog.setFetch(
async () => new Response(JSON.stringify(HORDE_MODELS), { status: 200 })
);
await aiHordeImageCatalog.refresh();
const ids = getCachedAiHordeImageCatalogEntries().map((model) => model.id);
assert.deepEqual(ids, ["aihorde/AlbedoBase XL (SDXL)", "aihorde/FLUX.1-schnell"]);
const listed = getAllImageModels().map((model) => model.id);
assert.ok(listed.includes("aihorde/FLUX.1-schnell"));
assert.equal(listed.includes("aihorde/DeadModel"), false);
});
test("parseImageModel accepts aihorde/ and horde/ prefixes for live names", () => {
assert.deepEqual(parseImageModel("aihorde/Flux.1-Schnell fp8 (Compact)"), {
provider: "aihorde",
model: "Flux.1-Schnell fp8 (Compact)",
});
assert.deepEqual(parseImageModel("horde/AlbedoBase XL (SDXL)"), {
provider: "aihorde",
model: "AlbedoBase XL (SDXL)",
});
assert.equal(getImageProvider("aihorde")?.format, "aihorde");
assert.equal(getImageProvider("aihorde")?.alias, "horde");
});

View File

@@ -0,0 +1,271 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-aihorde-image-"));
import {
capHordeN,
mapHordeGenerateRequest,
parseHordeSize,
stripHordeModelPrefix,
} from "../../open-sse/handlers/imageGeneration/providers/aihordeMapRequest.ts";
import { handleAiHordeImageGeneration } from "../../open-sse/handlers/imageGeneration/providers/aihorde.ts";
import { handleImageGeneration } from "../../open-sse/handlers/imageGeneration.ts";
import { aiHordeImageCatalog } from "../../open-sse/services/aihordeImageCatalog.ts";
test("map helpers snap size, cap n, and strip prefixes", () => {
assert.equal(stripHordeModelPrefix("aihorde/FLUX.1-schnell"), "FLUX.1-schnell");
assert.equal(stripHordeModelPrefix("horde/AlbedoBase XL (SDXL)"), "AlbedoBase XL (SDXL)");
assert.deepEqual(parseHordeSize("1000x1000"), { width: 1024, height: 1024 });
assert.equal(capHordeN(9), 4);
});
test("mapHordeGenerateRequest builds a native Horde payload", () => {
const payload = mapHordeGenerateRequest({
model: "aihorde/FLUX.1-schnell",
prompt: "a red fox in snow",
n: 2,
size: "1024x768",
});
assert.equal(payload.prompt, "a red fox in snow");
assert.deepEqual(payload.models, ["FLUX.1-schnell"]);
assert.equal((payload.params as { n: number }).n, 2);
assert.equal((payload.params as { width: number }).width, 1024);
assert.equal((payload.params as { height: number }).height, 768);
assert.equal(payload.r2, true);
});
test("handleAiHordeImageGeneration rejects a model with zero workers", async () => {
aiHordeImageCatalog.replace([
{ name: "AlbedoBase XL (SDXL)", count: 1, queued: 0, eta: 1, performance: 1, jobs: 0 },
]);
const result = await handleAiHordeImageGeneration({
model: "FLUX.1-schnell",
provider: "aihorde",
body: { model: "aihorde/FLUX.1-schnell", prompt: "fox" },
credentials: { apiKey: "horde-key" },
});
assert.equal(result.success, false);
assert.equal(result.status, 400);
assert.match(String(result.error), /No Horde workers/);
});
test("exceeding the deadline issues a DELETE cancel to Horde, not just a local timeout", async () => {
const originalFetch = globalThis.fetch;
const calls: Array<{ method: string; url: string }> = [];
aiHordeImageCatalog.replace([
{ name: "FLUX.1-schnell", count: 3, queued: 0, eta: 1, performance: 1, jobs: 0 },
]);
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
const url = String(input);
const method = (init?.method || "GET").toUpperCase();
calls.push({ method, url });
if (url.includes("/v2/generate/async")) {
return new Response(JSON.stringify({ id: "job-timeout" }), { status: 202 });
}
if (method === "DELETE" && url.includes("/v2/generate/status/")) {
return new Response(JSON.stringify({ id: "job-timeout" }), { status: 200 });
}
if (url.includes("/v2/generate/check/")) {
// Never reports done — the generation deadline must be what ends the loop.
return new Response(JSON.stringify({ done: false, is_possible: true, faulted: false }), {
status: 200,
});
}
return new Response("unexpected", { status: 500 });
}) as typeof fetch;
try {
const result = await handleAiHordeImageGeneration({
model: "FLUX.1-schnell",
provider: "aihorde",
body: { model: "aihorde/FLUX.1-schnell", prompt: "a red fox in snow" },
credentials: { apiKey: "horde-key" },
// Small enough that the poll loop's 1s interval crosses the deadline
// on its first iteration, but non-zero so submit itself isn't rejected.
timeoutMs: 50,
});
assert.equal(result.success, false);
assert.equal(result.status, 504);
assert.match(String(result.error), /timed out/);
const cancelCall = calls.find(
(call) => call.method === "DELETE" && call.url.includes("/v2/generate/status/job-timeout")
);
assert.ok(cancelCall, "expected a DELETE cancel call to Horde's status endpoint");
} finally {
globalThis.fetch = originalFetch;
}
});
test("a private-host R2 image URL is blocked by the SSRF guard, not fetched", async () => {
const originalFetch = globalThis.fetch;
let downloadAttempted = false;
aiHordeImageCatalog.replace([
{ name: "FLUX.1-schnell", count: 3, queued: 0, eta: 1, performance: 1, jobs: 0 },
]);
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
const url = String(input);
const method = (init?.method || "GET").toUpperCase();
if (url.includes("/v2/generate/async")) {
return new Response(JSON.stringify({ id: "job-ssrf" }), { status: 202 });
}
if (method === "DELETE") {
return new Response("{}", { status: 200 });
}
if (url.includes("/v2/generate/check/")) {
return new Response(JSON.stringify({ done: true, is_possible: true, faulted: false }), {
status: 200,
});
}
if (url.includes("/v2/generate/status/")) {
return new Response(
JSON.stringify({ generations: [{ img: "http://127.0.0.1:9999/internal-secret.png" }] }),
{ status: 200 }
);
}
// A real HTTP fetch reaching the private host means the guard failed to
// block it before the network call.
downloadAttempted = true;
return new Response("unexpected", { status: 500 });
}) as typeof fetch;
try {
const result = await handleAiHordeImageGeneration({
model: "FLUX.1-schnell",
provider: "aihorde",
body: { model: "aihorde/FLUX.1-schnell", prompt: "a red fox in snow" },
credentials: { apiKey: "horde-key" },
});
assert.equal(result.success, false);
assert.equal(downloadAttempted, false, "the private-host URL must never reach fetch()");
} finally {
globalThis.fetch = originalFetch;
}
});
test("an oversized R2 image download is rejected instead of buffered whole", async () => {
const originalFetch = globalThis.fetch;
aiHordeImageCatalog.replace([
{ name: "FLUX.1-schnell", count: 3, queued: 0, eta: 1, performance: 1, jobs: 0 },
]);
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
const url = String(input);
const method = (init?.method || "GET").toUpperCase();
if (url.includes("/v2/generate/async")) {
return new Response(JSON.stringify({ id: "job-oversized" }), { status: 202 });
}
if (method === "DELETE") {
return new Response("{}", { status: 200 });
}
if (url.includes("/v2/generate/check/")) {
return new Response(JSON.stringify({ done: true, is_possible: true, faulted: false }), {
status: 200,
});
}
// A raw public IP literal (not a hostname) skips the SSRF guard's real DNS
// lookup entirely — this test only cares about the byte-cap, not the host
// resolution path (already covered by the private-host test above), and
// the sandboxed test env has no DNS egress.
if (url.includes("/v2/generate/status/")) {
return new Response(
JSON.stringify({ generations: [{ img: "https://93.184.216.34/huge.png" }] }),
{ status: 200 }
);
}
if (url.includes("93.184.216.34")) {
return new Response("x", {
status: 200,
headers: { "content-length": String(30 * 1024 * 1024) },
});
}
return new Response("unexpected", { status: 500 });
}) as typeof fetch;
try {
const result = await handleAiHordeImageGeneration({
model: "FLUX.1-schnell",
provider: "aihorde",
body: { model: "aihorde/FLUX.1-schnell", prompt: "a red fox in snow" },
credentials: { apiKey: "horde-key" },
});
assert.equal(result.success, false);
assert.match(String(result.error), /exceeds|byte limit|too large/);
} finally {
globalThis.fetch = originalFetch;
}
});
test("handleImageGeneration dispatches aihorde and sends the apikey header", async () => {
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; headers: Record<string, string>; body?: unknown }> = [];
aiHordeImageCatalog.replace([
{ name: "FLUX.1-schnell", count: 3, queued: 0, eta: 1, performance: 1, jobs: 0 },
]);
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
const url = String(input);
const headers = Object.fromEntries(new Headers(init?.headers).entries());
let body: unknown;
if (typeof init?.body === "string") {
try {
body = JSON.parse(init.body);
} catch {
body = init.body;
}
}
calls.push({ url, headers, body });
if (url.includes("/v2/generate/async")) {
return new Response(JSON.stringify({ id: "job-1" }), { status: 202 });
}
if (url.includes("/v2/generate/check/")) {
return new Response(JSON.stringify({ done: true, is_possible: true, faulted: false }), {
status: 200,
});
}
if (url.includes("/v2/generate/status/")) {
return new Response(
JSON.stringify({ generations: [{ img: Buffer.from("png-bytes").toString("base64") }] }),
{ status: 200 }
);
}
return new Response("unexpected", { status: 500 });
}) as typeof fetch;
try {
const result = await handleImageGeneration({
body: { model: "aihorde/FLUX.1-schnell", prompt: "a red fox in snow", size: "1024x1024" },
credentials: { apiKey: "horde-registered-key" },
log: null,
});
assert.equal(result.success, true);
const submit = calls.find((call) => call.url.includes("/v2/generate/async"));
assert.ok(submit);
assert.equal(submit.headers.apikey, "horde-registered-key");
assert.ok(submit.headers["client-agent"]);
assert.deepEqual((submit.body as { models: string[] }).models, ["FLUX.1-schnell"]);
assert.equal(
(result as { data: { data: Array<{ b64_json: string }> } }).data.data[0].b64_json,
Buffer.from("png-bytes").toString("base64")
);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -0,0 +1,71 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { validateAiHordeProvider } from "../../src/lib/providers/validation/aihorde.ts";
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
test("empty Horde key is valid because the provider is optional", async () => {
const result = await validateAiHordeProvider({
apiKey: " ",
fetchImpl: async () => {
throw new Error("find_user must not run when no key is pasted");
},
});
assert.equal(result.valid, true);
assert.equal(result.method, "aihorde_anonymous");
});
test("junk Horde key is rejected by find_user 404", async () => {
const result = await validateAiHordeProvider({
apiKey: "junk-key-not-real",
fetchImpl: async () =>
jsonResponse(404, {
message: "User with api_key 'junk-key-not-real' not found.",
rc: "UserNotFound",
}),
});
assert.equal(result.valid, false);
assert.equal(result.error, "Invalid API key");
});
test("missing Horde key header is rejected by find_user 401", async () => {
const result = await validateAiHordeProvider({
apiKey: "not-a-real-key",
fetchImpl: async () =>
jsonResponse(401, { message: "No user matching sent API Key.", rc: "InvalidAPIKey" }),
});
assert.equal(result.valid, false);
assert.equal(result.error, "Invalid API key");
});
test("registered Horde key is accepted when find_user returns a username", async () => {
let sentKey = "";
let sentUrl = "";
const result = await validateAiHordeProvider({
apiKey: "horde-registered-key-123",
fetchImpl: async (url, init) => {
sentUrl = String(url);
sentKey = new Headers(init?.headers).get("apikey") || "";
return jsonResponse(200, { username: "tester#1234", kudos: 100 });
},
});
assert.equal(result.valid, true);
assert.equal(result.method, "aihorde_find_user");
assert.equal(sentKey, "horde-registered-key-123");
assert.match(sentUrl, /\/v2\/find_user$/);
});
test("validation.ts registers the Horde find_user specialty validator", () => {
const src = readFileSync(
new URL("../../src/lib/providers/validation.ts", import.meta.url),
"utf8"
);
assert.match(src, /aihorde:\s*validateAiHordeProvider/);
});

View File

@@ -0,0 +1,140 @@
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-aihorde-key-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "aihorde-optional-key-test-secret";
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const { getProviderCredentials } = await import("../../src/sse/services/auth.ts");
const { supportsApiKeyOnFreeProvider, providerAllowsOptionalApiKey } =
await import("../../src/shared/constants/providers.ts");
const { getCredentialRequirement } =
await import("../../src/shared/utils/providerCredentialRequirement.ts");
const { DefaultExecutor } = await import("../../open-sse/executors/default.ts");
const { isManagedProviderConnectionId } = await import("../../src/lib/providers/catalog.ts");
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("aihorde treats a registered key as optional, not required", () => {
assert.equal(providerAllowsOptionalApiKey("aihorde"), true);
assert.equal(supportsApiKeyOnFreeProvider("aihorde"), true);
assert.equal(getCredentialRequirement("aihorde"), "optional");
assert.equal(isManagedProviderConnectionId("aihorde"), true);
});
test("aihorde without a stored key still uses the synthetic no-auth path", async () => {
const creds = await getProviderCredentials("aihorde");
assert.ok(creds);
assert.equal((creds as { connectionId?: string }).connectionId, "noauth");
assert.equal((creds as { apiKey?: unknown }).apiKey, null);
});
let registeredKeyConnectionId: string | undefined;
test("aihorde prefers a stored API key over the anonymous fallback", async () => {
const created = await providersDb.createProviderConnection({
provider: "aihorde",
authType: "apikey",
name: "Horde kudos key",
apiKey: "horde-registered-key-123",
});
assert.ok(created?.id);
registeredKeyConnectionId = created.id;
const creds = await getProviderCredentials("aihorde");
assert.ok(creds);
assert.equal((creds as { apiKey?: string }).apiKey, "horde-registered-key-123");
assert.equal((creds as { connectionId?: string }).connectionId, created.id);
});
test("aihorde falls back to anonymous when the only stored key is rate-limited", async () => {
// Deactivate the healthy connection from the prior test so it cannot mask
// the fallback behavior being exercised here.
assert.ok(registeredKeyConnectionId);
await providersDb.updateProviderConnection(registeredKeyConnectionId, { isActive: false });
const created = await providersDb.createProviderConnection({
provider: "aihorde",
authType: "apikey",
name: "Horde cooling-down key",
apiKey: "horde-cooling-down-key",
});
assert.ok(created?.id);
await providersDb.updateProviderConnection(created.id, {
rateLimitedUntil: new Date(Date.now() + 60_000).toISOString(),
testStatus: "unavailable",
});
const creds = await getProviderCredentials("aihorde");
assert.ok(creds);
assert.equal((creds as { connectionId?: string }).connectionId, "noauth");
assert.equal((creds as { apiKey?: unknown }).apiKey, null);
});
test("aihorde falls back to anonymous when the only stored key is terminally banned", async () => {
const created = await providersDb.createProviderConnection({
provider: "aihorde",
authType: "apikey",
name: "Horde banned key",
apiKey: "horde-banned-key",
});
assert.ok(created?.id);
await providersDb.updateProviderConnection(created.id, { testStatus: "banned" });
const creds = await getProviderCredentials("aihorde");
assert.ok(creds);
assert.equal((creds as { connectionId?: string }).connectionId, "noauth");
assert.equal((creds as { apiKey?: unknown }).apiKey, null);
});
test("aihorde rotates past an unhealthy stored key to the next healthy one", async () => {
const unhealthy = await providersDb.createProviderConnection({
provider: "aihorde",
authType: "apikey",
name: "Horde unhealthy key",
apiKey: "horde-unhealthy-key",
priority: 1,
});
assert.ok(unhealthy?.id);
await providersDb.updateProviderConnection(unhealthy.id, {
rateLimitedUntil: new Date(Date.now() + 60_000).toISOString(),
testStatus: "unavailable",
});
const healthy = await providersDb.createProviderConnection({
provider: "aihorde",
authType: "apikey",
name: "Horde healthy key",
apiKey: "horde-healthy-key",
priority: 2,
});
assert.ok(healthy?.id);
const creds = await getProviderCredentials("aihorde");
assert.ok(creds);
assert.equal((creds as { apiKey?: string }).apiKey, "horde-healthy-key");
assert.equal((creds as { connectionId?: string }).connectionId, healthy.id);
});
test("DefaultExecutor uses a stored Horde key for chat, else the anonymous key", () => {
const executor = new DefaultExecutor("aihorde");
const withKey = executor.buildHeaders(
{ apiKey: "horde-registered-key-123", accessToken: null } as never,
true
) as Record<string, string>;
assert.equal(withKey.Authorization, "Bearer horde-registered-key-123");
const anonymous = executor.buildHeaders(
{ apiKey: null, accessToken: null } as never,
true
) as Record<string, string>;
assert.equal(anonymous.Authorization, "Bearer 0000000000");
});