Files
OmniRoute/src/sse/services/noAuthOptionalApiKey.ts
pageragatz b9cd5ed138 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>
2026-08-18 10:51:57 -03:00

128 lines
4.7 KiB
TypeScript

/**
* 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,
};
}