UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

342 lines (341 loc) 12.1 kB
import { A as resolvePositiveTimerTimeoutMs, d as clampPositiveTimerTimeoutMs } from "./number-coercion-CJQ8TR--.js"; import { t as createSubsystemLogger } from "./subsystem-BzXSmsuh.js"; import path from "node:path"; import { spawn } from "node:child_process"; //#region src/agents/provider-local-service.ts /** * Manages optional local provider sidecar processes attached to models. Leases * keep shared services alive while requests run and stop them after idle. */ const log = createSubsystemLogger("provider-local-service"); const DEFAULT_READY_TIMEOUT_MS = 12e4; const DEFAULT_PROBE_TIMEOUT_MS = 2e3; const PROBE_INTERVAL_MS = 250; const MODEL_PROVIDER_LOCAL_SERVICE_SYMBOL = Symbol.for("openclaw.modelProviderLocalService"); const services = /* @__PURE__ */ new Map(); let exitHandlerInstalled = false; /** Attach local-service startup metadata to a model without mutating the original object. */ function attachModelProviderLocalService(model, service) { if (!service) return model; const next = { ...model }; next[MODEL_PROVIDER_LOCAL_SERVICE_SYMBOL] = service; return next; } /** Read local-service startup metadata attached to a model. */ function getModelProviderLocalService(model) { return model[MODEL_PROVIDER_LOCAL_SERVICE_SYMBOL]; } /** Ensure a model's local provider service is healthy and return a lease. */ async function ensureModelProviderLocalService(model, probeHeaders, signal) { const service = getModelProviderLocalService(model); if (!service) return; throwIfAborted(signal); validateLocalServiceConfig(service, model.provider); const healthUrl = resolveHealthUrl(service, model.baseUrl); const healthHeaders = buildHealthProbeHeaders(model, probeHeaders); const key = localServiceKey(model.provider, service, healthUrl); installExitHandler(); const managed = services.get(key) ?? { active: 0 }; services.set(key, managed); clearIdleTimer(managed); managed.active += 1; let released = false; const release = () => { if (released) return; released = true; managed.active = Math.max(0, managed.active - 1); scheduleIdleStop(key, managed, service); }; try { if (managed.process && !hasLocalServiceProcessExited(managed.process) && await probeHealth(healthUrl, healthHeaders, signal)) return { release }; if (!managed.starting) { const startupAbort = new AbortController(); managed.startupAbort = startupAbort; managed.starting = startAndWaitForLocalService({ provider: model.provider, service, healthUrl, healthHeaders, managed, signal: startupAbort.signal }).finally(() => { managed.starting = void 0; if (managed.startupAbort === startupAbort) managed.startupAbort = void 0; }); } await waitForAbort(managed.starting, signal); if (!managed.process || hasLocalServiceProcessExited(managed.process)) { release(); return; } return { release }; } catch (error) { const abortingStartup = isAbortForSignal(error, signal) && Boolean(managed.starting); release(); if (isAbortForSignal(error, signal)) { if (abortingStartup && managed.active === 0) { managed.startupAbort?.abort(toAbortError(signal)); stopManagedService(key, managed, "startup-aborted"); } } else stopManagedService(key, managed, "startup-failed"); throw error; } } function validateLocalServiceConfig(service, provider) { if (!path.isAbsolute(service.command)) throw new Error(`models.providers.${provider}.localService.command must be an absolute path`); } function resolveHealthUrl(service, baseUrl) { const configured = service.healthUrl?.trim(); if (configured) return configured; return `${baseUrl.replace(/\/+$/, "")}/models`; } function localServiceKey(provider, service, healthUrl) { return JSON.stringify({ provider, command: service.command, args: service.args ?? [], cwd: service.cwd ?? "", env: sortedStringRecord(service.env), healthUrl }); } function sortedStringRecord(record) { if (!record) return {}; return Object.fromEntries(Object.entries(record).toSorted(([left], [right]) => left.localeCompare(right))); } function buildHealthProbeHeaders(model, requestHeaders) { const headers = new Headers(); const appendHeaders = (input) => { if (!input) return; for (const [key, value] of new Headers(input)) if (value.trim().length > 0 && value.trim().toLowerCase() !== "null") headers.set(key, value); }; appendHeaders(model.headers); appendHeaders(requestHeaders); return [...headers].length > 0 ? headers : void 0; } async function probeHealth(url, headers, signal) { throwIfAborted(signal); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), DEFAULT_PROBE_TIMEOUT_MS); timeout.unref?.(); const onAbort = () => controller.abort(toAbortError(signal)); signal?.addEventListener("abort", onAbort, { once: true }); try { return (await fetch(url, { headers, signal: controller.signal })).ok; } catch { if (signal?.aborted) throw toAbortError(signal); return false; } finally { clearTimeout(timeout); signal?.removeEventListener("abort", onAbort); } } async function startAndWaitForLocalService(params) { const { provider, service, healthUrl, healthHeaders, managed, signal } = params; if (await probeHealth(healthUrl, healthHeaders, signal)) return; if (managed.process && !hasLocalServiceProcessExited(managed.process)) { log.info(`restarting unhealthy ${provider} local service`); await stopManagedProcessForRestart(managed, signal); } log.info(`starting ${provider} local service: ${service.command}`); managed.process = spawn(service.command, service.args ?? [], { cwd: service.cwd, env: service.env ? { ...process.env, ...service.env } : process.env, stdio: "ignore" }); const child = managed.process; managed.lastExit = void 0; child.unref(); child.once("exit", (code, signalLocal) => { log.info(`${provider} local service exited: ${signalLocal ? `signal=${signalLocal}` : `code=${code ?? 0}`}`); if (managed.process === child) { managed.lastExit = { code, signal: signalLocal }; managed.process = void 0; } }); const spawnError = await waitForSpawnResult(child, signal); if (spawnError) throw new Error(`${provider} local service failed to start: ${spawnError.message}`); const readyTimeoutMs = resolvePositiveTimerTimeoutMs(service.readyTimeoutMs, DEFAULT_READY_TIMEOUT_MS); const deadline = Date.now() + readyTimeoutMs; for (;;) { if (await probeHealth(healthUrl, healthHeaders, signal)) { log.info(`${provider} local service ready`); return; } if (managed.lastExit) throw new Error(`${provider} local service exited before readiness with ${formatLocalServiceExit(managed.lastExit)}`); if (Date.now() >= deadline) throw new Error(`${provider} local service did not become ready at ${healthUrl}`); await sleep(PROBE_INTERVAL_MS, signal); } } function scheduleIdleStop(key, managed, service) { const idleStopMs = clampPositiveTimerTimeoutMs(service.idleStopMs); if (managed.active > 0) return; if (!managed.process) { if (!managed.starting) services.delete(key); return; } if (idleStopMs === void 0) return; managed.idleTimer = setTimeout(() => { if (managed.active === 0) stopManagedService(key, managed, "idle"); }, idleStopMs); managed.idleTimer.unref?.(); } function clearIdleTimer(managed) { if (managed.idleTimer) { clearTimeout(managed.idleTimer); managed.idleTimer = void 0; } } function stopManagedService(key, managed, reason) { clearIdleTimer(managed); managed.startupAbort?.abort(/* @__PURE__ */ new Error(`local service stopped: ${reason}`)); managed.startupAbort = void 0; const child = managed.process; managed.process = void 0; managed.lastExit = void 0; services.delete(key); if (child && !hasLocalServiceProcessExited(child)) { log.info(`stopping local model service: reason=${reason}`); child.kill("SIGTERM"); } } async function stopManagedProcessForRestart(managed, signal) { const child = managed.process; managed.process = void 0; managed.lastExit = void 0; if (!child || hasLocalServiceProcessExited(child)) return; child.kill("SIGTERM"); await waitForChildExit(child, signal, DEFAULT_PROBE_TIMEOUT_MS); if (!hasLocalServiceProcessExited(child)) { child.kill("SIGKILL"); await waitForChildExit(child, signal, DEFAULT_PROBE_TIMEOUT_MS); } } function formatLocalServiceExit(exit) { return exit.signal ? `signal ${exit.signal}` : `code ${exit.code ?? 0}`; } function installExitHandler() { if (exitHandlerInstalled) return; exitHandlerInstalled = true; process.once("exit", () => { for (const [key, managed] of services) stopManagedService(key, managed, "process-exit"); }); } function toAbortError(signal) { if (signal?.reason instanceof Error) return signal.reason; const error = /* @__PURE__ */ new Error("The operation was aborted."); error.name = "AbortError"; return error; } function throwIfAborted(signal) { if (signal?.aborted) throw toAbortError(signal); } function isAbortForSignal(error, signal) { return Boolean(signal?.aborted) && (error === signal?.reason || error instanceof Error && error.name === "AbortError"); } function waitForAbort(promise, signal) { throwIfAborted(signal); if (!signal) return promise; return new Promise((resolve, reject) => { const onAbort = () => { cleanup(); reject(toAbortError(signal)); }; const cleanup = () => signal.removeEventListener("abort", onAbort); signal.addEventListener("abort", onAbort, { once: true }); promise.then((value) => { cleanup(); resolve(value); }, (error) => { cleanup(); reject(toLintErrorObject(error, "Non-Error rejection")); }); }); } function sleep(ms, signal) { throwIfAborted(signal); return new Promise((resolve, reject) => { const cleanup = () => signal?.removeEventListener("abort", onAbort); const onDone = () => { cleanup(); resolve(); }; const onAbort = () => { clearTimeout(timeout); cleanup(); reject(toAbortError(signal)); }; const timeout = setTimeout(onDone, ms); timeout.unref?.(); signal?.addEventListener("abort", onAbort, { once: true }); }); } function waitForSpawnResult(child, signal) { throwIfAborted(signal); return new Promise((resolve) => { let settled = false; const finish = (error) => { if (settled) return; settled = true; child.off("error", onError); child.off("spawn", onSpawn); signal?.removeEventListener("abort", onAbort); resolve(error); }; const onError = (error) => finish(error); const onSpawn = () => finish(); const onAbort = () => finish(toAbortError(signal)); child.once("error", onError); child.once("spawn", onSpawn); signal?.addEventListener("abort", onAbort, { once: true }); setImmediate(() => { if (child.pid) finish(); }); }); } function waitForChildExit(child, signal, timeoutMs) { if (hasLocalServiceProcessExited(child)) return Promise.resolve(); throwIfAborted(signal); return new Promise((resolve, reject) => { const cleanup = () => { clearTimeout(timeout); child.off("exit", onExit); signal.removeEventListener("abort", onAbort); }; const finish = () => { cleanup(); resolve(); }; const onExit = () => finish(); const onAbort = () => { cleanup(); reject(toAbortError(signal)); }; const timeout = setTimeout(finish, timeoutMs); timeout.unref?.(); child.once("exit", onExit); signal.addEventListener("abort", onAbort, { once: true }); }); } /** Return whether a child process has already reported an exit code or signal. */ function hasLocalServiceProcessExited(child) { return child.exitCode !== null || child.signalCode !== null; } function toLintErrorObject(value, fallbackMessage) { if (value instanceof Error) return value; if (typeof value === "string") return new Error(value); const error = new Error(fallbackMessage, { cause: value }); if (typeof value === "object" && value !== null || typeof value === "function") Object.assign(error, value); return error; } //#endregion export { ensureModelProviderLocalService as n, getModelProviderLocalService as r, attachModelProviderLocalService as t };