From 201f11afefdb19a9c75e9d3542bf7425c80df7a7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 13 Sep 2026 10:55:04 -0700 Subject: [PATCH] fix: keep catalog discovery scoped and execution lazy (#147159) * fix(agents): initialize transport hooks at execution boundaries Keep catalog compatibility on a narrow internal import and install the existing transport host before session dispatch or completion preparation. Direct SDK completions now apply provider stream wrappers before sending a request, with owner and cancellation checks after initialization. * build(ai): emit the model compatibility entrypoint * perf: isolate catalog workers from unused execution code Use explicit catalog plugin owners without automatic agent slots. Load Ollama payload helpers when their streams run and acquire the TypeScript compiler only when a typed module is actually compiled. * test(agents): exercise admitted compaction cancellation Cancel the manual compaction after its provider is admitted so its exact rejection remains observable. Assert that cancellation before dispatch never calls the provider again. --- extensions/ollama/index.test.ts | 67 ++++++++++++++++--- extensions/ollama/src/stream-compat.ts | 36 ++++++++-- packages/ai/README.md | 2 +- packages/ai/package.json | 5 ++ .../src/internal/openai-completions-compat.ts | 1 + scripts/check-openclaw-package-tarball.mts | 5 ++ .../agent-model-discovery.imports.test.ts | 9 +++ .../harness/runtime-plugin-load-plan.ts | 22 ++++-- src/agents/harness/runtime-plugin.test.ts | 33 +++++++++ ...l-catalog-worker.scope.integration.test.ts | 27 ++++++-- ...pared-model-catalog-worker.test-support.ts | 6 +- src/agents/prepared-model-catalog.worker.ts | 7 +- src/agents/prepared-model-runtime.facts.ts | 3 + ...prepared-model-runtime.inbound-registry.ts | 49 +++++++++----- src/agents/runtime-plugins.test.ts | 31 +++++++++ src/agents/runtime-plugins.ts | 36 +++++++--- .../sessions/agent-session-compaction.test.ts | 24 ++----- src/agents/sessions/model-registry-runtime.ts | 1 - src/agents/sessions/sdk.ts | 3 + .../simple-completion-execution.test.ts | 25 +++++++ src/agents/simple-completion-execution.ts | 4 ++ src/agents/simple-completion-runtime.ts | 3 + src/llm/stream.complete-host.test.ts | 58 +++++++++++----- src/plugins/plugin-module-generation.test.ts | 30 ++++++++- src/plugins/plugin-source-build.ts | 30 ++++----- .../provider-model-compat.prepared.test.ts | 11 --- src/plugins/provider-model-compat.ts | 2 +- tsconfig.json | 3 + tsdown.ai.config.ts | 1 + 29 files changed, 407 insertions(+), 127 deletions(-) create mode 100644 packages/ai/src/internal/openai-completions-compat.ts diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index 954f172f32c5..24a30b03d844 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -370,7 +370,7 @@ async function augmentOllamaCatalog( } as never); } -function captureWrappedOllamaPayload( +async function captureWrappedOllamaPayload( thinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "max" | undefined, route: { provider?: string; modelId?: string; baseUrl?: string } = {}, ) { @@ -418,7 +418,7 @@ function captureWrappedOllamaPayload( if (!wrapped) { throw new Error("expected Ollama thinking stream wrapper"); } - void wrapped( + await wrapped( { api: "ollama", provider: providerId, @@ -2532,12 +2532,17 @@ describe("ollama plugin", () => { expect(auth).toBeUndefined(); }); - it("wraps OpenAI-compatible payloads with num_ctx for Ollama compat routes", () => { + it("wraps OpenAI-compatible payloads with num_ctx for Ollama compat routes", async () => { const provider = registerProvider(); let payloadSeen: Record | undefined; + const payloadResult = Promise.resolve(); + const onPayload = vi.fn((payload: unknown) => { + expect(payload).toEqual({ options: { temperature: 0.1, num_ctx: 32_768 } }); + return payloadResult; + }); const baseStreamFn = vi.fn((_model, _context, options) => { const payload: Record = { options: { temperature: 0.1 } }; - options?.onPayload?.(payload, _model); + expect(options?.onPayload?.(payload, _model)).toBe(payloadResult); payloadSeen = payload; return {} as never; }); @@ -2570,11 +2575,55 @@ describe("ollama plugin", () => { if (!wrapped) { throw new Error("expected Ollama OpenAI-compatible stream wrapper"); } - void wrapped({} as never, {} as never, {}); + await wrapped({} as never, {} as never, { onPayload }); expect(baseStreamFn).toHaveBeenCalledTimes(1); + expect(onPayload).toHaveBeenCalledOnce(); expect((payloadSeen?.options as Record | undefined)?.num_ctx).toBe(32_768); }); + it.each(["ollama", "openai-completions"] as const)( + "does not start the %s stream after cancellation during wrapper preparation", + async (api) => { + const provider = registerProvider(); + const controller = new AbortController(); + const reason = new Error("stream canceled during preparation"); + const baseStreamFn = vi.fn(() => ({}) as never); + const model = { + api, + provider: "ollama", + id: "qwen3:32b", + baseUrl: "http://127.0.0.1:11434/v1", + contextWindow: 32_768, + }; + const wrapped = expectDefined( + provider.wrapStreamFn?.({ + provider: "ollama", + modelId: model.id, + model, + streamFn: baseStreamFn, + thinkingLevel: "high", + }), + "Ollama stream wrapper", + ); + + const pending = wrapped(model as never, { messages: [] }, { signal: controller.signal }); + controller.abort(reason); + + await expect(pending).rejects.toBe(reason); + expect(baseStreamFn).not.toHaveBeenCalled(); + }, + ); + + it("preserves the original stream when no Ollama wrapper applies", () => { + const provider = registerProvider(); + const streamFn = vi.fn(() => ({}) as never); + const context = { provider: "ollama", modelId: "qwen3:32b" }; + + expect(provider.wrapStreamFn?.({ ...context, streamFn })).toBe(streamFn); + expect(provider.wrapStreamFn?.(context)).toBeUndefined(); + expect(streamFn).not.toHaveBeenCalled(); + }); + it("owns replay policy for OpenAI-compatible and native Ollama routes", () => { const provider = registerProvider(); @@ -2757,15 +2806,15 @@ describe("ollama plugin", () => { thinkingLevel: undefined, expectedThink: undefined, }, - ])("$name", ({ thinkingLevel, expectedThink }) => { - const { baseStreamFn, payloadSeen } = captureWrappedOllamaPayload(thinkingLevel); + ])("$name", async ({ thinkingLevel, expectedThink }) => { + const { baseStreamFn, payloadSeen } = await captureWrappedOllamaPayload(thinkingLevel); expect(baseStreamFn).toHaveBeenCalledTimes(1); expect(payloadSeen?.think).toBe(expectedThink); expect((payloadSeen?.options as Record | undefined)?.think).toBeUndefined(); }); - it("preserves native Ollama Cloud max thinking on the wire", () => { - const { payloadSeen } = captureWrappedOllamaPayload("max", { + it("preserves native Ollama Cloud max thinking on the wire", async () => { + const { payloadSeen } = await captureWrappedOllamaPayload("max", { provider: "ollama-cloud", modelId: "glm-5.2", baseUrl: "https://ollama.com", diff --git a/extensions/ollama/src/stream-compat.ts b/extensions/ollama/src/stream-compat.ts index 04a01b409b04..67d54da11189 100644 --- a/extensions/ollama/src/stream-compat.ts +++ b/extensions/ollama/src/stream-compat.ts @@ -1,24 +1,46 @@ import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import { + createLazyRuntimeModule, + createLazyRuntimeSurface, +} from "openclaw/plugin-sdk/lazy-runtime"; import type { OpenClawConfig, ProviderRuntimeModel, ProviderWrapStreamFnContext, } from "openclaw/plugin-sdk/plugin-entry"; import { + createMoonshotThinkingWrapper, DEFAULT_CONTEXT_TOKENS, normalizeProviderId, -} from "openclaw/plugin-sdk/provider-model-shared"; -import { - createMoonshotThinkingWrapper, - createPayloadPatchStreamWrapper, resolveMoonshotThinkingType, -} from "openclaw/plugin-sdk/provider-stream-shared"; +} from "openclaw/plugin-sdk/provider-model-shared"; import { isLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime"; import { shouldWrapOllamaCompatMoonshotThinking } from "./model-behavior.js"; import { supportsOllamaCloudFullThinkingEffort } from "./model-reasoning.js"; export type OllamaThinkValue = boolean | "low" | "medium" | "high" | "max"; +const loadProviderStreamRuntime = createLazyRuntimeModule( + () => import("openclaw/plugin-sdk/provider-stream-shared"), +); + +function createLazyPayloadPatchStreamWrapper( + baseFn: StreamFn | undefined, + patchPayload: Parameters< + (typeof import("openclaw/plugin-sdk/provider-stream-shared"))["createPayloadPatchStreamWrapper"] + >[1], +): StreamFn { + const loadStream = createLazyRuntimeSurface(loadProviderStreamRuntime, (runtime) => + runtime.createPayloadPatchStreamWrapper(baseFn, patchPayload), + ); + return async (model, context, options) => { + options?.signal?.throwIfAborted(); + const stream = await loadStream(); + options?.signal?.throwIfAborted(); + return stream(model, context, options); + }; +} + export function resolveConfiguredOllamaProviderConfig(params: { config?: OpenClawConfig; providerId?: string; @@ -98,7 +120,7 @@ export function shouldInjectOllamaCompatNumCtx(params: { } export function wrapOllamaCompatNumCtx(baseFn: StreamFn | undefined, numCtx: number): StreamFn { - return createPayloadPatchStreamWrapper(baseFn, ({ payload }) => { + return createLazyPayloadPatchStreamWrapper(baseFn, ({ payload }) => { if (!payload.options || typeof payload.options !== "object") { payload.options = {}; } @@ -110,7 +132,7 @@ function createOllamaThinkingWrapper( baseFn: StreamFn | undefined, think: OllamaThinkValue, ): StreamFn { - return createPayloadPatchStreamWrapper(baseFn, ({ payload }) => { + return createLazyPayloadPatchStreamWrapper(baseFn, ({ payload }) => { payload.think = think; }); } diff --git a/packages/ai/README.md b/packages/ai/README.md index e1902ae2a35f..cb9b47c15268 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -24,7 +24,7 @@ provider plugin hooks, and diagnostics logging) can be injected with `configureAiTransportHost`; the defaults are inert. The explicit `@openclaw/ai/internal/anthropic`, `google-model-family`, `openai`, -`openai-responses-payload-policy`, `retry-after`, `runtime`, `shared`, and +`openai-completions-compat`, `openai-responses-payload-policy`, `retry-after`, `runtime`, `shared`, and `tool-schema` subpaths exist for the OpenClaw application itself. They carry no semver guarantee and can change or disappear in any release; do not depend on them outside OpenClaw. diff --git a/packages/ai/package.json b/packages/ai/package.json index 4c4d898a86ce..a0179e09faa8 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -86,6 +86,11 @@ "import": "./dist/internal/openai.mjs", "default": "./dist/internal/openai.mjs" }, + "./internal/openai-completions-compat": { + "types": "./dist/internal/openai-completions-compat.d.mts", + "import": "./dist/internal/openai-completions-compat.mjs", + "default": "./dist/internal/openai-completions-compat.mjs" + }, "./internal/openai-responses-payload-policy": { "types": "./dist/internal/openai-responses-payload-policy.d.mts", "import": "./dist/internal/openai-responses-payload-policy.mjs", diff --git a/packages/ai/src/internal/openai-completions-compat.ts b/packages/ai/src/internal/openai-completions-compat.ts new file mode 100644 index 000000000000..1e8395739564 --- /dev/null +++ b/packages/ai/src/internal/openai-completions-compat.ts @@ -0,0 +1 @@ +export { resolveOpenAICompletionsCompat } from "../transports/openai-completions-compat.js"; diff --git a/scripts/check-openclaw-package-tarball.mts b/scripts/check-openclaw-package-tarball.mts index 9070eedb42d3..32e3fda8e271 100644 --- a/scripts/check-openclaw-package-tarball.mts +++ b/scripts/check-openclaw-package-tarball.mts @@ -126,6 +126,11 @@ const REQUIRED_BUNDLED_WORKSPACE_RUNTIME_ENTRIES = new Map([ entry: "dist/transports.mjs", whenExported: "./transports", }, + { + specifier: "@openclaw/ai/internal/openai-completions-compat", + entry: "dist/internal/openai-completions-compat.mjs", + whenExported: "./internal/openai-completions-compat", + }, { specifier: "@openclaw/ai/internal/openai-responses-payload-policy", entry: "dist/internal/openai-responses-payload-policy.mjs", diff --git a/src/agents/agent-model-discovery.imports.test.ts b/src/agents/agent-model-discovery.imports.test.ts index 1a2c8762ec76..0feac8916338 100644 --- a/src/agents/agent-model-discovery.imports.test.ts +++ b/src/agents/agent-model-discovery.imports.test.ts @@ -2,6 +2,15 @@ import { describe, expect, it } from "vitest"; import { findSourceImportBackedges } from "../../test/helpers/source-import-closure.js"; describe("agent model discovery imports", () => { + it("keeps prepared catalog metadata independent of streaming execution", () => { + expect( + findSourceImportBackedges("src/agents/prepared-model-catalog.worker.ts", [ + "src/agents/ai-transport-runtime-host.ts", + "packages/ai/src/transports.ts", + ]), + ).toEqual([]); + }); + it("keeps model discovery independent of session execution", () => { expect( findSourceImportBackedges("src/agents/agent-model-discovery.ts", [ diff --git a/src/agents/harness/runtime-plugin-load-plan.ts b/src/agents/harness/runtime-plugin-load-plan.ts index 932bafd94a4b..c5d9f9ed6575 100644 --- a/src/agents/harness/runtime-plugin-load-plan.ts +++ b/src/agents/harness/runtime-plugin-load-plan.ts @@ -41,6 +41,8 @@ export type AgentHarnessPluginSelection = { agentId?: string; }; +export type RuntimePluginLoadPurpose = "agent" | "model-catalog"; + function dedupePluginIds(values: readonly string[]): string[] { const result: string[] = []; for (const value of values) { @@ -313,20 +315,26 @@ export function resolveAgentRuntimePluginLoadPlan(params: { basePluginIds?: readonly string[]; selections: readonly AgentHarnessPluginSelection[]; metadataSnapshot: PluginMetadataSnapshot; + purpose?: RuntimePluginLoadPurpose; }): { config?: OpenClawConfig; pluginIds?: string[] } { let config = params.config; - const memoryPluginIds = resolveSelectedMemoryPluginIds({ - config: params.config, - metadataSnapshot: params.metadataSnapshot, - }); - const contextEnginePluginId = resolveSelectedContextEnginePluginId(params.config); + const includeAgentOwners = params.purpose !== "model-catalog"; + const memoryPluginIds = includeAgentOwners + ? resolveSelectedMemoryPluginIds({ + config: params.config, + metadataSnapshot: params.metadataSnapshot, + }) + : []; + const contextEnginePluginId = includeAgentOwners + ? resolveSelectedContextEnginePluginId(params.config) + : undefined; const contextEnginePluginIds = contextEnginePluginId ? [contextEnginePluginId] : []; const basePluginIds = (params.basePluginIds ?? []).filter( (pluginId) => !restrictiveAllowlistOmitsPlugin(params.config, pluginId), ); const pluginIds = [...basePluginIds, ...memoryPluginIds, ...contextEnginePluginIds]; const forceActivatedPluginIds = [...memoryPluginIds, ...contextEnginePluginIds]; - for (const selection of params.selections) { + for (const selection of includeAgentOwners ? params.selections : []) { const runtime = resolveSelectedAgentHarnessRuntime(selection, config); const providerOwnerPluginIds = resolveSelectedProviderOwnerPluginIds({ provider: selection.provider, @@ -386,7 +394,7 @@ export function resolveAgentRuntimePluginLoadPlan(params: { } return { ...(config ? { config } : {}), - ...(params.basePluginIds === undefined && scopedPluginIds.length === 0 + ...(includeAgentOwners && params.basePluginIds === undefined && scopedPluginIds.length === 0 ? {} : { pluginIds: scopedPluginIds }), }; diff --git a/src/agents/harness/runtime-plugin.test.ts b/src/agents/harness/runtime-plugin.test.ts index f87a91e66aa1..4232038fb392 100644 --- a/src/agents/harness/runtime-plugin.test.ts +++ b/src/agents/harness/runtime-plugin.test.ts @@ -601,6 +601,39 @@ describe("harness runtime plugins", () => { expect(plan.config?.plugins?.entries?.["custom-context-engine"]).toEqual({ enabled: true }); }); + it.each([ + { basePluginIds: [], allowed: ["catalog-provider"], expected: [] }, + { + basePluginIds: ["catalog-provider"], + allowed: ["catalog-provider"], + expected: ["catalog-provider"], + }, + { basePluginIds: ["memory-core"], allowed: ["memory-core"], expected: ["memory-core"] }, + { basePluginIds: ["catalog-provider"], allowed: ["other-provider"], expected: [] }, + ])( + "keeps catalog scope $basePluginIds within allowlist $allowed", + ({ basePluginIds, allowed, expected }) => { + const config: OpenClawConfig = { + plugins: { + allow: [...allowed, "memory-lancedb", "custom-context-engine", "codex"], + slots: { memory: "memory-lancedb", contextEngine: "custom-context-engine" }, + }, + }; + const plan = resolveAgentRuntimePluginLoadPlan({ + metadataSnapshot: createMemoryPlanMetadataSnapshot(), + config, + workspaceDir: "/tmp/workspace", + basePluginIds, + selections: [{ provider: "openai", modelId: "gpt-5.5", runtime: "codex" }], + purpose: "model-catalog", + }); + + expect(plan.pluginIds).toEqual(expected); + expect(plan.config?.plugins?.entries).toBeUndefined(); + expect(plan.config?.plugins?.slots).toEqual(config.plugins?.slots); + }, + ); + const memorySelectionCases: Array<{ name: string; config: OpenClawConfig; diff --git a/src/agents/prepared-model-catalog-worker.scope.integration.test.ts b/src/agents/prepared-model-catalog-worker.scope.integration.test.ts index 7e4e97bfeed4..92124a02193a 100644 --- a/src/agents/prepared-model-catalog-worker.scope.integration.test.ts +++ b/src/agents/prepared-model-catalog-worker.scope.integration.test.ts @@ -34,10 +34,15 @@ const { makeTempDir, retireAfterTest, waitForMarker, waitForWorkers } = describe("prepared model catalog worker plugin scope", () => { it.each([ - { first: "full", asyncSyntheticAuth: false, syntheticAuthAvailable: true }, - { first: "scoped", asyncSyntheticAuth: true, syntheticAuthAvailable: false }, - { first: "held", asyncSyntheticAuth: true, syntheticAuthAvailable: false }, - ])("keeps models.list scoped with $first catalog discovery first", async (selection) => { + { first: "full", slot: "memory", asyncSyntheticAuth: false, syntheticAuthAvailable: true }, + { + first: "scoped", + slot: "contextEngine", + asyncSyntheticAuth: true, + syntheticAuthAvailable: false, + }, + { first: "held", slot: "none", asyncSyntheticAuth: true, syntheticAuthAvailable: false }, + ])("keeps models.list scoped with $first discovery and $slot selected", async (selection) => { const root = makeTempDir("openclaw-model-catalog-scope-worker-"); const stateDir = path.join(root, "state"); const agentDir = path.join(stateDir, "agents", "main", "agent"); @@ -48,7 +53,14 @@ describe("prepared model catalog worker plugin scope", () => { fs.mkdirSync(workspaceDir, { recursive: true }); const pluginFile = writeFixturePlugin({ root, spinMs: 0, ...selection }); - const unrelatedPluginFile = writeUnrelatedFixturePlugin(root); + const unrelatedPluginFile = writeUnrelatedFixturePlugin( + root, + selection.slot === "memory" + ? "memory" + : selection.slot === "contextEngine" + ? "context-engine" + : undefined, + ); const config = { agents: { defaults: { @@ -81,6 +93,11 @@ describe("prepared model catalog worker plugin scope", () => { }, plugins: { allow: [PLUGIN_ID, UNRELATED_PLUGIN_ID], + ...(selection.slot === "memory" + ? { slots: { memory: UNRELATED_PLUGIN_ID } } + : selection.slot === "contextEngine" + ? { slots: { contextEngine: UNRELATED_PLUGIN_ID } } + : {}), load: { paths: [pluginFile, unrelatedPluginFile] }, entries: { [PLUGIN_ID]: { enabled: true }, diff --git a/src/agents/prepared-model-catalog-worker.test-support.ts b/src/agents/prepared-model-catalog-worker.test-support.ts index 2910571ddd0e..5ede999bb3ea 100644 --- a/src/agents/prepared-model-catalog-worker.test-support.ts +++ b/src/agents/prepared-model-catalog-worker.test-support.ts @@ -60,7 +60,10 @@ export const EXTERNAL_AUTH_PATH_ENV = "OPENCLAW_WORKER_EXTERNAL_AUTH_PATH"; export const UNRELATED_PLUGIN_ID = "worker-catalog-unrelated"; export const UNRELATED_PLUGIN_WORKER_MARKER_ENV = "OPENCLAW_WORKER_UNRELATED_PLUGIN_MARKER"; -export function writeUnrelatedFixturePlugin(root: string): string { +export function writeUnrelatedFixturePlugin( + root: string, + kind?: "memory" | "context-engine", +): string { const pluginDir = path.join(root, "unrelated-plugin"); fs.mkdirSync(pluginDir, { recursive: true }); const pluginFile = path.join(pluginDir, "index.cjs"); @@ -79,6 +82,7 @@ module.exports = { id: ${JSON.stringify(UNRELATED_PLUGIN_ID)}, register() {} }; path.join(pluginDir, "openclaw.plugin.json"), JSON.stringify({ id: UNRELATED_PLUGIN_ID, + ...(kind ? { kind } : {}), configSchema: { type: "object", additionalProperties: false, properties: {} }, }), "utf8", diff --git a/src/agents/prepared-model-catalog.worker.ts b/src/agents/prepared-model-catalog.worker.ts index 8456d3347aaa..3d6bb48901c0 100644 --- a/src/agents/prepared-model-catalog.worker.ts +++ b/src/agents/prepared-model-catalog.worker.ts @@ -129,13 +129,13 @@ async function prepareWorkerGeneration(value: PreparedModelCatalogWorkerInput) { .map((plugin) => plugin.id) .toSorted((left, right) => left.localeCompare(right)); const prepared = await prepareWorkspaceBuildGroup( - [{ ...value.input, runtimePluginSelections: [] }], + [value.input], "static", { preferBuiltPluginArtifacts: value.preferBuiltPluginArtifacts, basePluginIds, providerDiscoveryProviderIds: value.providerIds, - getConfiguredHarnessRuntimes: () => [], + purpose: "model-catalog", }, undefined, undefined, @@ -285,8 +285,7 @@ export async function runPreparedModelCatalogWorkerRequest( // catalog owners from the captured metadata before binding the authoritative registry. const catalogRegistry = loadAgentRuntimePluginRegistryHandle({ ...value.input, - selections: [], - configuredHarnessRuntimes: [], + purpose: "model-catalog", metadataSnapshot: pluginMetadataSnapshot, preferBuiltPluginArtifacts: value.preferBuiltPluginArtifacts, reusableRegistry: pluginRegistry, diff --git a/src/agents/prepared-model-runtime.facts.ts b/src/agents/prepared-model-runtime.facts.ts index 6edbba57d734..d6cc3bdbfb39 100644 --- a/src/agents/prepared-model-runtime.facts.ts +++ b/src/agents/prepared-model-runtime.facts.ts @@ -38,6 +38,7 @@ import { loadBundledProviderStaticCatalogContextModels, } from "./embedded-agent-runner/model.static-catalog.js"; import { createStaticModelIdMatcher } from "./embedded-agent-runner/model.static-id.js"; +import type { RuntimePluginLoadPurpose } from "./harness/runtime-plugin-load-plan.js"; import { modelCatalogRowToEntry } from "./model-catalog-entry.js"; import { buildConfiguredModelCatalog, @@ -163,6 +164,7 @@ export async function prepareWorkspaceBuildGroup( basePluginIds?: readonly string[]; onStage?: (stage: string) => void; registryResources?: PreparedModelRuntimeBuildResources; + purpose?: RuntimePluginLoadPurpose; } = {}, loadInboundPluginRegistry?: PreparedInboundRegistryLoader, reusablePluginGeneration?: PreparedModelRuntimePluginGeneration, @@ -215,6 +217,7 @@ export async function prepareWorkspaceBuildGroup( options.getConfiguredHarnessRuntimes, options.basePluginIds, options.registryResources, + options.purpose, ); const { inboundPluginRegistry, runtimePluginRegistry, primaryRegistry } = preparingRegistries instanceof Promise ? await preparingRegistries : preparingRegistries; diff --git a/src/agents/prepared-model-runtime.inbound-registry.ts b/src/agents/prepared-model-runtime.inbound-registry.ts index 0af935147034..e8b12b4a19ea 100644 --- a/src/agents/prepared-model-runtime.inbound-registry.ts +++ b/src/agents/prepared-model-runtime.inbound-registry.ts @@ -11,6 +11,7 @@ import { getActivePluginRegistryWorkspaceDir, getActivePluginRuntimeSubagentMode, } from "../plugins/runtime.js"; +import type { RuntimePluginLoadPurpose } from "./harness/runtime-plugin-load-plan.js"; import { prepareOwnedPluginLoadContext } from "./prepared-model-runtime.plugin-context.js"; import type { PreparedModelRuntimeBuildResources } from "./prepared-model-runtime.resources.js"; import type { @@ -154,20 +155,31 @@ export function prepareWorkspacePluginRegistries( getConfiguredHarnessRuntimes?: () => readonly string[], basePluginIds?: readonly string[], registryResources?: PreparedModelRuntimeBuildResources, + purpose?: RuntimePluginLoadPurpose, ): PreparedWorkspacePluginRegistries | Promise { - // Read-only catalog owners stay runtime-free. Executable probes opt in to provider runtime, - // while non-core harness probes carry the exact selected plugin generation. - if (input.readOnly && !input.loadRuntimePlugins && !input.runtimePluginSelections) { + // Passive reads stay runtime-free; catalog workers and executable probes carry explicit scope. + if ( + purpose !== "model-catalog" && + input.readOnly && + !input.loadRuntimePlugins && + !input.runtimePluginSelections + ) { return {}; } // Resolve batch facts only for a registry load; read-only and reused registries need no scan. let primaryRegistry: PluginRegistry | undefined; - const inboundPluginRegistry = input.readOnly - ? undefined - : (reusableGeneration?.inboundPluginRegistry ?? - loadInboundRegistry?.(input, metadataSnapshot, getConfiguredHarnessRuntimes?.(), (source) => { - primaryRegistry = source; - })); + const inboundPluginRegistry = + input.readOnly || purpose === "model-catalog" + ? undefined + : (reusableGeneration?.inboundPluginRegistry ?? + loadInboundRegistry?.( + input, + metadataSnapshot, + getConfiguredHarnessRuntimes?.(), + (source) => { + primaryRegistry = source; + }, + )); const baseRegistry = reusableGeneration?.pluginRegistry ?? inboundPluginRegistry; primaryRegistry ??= reusableGeneration?.mediaCapabilityProviderSource?.registry ?? baseRegistry; let loadedPrimaryRegistry: PluginRegistry | undefined; @@ -175,16 +187,18 @@ export function prepareWorkspacePluginRegistries( ? registryResources.load.bind(registryResources) : loadAgentRuntimePluginRegistryHandle; const runtimePluginRegistry = - input.runtimePluginSelections || !baseRegistry + purpose === "model-catalog" || input.runtimePluginSelections || !baseRegistry ? loadRuntimeRegistry( { - ...(input.loadRuntimePlugins - ? { basePluginIds: [] } - : baseRegistry - ? { basePluginIds: listRuntimePluginIdsFromRegistry(baseRegistry) } - : basePluginIds !== undefined - ? { basePluginIds } - : {}), + ...(purpose === "model-catalog" + ? { basePluginIds: basePluginIds ?? [] } + : input.loadRuntimePlugins + ? { basePluginIds: [] } + : baseRegistry + ? { basePluginIds: listRuntimePluginIdsFromRegistry(baseRegistry) } + : basePluginIds !== undefined + ? { basePluginIds } + : {}), ...(reusableGeneration?.pluginRegistry ? { reusableRegistry: reusableGeneration.pluginRegistry } : {}), @@ -196,6 +210,7 @@ export function prepareWorkspacePluginRegistries( ...(preferBuiltPluginArtifacts ? { preferBuiltPluginArtifacts: true } : {}), selections: input.runtimePluginSelections, configuredHarnessRuntimes: getConfiguredHarnessRuntimes?.(), + ...(purpose ? { purpose } : {}), }, (source) => { loadedPrimaryRegistry = diff --git a/src/agents/runtime-plugins.test.ts b/src/agents/runtime-plugins.test.ts index f4dfb8d738e6..16edaffe315f 100644 --- a/src/agents/runtime-plugins.test.ts +++ b/src/agents/runtime-plugins.test.ts @@ -154,6 +154,37 @@ describe("agent runtime plugin registries", () => { ); }); + it.each([false, true])( + "keeps catalog registries exact with broader reusable scope=%s", + (broader) => { + const reusableRegistry = createEmptyPluginRegistry(); + reusableRegistry.plugins.push(createPluginRecord({ id: "catalog-provider" })); + if (broader) { + reusableRegistry.plugins.push(createPluginRecord({ id: "memory-core" })); + } + const primaryRegistry = createEmptyPluginRegistry(); + primaryRegistry.plugins.push(createPluginRecord({ id: "catalog-provider" })); + hoisted.getActivePluginRegistry.mockReturnValue(createEmptyPluginRegistry()); + hoisted.loadPluginRegistryHandle.mockReturnValue(primaryRegistry); + hoisted.resolveAgentRuntimePluginLoadPlan.mockReturnValue({ + config: {}, + pluginIds: ["catalog-provider"], + }); + + const registry = loadAgentRuntimePluginRegistryHandle({ + config: {}, + basePluginIds: ["catalog-provider"], + reusableRegistry, + purpose: "model-catalog", + }); + + expect(registry).toBe(broader ? primaryRegistry : reusableRegistry); + expect(hoisted.loadPluginRegistryHandle).toHaveBeenCalledTimes(broader ? 1 : 0); + expect(hoisted.adoptRuntimeContextEngineRegistrations).not.toHaveBeenCalled(); + expect(hoisted.adoptRuntimeWidgetPresenterRegistrations).not.toHaveBeenCalled(); + }, + ); + it("uses harness runtimes prepared by the lifecycle batch", () => { const configuredHarnessRuntimes = ["codex"]; diff --git a/src/agents/runtime-plugins.ts b/src/agents/runtime-plugins.ts index 2b67faf52881..3faa78c9b5c6 100644 --- a/src/agents/runtime-plugins.ts +++ b/src/agents/runtime-plugins.ts @@ -36,6 +36,7 @@ import { resolveAgentRuntimePluginLoadPlan, resolveAgentRuntimePluginSelections, type AgentHarnessPluginSelection, + type RuntimePluginLoadPurpose, } from "./harness/runtime-plugin-load-plan.js"; type AgentRuntimePluginRegistryParams = { @@ -53,6 +54,7 @@ type AgentRuntimePluginRegistryParams = { /** Lifecycle-owned selection; standalone/direct generations stay source-default. */ preferBuiltPluginArtifacts?: boolean; metadataSnapshot?: PluginMetadataSnapshot; + purpose?: RuntimePluginLoadPurpose; }; function resolveAgentRuntimePluginRegistryLoad( @@ -86,10 +88,13 @@ function resolveAgentRuntimePluginRegistryLoad( // startup runtime plugin ids plus selected run owners bound the registry scope. const activePluginIds = listLoadedRuntimePluginIds(); const startupPluginIds = - params.basePluginIds ?? - (requestPluginRegistry - ? listRuntimePluginIdsFromRegistry(requestPluginRegistry) - : (metadataSnapshot.pluginIds ?? (activePluginIds.length > 0 ? activePluginIds : undefined))); + params.purpose === "model-catalog" + ? (params.basePluginIds ?? []) + : (params.basePluginIds ?? + (requestPluginRegistry + ? listRuntimePluginIdsFromRegistry(requestPluginRegistry) + : (metadataSnapshot.pluginIds ?? + (activePluginIds.length > 0 ? activePluginIds : undefined)))); const plan = resolveAgentRuntimePluginLoadPlan({ config: params.config, workspaceDir: workspaceDir ?? process.cwd(), @@ -97,9 +102,10 @@ function resolveAgentRuntimePluginRegistryLoad( selections: resolveAgentRuntimePluginSelections( params.config, params.selections ?? [], - params.configuredHarnessRuntimes, + params.purpose === "model-catalog" ? [] : params.configuredHarnessRuntimes, ), metadataSnapshot, + ...(params.purpose ? { purpose: params.purpose } : {}), }); return { ...loadOptions, @@ -119,19 +125,27 @@ function reusableAgentRuntimeRegistry( params: AgentRuntimePluginRegistryParams, loadOptions: PluginLoadOptions, ): PluginRegistry | undefined { + const pluginIds = loadOptions.onlyPluginIds; return params.reusableRegistry && - loadOptions.onlyPluginIds !== undefined && - registryContainsRuntimePluginIds(params.reusableRegistry, loadOptions.onlyPluginIds) + pluginIds !== undefined && + (params.purpose !== "model-catalog" || + listRuntimePluginIdsFromRegistry(params.reusableRegistry).every((pluginId) => + pluginIds.includes(pluginId), + )) && + registryContainsRuntimePluginIds(params.reusableRegistry, pluginIds) ? params.reusableRegistry : undefined; } -function adoptAgentRuntimeRegistrations(pluginRegistry: PluginRegistry): { +function adoptAgentRuntimeRegistrations( + pluginRegistry: PluginRegistry, + purpose: RuntimePluginLoadPurpose | undefined, +): { registry: PluginRegistry; donor?: PluginRegistry; } { const activeRegistry = getActivePluginRegistry(); - if (!activeRegistry) { + if (!activeRegistry || purpose === "model-catalog") { return { registry: pluginRegistry }; } const registry = bindPluginRegistryResourceOwner( @@ -167,7 +181,7 @@ export async function acquireAgentRuntimePluginRegistry( ? withPluginMetadataSnapshotScope(params.metadataSnapshot, acquire) : acquire()); try { - const { registry, donor } = adoptAgentRuntimeRegistrations(acquired.registry); + const { registry, donor } = adoptAgentRuntimeRegistrations(acquired.registry, params.purpose); const primaryResources = getPluginRegistryInspectionResources(acquired.registry); if (!primaryResources) { throw new Error("Acquired prepared registry has no registration resource owner"); @@ -218,7 +232,7 @@ export function loadAgentRuntimePluginRegistryHandle( : load(); // Media providers remain owned by this source when full-only donors require a copy. onPrimaryRegistry?.(pluginRegistry); - return adoptAgentRuntimeRegistrations(pluginRegistry).registry; + return adoptAgentRuntimeRegistrations(pluginRegistry, params.purpose).registry; } /** Binds a scoped plugin generation when a direct host has no Gateway owner. */ diff --git a/src/agents/sessions/agent-session-compaction.test.ts b/src/agents/sessions/agent-session-compaction.test.ts index a4c645eff5bf..e440608ae662 100644 --- a/src/agents/sessions/agent-session-compaction.test.ts +++ b/src/agents/sessions/agent-session-compaction.test.ts @@ -573,6 +573,7 @@ describe("AgentSession compaction", () => { await session.prompt("continue"); + expect(streamMocks.streamSimple).toHaveBeenCalledOnce(); const compactionEvents = onAgentEvent.mock.calls .map(([event]) => event) .filter((event) => event.stream === "compaction"); @@ -605,29 +606,15 @@ describe("AgentSession compaction", () => { ...createAssistant(testModel, [{ type: "text", text: "old answer" }]), timestamp: 2, }); - const handlers = createCompactionHandlers(); const syntheticError = new Error("synthetic manual cancellation rejection"); - const abortActiveCompaction = () => session.abortCompaction(); - handlers.set("session_before_compact", [ - async () => { - abortActiveCompaction(); - throw syntheticError; - }, - ]); streamMocks.streamSimple.mockImplementation( (_activeModel: Model, _context: Context, options?: SimpleStreamOptions) => { - if (options?.signal?.aborted) { - throw syntheticError; - } - return createAssistantResultStream( - createAssistant(testModel, [{ type: "text", text: "unexpected compaction" }]), - ); + expect(options?.signal?.aborted).toBe(false); + session.abortCompaction(); + throw syntheticError; }, ); - const { session } = await createTestSession({ - sessionManager, - resourceLoader: createResourceLoader(handlers), - }); + const { session } = await createTestSession({ sessionManager }); const onAgentEvent = vi.fn(); const subscription = subscribeEmbeddedAgentSession({ session, @@ -637,6 +624,7 @@ describe("AgentSession compaction", () => { await expect(session.compact()).rejects.toBe(syntheticError); + expect(streamMocks.streamSimple).toHaveBeenCalledOnce(); const compactionEvents = onAgentEvent.mock.calls .map(([event]) => event) .filter((event) => event.stream === "compaction"); diff --git a/src/agents/sessions/model-registry-runtime.ts b/src/agents/sessions/model-registry-runtime.ts index 95aae0c384dc..f998f1f1ba06 100644 --- a/src/agents/sessions/model-registry-runtime.ts +++ b/src/agents/sessions/model-registry-runtime.ts @@ -5,7 +5,6 @@ import { type LlmRuntime, } from "@openclaw/ai"; import { registerBuiltInApiProviders } from "@openclaw/ai/providers"; -import "../ai-transport-runtime-host.js"; import { bindStreamLlmRuntime } from "../../llm/model-runtime-binding.js"; import { runPluginStreamConsumer } from "../../plugins/plugin-instance-scope.js"; diff --git a/src/agents/sessions/sdk.ts b/src/agents/sessions/sdk.ts index 7046c165fa3f..7932381c98ab 100644 --- a/src/agents/sessions/sdk.ts +++ b/src/agents/sessions/sdk.ts @@ -455,6 +455,9 @@ async function createAgentSessionImpl( if (!auth.ok) { throw new Error(auth.error); } + // Isolated session streams bypass the process-default stream facade. + await import("../ai-transport-runtime-host.js"); + optionsLocal?.signal?.throwIfAborted(); const providerRetrySettings = settingsManager.getProviderRetrySettings(); const attributionHeaders = getAttributionHeaders(modelResult, settingsManager); return modelRegistryRuntime.llmRuntime.streamSimple(modelResult, context, { diff --git a/src/agents/simple-completion-execution.test.ts b/src/agents/simple-completion-execution.test.ts index ffb2e0efc4b9..6060efbff26c 100644 --- a/src/agents/simple-completion-execution.test.ts +++ b/src/agents/simple-completion-execution.test.ts @@ -1,6 +1,7 @@ import { reasoningTagTextPolicy } from "@openclaw/ai/internal/openai"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { findSourceImportBackedges } from "../../test/helpers/source-import-closure.js"; +import { bindModelCompletionOwner } from "../llm/model-runtime-binding.js"; import type { Model } from "../llm/types.js"; const mocks = vi.hoisted(() => ({ @@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("../llm/stream.js", () => ({ completeSimple: mocks.complete })); +vi.mock("./ai-transport-runtime-host.js", () => ({})); vi.mock("@openclaw/ai/transports", async (importOriginal) => ({ ...(await importOriginal()), prepareModelForSimpleCompletion: mocks.prepareModel, @@ -58,6 +60,29 @@ describe("prepared completion import boundary", () => { }); describe("completeWithPreparedSimpleCompletionModel", () => { + it("stops before transport preparation when its owner retires during host initialization", async () => { + const retired = new Error("Completion owner retired."); + let current = true; + const model = bindModelCompletionOwner(baseModel, { + run: (run) => run(), + assertCurrent: () => { + if (!current) { + throw retired; + } + }, + }); + const completion = completeWithPreparedSimpleCompletionModel({ + model, + auth: { apiKey: "test-key", source: "test", mode: "api-key" }, + context, + }); + current = false; + + await expect(completion).rejects.toBe(retired); + expect(mocks.prepareModel).not.toHaveBeenCalled(); + expect(mocks.complete).not.toHaveBeenCalled(); + }); + it.each([ "openai-completions", "openai-responses", diff --git a/src/agents/simple-completion-execution.ts b/src/agents/simple-completion-execution.ts index 92ccf150d1bd..302fb9899e41 100644 --- a/src/agents/simple-completion-execution.ts +++ b/src/agents/simple-completion-execution.ts @@ -70,6 +70,10 @@ export async function completeWithPreparedSimpleCompletionModel( } async function completePreparedModel(params: PreparedCompletionParams): Promise { + // Direct SDK calls prepare transport hooks before entering the stream facade. + await import("./ai-transport-runtime-host.js"); + params.assertCurrent?.(); + params.options?.signal?.throwIfAborted(); const runtime = getModelLlmRuntime(params.model); let completionModel = getModelCompletionTransport(params.model) ?? diff --git a/src/agents/simple-completion-runtime.ts b/src/agents/simple-completion-runtime.ts index 1de939bc5e70..b4700e5fb194 100644 --- a/src/agents/simple-completion-runtime.ts +++ b/src/agents/simple-completion-runtime.ts @@ -432,6 +432,9 @@ async function prepareSimpleCompletionModelCore( }) : fingerprintResolvedProviderAuth(auth) : undefined; + await import("./ai-transport-runtime-host.js"); + assertCurrent?.(); + params.signal?.throwIfAborted(); const modelRuntime = getModelRegistryRuntime(resolved.modelRegistry); const model = applySecretRefHeaderSentinels( applyLocalNoAuthHeaderOverride(resolvedModel, resolvedAuth), diff --git a/src/llm/stream.complete-host.test.ts b/src/llm/stream.complete-host.test.ts index 1cb44d970e8b..8c5cadc4f374 100644 --- a/src/llm/stream.complete-host.test.ts +++ b/src/llm/stream.complete-host.test.ts @@ -8,6 +8,7 @@ import type { } from "@openclaw/llm-core"; import { describe, expect, it, vi } from "vitest"; import { createZeroUsageFixture } from "../agents/test-helpers/usage-fixtures.js"; +import { attachModelProviderRuntimePluginHandle } from "../plugins/provider-hook-runtime.js"; import { bindModelLlmRuntime } from "./model-runtime-binding.js"; import { completeSimple } from "./stream.js"; import { createAssistantMessageEventStream } from "./utils/event-stream.js"; @@ -57,30 +58,51 @@ function createCompletionRuntime( stream: providerStream, streamSimple: providerStream, }); - return { model: bindModelLlmRuntime(model, runtime), message, providerStream }; + return { model: bindModelLlmRuntime(model, runtime), runtime, message, providerStream }; } describe("LLM completion transport host", () => { - it("installs runtime transport ports before a bare simple completion", async () => { + it("installs runtime transport ports before direct preparation and bare completion", async () => { const inertWrapper = getAiTransportHost().plugin.wrapSimpleCompletionStream; - const { model, message, providerStream } = createCompletionRuntime((runtimeModel, context) => { - const wrapper = getAiTransportHost().plugin.wrapSimpleCompletionStream; - expect(wrapper).not.toBe(inertWrapper); - expect( - wrapper({ - provider: runtimeModel.provider, - context: { - provider: runtimeModel.provider, - modelId: runtimeModel.id, - model: runtimeModel, - streamFn: providerStream, - }, - }), - ).toBeUndefined(); - expect(context.messages).toEqual([]); - }); + const { model, runtime, message, providerStream } = createCompletionRuntime( + (_runtimeModel, context) => { + expect(getAiTransportHost().plugin.wrapSimpleCompletionStream).not.toBe(inertWrapper); + expect(context.messages).toEqual([]); + }, + ); + const directModel = bindModelLlmRuntime( + attachModelProviderRuntimePluginHandle(model, { + provider: model.provider, + plugin: { + id: model.provider, + label: "Completion fixture", + auth: [], + wrapSimpleCompletionStreamFn: ({ streamFn }) => + streamFn && + ((target, context, options) => + streamFn(target, context, { + ...options, + headers: { ...options?.headers, "x-runtime-host": "prepared" }, + })), + }, + }), + runtime, + ); + const { completeWithPreparedSimpleCompletionModel } = + await import("../agents/simple-completion-execution.js"); + await expect( + completeWithPreparedSimpleCompletionModel({ + model: directModel, + auth: { apiKey: "fixture-key", source: "test", mode: "api-key" }, + context: { messages: [] }, + }), + ).resolves.toEqual(message); await expect(completeSimple(model, { messages: [] })).resolves.toEqual(message); + expect(providerStream.mock.calls.map((call) => call[2]?.headers)).toEqual([ + { "x-runtime-host": "prepared" }, + undefined, + ]); }); it.each(["current", "retired", "aborted"] as const)( diff --git a/src/plugins/plugin-module-generation.test.ts b/src/plugins/plugin-module-generation.test.ts index 7a71a720f7e3..3aa6b937d451 100644 --- a/src/plugins/plugin-module-generation.test.ts +++ b/src/plugins/plugin-module-generation.test.ts @@ -1,6 +1,6 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; -import { createRequire } from "node:module"; +import Module, { createRequire } from "node:module"; import path from "node:path"; import { createJiti } from "jiti"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -777,6 +777,34 @@ describe("plugin module generations", () => { }, ); + it("resolves deferred TypeScript without acquiring the compiler until execution", () => { + const root = temp.make("plugin-resolve-only-typescript-"); + fs.writeFileSync( + path.join(root, "index.cjs"), + "exports.resolve = () => require.resolve('./deferred.ts'); exports.read = () => require('./deferred.ts').value;", + ); + fs.writeFileSync(path.join(root, "deferred.ts"), "exports.value = 42 as number;"); + // oxlint-disable-next-line typescript/unbound-method -- called below with the intercepted module receiver. + const originalRequire = Module.prototype.require; + const compilerGuard = vi.spyOn(Module.prototype, "require").mockImplementation(function ( + this: NodeJS.Module, + id: string, + ) { + if (id === "typescript") { + throw new Error("Resolution must not acquire the TypeScript compiler"); + } + return originalRequire.call(this, id); + }); + let plugin: { resolve(): string; read(): number }; + try { + plugin = load(root, "index.cjs").value as typeof plugin; + expect(fs.existsSync(plugin.resolve())).toBe(true); + } finally { + compilerGuard.mockRestore(); + } + expect(plugin.read()).toBe(42); + }); + it("defers unused TypeScript syntax errors until their module is loaded", async () => { const root = temp.make("plugin-lazy-source-error-"); fs.writeFileSync( diff --git a/src/plugins/plugin-source-build.ts b/src/plugins/plugin-source-build.ts index b62343e45e26..790e6de61a5d 100644 --- a/src/plugins/plugin-source-build.ts +++ b/src/plugins/plugin-source-build.ts @@ -22,7 +22,6 @@ export type PluginSourceFile = { /** Compile captured source into a private namespace; native files stay with their capture owner. */ export function buildPluginTypeScriptSource(root: string) { - const ts: typeof TypeScript = require("typescript"); const directory = fs.mkdtempSync(path.join(path.dirname(root), ".source-")); const outputs = new Map(); const formats = new Map(); @@ -68,21 +67,22 @@ export function buildPluginTypeScriptSource(root: string) { for (const name of fs.readdirSync(root).toSorted()) { include(path.join(root, name)); } - const options: TypeScript.CompilerOptions = { - target: ts.ScriptTarget.ES2022, - module: ts.ModuleKind.NodeNext, - moduleResolution: ts.ModuleResolutionKind.NodeNext, - experimentalDecorators: true, - esModuleInterop: true, - jsx: jsx ? ts.JsxEmit.React : ts.JsxEmit.Preserve, - allowJs: true, - noResolve: true, - noLib: true, - types: [], - outDir: directory, - rootDir: root, - }; const compile = (input: string, destination: string, format: string | null | undefined) => { + const ts: typeof TypeScript = require("typescript"); + const options: TypeScript.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + experimentalDecorators: true, + esModuleInterop: true, + jsx: jsx ? ts.JsxEmit.React : ts.JsxEmit.Preserve, + allowJs: true, + noResolve: true, + noLib: true, + types: [], + outDir: directory, + rootDir: root, + }; // TypeScript uses slash-form filenames; native paths stay with filesystem operations. // Map Jiti's .mtsx/.ctsx extensions while retaining their explicit JSX grammar. const compilerInput = input.replaceAll("\\", "/").replace(/\.([cm])tsx$/, ".$1ts"); diff --git a/src/plugins/provider-model-compat.prepared.test.ts b/src/plugins/provider-model-compat.prepared.test.ts index 3623d48111fd..2aeed29cfc59 100644 --- a/src/plugins/provider-model-compat.prepared.test.ts +++ b/src/plugins/provider-model-compat.prepared.test.ts @@ -15,17 +15,6 @@ vi.mock("../agents/provider-attribution.js", () => ({ resolveProviderRequestCapabilities, })); -vi.mock("@openclaw/ai/transports", () => ({ - resolveOpenAICompletionsCompat: ( - model: Model, - resolveCapabilities: (input: Model) => { - supportsDeveloperRole: boolean; - supportsUsageInStreaming: boolean; - supportsStrictMode: boolean; - }, - ) => resolveCapabilities(model), -})); - import { normalizeModelCompat } from "./provider-model-compat.js"; function makeOwners(provider: string): PluginMetadataSnapshotOwnerMaps { diff --git a/src/plugins/provider-model-compat.ts b/src/plugins/provider-model-compat.ts index 5cf9941cc6d2..37534b8bc6b2 100644 --- a/src/plugins/provider-model-compat.ts +++ b/src/plugins/provider-model-compat.ts @@ -1,6 +1,6 @@ +import { resolveOpenAICompletionsCompat } from "@openclaw/ai/internal/openai-completions-compat"; // Normalizes provider model compatibility metadata from plugins. import { resolveUnsupportedToolSchemaKeywords } from "@openclaw/ai/internal/tool-schema"; -import { resolveOpenAICompletionsCompat } from "@openclaw/ai/transports"; import { resolveProviderRequestCapabilities } from "../agents/provider-attribution.js"; import { getModelProviderRequestRouteFacts } from "../agents/provider-request-config.js"; import type { ModelCompatConfig } from "../config/types.models.js"; diff --git a/tsconfig.json b/tsconfig.json index 77c654cd77f5..9f9c7dbca2f5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,6 +38,9 @@ "./packages/ai/src/internal/google-model-family.ts" ], "@openclaw/ai/internal/openai": ["./packages/ai/src/internal/openai.ts"], + "@openclaw/ai/internal/openai-completions-compat": [ + "./packages/ai/src/internal/openai-completions-compat.ts" + ], "@openclaw/ai/internal/openai-responses-payload-policy": [ "./packages/ai/src/internal/openai-responses-payload-policy.ts" ], diff --git a/tsdown.ai.config.ts b/tsdown.ai.config.ts index d095b7a5fd1e..71763e952c40 100644 --- a/tsdown.ai.config.ts +++ b/tsdown.ai.config.ts @@ -27,6 +27,7 @@ const config = { "internal/anthropic": "packages/ai/src/internal/anthropic.ts", "internal/google-model-family": "packages/ai/src/internal/google-model-family.ts", "internal/openai": "packages/ai/src/internal/openai.ts", + "internal/openai-completions-compat": "packages/ai/src/internal/openai-completions-compat.ts", "internal/openai-responses-payload-policy": "packages/ai/src/internal/openai-responses-payload-policy.ts", "internal/retry-after": "packages/ai/src/internal/retry-after.ts",