mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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.
This commit is contained in:
parent
fe625db116
commit
201f11afef
29 changed files with 407 additions and 127 deletions
|
|
@ -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<string, unknown> | 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<string, unknown> = { 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<string, unknown> | 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<string, unknown> | 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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
1
packages/ai/src/internal/openai-completions-compat.ts
Normal file
1
packages/ai/src/internal/openai-completions-compat.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { resolveOpenAICompletionsCompat } from "../transports/openai-completions-compat.js";
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", [
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<PreparedWorkspacePluginRegistries> {
|
||||
// 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 =
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@openclaw/ai/transports")>()),
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ export async function completeWithPreparedSimpleCompletionModel(
|
|||
}
|
||||
|
||||
async function completePreparedModel(params: PreparedCompletionParams): Promise<AssistantMessage> {
|
||||
// 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) ??
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<string, string>();
|
||||
const formats = new Map<string, "module" | "commonjs">();
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue