UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

464 lines (463 loc) 19.2 kB
import { P as resolvePositiveTimerTimeoutMs, p as clampPositiveTimerTimeoutMs } from "./number-coercion-CLj0HTDM.js"; import { l as toErrorObject } from "./error-coercion-D_-xJ90S.js"; import { p as redactSensitiveText, r as isSensitiveFieldKey } from "./redact-BtvPPfTi.js"; import { s as sleepWithAbort } from "./src-BQ327IOM.js"; import "./errors-Db3Ymjlb.js"; import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js"; import { t as mergeProcessEnv } from "./process-env-DlZFJzq6.js"; import { f as isLoopbackIpAddress, i as isCanonicalDottedDecimalIPv4 } from "./ip-BkT2Is0E.js"; import { n as prepareOomScoreAdjustedSpawnPreservingExecEnv } from "./linux-oom-score-YCP0ajt5.js"; import { r as unwrapHeadersInitSentinelsForProviderEgress } from "./provider-secret-egress-C-JiHB7J.js"; import { i as signalChildProcessTree, n as isChildProcessTreeAlive, r as shouldDetachChildForProcessTree, t as forceKillChildProcessTree } from "./child-process-tree-3-FAMrIm.js"; import { r as setManagedProviderLocalServicesActive } from "./provider-runtime-lifecycle-Cn0O7tiR.js"; import path from "node:path"; import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; //#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 PROCESS_TREE_EXIT_POLL_MS = 25; const LOCAL_SERVICE_OUTPUT_TAIL_MAX_BYTES = 8192; const MODEL_PROVIDER_LOCAL_SERVICE_SYMBOL = Symbol.for("openclaw.modelProviderLocalService"); const services = /* @__PURE__ */ new Map(); let exitHandlerInstalled = false; /** Bind local-service acquisition to a host-owned config snapshot. */ function createConfiguredProviderLocalServiceAcquirer(getConfig) { return async (target, signal) => { const provider = getConfig().models?.providers?.[target.providerId]; const service = provider?.localService; if (!service) return; if (!isConfiguredProviderBaseUrl(target.baseUrl, readConfiguredProviderBaseUrl(provider))) throw new Error(`Local service target must match models.providers.${target.providerId}.baseUrl`); return await ensureProviderLocalService({ ...target, service }, signal); }; } function readConfiguredProviderBaseUrl(provider) { const canonical = provider?.baseUrl?.trim(); if (canonical) return canonical; const alternate = provider?.baseURL; return typeof alternate === "string" && alternate.trim() ? alternate.trim() : void 0; } function normalizeProviderBaseUrl(value) { const trimmed = value.trim(); const candidates = /^[a-z][a-z\d+.-]*:\/\//iu.test(trimmed) ? [trimmed] : [`http://${trimmed}`]; for (const candidate of candidates) try { const url = new URL(candidate); if (url.protocol !== "http:" && url.protocol !== "https:") continue; url.search = ""; url.hash = ""; url.pathname = url.pathname.replace(/\/+$/u, "") || "/"; return url.toString().replace(/\/$/u, ""); } catch { continue; } } function configuredProviderBaseUrlVariants(value) { const normalized = normalizeProviderBaseUrl(value); if (!normalized) return /* @__PURE__ */ new Set(); const withoutOpenAiPath = normalized.replace(/\/v1$/iu, ""); return /* @__PURE__ */ new Set([ normalized, withoutOpenAiPath, `${withoutOpenAiPath}/v1` ]); } function isLoopbackProviderBaseUrl(value) { const normalized = normalizeProviderBaseUrl(value); if (!normalized) return false; const hostname = new URL(normalized).hostname.toLowerCase(); return hostname === "localhost" || hostname === "[::1]" || isCanonicalDottedDecimalIPv4(hostname) && isLoopbackIpAddress(hostname); } function isConfiguredProviderBaseUrl(targetBaseUrl, configuredBaseUrl) { const target = normalizeProviderBaseUrl(targetBaseUrl); if (!target) return false; const configured = configuredBaseUrl?.trim(); return configured ? configuredProviderBaseUrlVariants(configured).has(target) : isLoopbackProviderBaseUrl(target); } /** 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); return await ensureProviderLocalService({ providerId: model.provider, baseUrl: model.baseUrl, headers: buildHealthProbeHeaders(model.headers, probeHeaders), service }, signal); } /** Ensure a provider endpoint's local service is healthy and return a request lease. */ async function ensureProviderLocalService(target, signal) { const service = target.service; if (!service) return; throwIfAborted(signal); validateLocalServiceConfig(service, target.providerId); const healthUrl = resolveHealthUrl(service, target.baseUrl); const healthHeaders = buildHealthProbeHeaders(target.headers, void 0); const key = localServiceKey(target.providerId, service, healthUrl); installExitHandler(); const managed = services.get(key) ?? { active: 0 }; services.set(key, managed); setManagedProviderLocalServicesActive(true); 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: target.providerId, 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)); await stopManagedService(key, managed, "startup-aborted"); } } else await stopManagedService(key, managed, "startup-failed"); throw error; } } /** Stop all managed local services owned by this process. */ async function stopManagedProviderLocalServices() { await Promise.all([...services].map(([key, managed]) => stopManagedService(key, managed, "host-shutdown"))); } /** Return bounded local-service state for focused lifecycle tests. */ function getManagedProviderLocalServiceDiagnosticsForTest() { return structuredClone([...services.values()].map((managed) => managed.diagnostics).filter((value) => value !== void 0)); } 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) { return service.healthUrl?.trim() || `${baseUrl.replace(/\/+$/, "")}/models`; } function localServiceKey(provider, service, healthUrl) { return JSON.stringify({ provider, command: service.command, args: service.args ?? [], cwd: service.cwd ?? "", envHash: hashStringRecord(service.env), healthUrl }); } function hashStringRecord(record) { const sorted = Object.entries(record ?? {}).toSorted(([left], [right]) => left.localeCompare(right)); return createHash("sha256").update(JSON.stringify(sorted)).digest("hex"); } function buildHealthProbeHeaders(providerHeaders, 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(providerHeaders); appendHeaders(requestHeaders); return [...headers].length > 0 ? headers : void 0; } async function probeHealth(url, headers, signal) { throwIfAborted(signal); const egressHeaders = unwrapHeadersInitSentinelsForProviderEgress(headers, "to probe local model provider health"); 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 }); let response; try { response = await fetch(url, { headers: egressHeaders, signal: controller.signal }); return response.ok; } catch { if (signal?.aborted) throw toAbortError(signal); return false; } finally { clearTimeout(timeout); signal?.removeEventListener("abort", onAbort); await response?.body?.cancel?.().catch(() => void 0); } } 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 stopManagedProcess(managed, signal); } const startedAt = Date.now(); const diagnostics = { providerId: provider, healthUrl, startedAt, stdoutTail: "", stderrTail: "" }; managed.diagnostics = diagnostics; throwIfAborted(signal); log.info(`starting ${provider} local service: ${service.command}`); const serviceEnv = service.env ? mergeProcessEnv([process.env, service.env]) : process.env; const preparedSpawn = prepareOomScoreAdjustedSpawnPreservingExecEnv(service.command, service.args ?? [], { env: serviceEnv }); managed.process = spawn(preparedSpawn.command, preparedSpawn.args, { cwd: service.cwd, env: preparedSpawn.env, stdio: [ "ignore", "pipe", "pipe" ], detached: shouldDetachChildForProcessTree() }); const child = managed.process; diagnostics.pid = child.pid; managed.lastExit = void 0; child.stdout?.setEncoding("utf8"); child.stderr?.setEncoding("utf8"); const captureStdout = (chunk) => { diagnostics.stdoutTail = appendLocalServiceOutputTail(diagnostics.stdoutTail, chunk, service.env, process.env, service.args, healthHeaders); }; const captureStderr = (chunk) => { diagnostics.stderrTail = appendLocalServiceOutputTail(diagnostics.stderrTail, chunk, service.env, process.env, service.args, healthHeaders); }; child.stdout?.on("data", captureStdout); child.stderr?.on("data", captureStderr); child.unref(); child.once("exit", (code, signalLocal) => { const exit = { code, signal: signalLocal }; diagnostics.lastExit = exit; log.info(`${provider} local service exited: ${signalLocal ? `signal=${signalLocal}` : `code=${code ?? 0}`}`); if (managed.process === child) { managed.lastExit = exit; managed.process = void 0; } }); const spawnError = await waitForSpawnResult(child, signal); if (spawnError) throw new Error(`${provider} local service failed to start: ${spawnError.message}${formatLocalServiceDiagnosticTail(diagnostics)}`); diagnostics.spawnedAt = Date.now(); const readyTimeoutMs = resolvePositiveTimerTimeoutMs(service.readyTimeoutMs, DEFAULT_READY_TIMEOUT_MS); const deadline = Date.now() + readyTimeoutMs; for (;;) { if (await probeHealth(healthUrl, healthHeaders, signal)) { diagnostics.readyAt = Date.now(); diagnostics.lastHealthyAt = diagnostics.readyAt; diagnostics.stdoutTail = ""; diagnostics.stderrTail = ""; drainLocalServiceOutput(child); log.info(`${provider} local service ready: pid=${diagnostics.pid ?? "unknown"} spawnMs=${diagnostics.spawnedAt - startedAt} readyMs=${diagnostics.readyAt - startedAt}`); return; } if (managed.lastExit) throw new Error(`${provider} local service exited before readiness with ${formatLocalServiceExit(managed.lastExit)}${formatLocalServiceDiagnosticTail(diagnostics)}`); if (Date.now() >= deadline) throw new Error(`${provider} local service did not become ready at ${healthUrl}`); await sleepWithAbort(PROBE_INTERVAL_MS, signal, { ref: false }); } } function appendLocalServiceOutputTail(current, chunk, serviceEnv, inheritedEnv, serviceArgs, healthHeaders) { let redacted = redactSensitiveText(`${current}${chunk.toString()}`, { mode: "tools" }); for (const value of Object.values(serviceEnv ?? {})) if (value) redacted = redacted.replaceAll(value, "[redacted]"); for (const [key, value] of Object.entries(inheritedEnv)) if (value && isSensitiveFieldKey(key)) redacted = redacted.replaceAll(value, "[redacted]"); for (const value of serviceArgs ?? []) if (value) redacted = redacted.replaceAll(value, "[redacted]"); for (const [, value] of new Headers(healthHeaders)) if (value) redacted = redacted.replaceAll(value, "[redacted]"); const bytes = Buffer.from(redacted); if (bytes.byteLength <= LOCAL_SERVICE_OUTPUT_TAIL_MAX_BYTES) return redacted; let start = bytes.byteLength - LOCAL_SERVICE_OUTPUT_TAIL_MAX_BYTES; while (start < bytes.byteLength) { const byte = bytes.at(start); if (byte === void 0 || (byte & 192) !== 128) break; start += 1; } return bytes.subarray(start).toString("utf8"); } function unrefLocalServiceOutput(stream) { stream?.unref?.(); } function drainLocalServiceOutput(child) { child.stdout?.removeAllListeners("data"); child.stderr?.removeAllListeners("data"); child.stdout?.resume(); child.stderr?.resume(); unrefLocalServiceOutput(child.stdout); unrefLocalServiceOutput(child.stderr); } function formatLocalServiceDiagnosticTail(diagnostics) { return diagnostics.stderrTail ? `; stderr: ${diagnostics.stderrTail}` : ""; } function scheduleIdleStop(key, managed, service) { const idleStopMs = clampPositiveTimerTimeoutMs(service.idleStopMs); if (managed.active > 0) return; if (!managed.process) { if (!managed.starting) { services.delete(key); setManagedProviderLocalServicesActive(services.size > 0); } 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; } } async function stopManagedService(key, managed, reason) { clearIdleTimer(managed); managed.startupAbort?.abort(/* @__PURE__ */ new Error(`local service stopped: ${reason}`)); managed.startupAbort = void 0; services.delete(key); setManagedProviderLocalServicesActive(services.size > 0); if (managed.process && !hasLocalServiceProcessExited(managed.process)) log.info(`stopping local model service: reason=${reason}`); await stopManagedProcess(managed, new AbortController().signal); } async function stopManagedProcess(managed, signal) { const child = managed.process; managed.process = void 0; managed.lastExit = void 0; if (!child || hasLocalServiceProcessExited(child)) return; drainLocalServiceOutput(child); signalChildProcessTree(child, "SIGTERM"); await waitForChildProcessTreeExit(child, signal, DEFAULT_PROBE_TIMEOUT_MS); if (process.platform === "win32" || isChildProcessTreeAlive(child)) { forceKillChildProcessTree(child); await waitForChildProcessTreeExit(child, signal, DEFAULT_PROBE_TIMEOUT_MS); } } function forceStopManagedService(key, managed) { clearIdleTimer(managed); const child = managed.process; managed.process = void 0; services.delete(key); if (!child || hasLocalServiceProcessExited(child)) return; drainLocalServiceOutput(child); forceKillChildProcessTree(child); } 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) forceStopManagedService(key, managed); setManagedProviderLocalServicesActive(false); }); } 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(toErrorObject(error, "Non-Error rejection")); }); }); } 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(); }); }); } async function waitForChildProcessTreeExit(child, signal, timeoutMs) { const deadline = Date.now() + timeoutMs; while (isChildProcessTreeAlive(child)) { throwIfAborted(signal); const remainingMs = deadline - Date.now(); if (remainingMs <= 0) return; await sleepWithAbort(Math.min(PROCESS_TREE_EXIT_POLL_MS, remainingMs), signal); } } /** Return whether a child process has already reported an exit code or signal. */ function hasLocalServiceProcessExited(child) { return child.exitCode !== null || child.signalCode !== null; } //#endregion export { getManagedProviderLocalServiceDiagnosticsForTest as a, stopManagedProviderLocalServices as c, ensureProviderLocalService as i, createConfiguredProviderLocalServiceAcquirer as n, getModelProviderLocalService as o, ensureModelProviderLocalService as r, hasLocalServiceProcessExited as s, attachModelProviderLocalService as t };