mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 05:32:19 +03:00
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:
@@ -551,7 +551,7 @@ export default function ProviderDetailPageClient() {
|
||||
providerName={providerInfo?.name || providerId}
|
||||
/>
|
||||
)}
|
||||
{!isUpstreamProxyProvider && !isFreeNoAuth && (
|
||||
{!isUpstreamProxyProvider && (!isFreeNoAuth || providerSupportsPat) && (
|
||||
<Card>
|
||||
<ProviderAccountRoutingCard
|
||||
providerKey={providerId}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
63
src/lib/providers/validation/aihorde.ts
Normal file
63
src/lib/providers/validation/aihorde.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
127
src/sse/services/noAuthOptionalApiKey.ts
Normal file
127
src/sse/services/noAuthOptionalApiKey.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user