Add Spark Control hardware backend (diarize, queue, discovery)

This commit is contained in:
Keysat
2026-06-13 13:36:04 -05:00
parent 705807e286
commit 84d56c94c9
6 changed files with 1435 additions and 172 deletions
+207
View File
@@ -0,0 +1,207 @@
// Optional service-discovery client. When the operator points
// relay_spark_control_url at a Spark Control (or compatible) endpoint
// that serves a JSON document describing the local LLM/STT/TTS
// services, the relay uses that to fill in any URL + model fields
// the operator left blank in their per-backend config.
//
// Expected JSON shape (Spark Control's /api/endpoints):
// {
// "vllm": { ready: bool, base_url: "...", model: "...", openai_compat: bool },
// "parakeet": { ready: bool, base_url: "...", kind: "stt", model: "..." },
// "kokoro": { ready: bool, base_url: "...", kind: "tts" }
// }
//
// Cached for SHORT_CACHE_TTL_MS to keep relay responses snappy while
// still picking up model swaps on the operator's GPU box within a
// minute. Unreachable / failing endpoint falls back to operator-typed
// values silently — no boot-time hard dep.
import { lanFetch } from "./lan-fetch.js";
const SHORT_CACHE_TTL_MS = 60 * 1000; // 60s
const FETCH_TIMEOUT_MS = 3000;
let cached = { fetched_at: 0, url: null, discovery: null };
// Last error from a discovery fetch attempt — surfaced in the
// dashboard so the operator can see when discovery is silently
// failing (the alternative is "operator-typed values silently win"
// which produces confusing "fetch failed" errors downstream when the
// override URL is also broken). Cleared on each successful fetch.
let lastError = { at: 0, message: null };
// Fire-and-forget background refresh while serving from cache —
// callers never block on the network. The first call after the cache
// expires returns the stale snapshot but kicks off a refresh in the
// background, so the next call gets the fresh data without ever
// having paid a round-trip in the critical path.
let inflight = null;
// Operator-visible discovery snapshot used by the admin dashboard.
// Returns: { configured, url, lastFetched, lastError, services }
// - configured: whether the operator set a discovery URL at all
// - url: the configured URL (or null)
// - lastFetched: epoch ms of last successful fetch (or 0)
// - lastError: { at, message } of last failed fetch (cleared on success)
// - services: parsed map of { parakeet: {...}, vllm: {...}, ... }
// when the last fetch succeeded; null otherwise.
//
// `configured` reflects the operator's config; the other fields
// reflect what actually happened on the network. Read-only — never
// triggers a fetch, just reports the cached state. The dashboard's
// Settings tab renders this so the operator can spot a silently-
// failing discovery (typo, unreachable host, TLS cert that the
// container doesn't trust, etc.) without grepping container logs.
export function getSparkDiscoveryStatus(configuredUrl) {
const url = (configuredUrl || "").trim();
return {
configured: !!url,
url: url || null,
lastFetched: cached.url === url ? cached.fetched_at : 0,
lastError: cached.url === url && lastError.at > cached.fetched_at
? { at: lastError.at, message: lastError.message }
: null,
services: cached.url === url && cached.discovery
? Object.fromEntries(
Object.entries(cached.discovery).map(([k, v]) => [
k,
{
ready: !!(v && v.ready),
base_url: v && v.base_url ? String(v.base_url) : null,
model: v && v.model ? String(v.model) : null,
kind: v && v.kind ? String(v.kind) : null,
},
])
)
: null,
};
}
export async function getSparkControlDiscovery(url) {
if (!url) return null;
// If the URL changed (operator updated config), invalidate.
if (cached.url !== url) {
cached = { fetched_at: 0, url, discovery: null };
}
const fresh = Date.now() - cached.fetched_at < SHORT_CACHE_TTL_MS;
if (fresh && cached.discovery) {
return cached.discovery;
}
// Stale (or never fetched). Trigger a background refresh; return
// whatever we currently have (might be null on cold boot).
if (!inflight) {
inflight = fetchDiscovery(url)
.then((discovery) => {
cached = { fetched_at: Date.now(), url, discovery };
lastError = { at: 0, message: null };
})
.catch((err) => {
const msg = err?.message || String(err);
lastError = { at: Date.now(), message: msg };
console.warn(`[spark-control] discovery fetch failed for ${url}: ${msg}`);
})
.finally(() => {
inflight = null;
});
}
// On cold start, wait briefly for the first fetch so we don't serve
// a request with null discovery if Spark Control is alive.
if (!cached.discovery) {
try {
await Promise.race([
inflight,
new Promise((r) => setTimeout(r, FETCH_TIMEOUT_MS + 500)),
]);
} catch {}
}
return cached.discovery;
}
async function fetchDiscovery(url) {
// lanFetch uses an undici Agent with rejectUnauthorized:false so
// that Spark Control's StartOS Local Intermediate CA cert (the
// standard Start9 LAN setup) doesn't fail TLS validation in the
// relay container. Plain-http LAN URLs work through the same path
// without TLS overhead. Public-internet calls (Gemini, Keysat,
// BTCPay) keep using the global fetch with full cert validation
// — see lan-fetch.js for the scope rationale.
const res = await lanFetch(url, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
redirect: "follow",
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return await res.json();
}
// Given a Spark Control URL and a "kind" hint, return the live
// service state. Three outcomes:
//
// { status: "ready", base_url, model }
// Service is up + healthy. Use these values.
//
// { status: "not_ready", reason }
// Service is listed in discovery but ready=false. A model swap
// might be in progress; or the operator hasn't loaded a model
// yet; or the wrapper crashed. Route handlers should fail fast
// with this message so the user knows what to fix on Spark
// Control instead of seeing a generic 500.
//
// { status: "unknown" }
// Discovery URL not configured, not reachable, or the requested
// service isn't in the response. Caller should fall back to
// operator-typed config.
//
// kind: "transcribe" → uses .parakeet (any STT-shaped entry)
// kind: "analyze" → uses .vllm (any OpenAI-compat LLM entry)
// kind: "tts" → uses .kokoro (Kokoro TTS entry; no `model`
// field — voice is chosen per-request by the
// caller, so a ready kokoro with a base_url is
// enough)
export async function getSparkServiceState(url, kind) {
if (!url) return { status: "unknown" };
let discovery;
try {
discovery = await getSparkControlDiscovery(url);
} catch {
return { status: "unknown" };
}
if (!discovery) return { status: "unknown" };
const target =
kind === "transcribe"
? discovery.parakeet
: kind === "analyze"
? discovery.vllm
: kind === "tts"
? discovery.kokoro
: null;
if (!target) return { status: "unknown" };
if (!target.base_url) return { status: "unknown" };
if (target.ready === false) {
return {
status: "not_ready",
reason:
kind === "transcribe"
? "Spark Control reports STT (parakeet) is offline. Check spark-control — a model swap may be in progress, or the wrapper needs attention."
: kind === "tts"
? "Spark Control reports TTS (kokoro) is offline. Check spark-control — the Kokoro container may be down or restarting."
: "Spark Control reports the LLM (vllm) is offline. Check spark-control — load a model via the dashboard or wait for an in-progress swap to finish.",
};
}
return {
status: "ready",
base_url: target.base_url,
model: target.model || null,
};
}
// Kept for backward compatibility — same signature as before, returns
// null when the service isn't ready. New code should call
// getSparkServiceState directly so it can distinguish "not configured"
// from "configured but not ready".
export async function getDiscoveredEndpoint(url, kind) {
const state = await getSparkServiceState(url, kind);
if (state.status !== "ready") return null;
return { base_url: state.base_url, model: state.model };
}