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

@@ -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,
};
}