diff --git a/open-sse/config/constants.ts b/open-sse/config/constants.ts index f310f8ab2c..9b4aeaf720 100644 --- a/open-sse/config/constants.ts +++ b/open-sse/config/constants.ts @@ -135,6 +135,7 @@ export const COOLDOWN_MS = { unauthorized: 2 * 60 * 1000, // 401 → 2 min paymentRequired: 2 * 60 * 1000, // 402/403 → 2 min notFound: 2 * 60 * 1000, // 404 → 2 minutes + notFoundLocal: 5 * 1000, // 404 on local provider → 5s model-only lockout (connection stays active) transientInitial: 5 * 1000, // 408/500/502/503/504 first hit → 5s (backoff from here) transientMax: 60 * 1000, // 502/503/504 backoff ceiling → 60s transient: 5 * 1000, // Legacy alias → points to transientInitial @@ -162,6 +163,16 @@ export const PROVIDER_PROFILES = { circuitBreakerThreshold: 5, // More tolerant (occasional 502 is normal) circuitBreakerReset: 30000, // 30s reset }, + // Local providers (localhost inference backends like Ollama, LM Studio, oMLX). + // Not yet wired into getProviderProfile() — will be used when local provider_nodes + // are integrated into the resilience layer. Kept here to avoid a second constants change. + local: { + transientCooldown: 2000, // 2s (local — very fast recovery) + rateLimitCooldown: 5000, // 5s (local — no real rate limits) + maxBackoffLevel: 3, // Low ceiling (local either works or doesn't) + circuitBreakerThreshold: 2, // Opens fast (if local is down, it's down) + circuitBreakerReset: 15000, // 15s reset (check again quickly) + }, }; // Default rate limit values for API Key providers (auto-enabled safety net) diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 9f56748fd7..7b163acffc 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1057,6 +1057,38 @@ export function generateAliasMap(): Record { return map; } +// ── Local Provider Detection ────────────────────────────────────────────── + +// Evaluated once at module load time — process restart required for env var changes. +const LOCAL_HOSTNAMES = new Set([ + "localhost", + "127.0.0.1", + "::1", + "[::1]", + ...(typeof process !== "undefined" && process.env.LOCAL_HOSTNAMES + ? process.env.LOCAL_HOSTNAMES.split(",") + .map((h) => h.trim()) + .filter(Boolean) + : []), +]); + +/** + * Detect if a base URL points to a local inference backend. + * Used for shorter 404 cooldowns (model-only, not connection) and health check targets. + * + * Operators can extend via LOCAL_HOSTNAMES env var (comma-separated) for Docker + * hostnames (e.g., LOCAL_HOSTNAMES=omlx,mlx-audio). + */ +export function isLocalProvider(baseUrl?: string | null): boolean { + if (!baseUrl) return false; + try { + const url = new URL(baseUrl); + return LOCAL_HOSTNAMES.has(url.hostname); + } catch { + return false; + } +} + // ── Registry Lookup Helpers ─────────────────────────────────────────────── const _byAlias = new Map(); diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index f10c5077d4..bd0b0e59c8 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -382,7 +382,8 @@ async function handleSingleModelChat( credentials.connectionId, result.status, result.error, - provider + provider, + model ); if (shouldFallback) { diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index ac8bd67785..d4f6ead196 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -14,6 +14,8 @@ import { isModelLocked, lockModel, } from "@omniroute/open-sse/services/accountFallback.ts"; +import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { COOLDOWN_MS } from "@omniroute/open-sse/config/constants.ts"; import * as log from "../utils/logger"; import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck"; @@ -563,6 +565,23 @@ export async function markAccountUnavailable( ); if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 }; + // ── Local provider 404: model-only lockout, connection stays active ── + // Detection: URL-based only (apiKey===null heuristic was too broad — could match + // cloud providers with non-standard auth stored in providerSpecificData). + const connBaseUrl = (conn?.providerSpecificData as Record)?.baseUrl as + | string + | undefined; + + if (isLocalProvider(connBaseUrl) && status === 404 && provider && model) { + const localCooldown = COOLDOWN_MS.notFoundLocal; + lockModel(provider, connectionId, model, "local_not_found", localCooldown); + log.info( + "AUTH", + `Local 404 for ${model} — model-only lockout ${localCooldown / 1000}s (connection stays active)` + ); + return { shouldFallback: true, cooldownMs: localCooldown }; + } + const rateLimitedUntil = getUnavailableUntil(cooldownMs); const errorMsg = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";