// Runtime LLM helpers adapt plugin provider hooks into the core model runtime. import { asFiniteNumber, asFiniteNumberInRange } from "@openclaw/normalization-core"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { splitTrailingAuthProfile } from "../../agents/model-ref-profile.js"; import { normalizeModelRef } from "../../agents/model-ref-shared.js"; import type { UsageLike } from "../../agents/usage.js"; import { normalizeUsage } from "../../agents/usage.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { emitTrustedDiagnosticEvent, isDiagnosticsEnabled } from "../../infra/diagnostic-events.js"; import { markHostPluginUsageDiagnosticEvent } from "../../infra/diagnostic-plugin-usage-provenance.js"; import type { Api, Message } from "../../llm/types.js"; import { getChildLogger } from "../../logging.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { AsyncWorkScope, captureAsyncWorkTracker } from "../../shared/async-work-scope.js"; import { createDeferredCore } from "../../shared/deferred.js"; import { modelKey } from "../../shared/model-key.js"; import { estimateAggregateUsageCost, estimateUsageCost, resolveModelCostConfig, } from "../../utils/usage-format.js"; import { normalizePluginsConfig } from "../config-state.js"; import { compileModelAllowlist, type CompiledModelAllowlist } from "../model-allowlist.js"; import { getPluginRuntimeGatewayRequestScope } from "./gateway-request-scope.js"; import { createLlmCompleteError as completionError } from "./runtime-llm-error.js"; import { assertSupportedExecutionMode, isIsolatedAgentRuntimeRequest, runIsolatedAgentRuntimeCompletion, } from "./runtime-llm-isolated.js"; import { writeRuntimeLog } from "./runtime-logging.js"; import type { LlmCompleteCaller, LlmCompleteParams, LlmCompleteResult, LlmCompleteUsage, PluginRuntimeCore, RuntimeLogger, } from "./types-core.js"; export type RuntimeLlmAuthority = { caller?: LlmCompleteCaller; /** Trusted host-derived plugin id used only for config policy lookup. */ pluginIdForPolicy?: string; sessionKey?: string; agentId?: string; preferredProfile?: string; requiresBoundAgent?: boolean; allowAgentIdOverride?: boolean; allowModelOverride?: boolean; allowedModels?: readonly string[]; allowedCompletionModels?: readonly string[]; allowAuthProfileOverride?: boolean; allowComplete?: boolean; denyReason?: string; }; export type CreateRuntimeLlmOptions = { getConfig?: () => OpenClawConfig | undefined; authority?: RuntimeLlmAuthority; logger?: RuntimeLogger; }; type RuntimeLlmPolicy = { allowAgentIdOverride: boolean; allowModelOverride: boolean; allowAuthProfileOverride: boolean; overrideModels: CompiledModelAllowlist; completionModels: CompiledModelAllowlist; }; const defaultLogger = getChildLogger({ capability: "runtime.llm" }); function toRuntimeLogger(logger: typeof defaultLogger): RuntimeLogger { return { debug: (message, meta) => writeRuntimeLog(logger, "debug", message, meta), info: (message, meta) => writeRuntimeLog(logger, "info", message, meta), warn: (message, meta) => writeRuntimeLog(logger, "warn", message, meta), error: (message, meta) => writeRuntimeLog(logger, "error", message, meta), }; } function normalizeCaller( caller?: LlmCompleteCaller, fallback?: LlmCompleteCaller, ): LlmCompleteCaller { const source = caller ?? fallback; if (!source) { return { kind: "unknown" }; } return { kind: source.kind, ...(normalizeOptionalString(source.id) ? { id: source.id!.trim() } : {}), ...(normalizeOptionalString(source.name) ? { name: source.name!.trim() } : {}), }; } function resolveTrustedCaller(authority?: RuntimeLlmAuthority): LlmCompleteCaller { if (authority?.caller?.kind === "context-engine") { return normalizeCaller(authority.caller); } const scope = getPluginRuntimeGatewayRequestScope(); const scopedPluginId = normalizeOptionalString(scope?.pluginId); if (scopedPluginId) { return { kind: "plugin", id: scopedPluginId }; } return normalizeCaller(authority?.caller); } function resolveRuntimeConfig(options: CreateRuntimeLlmOptions): OpenClawConfig { const cfg = options.getConfig?.(); if (!cfg) { throw new Error("Plugin LLM completion requires an injected runtime config scope."); } return cfg; } async function resolveAgentId(params: { request: LlmCompleteParams; cfg: OpenClawConfig; authority?: RuntimeLlmAuthority; allowAgentIdOverride: boolean; }): Promise { const authorityAgentIdRaw = normalizeOptionalString(params.authority?.agentId); const requestedAgentIdRaw = normalizeOptionalString(params.request.agentId); const authorityAgentId = authorityAgentIdRaw ? normalizeAgentId(authorityAgentIdRaw) : undefined; const requestedAgentId = requestedAgentIdRaw ? normalizeAgentId(requestedAgentIdRaw) : undefined; if (params.authority?.requiresBoundAgent && !authorityAgentId) { throw completionError( "LLM_COMPLETION_NOT_AUTHORIZED", "Plugin LLM completion is not bound to an active session agent.", ); } if (authorityAgentId) { if (requestedAgentId && requestedAgentId !== authorityAgentId && !params.allowAgentIdOverride) { throw completionError( "LLM_COMPLETION_NOT_AUTHORIZED", "Plugin LLM completion cannot override the active session agent.", ); } return authorityAgentId; } if (requestedAgentId) { if (!params.allowAgentIdOverride) { throw completionError( "LLM_COMPLETION_NOT_AUTHORIZED", "Plugin LLM completion cannot override the target agent.", ); } return requestedAgentId; } const { resolveAmbientOwnerAgentId } = await import("../../agents/agent-scope.js"); return resolveAmbientOwnerAgentId(params.cfg); } function buildSystemPrompt(params: LlmCompleteParams): string | undefined { const segments = [ normalizeOptionalString(params.systemPrompt), ...params.messages .filter((message) => message.role === "system") .map((message) => normalizeOptionalString(message.content)), ].filter((segment): segment is string => Boolean(segment)); return segments.length > 0 ? segments.join("\n\n") : undefined; } function buildMessages(params: { request: LlmCompleteParams; provider: string; model: string; api: Api; }): Message[] { const now = Date.now(); return params.request.messages .filter((message) => message.role !== "system") .map((message) => message.role === "user" ? { role: "user" as const, content: message.content, timestamp: now } : { role: "assistant" as const, content: [{ type: "text" as const, text: message.content }], api: params.api, provider: params.provider, model: params.model, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop" as const, timestamp: now, }, ); } function readFiniteNonNegativeNumber(value: unknown): number | undefined { return asFiniteNumberInRange(value, { min: 0 }); } function readExplicitCostUsd(raw: unknown): number | undefined { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { return undefined; } const cost = (raw as { cost?: unknown }).cost; if (typeof cost === "number") { return readFiniteNonNegativeNumber(cost); } if (!cost || typeof cost !== "object" || Array.isArray(cost)) { return undefined; } return ( readFiniteNonNegativeNumber((cost as { total?: unknown; totalUsd?: unknown }).totalUsd) ?? readFiniteNonNegativeNumber((cost as { total?: unknown }).total) ); } export function finalizePluginLlmCompletion(params: { cfg: OpenClawConfig; hostPluginId?: string; suppressUsage?: boolean; rawUsage: unknown; logger?: RuntimeLogger; result: Omit; }): LlmCompleteResult { const normalized = normalizeUsage(params.rawUsage as UsageLike | undefined); const costConfig = resolveModelCostConfig({ provider: params.result.provider, model: params.result.model, config: params.cfg, }); // Isolated runtimes may report a whole run; only direct calls retain tier boundaries here. const estimateCost = params.result.execution.mode === "direct-provider" ? estimateUsageCost : estimateAggregateUsageCost; const costUsd = readExplicitCostUsd(params.rawUsage) ?? estimateCost({ usage: normalized, cost: costConfig }); const usage: LlmCompleteUsage = { ...(normalized?.input !== undefined ? { inputTokens: normalized.input } : {}), ...(normalized?.output !== undefined ? { outputTokens: normalized.output } : {}), ...(normalized?.cacheRead !== undefined ? { cacheReadTokens: normalized.cacheRead } : {}), ...(normalized?.cacheWrite !== undefined ? { cacheWriteTokens: normalized.cacheWrite } : {}), ...(normalized?.total !== undefined ? { totalTokens: normalized.total } : {}), ...(costUsd !== undefined ? { costUsd } : {}), }; const logger = params.logger ?? toRuntimeLogger(defaultLogger); logger.info("plugin llm completion", { caller: params.result.audit.caller, purpose: params.result.audit.purpose, sessionKey: params.result.audit.sessionKey, agentId: params.result.agentId, provider: params.result.provider, model: params.result.model, executionMode: params.result.execution.mode, executionOwner: params.result.execution.owner, usage, }); const input = normalized?.input ?? 0; const output = normalized?.output ?? 0; const cacheRead = normalized?.cacheRead ?? 0; const cacheWrite = normalized?.cacheWrite ?? 0; const promptTokens = input + cacheRead + cacheWrite; const total = normalized?.total ?? promptTokens + output; const hasPositiveUsage = [input, output, cacheRead, cacheWrite, total, usage.costUsd].some( (value) => typeof value === "number" && Number.isFinite(value) && value > 0, ); if (params.suppressUsage !== true && isDiagnosticsEnabled(params.cfg) && hasPositiveUsage) { emitTrustedDiagnosticEvent( markHostPluginUsageDiagnosticEvent( { type: "model.usage", ...(params.result.audit.sessionKey ? { sessionKey: params.result.audit.sessionKey } : {}), agentId: params.result.agentId, provider: params.result.provider, model: params.result.model, usage: { input, output, cacheRead, cacheWrite, promptTokens, total, }, ...(usage.costUsd !== undefined ? { costUsd: usage.costUsd } : {}), }, params.hostPluginId, ), ); } return { ...params.result, usage }; } function buildPolicyFromEntry(entry: { allowAgentIdOverride?: boolean; allowModelOverride?: boolean; allowAuthProfileOverride?: boolean; hasAllowedModelsConfig?: boolean; allowedModels?: readonly string[]; hasAllowedCompletionModelsConfig?: boolean; allowedCompletionModels?: readonly string[]; }): RuntimeLlmPolicy { return { allowAgentIdOverride: entry.allowAgentIdOverride === true, allowModelOverride: entry.allowModelOverride === true, allowAuthProfileOverride: entry.allowAuthProfileOverride === true, overrideModels: compileModelAllowlist({ configured: entry.hasAllowedModelsConfig === true, values: entry.allowedModels, formatKey: modelKey, }), completionModels: compileModelAllowlist({ configured: entry.hasAllowedCompletionModelsConfig === true, values: entry.allowedCompletionModels, formatKey: modelKey, }), }; } function resolvePluginPolicyId( authority: RuntimeLlmAuthority | undefined, caller: LlmCompleteCaller, ): string | undefined { const authorityPluginId = normalizeOptionalString(authority?.pluginIdForPolicy); if (authorityPluginId) { return authorityPluginId; } if (caller.kind !== "plugin") { return undefined; } const pluginId = normalizeOptionalString(caller.id); return pluginId; } function resolvePluginLlmPolicy( cfg: OpenClawConfig, pluginId: string | undefined, ): RuntimeLlmPolicy | undefined { if (!pluginId) { return undefined; } const entry = normalizePluginsConfig(cfg.plugins).entries[pluginId]?.llm; return entry ? buildPolicyFromEntry(entry) : undefined; } function resolveAuthorityModelPolicy( authority?: RuntimeLlmAuthority, ): RuntimeLlmPolicy | undefined { if ( authority?.allowAgentIdOverride !== true && authority?.allowModelOverride !== true && authority?.allowAuthProfileOverride !== true && authority?.allowedModels === undefined && authority?.allowedCompletionModels === undefined ) { return undefined; } return buildPolicyFromEntry({ allowAgentIdOverride: authority.allowAgentIdOverride, allowModelOverride: authority.allowModelOverride, allowAuthProfileOverride: authority.allowAuthProfileOverride, hasAllowedModelsConfig: authority.allowedModels !== undefined, allowedModels: authority.allowedModels, hasAllowedCompletionModelsConfig: authority.allowedCompletionModels !== undefined, allowedCompletionModels: authority.allowedCompletionModels, }); } function assertAllowedAuthProfileOverride(params: { authProfileId: string | undefined; authorityPolicy: RuntimeLlmPolicy | undefined; pluginPolicy: RuntimeLlmPolicy | undefined; }): void { if (!params.authProfileId) { return; } if ( params.authorityPolicy?.allowAuthProfileOverride === true || params.pluginPolicy?.allowAuthProfileOverride === true ) { return; } throw completionError( "LLM_COMPLETION_NOT_AUTHORIZED", "Plugin LLM completion cannot override the auth profile. Enable plugins.entries..llm.allowAuthProfileOverride to authorize it.", ); } function assertModelAllowed(params: { kind: "override" | "completion"; resolvedModelRef: string | null; policy: RuntimeLlmPolicy | undefined; policyOwnerPluginId?: string; }): void { const allowlist = params.kind === "override" ? params.policy?.overrideModels : params.policy?.completionModels; if (!allowlist?.configured || allowlist.allowAny) { return; } const target = params.kind === "override" ? "model override" : "model"; if (allowlist.models.size === 0) { throw completionError( "LLM_COMPLETION_NOT_AUTHORIZED", `Plugin LLM completion ${target} allowlist has no valid models.`, ); } if (!params.resolvedModelRef) { throw completionError( "LLM_COMPLETION_NOT_AUTHORIZED", `Plugin LLM completion ${target} allowlist requires a resolvable provider/model target.`, ); } if (!allowlist.models.has(params.resolvedModelRef)) { const owner = params.policyOwnerPluginId ? ` for plugin "${params.policyOwnerPluginId}"` : ""; const usage = params.kind === "completion" ? " for completions" : ""; throw completionError( "LLM_COMPLETION_NOT_AUTHORIZED", `Plugin LLM completion ${target} "${params.resolvedModelRef}" is not allowlisted${usage}${owner}.`, ); } } function assertAllowedModelOverride(params: { resolvedModelRef: string | null; pluginPolicyId: string | undefined; authorityPolicy: RuntimeLlmPolicy | undefined; pluginPolicy: RuntimeLlmPolicy | undefined; }): void { if ( params.authorityPolicy?.allowModelOverride !== true && params.pluginPolicy?.allowModelOverride !== true ) { throw completionError( "LLM_COMPLETION_NOT_AUTHORIZED", "Plugin LLM completion cannot override the target model.", ); } // Host and operator policy are independent trust boundaries. When both // configure a restriction, an override must satisfy their intersection. assertModelAllowed({ kind: "override", resolvedModelRef: params.resolvedModelRef, policy: params.authorityPolicy, }); assertModelAllowed({ kind: "override", resolvedModelRef: params.resolvedModelRef, policy: params.pluginPolicy, policyOwnerPluginId: params.pluginPolicyId, }); } /** * Create the host-owned generic LLM completion runtime for trusted plugin callers. */ export function createRuntimeLlm( options: CreateRuntimeLlmOptions = {}, ): Pick { const logger = options.logger ?? toRuntimeLogger(defaultLogger); return { complete: async (params: LlmCompleteParams): Promise => { const caller = resolveTrustedCaller(options.authority); if (options.authority?.allowComplete === false) { const reason = options.authority.denyReason ?? "capability denied"; logger.warn("plugin llm completion denied", { caller, purpose: params.purpose, reason, }); throw completionError( "LLM_COMPLETION_NOT_AUTHORIZED", `Plugin LLM completion denied: ${reason}`, ); } assertSupportedExecutionMode(params); const [ { acquireSimpleCompletionModelForAgent, completeWithPreparedSimpleCompletionModel, resolveSimpleCompletionSelectionForAgent, }, cfg, ] = await Promise.all([ import("../../agents/simple-completion-runtime.js"), Promise.resolve(resolveRuntimeConfig(options)), ]); const pluginPolicyId = resolvePluginPolicyId(options.authority, caller); const pluginPolicy = resolvePluginLlmPolicy(cfg, pluginPolicyId); const authorityPolicy = resolveAuthorityModelPolicy(options.authority); const preferredProfile = normalizeOptionalString(options.authority?.preferredProfile); const audit = { caller, ...(params.purpose ? { purpose: params.purpose } : {}), ...(options.authority?.sessionKey ? { sessionKey: options.authority.sessionKey } : {}), }; const agentId = await resolveAgentId({ request: params, cfg, authority: options.authority, allowAgentIdOverride: options.authority?.allowAgentIdOverride === false ? false : authorityPolicy?.allowAgentIdOverride === true || pluginPolicy?.allowAgentIdOverride === true, }); const requestedModel = normalizeOptionalString(params.model); const requestedModelProfile = requestedModel ? normalizeOptionalString(splitTrailingAuthProfile(requestedModel).profile) : undefined; const selection = resolveSimpleCompletionSelectionForAgent({ cfg, agentId, modelRef: requestedModel, }); if (!selection) { throw completionError("LLM_COMPLETION_FAILED", `No model configured for agent ${agentId}.`); } const normalizedSelection = normalizeModelRef(selection.provider, selection.modelId); const resolvedModelRef = modelKey(normalizedSelection.provider, normalizedSelection.model); assertModelAllowed({ kind: "completion", resolvedModelRef, policy: authorityPolicy }); assertModelAllowed({ kind: "completion", resolvedModelRef, policy: pluginPolicy, policyOwnerPluginId: pluginPolicyId, }); if (requestedModel) { assertAllowedModelOverride({ resolvedModelRef, pluginPolicyId, authorityPolicy, pluginPolicy, }); } const isolatedRequest = isIsolatedAgentRuntimeRequest(params); const executionProfile = isolatedRequest ? normalizeOptionalString(params.execution.authProfileId) : undefined; const modelProfile = normalizeOptionalString(selection.profileId); if (executionProfile && requestedModelProfile && executionProfile !== requestedModelProfile) { throw completionError( "LLM_ISOLATED_INPUT_REJECTED", "Isolated completion received conflicting auth profiles in model and execution.authProfileId.", ); } if (isolatedRequest) { // Direct completions preserve the shipped model@profile contract under model // override authority. Isolated credential routing requires separate authority. assertAllowedAuthProfileOverride({ authProfileId: executionProfile ?? requestedModelProfile, authorityPolicy, pluginPolicy, }); const result = await runIsolatedAgentRuntimeCompletion({ request: params, cfg, agentId, provider: selection.provider, model: selection.modelId, // Request-authorized profiles win, then the host/session binding. Only // an unbound call may fall back to the agent's configured selection. authProfileId: executionProfile ?? requestedModelProfile ?? preferredProfile ?? modelProfile, }); return finalizePluginLlmCompletion({ cfg, hostPluginId: pluginPolicyId, rawUsage: result.usage, logger, result: { text: result.text, provider: result.provider, model: result.model, agentId, execution: { mode: params.execution.mode, owner: result.owner }, audit, }, }); } const callerResult = createDeferredCore(); const trackOwner = captureAsyncWorkTracker(); // Admit drainage with the parent before acquisition; the caller only waits for its result. void trackOwner(async () => { const preparation = await acquireSimpleCompletionModelForAgent({ cfg, agentId, modelRef: params.model, preferredProfile, ...(requestedModelProfile ? { bindAuthOwner: true } : {}), allowBundledStaticCatalogFallback: true, allowMissingApiKeyModes: ["aws-sdk"], skipAgentDiscovery: true, signal: params.signal, }); if ("error" in preparation) { throw new Error(`Plugin LLM completion failed: ${preparation.error}`); } await using prepared = preparation; const work = new AsyncWorkScope(); try { callerResult.resolve( await work.track(async () => { if (params.requiredAuthMode && prepared.auth.mode !== params.requiredAuthMode) { throw completionError( "LLM_COMPLETION_NOT_AUTHORIZED", "Plugin LLM completion selected a credential with the wrong authentication mode.", ); } if (requestedModelProfile && prepared.auth.profileId !== requestedModelProfile) { throw completionError( "LLM_COMPLETION_NOT_AUTHORIZED", "Plugin LLM completion selected a different authentication profile.", ); } const context = { systemPrompt: buildSystemPrompt(params), messages: buildMessages({ request: params, provider: prepared.model.provider, model: prepared.model.id, api: prepared.model.api, }), }; const result = await completeWithPreparedSimpleCompletionModel({ model: prepared.model, auth: prepared.auth, cfg, context, options: { maxTokens: asFiniteNumber(params.maxTokens), temperature: asFiniteNumber(params.temperature), ...(params.responseFormat !== undefined ? { responseFormat: params.responseFormat } : {}), ...(params.reasoning !== undefined ? { reasoning: params.reasoning } : {}), signal: params.signal, }, }); const text = result.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) .join(""); return finalizePluginLlmCompletion({ cfg, hostPluginId: pluginPolicyId, // Provider failures resolve as messages; only visible successful output owns usage. suppressUsage: !text.trim() || !["stop", "length", "toolUse"].includes(result.stopReason), rawUsage: result.usage, logger, result: { text, provider: prepared.selection.provider, model: prepared.selection.modelId, responseModel: result.responseModel, stopReason: result.stopReason, agentId, execution: { mode: "direct-provider", owner: { kind: "provider", id: prepared.selection.provider }, }, audit, }, }); }), ); } catch (error) { callerResult.reject(error); } finally { await work.drain(); } }).catch((error: unknown) => callerResult.reject(error)); return await callerResult.promise; }, }; }