mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
refactor(agents): make API registry ownership lifecycle-local (#111137)
* refactor(agents): make API registries lifecycle-owned * refactor(agents): keep registry runtime ownership internal * fix(agents): bind session streams to registry runtime * refactor(agents): keep prepared runtime ownership internal * test(agents): model lifecycle runtime fixtures * fix(amazon-bedrock): adapt lifecycle stream types * fix(agents): complete lifecycle runtime migration * test(agents): satisfy lifecycle registry static gates
This commit is contained in:
@@ -145,7 +145,7 @@ d3102bd433eea32ca2a761030788fe18294aa34266f2fb2d01d74268cbf8fb54 module/inbound
|
||||
ee34dd840075bd687624ead228d3aa906fc52965e1708a5a335bb3ad3b671949 module/json-unsafe-integers
|
||||
8f37bca66178f4d77303fdd3ded0d445c9358b4f094c07a2f8e32b1721e953e4 module/keyed-async-queue
|
||||
d8ca27a737f235e09f1a9f8cd4b82ac0cde96f10c662347b6ac82e27b70b4a40 module/lazy-runtime
|
||||
15af70a301d18c36aef7dc3cd75350308f7a020738cc8fe1e118cfc90172729d module/llm
|
||||
11fc218065ff47d3b2c0a2ca36ee66a1effac4e24e2981c9859f17b7b4cb82ae module/llm
|
||||
6ae9b0f1f55ec50fa3e0ebc02052bfd04eca5f34c4a936692634b36a4cfa2035 module/lmstudio
|
||||
dd166527916dc5b9694512e3526e774da0b2427ba7fc8316d2fd799c06238e80 module/lmstudio-runtime
|
||||
fd00374a91ab5b393152bbbaf90010f9140cf15827bde360d0c6c8724ed34e2e module/logging-core
|
||||
|
||||
@@ -307,6 +307,17 @@ describe("amazon-bedrock provider plugin", () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("publishes its stream through the provider lifecycle", async () => {
|
||||
const provider = await registerSingleProviderPlugin(amazonBedrockPlugin);
|
||||
|
||||
expect(
|
||||
provider.createStreamFn?.({ model: { api: "bedrock-converse-stream" } } as never),
|
||||
).toBeTypeOf("function");
|
||||
expect(
|
||||
provider.createStreamFn?.({ model: { api: "anthropic-messages" } } as never),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("marks Claude 4.6 Bedrock models as adaptive by default", async () => {
|
||||
const provider = await registerSingleProviderPlugin(amazonBedrockPlugin);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import type { BedrockClient } from "@aws-sdk/client-bedrock";
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { registerApiProvider, streamSimple } from "openclaw/plugin-sdk/llm";
|
||||
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import type {
|
||||
OpenClawPluginApi,
|
||||
@@ -25,7 +24,7 @@ import { supportsBedrockPromptCaching } from "./bedrock-options.js";
|
||||
import { loadBedrockControlPlaneSdk, runBedrockControlPlaneRequest } from "./control-plane.js";
|
||||
import { mergeImplicitBedrockProvider, resolveBedrockConfigApiKey } from "./discovery-shared.js";
|
||||
import { bedrockMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
|
||||
import { streamBedrock, streamSimpleBedrock } from "./stream.runtime.js";
|
||||
import { streamSimpleBedrock } from "./stream.runtime.js";
|
||||
import {
|
||||
isLatestAdaptiveBedrockModelRef,
|
||||
isOpus47OrNewerBedrockModelRef,
|
||||
@@ -95,8 +94,16 @@ function isAnthropicBedrockModel(modelId: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bedrockStreamFn: StreamFn = (model, context, options) => {
|
||||
if (model.api !== "bedrock-converse-stream") {
|
||||
throw new Error(`Amazon Bedrock stream received unsupported API: ${model.api}`);
|
||||
}
|
||||
// The API check narrows the generic host model to the transport contract.
|
||||
return streamSimpleBedrock(model as Parameters<typeof streamSimpleBedrock>[0], context, options);
|
||||
};
|
||||
|
||||
function createBedrockNoCacheWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
const underlying = baseStreamFn ?? bedrockStreamFn;
|
||||
return (model, context, options) =>
|
||||
underlying(model, context, {
|
||||
...options,
|
||||
@@ -382,15 +389,6 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
|
||||
});
|
||||
const startupPluginConfig = (api.pluginConfig ?? {}) as AmazonBedrockPluginConfig;
|
||||
|
||||
registerApiProvider(
|
||||
{
|
||||
api: "bedrock-converse-stream",
|
||||
stream: streamBedrock,
|
||||
streamSimple: streamSimpleBedrock,
|
||||
},
|
||||
`plugin:${providerId}`,
|
||||
);
|
||||
|
||||
function resolveCurrentPluginConfig(
|
||||
config: OpenClawConfig | undefined,
|
||||
): AmazonBedrockPluginConfig | undefined {
|
||||
@@ -543,6 +541,8 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
|
||||
},
|
||||
resolveConfigApiKey: ({ env }) => resolveBedrockConfigApiKey(env),
|
||||
normalizeResolvedModel: normalizeBedrockResolvedModel,
|
||||
createStreamFn: ({ model }) =>
|
||||
model.api === "bedrock-converse-stream" ? bedrockStreamFn : undefined,
|
||||
...anthropicByModelReplayHooks,
|
||||
wrapStreamFn: ({ modelId, config, model, streamFn, thinkingLevel, extraParams }) => {
|
||||
const currentPluginConfig = resolveCurrentPluginConfig(config);
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
} from "@aws-sdk/client-bedrock-runtime";
|
||||
import { onLlmRequestActivity } from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { streamBedrock, streamSimpleBedrock } from "./stream.runtime.js";
|
||||
import type { BedrockOptions } from "./bedrock-options.js";
|
||||
import { streamSimpleBedrock } from "./stream.runtime.js";
|
||||
import { streamTesting as testing } from "./test-support.js";
|
||||
|
||||
function bedrockModel(overrides: Record<string, unknown>) {
|
||||
@@ -52,9 +53,17 @@ async function* streamEvents(events: unknown[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function streamBedrockForTest(
|
||||
model: Parameters<typeof streamSimpleBedrock>[0],
|
||||
context: Parameters<typeof streamSimpleBedrock>[1],
|
||||
options: BedrockOptions = {},
|
||||
) {
|
||||
return streamSimpleBedrock(model, context, options as never);
|
||||
}
|
||||
|
||||
async function captureClientRegion(
|
||||
model: Parameters<typeof streamBedrock>[0],
|
||||
options: Parameters<typeof streamBedrock>[2] = {},
|
||||
model: Parameters<typeof streamSimpleBedrock>[0],
|
||||
options: BedrockOptions = {},
|
||||
): Promise<string> {
|
||||
const send = vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({
|
||||
$metadata: { httpStatusCode: 200 },
|
||||
@@ -64,7 +73,7 @@ async function captureClientRegion(
|
||||
]),
|
||||
} as never);
|
||||
|
||||
await streamBedrock(
|
||||
await streamBedrockForTest(
|
||||
model,
|
||||
{ messages: [{ role: "user", content: "Hello", timestamp: 0 }] } as never,
|
||||
options,
|
||||
@@ -275,6 +284,7 @@ describe("Bedrock profile endpoint resolution", () => {
|
||||
])(
|
||||
"resolves $name to $expectedRegion",
|
||||
async ({ modelId, ambientRegion, fallbackRegion, explicitRegion, expectedRegion }) => {
|
||||
vi.stubEnv("AWS_PROFILE", "");
|
||||
vi.stubEnv("AWS_REGION", ambientRegion);
|
||||
if (fallbackRegion !== undefined) {
|
||||
vi.stubEnv("AWS_DEFAULT_REGION", fallbackRegion);
|
||||
@@ -305,7 +315,7 @@ describe("Bedrock stop reasons", () => {
|
||||
]),
|
||||
} as never);
|
||||
|
||||
const result = await streamBedrock(bedrockModel({}), {
|
||||
const result = await streamBedrockForTest(bedrockModel({}), {
|
||||
messages: [{ role: "user", content: "Hello", timestamp: 0 }],
|
||||
} as never).result();
|
||||
|
||||
@@ -579,7 +589,7 @@ describe("Bedrock Fable contract", () => {
|
||||
]),
|
||||
} as never);
|
||||
|
||||
const stream = streamBedrock(fableModel(), context(), {
|
||||
const stream = streamBedrockForTest(fableModel(), context(), {
|
||||
reasoning: "high",
|
||||
temperature: 0.2,
|
||||
toolChoice: "any",
|
||||
@@ -614,7 +624,7 @@ describe("Bedrock Fable contract", () => {
|
||||
]),
|
||||
} as never);
|
||||
|
||||
const stream = streamBedrock(fableModel(), context(), {
|
||||
const stream = streamBedrockForTest(fableModel(), context(), {
|
||||
reasoning: "high",
|
||||
toolChoice: "none",
|
||||
});
|
||||
|
||||
@@ -126,7 +126,7 @@ function resolveAdaptiveBedrockMaxTokens(
|
||||
}
|
||||
|
||||
/** Stream a Bedrock Converse request using Bedrock-specific options. */
|
||||
export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOptions> = (
|
||||
const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOptions> = (
|
||||
model: Model<"bedrock-converse-stream">,
|
||||
context: Context,
|
||||
options: BedrockOptions = {},
|
||||
@@ -400,7 +400,11 @@ function resolveSimpleBedrockOptions(
|
||||
model: Model<"bedrock-converse-stream">,
|
||||
options?: SimpleStreamOptions,
|
||||
): BedrockOptions {
|
||||
const base = buildBaseOptions(model, options, undefined);
|
||||
const bedrockOptions = options as BedrockOptions | undefined;
|
||||
const base = {
|
||||
...bedrockOptions,
|
||||
...buildBaseOptions(model, options, undefined),
|
||||
};
|
||||
if (requiresMandatoryAdaptiveThinking(model)) {
|
||||
return {
|
||||
...base,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiRegistry } from "../api-registry.js";
|
||||
import { createLlmRuntime } from "../stream.js";
|
||||
|
||||
const DEFAULT_RUNTIME_KEY = Symbol.for("openclaw.ai.defaultRuntime");
|
||||
const globalStore = globalThis as Record<PropertyKey, unknown>;
|
||||
const originalDefaultRuntime = globalStore[DEFAULT_RUNTIME_KEY];
|
||||
|
||||
afterEach(() => {
|
||||
if (originalDefaultRuntime === undefined) {
|
||||
delete globalStore[DEFAULT_RUNTIME_KEY];
|
||||
} else {
|
||||
globalStore[DEFAULT_RUNTIME_KEY] = originalDefaultRuntime;
|
||||
}
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("default LLM runtime compatibility state", () => {
|
||||
it("keeps opaque legacy registrations out of lifecycle publications", async () => {
|
||||
const registry = createApiRegistry();
|
||||
const stream = () => ({}) as never;
|
||||
registry.registerApiProvider(
|
||||
{ api: "test-legacy-plugin", stream, streamSimple: stream },
|
||||
"plugin:test-legacy",
|
||||
);
|
||||
globalStore[DEFAULT_RUNTIME_KEY] = {
|
||||
registry,
|
||||
runtime: createLlmRuntime(registry),
|
||||
};
|
||||
vi.resetModules();
|
||||
|
||||
const runtime = await import("./default-runtime.js");
|
||||
|
||||
expect(runtime.defaultApiRegistry).toBe(registry);
|
||||
expect(runtime.getPublishedApiProviders()).toEqual([]);
|
||||
|
||||
runtime.registerApiProvider(
|
||||
{ api: "test-current-plugin", stream, streamSimple: stream },
|
||||
"plugin:test-current",
|
||||
);
|
||||
|
||||
expect(runtime.getApiProvider("test-legacy-plugin")).toBeDefined();
|
||||
expect(runtime.getPublishedApiProviders().map((provider) => provider.api)).toEqual([
|
||||
"test-current-plugin",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,20 @@
|
||||
import type { Api, StreamOptions } from "@openclaw/llm-core";
|
||||
// Process-default registry/runtime retained for the OpenClaw compatibility
|
||||
// facade (src/llm). Deliberately not part of the public package API: external
|
||||
// consumers create isolated runtimes via createLlmRuntime(); exporting these
|
||||
// from the root barrel would reintroduce the mutable process-global registry.
|
||||
import { createApiRegistry, type ApiRegistry } from "../api-registry.js";
|
||||
import {
|
||||
createApiRegistry,
|
||||
type ApiProvider,
|
||||
type ApiRegistry,
|
||||
type RegisteredApiProvider,
|
||||
} from "../api-registry.js";
|
||||
import { createLlmRuntime, type LlmRuntime } from "../stream.js";
|
||||
|
||||
type DefaultRuntimeState = {
|
||||
registry: ApiRegistry;
|
||||
runtime: LlmRuntime;
|
||||
publishedRegistry: ApiRegistry;
|
||||
};
|
||||
|
||||
const DEFAULT_RUNTIME_KEY = Symbol.for("openclaw.ai.defaultRuntime");
|
||||
@@ -15,11 +22,19 @@ const DEFAULT_RUNTIME_KEY = Symbol.for("openclaw.ai.defaultRuntime");
|
||||
function resolveDefaultRuntime(): DefaultRuntimeState {
|
||||
const globalStore = globalThis as Record<PropertyKey, unknown>;
|
||||
if (Object.hasOwn(globalStore, DEFAULT_RUNTIME_KEY)) {
|
||||
return globalStore[DEFAULT_RUNTIME_KEY] as DefaultRuntimeState;
|
||||
const existing = globalStore[DEFAULT_RUNTIME_KEY] as Omit<
|
||||
DefaultRuntimeState,
|
||||
"publishedRegistry"
|
||||
> &
|
||||
Partial<Pick<DefaultRuntimeState, "publishedRegistry">>;
|
||||
// Keep the legacy facade registry intact. Its entries are opaque, so only
|
||||
// registrations made through this host version enter lifecycle snapshots.
|
||||
existing.publishedRegistry ??= createApiRegistry();
|
||||
return existing as DefaultRuntimeState;
|
||||
}
|
||||
const registry = createApiRegistry();
|
||||
const runtime = createLlmRuntime(registry);
|
||||
const state = { registry, runtime };
|
||||
const state = { registry, runtime, publishedRegistry: createApiRegistry() };
|
||||
globalStore[DEFAULT_RUNTIME_KEY] = state;
|
||||
return state;
|
||||
}
|
||||
@@ -29,12 +44,29 @@ const defaultRuntime = resolveDefaultRuntime();
|
||||
export const defaultApiRegistry = defaultRuntime.registry;
|
||||
export const defaultLlmRuntime = defaultRuntime.runtime;
|
||||
|
||||
export const {
|
||||
registerApiProvider,
|
||||
getApiProvider,
|
||||
getApiProviders,
|
||||
unregisterApiProviders,
|
||||
clearApiProviders,
|
||||
} = defaultApiRegistry;
|
||||
export function registerApiProvider<TApi extends Api, TOptions extends StreamOptions>(
|
||||
provider: ApiProvider<TApi, TOptions>,
|
||||
sourceId?: string,
|
||||
): void {
|
||||
defaultApiRegistry.registerApiProvider(provider, sourceId);
|
||||
defaultRuntime.publishedRegistry.registerApiProvider(provider, sourceId);
|
||||
}
|
||||
|
||||
export const { getApiProvider, getApiProviders } = defaultApiRegistry;
|
||||
|
||||
/** Returns only explicit compatibility registrations, excluding request-generated aliases. */
|
||||
export function getPublishedApiProviders(): RegisteredApiProvider[] {
|
||||
return defaultRuntime.publishedRegistry.getApiProviders();
|
||||
}
|
||||
|
||||
export function unregisterApiProviders(sourceId: string): void {
|
||||
defaultApiRegistry.unregisterApiProviders(sourceId);
|
||||
defaultRuntime.publishedRegistry.unregisterApiProviders(sourceId);
|
||||
}
|
||||
|
||||
export function clearApiProviders(): void {
|
||||
defaultApiRegistry.clearApiProviders();
|
||||
defaultRuntime.publishedRegistry.clearApiProviders();
|
||||
}
|
||||
|
||||
export const { stream, complete, streamSimple, completeSimple } = defaultLlmRuntime;
|
||||
|
||||
@@ -20,6 +20,7 @@ const buildSessionContextMock = vi.fn();
|
||||
const ensureOpenClawModelsJsonMock = vi.fn();
|
||||
const discoverAuthStorageMock = vi.fn();
|
||||
const discoverModelsMock = vi.fn();
|
||||
const getModelRegistryRuntimeMock = vi.fn();
|
||||
const resolveModelWithRegistryMock = vi.fn();
|
||||
const ensureAuthProfileStoreMock = vi.fn();
|
||||
const ensureAuthProfileStoreWithoutExternalProfilesMock = vi.fn();
|
||||
@@ -82,6 +83,10 @@ vi.mock("./agent-model-discovery.js", () => ({
|
||||
discoverModels: (...args: unknown[]) => discoverModelsMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./sessions/model-registry-runtime.js", () => ({
|
||||
getModelRegistryRuntime: (...args: unknown[]) => getModelRegistryRuntimeMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./embedded-agent-runner/model.js", () => ({
|
||||
resolveModelAsync: (...args: unknown[]) => resolveModelAsyncMock(...args),
|
||||
resolveModelWithRegistry: (...args: unknown[]) => resolveModelWithRegistryMock(...args),
|
||||
@@ -532,6 +537,11 @@ describe("runBtwSideQuestion", () => {
|
||||
ensureOpenClawModelsJsonMock.mockReset();
|
||||
discoverAuthStorageMock.mockReset();
|
||||
discoverModelsMock.mockReset();
|
||||
getModelRegistryRuntimeMock.mockReset();
|
||||
getModelRegistryRuntimeMock.mockReturnValue({
|
||||
apiRegistry: {},
|
||||
llmRuntime: { streamSimple: streamSimpleMock },
|
||||
});
|
||||
resolveModelAsyncMock.mockReset();
|
||||
resolveModelWithRegistryMock.mockReset();
|
||||
ensureAuthProfileStoreMock.mockReset();
|
||||
|
||||
+5
-2
@@ -11,7 +11,6 @@ import type { ChatType } from "../channels/chat-type.js";
|
||||
import type { SessionEntry as StoredSessionEntry } from "../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { streamWithPayloadPatch } from "../llm/providers/stream-wrappers/stream-payload-utils.js";
|
||||
import { streamSimple } from "../llm/stream.js";
|
||||
import type {
|
||||
AssistantMessageEvent,
|
||||
ImageContent,
|
||||
@@ -81,6 +80,7 @@ import {
|
||||
import type { AgentRuntimeAuthPlan } from "./runtime-plan/types.js";
|
||||
import { resolveSessionRuntimeOverrideForProvider } from "./session-runtime-compat.js";
|
||||
import { stripToolResultDetails } from "./session-transcript-repair.js";
|
||||
import { getModelRegistryRuntime } from "./sessions/model-registry-runtime.js";
|
||||
import { resolveAgentTimeoutMs } from "./timeout.js";
|
||||
import { sanitizeImageBlocks } from "./tool-images.js";
|
||||
|
||||
@@ -1145,6 +1145,7 @@ export async function runBtwSideQuestion(
|
||||
}
|
||||
}
|
||||
runtimeModel = applySecretRefHeaderSentinels(runtimeModel, params.cfg);
|
||||
const modelRegistryRuntime = getModelRegistryRuntime(modelRegistry);
|
||||
|
||||
// Use the provider's own stream fn so providers like Ollama (which build
|
||||
// `/api/chat` or `/v1/chat/completions` paths based on api mode) construct
|
||||
@@ -1156,9 +1157,11 @@ export async function runBtwSideQuestion(
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir,
|
||||
env: process.env,
|
||||
apiRegistry: modelRegistryRuntime.apiRegistry,
|
||||
});
|
||||
const streamFn = resolveEmbeddedAgentStreamFn({
|
||||
currentStreamFn: streamSimple,
|
||||
llmRuntime: modelRegistryRuntime.llmRuntime,
|
||||
currentStreamFn: modelRegistryRuntime.llmRuntime.streamSimple,
|
||||
providerStreamFn,
|
||||
sessionId,
|
||||
signal: params.opts?.abortSignal,
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import {
|
||||
clearApiProviders,
|
||||
defaultApiRegistry,
|
||||
getApiProvider,
|
||||
registerApiProvider,
|
||||
unregisterApiProviders,
|
||||
} from "@openclaw/ai/internal/runtime";
|
||||
import { registerBuiltInApiProviders, resetApiProviders } from "@openclaw/ai/providers";
|
||||
import { createApiRegistry, type ApiRegistry } from "@openclaw/ai";
|
||||
import { resetApiProviders } from "@openclaw/ai/providers";
|
||||
// Covers dynamic registration of custom model API providers.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js";
|
||||
import { ensureCustomApiRegistered } from "./custom-api-registry.js";
|
||||
import { buildAssistantMessage, buildUsageWithNoCost } from "./stream-message-shared.js";
|
||||
|
||||
let registry: ApiRegistry;
|
||||
|
||||
function getRegisteredTestProvider() {
|
||||
const provider = getApiProvider("test-custom-api");
|
||||
const provider = registry.getApiProvider("test-custom-api");
|
||||
if (!provider) {
|
||||
throw new Error("expected test-custom-api provider to be registered");
|
||||
}
|
||||
@@ -21,9 +17,8 @@ function getRegisteredTestProvider() {
|
||||
}
|
||||
|
||||
describe("ensureCustomApiRegistered", () => {
|
||||
afterEach(() => {
|
||||
clearApiProviders();
|
||||
registerBuiltInApiProviders(defaultApiRegistry);
|
||||
beforeEach(() => {
|
||||
registry = createApiRegistry();
|
||||
});
|
||||
|
||||
it("registers a custom api provider once", () => {
|
||||
@@ -31,8 +26,8 @@ describe("ensureCustomApiRegistered", () => {
|
||||
// replace provider entries or create duplicate sources.
|
||||
const streamFn = vi.fn(() => createAssistantMessageEventStream());
|
||||
|
||||
expect(ensureCustomApiRegistered("test-custom-api", streamFn)).toBe(true);
|
||||
expect(ensureCustomApiRegistered("test-custom-api", streamFn)).toBe(false);
|
||||
expect(ensureCustomApiRegistered(registry, "test-custom-api", streamFn)).toBe(true);
|
||||
expect(ensureCustomApiRegistered(registry, "test-custom-api", streamFn)).toBe(false);
|
||||
|
||||
const provider = getRegisteredTestProvider();
|
||||
expect(typeof provider.stream).toBe("function");
|
||||
@@ -42,7 +37,7 @@ describe("ensureCustomApiRegistered", () => {
|
||||
it("delegates both stream entrypoints to the provided stream function", () => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
const streamFn = vi.fn(() => stream);
|
||||
ensureCustomApiRegistered("test-custom-api", streamFn);
|
||||
ensureCustomApiRegistered(registry, "test-custom-api", streamFn);
|
||||
|
||||
const provider = getRegisteredTestProvider();
|
||||
|
||||
@@ -68,7 +63,7 @@ describe("ensureCustomApiRegistered", () => {
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
return stream;
|
||||
});
|
||||
ensureCustomApiRegistered("test-custom-api", streamFn);
|
||||
ensureCustomApiRegistered(registry, "test-custom-api", streamFn);
|
||||
|
||||
const provider = getRegisteredTestProvider();
|
||||
const stream = provider.stream(
|
||||
@@ -85,7 +80,7 @@ describe("ensureCustomApiRegistered", () => {
|
||||
const streamFn = vi.fn(async () => {
|
||||
throw new Error("factory failed");
|
||||
});
|
||||
ensureCustomApiRegistered("test-custom-api", streamFn);
|
||||
ensureCustomApiRegistered(registry, "test-custom-api", streamFn);
|
||||
|
||||
const provider = getRegisteredTestProvider();
|
||||
const stream = provider.stream(
|
||||
@@ -107,7 +102,7 @@ describe("ensureCustomApiRegistered", () => {
|
||||
const api = "test-reset-plugin-api";
|
||||
const streamFn = vi.fn(() => createAssistantMessageEventStream());
|
||||
const streamSimpleFn = vi.fn(() => createAssistantMessageEventStream());
|
||||
registerApiProvider(
|
||||
registry.registerApiProvider(
|
||||
{
|
||||
api,
|
||||
stream: streamFn,
|
||||
@@ -116,11 +111,11 @@ describe("ensureCustomApiRegistered", () => {
|
||||
sourceId,
|
||||
);
|
||||
|
||||
resetApiProviders(defaultApiRegistry);
|
||||
resetApiProviders(registry);
|
||||
|
||||
expect(getApiProvider(api)).toBeDefined();
|
||||
expect(getApiProvider("openai-responses")).toBeDefined();
|
||||
expect(registry.getApiProvider(api)).toBeDefined();
|
||||
expect(registry.getApiProvider("openai-responses")).toBeDefined();
|
||||
|
||||
unregisterApiProviders(sourceId);
|
||||
registry.unregisterApiProviders(sourceId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Registers caller-supplied custom API stream functions with the LLM registry.
|
||||
*/
|
||||
import { getApiProvider, registerApiProvider } from "@openclaw/ai/internal/runtime";
|
||||
import type { ApiRegistry } from "@openclaw/ai";
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessageEventStreamContract,
|
||||
@@ -47,12 +47,16 @@ function adaptCustomStream(
|
||||
}
|
||||
|
||||
/** Registers a custom API stream function when no provider already owns it. */
|
||||
export function ensureCustomApiRegistered(api: Api, streamFn: StreamFn): boolean {
|
||||
if (getApiProvider(api)) {
|
||||
export function ensureCustomApiRegistered(
|
||||
registry: ApiRegistry,
|
||||
api: Api,
|
||||
streamFn: StreamFn,
|
||||
): boolean {
|
||||
if (registry.getApiProvider(api)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
registerApiProvider(
|
||||
registry.registerApiProvider(
|
||||
{
|
||||
api,
|
||||
stream: (model, context, options) =>
|
||||
|
||||
@@ -208,6 +208,10 @@ export const buildEmbeddedSystemPromptMock = vi.fn(() => "");
|
||||
export const resolveEmbeddedAgentStreamFnMock: Mock<
|
||||
(params?: unknown) => MockEmbeddedAgentStreamFn
|
||||
> = vi.fn((_params?: unknown) => vi.fn());
|
||||
const getModelRegistryRuntimeMock = vi.fn(() => ({
|
||||
apiRegistry: {},
|
||||
llmRuntime: { streamSimple: vi.fn() },
|
||||
}));
|
||||
export const getApiKeyForModelMock: Mock<
|
||||
(params?: { profileId?: string; allowAuthProfileFallback?: boolean }) => Promise<{
|
||||
apiKey: string;
|
||||
@@ -457,6 +461,11 @@ export function resetCompactSessionStateMocks(): void {
|
||||
}));
|
||||
resolveEmbeddedAgentStreamFnMock.mockReset();
|
||||
resolveEmbeddedAgentStreamFnMock.mockImplementation((_params?: unknown) => vi.fn());
|
||||
getModelRegistryRuntimeMock.mockReset();
|
||||
getModelRegistryRuntimeMock.mockReturnValue({
|
||||
apiRegistry: {},
|
||||
llmRuntime: { streamSimple: vi.fn() },
|
||||
});
|
||||
getApiKeyForModelMock.mockReset();
|
||||
getApiKeyForModelMock.mockImplementation(async (params?: { profileId?: string }) => ({
|
||||
apiKey: "test",
|
||||
@@ -677,6 +686,10 @@ export async function loadCompactHooksHarness(): Promise<{
|
||||
registerProviderStreamForModel: registerProviderStreamForModelMock,
|
||||
}));
|
||||
|
||||
vi.doMock("../sessions/model-registry-runtime.js", () => ({
|
||||
getModelRegistryRuntime: getModelRegistryRuntimeMock,
|
||||
}));
|
||||
|
||||
vi.doMock("../../hooks/internal-hooks.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../hooks/internal-hooks.js")>(
|
||||
"../../hooks/internal-hooks.js",
|
||||
|
||||
@@ -856,6 +856,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
|
||||
await compactTesting.prepareCompactionSessionAgent({
|
||||
session: session as never,
|
||||
llmRuntime: { streamSimple: vi.fn() } as never,
|
||||
providerStreamFn: vi.fn(),
|
||||
sessionId: "session-1",
|
||||
signal: new AbortController().signal,
|
||||
@@ -921,6 +922,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
agent: { streamFn: vi.fn() },
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
} as never,
|
||||
llmRuntime: { streamSimple: vi.fn() } as never,
|
||||
providerStreamFn: vi.fn(),
|
||||
sessionId: "session-1",
|
||||
signal: new AbortController().signal,
|
||||
@@ -2297,6 +2299,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
config: undefined,
|
||||
agentDir: "/tmp",
|
||||
effectiveWorkspace: "/tmp",
|
||||
apiRegistry: {} as never,
|
||||
});
|
||||
|
||||
expect(result).toBe(streamFn);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import type { ApiRegistry } from "@openclaw/ai";
|
||||
import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js";
|
||||
import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
import { resolveAgentModelFallbackValues } from "../../config/model-input.js";
|
||||
@@ -158,6 +159,7 @@ import {
|
||||
resolveSessionWriteLockOptions,
|
||||
} from "../session-write-lock.js";
|
||||
import { createAgentSession, estimateTokens, SessionManager } from "../sessions/index.js";
|
||||
import { getModelRegistryRuntime } from "../sessions/model-registry-runtime.js";
|
||||
import { detectRuntimeShell } from "../shell-utils.js";
|
||||
import {
|
||||
filterProviderNormalizableTools,
|
||||
@@ -257,12 +259,14 @@ function resolveCompactionProviderStream(params: {
|
||||
config?: OpenClawConfig;
|
||||
agentDir: string;
|
||||
effectiveWorkspace: string;
|
||||
apiRegistry: ApiRegistry;
|
||||
}) {
|
||||
return registerProviderStreamForModel({
|
||||
model: params.effectiveModel,
|
||||
cfg: params.config,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir: params.effectiveWorkspace,
|
||||
apiRegistry: params.apiRegistry,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1499,6 +1503,7 @@ async function compactEmbeddedAgentSessionDirectOnce(
|
||||
config: params.config,
|
||||
agentDir,
|
||||
effectiveWorkspace,
|
||||
apiRegistry: getModelRegistryRuntime(modelRegistry).apiRegistry,
|
||||
});
|
||||
while (true) {
|
||||
// Rebuild the compaction session on retry so provider wrappers, payload
|
||||
@@ -1527,6 +1532,7 @@ async function compactEmbeddedAgentSessionDirectOnce(
|
||||
// through the same transport/payload shaping stack as normal turns.
|
||||
await prepareCompactionSessionAgent({
|
||||
session,
|
||||
llmRuntime: getModelRegistryRuntime(modelRegistry).llmRuntime,
|
||||
providerStreamFn,
|
||||
sessionId: params.sessionId,
|
||||
signal: runAbortController.signal,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { LlmRuntime } from "@openclaw/ai";
|
||||
import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
|
||||
@@ -14,6 +15,7 @@ import { mapThinkingLevelForProvider } from "./utils.js";
|
||||
|
||||
export async function prepareCompactionSessionAgent(params: {
|
||||
session: { agent: { streamFn?: unknown } };
|
||||
llmRuntime: LlmRuntime;
|
||||
providerStreamFn: unknown;
|
||||
sessionId: string;
|
||||
signal: AbortSignal;
|
||||
@@ -58,6 +60,7 @@ export async function prepareCompactionSessionAgent(params: {
|
||||
})
|
||||
: params.resolvedApiKey;
|
||||
params.session.agent.streamFn = resolveEmbeddedAgentStreamFn({
|
||||
llmRuntime: params.llmRuntime,
|
||||
currentStreamFn: resolveEmbeddedAgentBaseStreamFn({ session: params.session as never }),
|
||||
providerStreamFn: params.providerStreamFn as never,
|
||||
sessionId: params.sessionId,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Coverage for cache-retention defaults and overrides in extra params.
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createLlmStreamSimpleMock } from "../../../test/helpers/agents/llm-stream-simple-mock.js";
|
||||
import { applyExtraParamsToAgent } from "./extra-params.js";
|
||||
import { testing as extraParamsTesting } from "./extra-params.test-support.js";
|
||||
import { log } from "./logger.js";
|
||||
@@ -16,7 +15,7 @@ function applyAndExpectWrapped(params: {
|
||||
}) {
|
||||
// Wrapping is the observable signal that cache-retention handling was enabled
|
||||
// without requiring a real provider stream call.
|
||||
const agent: { streamFn?: StreamFn } = {};
|
||||
const agent: { streamFn?: StreamFn } = { streamFn: vi.fn() as StreamFn };
|
||||
|
||||
applyExtraParamsToAgent(
|
||||
agent,
|
||||
@@ -43,8 +42,6 @@ vi.mock("./logger.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../llm/stream.js", () => createLlmStreamSimpleMock());
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(log.warn).mockClear();
|
||||
extraParamsTesting.setProviderRuntimeDepsForTest({
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from "../../llm/providers/stream-wrappers/openai.js";
|
||||
import { createOpenRouterSystemCacheWrapper } from "../../llm/providers/stream-wrappers/proxy.js";
|
||||
import { streamWithPayloadPatch } from "../../llm/providers/stream-wrappers/stream-payload-utils.js";
|
||||
import { streamSimple } from "../../llm/stream.js";
|
||||
import type { SimpleStreamOptions } from "../../llm/types.js";
|
||||
import {
|
||||
createDeepSeekV4OpenAICompatibleThinkingWrapper,
|
||||
@@ -43,6 +42,13 @@ import type { StreamFn } from "../runtime/index.js";
|
||||
import type { SettingsManager } from "../sessions/index.js";
|
||||
import { log } from "./logger.js";
|
||||
import { parseCacheRetention, resolveCacheRetention } from "./prompt-cache-retention.js";
|
||||
|
||||
function requireBaseStreamFn(streamFn: StreamFn | undefined): StreamFn {
|
||||
if (!streamFn) {
|
||||
throw new Error("Cannot apply stream policy without a lifecycle-owned base stream.");
|
||||
}
|
||||
return streamFn;
|
||||
}
|
||||
import type { ProviderThinkLevel } from "./utils.js";
|
||||
|
||||
const defaultProviderRuntimeDeps = {
|
||||
@@ -558,7 +564,7 @@ function createStreamFnWithExtraParams(
|
||||
log.debug(`creating streamFn wrapper with params: ${JSON.stringify(debugParams)}`);
|
||||
}
|
||||
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
const underlying = requireBaseStreamFn(baseStreamFn);
|
||||
const wrappedStreamFn: StreamFn = (callModel, context, options) => {
|
||||
const cacheRetention = resolveCacheRetention(
|
||||
extraParams,
|
||||
@@ -661,7 +667,7 @@ function createParallelToolCallsWrapper(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
enabled: boolean,
|
||||
): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
const underlying = requireBaseStreamFn(baseStreamFn);
|
||||
return (model, context, options) => {
|
||||
if (!supportsGptParallelToolCallsPayload(model.api)) {
|
||||
return underlying(model, context, options);
|
||||
@@ -695,7 +701,7 @@ function shouldStripOpenAICompletionsStore(model: ProviderRuntimeModel): boolean
|
||||
}
|
||||
|
||||
function createOpenAICompletionsStoreCompatWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
const underlying = requireBaseStreamFn(baseStreamFn);
|
||||
return (model, context, options) => {
|
||||
if (!shouldStripOpenAICompletionsStore(model as ProviderRuntimeModel)) {
|
||||
return underlying(model, context, options);
|
||||
@@ -751,7 +757,7 @@ function createOpenAICompletionsChatTemplateKwargsWrapper(params: {
|
||||
baseStreamFn: StreamFn | undefined;
|
||||
configured: Record<string, unknown>;
|
||||
}): StreamFn {
|
||||
const underlying = params.baseStreamFn ?? streamSimple;
|
||||
const underlying = requireBaseStreamFn(params.baseStreamFn);
|
||||
return (model, context, options) => {
|
||||
if (model.api !== "openai-completions") {
|
||||
return underlying(model, context, options);
|
||||
@@ -774,7 +780,7 @@ function createOpenAICompletionsExtraBodyWrapper(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
extraBody: Record<string, unknown>,
|
||||
): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
const underlying = requireBaseStreamFn(baseStreamFn);
|
||||
return (model, context, options) => {
|
||||
if (model.api !== "openai-completions") {
|
||||
return underlying(model, context, options);
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
IngestResult,
|
||||
} from "../../../context-engine/types.js";
|
||||
import { formatErrorMessage } from "../../../infra/errors.js";
|
||||
import { bindStreamLlmRuntime } from "../../../llm/model-runtime-binding.js";
|
||||
import type { Model } from "../../../llm/types.js";
|
||||
import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.js";
|
||||
import { createLazyPromise } from "../../../shared/lazy-runtime.js";
|
||||
@@ -27,6 +28,10 @@ import type {
|
||||
MessagingToolSourceReplyPayload,
|
||||
} from "../../embedded-agent-messaging.types.js";
|
||||
import type { AgentMessage } from "../../runtime/index.js";
|
||||
import {
|
||||
getModelRegistryRuntime,
|
||||
initializeModelRegistryRuntime,
|
||||
} from "../../sessions/model-registry-runtime.js";
|
||||
import type { WorkspaceBootstrapFile } from "../../workspace.js";
|
||||
|
||||
type SubscribeEmbeddedAgentSessionFn =
|
||||
@@ -1342,14 +1347,21 @@ export async function createContextEngineAttemptRunner(params: {
|
||||
.mockReset()
|
||||
.mockReturnValue({ messages: params.sessionMessagesAfterRepair ?? seedMessages });
|
||||
|
||||
hoisted.createAgentSessionMock.mockImplementation(async () => ({
|
||||
session:
|
||||
const modelRegistry = {};
|
||||
initializeModelRegistryRuntime(modelRegistry);
|
||||
const modelRuntime = getModelRegistryRuntime(modelRegistry).llmRuntime;
|
||||
hoisted.createAgentSessionMock.mockImplementation(async () => {
|
||||
const session =
|
||||
params.createSession?.() ??
|
||||
createDefaultEmbeddedSession({
|
||||
initialMessages: seedMessages,
|
||||
prompt: params.sessionPrompt,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
if (session.agent.streamFn) {
|
||||
bindStreamLlmRuntime(session.agent.streamFn, modelRuntime);
|
||||
}
|
||||
return { session };
|
||||
});
|
||||
|
||||
const previousTrajectoryEnv = process.env.OPENCLAW_TRAJECTORY;
|
||||
const previousTrajectoryDirEnv = process.env.OPENCLAW_TRAJECTORY_DIR;
|
||||
@@ -1378,7 +1390,7 @@ export async function createContextEngineAttemptRunner(params: {
|
||||
model: testModel,
|
||||
authStorage: testAuthStorage as never,
|
||||
authProfileStore: { version: 1, profiles: {} },
|
||||
modelRegistry: {} as never,
|
||||
modelRegistry: modelRegistry as never,
|
||||
thinkLevel: "off",
|
||||
disableTools: true,
|
||||
disableMessageTool: true,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { streamSimple } from "../../../llm/stream.js";
|
||||
vi.mock("../context-engine-capabilities.js", () => ({
|
||||
resolveContextEngineCapabilities: async () => ({ llm: undefined }),
|
||||
}));
|
||||
import type { LlmRuntime } from "@openclaw/ai";
|
||||
import { defaultLlmRuntime } from "@openclaw/ai/internal/runtime";
|
||||
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import { addSession } from "../../bash-process-registry.js";
|
||||
@@ -15,7 +17,7 @@ import { buildAgentSystemPrompt } from "../../system-prompt.js";
|
||||
import type { NormalizedUsage } from "../../usage.js";
|
||||
import {
|
||||
resolveEmbeddedAgentBaseStreamFn,
|
||||
resolveEmbeddedAgentStreamFn,
|
||||
resolveEmbeddedAgentStreamFn as resolveEmbeddedAgentStreamFnImpl,
|
||||
} from "../stream-resolution.js";
|
||||
import { buildContextEnginePromptCacheInfo } from "./attempt.context-engine-helpers.js";
|
||||
import {
|
||||
@@ -36,6 +38,17 @@ import {
|
||||
} from "./attempt.tool-call-normalization.js";
|
||||
import { buildEmbeddedAttemptToolRunContext } from "./attempt.tool-run-context.js";
|
||||
|
||||
const llmRuntime = {
|
||||
...defaultLlmRuntime,
|
||||
streamSimple,
|
||||
} as LlmRuntime;
|
||||
|
||||
function resolveEmbeddedAgentStreamFn(
|
||||
params: Omit<Parameters<typeof resolveEmbeddedAgentStreamFnImpl>[0], "llmRuntime">,
|
||||
) {
|
||||
return resolveEmbeddedAgentStreamFnImpl({ ...params, llmRuntime });
|
||||
}
|
||||
|
||||
type FakeWrappedStream = {
|
||||
result: () => Promise<unknown>;
|
||||
[Symbol.asyncIterator]: () => AsyncIterator<unknown>;
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { getApiProvider } from "@openclaw/ai/internal/runtime";
|
||||
import type { LlmRuntime } from "@openclaw/ai";
|
||||
import { defaultLlmRuntime, getApiProvider } from "@openclaw/ai/internal/runtime";
|
||||
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared";
|
||||
// Stream resolution tests cover how embedded runs choose provider, boundary,
|
||||
// native Codex, or custom stream functions and pass auth/cache/signal options.
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { bindStreamLlmRuntime } from "../../llm/model-runtime-binding.js";
|
||||
import { streamSimple } from "../../llm/stream.js";
|
||||
import { mintSecretSentinel } from "../../secrets/sentinel.js";
|
||||
import * as providerTransportStream from "../provider-transport-stream.js";
|
||||
import {
|
||||
describeEmbeddedAgentStreamStrategy,
|
||||
describeEmbeddedAgentStreamStrategy as describeEmbeddedAgentStreamStrategyImpl,
|
||||
resolveEmbeddedAgentApiKey,
|
||||
resolveEmbeddedAgentStreamFn,
|
||||
resolveEmbeddedAgentStreamFn as resolveEmbeddedAgentStreamFnImpl,
|
||||
} from "./stream-resolution.js";
|
||||
|
||||
const streamMocks = vi.hoisted(() => ({
|
||||
@@ -38,6 +40,23 @@ vi.mock("../provider-transport-stream.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const llmRuntime = {
|
||||
...defaultLlmRuntime,
|
||||
streamSimple: streamSimple as StreamFn,
|
||||
} as LlmRuntime;
|
||||
|
||||
function describeEmbeddedAgentStreamStrategy(
|
||||
params: Omit<Parameters<typeof describeEmbeddedAgentStreamStrategyImpl>[0], "llmRuntime">,
|
||||
) {
|
||||
return describeEmbeddedAgentStreamStrategyImpl({ ...params, llmRuntime });
|
||||
}
|
||||
|
||||
function resolveEmbeddedAgentStreamFn(
|
||||
params: Omit<Parameters<typeof resolveEmbeddedAgentStreamFnImpl>[0], "llmRuntime">,
|
||||
) {
|
||||
return resolveEmbeddedAgentStreamFnImpl({ ...params, llmRuntime });
|
||||
}
|
||||
|
||||
const overrideBoundaryAwareStreamFnOnce = (streamFn: StreamFn): void => {
|
||||
// Boundary wrapping remains real by default; individual cases replace only
|
||||
// the inner stream when they need to inspect forwarded options.
|
||||
@@ -75,6 +94,21 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("describeEmbeddedAgentStreamStrategy", () => {
|
||||
it("recovers the lifecycle owner from a prepared session stream", () => {
|
||||
bindStreamLlmRuntime(streamSimple, llmRuntime);
|
||||
|
||||
expect(
|
||||
describeEmbeddedAgentStreamStrategyImpl({
|
||||
currentStreamFn: streamSimple,
|
||||
model: {
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
id: "gpt-5.4",
|
||||
} as never,
|
||||
}),
|
||||
).toBe("boundary-aware:openai-responses");
|
||||
});
|
||||
|
||||
it("describes provider-owned stream paths explicitly", () => {
|
||||
expect(
|
||||
describeEmbeddedAgentStreamStrategy({
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Resolves provider stream functions and API keys for embedded agents.
|
||||
*/
|
||||
import { getApiProvider } from "@openclaw/ai/internal/runtime";
|
||||
import type { LlmRuntime } from "@openclaw/ai";
|
||||
import { stripSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared";
|
||||
import { streamSimple } from "../../llm/stream.js";
|
||||
import { getStreamLlmRuntime } from "../../llm/model-runtime-binding.js";
|
||||
import { createAnthropicVertexStreamFnForModel } from "../anthropic-vertex-stream.js";
|
||||
import { createBoundaryAwareStreamFnForModel } from "../provider-transport-stream.js";
|
||||
import type { StreamFn } from "../runtime/index.js";
|
||||
@@ -18,28 +18,53 @@ type EmbeddedStreamOptions = Parameters<StreamFn>[2] & {
|
||||
|
||||
export function resolveEmbeddedAgentBaseStreamFn(params: {
|
||||
session: { agent: { streamFn?: StreamFn } };
|
||||
}): StreamFn | undefined {
|
||||
}): StreamFn {
|
||||
const cached = embeddedAgentBaseStreamFnCache.get(params.session);
|
||||
if (cached !== undefined || embeddedAgentBaseStreamFnCache.has(params.session)) {
|
||||
if (!cached) {
|
||||
throw new Error("Agent session has no lifecycle-owned base stream.");
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
const baseStreamFn = params.session.agent.streamFn;
|
||||
embeddedAgentBaseStreamFnCache.set(params.session, baseStreamFn);
|
||||
if (!baseStreamFn) {
|
||||
throw new Error("Agent session has no lifecycle-owned base stream.");
|
||||
}
|
||||
return baseStreamFn;
|
||||
}
|
||||
|
||||
type EmbeddedStreamRuntimeOwner =
|
||||
| {
|
||||
llmRuntime: LlmRuntime;
|
||||
currentStreamFn: StreamFn | undefined;
|
||||
}
|
||||
| {
|
||||
llmRuntime?: never;
|
||||
currentStreamFn: StreamFn;
|
||||
};
|
||||
|
||||
function resolveEmbeddedStreamRuntime(owner: EmbeddedStreamRuntimeOwner): LlmRuntime {
|
||||
const runtime = owner.llmRuntime ?? getStreamLlmRuntime(owner.currentStreamFn);
|
||||
if (!runtime) {
|
||||
throw new Error("Embedded stream has no lifecycle runtime owner.");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
function isDefaultOpenClawStreamFnForModel(
|
||||
model: EmbeddedRunAttemptParams["model"],
|
||||
streamFn: StreamFn | undefined,
|
||||
llmRuntime: LlmRuntime,
|
||||
): boolean {
|
||||
if (!streamFn || streamFn === streamSimple) {
|
||||
if (!streamFn || streamFn === llmRuntime.streamSimple) {
|
||||
return true;
|
||||
}
|
||||
const api = typeof model.api === "string" ? model.api.trim() : "";
|
||||
if (!api) {
|
||||
return false;
|
||||
}
|
||||
const provider = getApiProvider(api as never);
|
||||
const provider = llmRuntime.registry.getApiProvider(api as never);
|
||||
return streamFn === provider?.streamSimple || streamFn === provider?.stream;
|
||||
}
|
||||
|
||||
@@ -54,22 +79,25 @@ function isOpenAICodexResponsesModel(model: EmbeddedRunAttemptParams["model"]):
|
||||
function resolveOpenClawNativeCodexResponsesStreamFn(params: {
|
||||
model: EmbeddedRunAttemptParams["model"];
|
||||
currentStreamFn: StreamFn | undefined;
|
||||
llmRuntime: LlmRuntime;
|
||||
}): StreamFn | undefined {
|
||||
if (!isOpenAICodexResponsesModel(params.model)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isDefaultOpenClawStreamFnForModel(params.model, params.currentStreamFn)) {
|
||||
if (!isDefaultOpenClawStreamFnForModel(params.model, params.currentStreamFn, params.llmRuntime)) {
|
||||
return undefined;
|
||||
}
|
||||
return params.currentStreamFn ?? streamSimple;
|
||||
return params.currentStreamFn ?? params.llmRuntime.streamSimple;
|
||||
}
|
||||
|
||||
export function describeEmbeddedAgentStreamStrategy(params: {
|
||||
currentStreamFn: StreamFn | undefined;
|
||||
providerStreamFn?: StreamFn;
|
||||
model: EmbeddedRunAttemptParams["model"];
|
||||
resolvedApiKey?: string;
|
||||
}): string {
|
||||
export function describeEmbeddedAgentStreamStrategy(
|
||||
params: EmbeddedStreamRuntimeOwner & {
|
||||
providerStreamFn?: StreamFn;
|
||||
model: EmbeddedRunAttemptParams["model"];
|
||||
resolvedApiKey?: string;
|
||||
},
|
||||
): string {
|
||||
const llmRuntime = resolveEmbeddedStreamRuntime(params);
|
||||
if (params.providerStreamFn) {
|
||||
return "provider";
|
||||
}
|
||||
@@ -80,11 +108,12 @@ export function describeEmbeddedAgentStreamStrategy(params: {
|
||||
resolveOpenClawNativeCodexResponsesStreamFn({
|
||||
model: params.model,
|
||||
currentStreamFn: params.currentStreamFn,
|
||||
llmRuntime,
|
||||
})
|
||||
) {
|
||||
return "openclaw-native-codex-responses";
|
||||
}
|
||||
if (isDefaultOpenClawStreamFnForModel(params.model, params.currentStreamFn)) {
|
||||
if (isDefaultOpenClawStreamFnForModel(params.model, params.currentStreamFn, llmRuntime)) {
|
||||
return createBoundaryAwareStreamFnForModel(params.model)
|
||||
? `boundary-aware:${params.model.api}`
|
||||
: "stream-simple";
|
||||
@@ -110,18 +139,20 @@ export async function resolveEmbeddedAgentApiKey(params: {
|
||||
return params.authStorage ? await params.authStorage.getApiKey(params.provider) : undefined;
|
||||
}
|
||||
|
||||
export function resolveEmbeddedAgentStreamFn(params: {
|
||||
currentStreamFn: StreamFn | undefined;
|
||||
providerStreamFn?: StreamFn;
|
||||
sessionId: string;
|
||||
promptCacheKey?: string;
|
||||
signal?: AbortSignal;
|
||||
model: EmbeddedRunAttemptParams["model"];
|
||||
resolvedApiKey?: string;
|
||||
transportAuthAvailable?: boolean;
|
||||
authProfileId?: string;
|
||||
authStorage?: { getApiKey(provider: string): Promise<string | undefined> };
|
||||
}): StreamFn {
|
||||
export function resolveEmbeddedAgentStreamFn(
|
||||
params: EmbeddedStreamRuntimeOwner & {
|
||||
providerStreamFn?: StreamFn;
|
||||
sessionId: string;
|
||||
promptCacheKey?: string;
|
||||
signal?: AbortSignal;
|
||||
model: EmbeddedRunAttemptParams["model"];
|
||||
resolvedApiKey?: string;
|
||||
transportAuthAvailable?: boolean;
|
||||
authProfileId?: string;
|
||||
authStorage?: { getApiKey(provider: string): Promise<string | undefined> };
|
||||
},
|
||||
): StreamFn {
|
||||
const llmRuntime = resolveEmbeddedStreamRuntime(params);
|
||||
if (params.providerStreamFn) {
|
||||
return wrapEmbeddedAgentStreamFn(params.providerStreamFn, {
|
||||
runSignal: params.signal,
|
||||
@@ -140,7 +171,7 @@ export function resolveEmbeddedAgentStreamFn(params: {
|
||||
});
|
||||
}
|
||||
|
||||
const currentStreamFn = params.currentStreamFn ?? streamSimple;
|
||||
const currentStreamFn = params.currentStreamFn ?? llmRuntime.streamSimple;
|
||||
if (params.model.provider === "anthropic-vertex") {
|
||||
return createAnthropicVertexStreamFnForModel(params.model);
|
||||
}
|
||||
@@ -148,6 +179,7 @@ export function resolveEmbeddedAgentStreamFn(params: {
|
||||
const openClawNativeCodexResponsesStreamFn = resolveOpenClawNativeCodexResponsesStreamFn({
|
||||
model: params.model,
|
||||
currentStreamFn: params.currentStreamFn,
|
||||
llmRuntime,
|
||||
});
|
||||
if (openClawNativeCodexResponsesStreamFn) {
|
||||
return wrapEmbeddedAgentStreamFn(openClawNativeCodexResponsesStreamFn, {
|
||||
@@ -169,7 +201,7 @@ export function resolveEmbeddedAgentStreamFn(params: {
|
||||
}
|
||||
|
||||
if (
|
||||
isDefaultOpenClawStreamFnForModel(params.model, params.currentStreamFn) ||
|
||||
isDefaultOpenClawStreamFnForModel(params.model, params.currentStreamFn, llmRuntime) ||
|
||||
hasResolvedRuntimeApiKey(params.resolvedApiKey) ||
|
||||
params.transportAuthAvailable ||
|
||||
// Proxied anthropic-messages providers (provider !== "anthropic", e.g. pioneer)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* Decodes HTML-entity escaped tool-call arguments in stream wrappers.
|
||||
*/
|
||||
import { streamSimple } from "../../llm/stream.js";
|
||||
import { decodeHtmlEntities } from "../../shared/html-entities.js";
|
||||
import { visitObjectContentBlocks } from "../../shared/message-content-blocks.js";
|
||||
import type { StreamFn } from "../runtime/index.js";
|
||||
@@ -91,12 +90,9 @@ function wrapStreamMessageObjects(
|
||||
}
|
||||
|
||||
/** Wraps a stream function so tool-call arguments are decoded before consumers inspect them. */
|
||||
export function createHtmlEntityToolCallArgumentDecodingWrapper(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
export function createHtmlEntityToolCallArgumentDecodingWrapper(baseStreamFn: StreamFn): StreamFn {
|
||||
return (model, context, options) => {
|
||||
const maybeStream = underlying(model, context, options);
|
||||
const maybeStream = baseStreamFn(model, context, options);
|
||||
if (maybeStream && typeof maybeStream === "object" && "then" in maybeStream) {
|
||||
return Promise.resolve(maybeStream).then((stream) =>
|
||||
wrapStreamMessageObjects(stream, decodeToolCallArgumentsHtmlEntitiesInMessage),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ApiRegistry } from "@openclaw/ai";
|
||||
// Verifies the Google simple-completion wrapper and thinking-payload sanitizer hook.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Model } from "../llm/types.js";
|
||||
@@ -5,6 +6,9 @@ import type { Model } from "../llm/types.js";
|
||||
const streamSimple = vi.fn();
|
||||
const sanitizeGoogleThinkingPayload = vi.fn();
|
||||
const ensureCustomApiRegistered = vi.fn();
|
||||
const apiRegistry = {
|
||||
getApiProvider: vi.fn(() => ({ streamSimple })),
|
||||
} as unknown as ApiRegistry;
|
||||
|
||||
vi.mock("../llm/stream.js", () => ({
|
||||
streamSimple,
|
||||
@@ -72,7 +76,7 @@ describe("prepareGoogleSimpleCompletionModel", () => {
|
||||
api: "openai-responses",
|
||||
} as unknown as Model<"openai-responses">;
|
||||
|
||||
const result = prepareGoogleSimpleCompletionModel(model);
|
||||
const result = prepareGoogleSimpleCompletionModel(apiRegistry, model);
|
||||
|
||||
expect(result).toBe(model);
|
||||
expect(ensureCustomApiRegistered).not.toHaveBeenCalled();
|
||||
@@ -81,22 +85,23 @@ describe("prepareGoogleSimpleCompletionModel", () => {
|
||||
it("registers an OpenClaw-owned Google simple-completion api alias", () => {
|
||||
const model = makeGoogleModel();
|
||||
|
||||
const result = prepareGoogleSimpleCompletionModel(model);
|
||||
const result = prepareGoogleSimpleCompletionModel(apiRegistry, model);
|
||||
|
||||
expect(result).toEqual({
|
||||
...model,
|
||||
api: GOOGLE_SIMPLE_COMPLETION_API,
|
||||
});
|
||||
expect(ensureCustomApiRegistered).toHaveBeenCalledTimes(1);
|
||||
expect(ensureCustomApiRegistered.mock.calls[0]?.[0]).toBe(GOOGLE_SIMPLE_COMPLETION_API);
|
||||
expect(ensureCustomApiRegistered.mock.calls[0]?.[0]).toBe(apiRegistry);
|
||||
expect(ensureCustomApiRegistered.mock.calls[0]?.[1]).toBe(GOOGLE_SIMPLE_COMPLETION_API);
|
||||
});
|
||||
|
||||
it.each(["off", "low", "medium", "high", "adaptive"] as const)(
|
||||
"sanitizes outbound thinking payload for gemini-flash-latest with reasoning=%s",
|
||||
async (reasoning) => {
|
||||
const model = makeGoogleModel();
|
||||
const wrapped = prepareGoogleSimpleCompletionModel(model);
|
||||
const streamFn = ensureCustomApiRegistered.mock.calls[0]?.[1] as (
|
||||
const wrapped = prepareGoogleSimpleCompletionModel(apiRegistry, model);
|
||||
const streamFn = ensureCustomApiRegistered.mock.calls[0]?.[2] as (
|
||||
...args: unknown[]
|
||||
) => unknown;
|
||||
|
||||
@@ -130,8 +135,8 @@ describe("prepareGoogleSimpleCompletionModel", () => {
|
||||
payload.generationConfig.thinkingConfig.thinkingLevel = "MINIMAL";
|
||||
});
|
||||
const model = makeGoogleModel();
|
||||
prepareGoogleSimpleCompletionModel(model);
|
||||
const streamFn = ensureCustomApiRegistered.mock.calls[0]?.[1] as (
|
||||
prepareGoogleSimpleCompletionModel(apiRegistry, model);
|
||||
const streamFn = ensureCustomApiRegistered.mock.calls[0]?.[2] as (
|
||||
...args: unknown[]
|
||||
) => unknown;
|
||||
|
||||
@@ -181,8 +186,8 @@ describe("prepareGoogleSimpleCompletionModel", () => {
|
||||
max: null,
|
||||
},
|
||||
});
|
||||
const wrapped = prepareGoogleSimpleCompletionModel(model);
|
||||
const streamFn = ensureCustomApiRegistered.mock.calls[0]?.[1] as (
|
||||
const wrapped = prepareGoogleSimpleCompletionModel(apiRegistry, model);
|
||||
const streamFn = ensureCustomApiRegistered.mock.calls[0]?.[2] as (
|
||||
...args: unknown[]
|
||||
) => unknown;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ApiRegistry } from "@openclaw/ai";
|
||||
/**
|
||||
* Google simple-completion stream adapter.
|
||||
*
|
||||
@@ -5,7 +6,6 @@
|
||||
* backend but sanitizes unsupported thinking payload options for simple models.
|
||||
*/
|
||||
import { clampThinkingLevel } from "@openclaw/ai/internal/runtime";
|
||||
import { streamSimple } from "../llm/stream.js";
|
||||
import type { Api, Model, ModelThinkingLevel } from "../llm/types.js";
|
||||
import {
|
||||
sanitizeGoogleThinkingPayload,
|
||||
@@ -40,11 +40,15 @@ function resolveGoogleSimpleThinkingLevel(
|
||||
}
|
||||
}
|
||||
|
||||
function buildGoogleSimpleCompletionStreamFn(): StreamFn {
|
||||
function buildGoogleSimpleCompletionStreamFn(registry: ApiRegistry): StreamFn {
|
||||
return (model, context, options) => {
|
||||
const googleModel: Model = { ...model, api: SOURCE_API };
|
||||
const sourceProvider = registry.getApiProvider(SOURCE_API);
|
||||
if (!sourceProvider) {
|
||||
throw new Error(`No API provider registered for api: ${SOURCE_API}`);
|
||||
}
|
||||
return streamWithPayloadPatch(
|
||||
streamSimple as unknown as StreamFn,
|
||||
sourceProvider.streamSimple as StreamFn,
|
||||
googleModel,
|
||||
context,
|
||||
options,
|
||||
@@ -63,10 +67,17 @@ function buildGoogleSimpleCompletionStreamFn(): StreamFn {
|
||||
}
|
||||
|
||||
/** Rewrites Google generative-ai models to the simple-completion adapter when needed. */
|
||||
export function prepareGoogleSimpleCompletionModel<TApi extends Api>(model: Model<TApi>): Model {
|
||||
export function prepareGoogleSimpleCompletionModel<TApi extends Api>(
|
||||
registry: ApiRegistry,
|
||||
model: Model<TApi>,
|
||||
): Model {
|
||||
if (model.api !== SOURCE_API) {
|
||||
return model;
|
||||
}
|
||||
ensureCustomApiRegistered(GOOGLE_SIMPLE_COMPLETION_API, buildGoogleSimpleCompletionStreamFn());
|
||||
ensureCustomApiRegistered(
|
||||
registry,
|
||||
GOOGLE_SIMPLE_COMPLETION_API,
|
||||
buildGoogleSimpleCompletionStreamFn(registry),
|
||||
);
|
||||
return { ...model, api: GOOGLE_SIMPLE_COMPLETION_API };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createLlmRuntime, type LlmRuntime } from "@openclaw/ai";
|
||||
import type { OpenAICompletionsOptions } from "@openclaw/ai/internal/openai";
|
||||
import { getEnvApiKey } from "@openclaw/ai/internal/runtime";
|
||||
import { registerBuiltInApiProviders } from "@openclaw/ai/providers";
|
||||
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
@@ -20,7 +22,7 @@ import { formatErrorMessage } from "../infra/errors.js";
|
||||
* Scans remote provider model catalogs for configured providers.
|
||||
*/
|
||||
import { readResponseWithLimit } from "../infra/http-body.js";
|
||||
import { complete } from "../llm/stream.js";
|
||||
import "../llm/ai-transport-host.js";
|
||||
import type { Context, Model, Tool } from "../llm/types.js";
|
||||
import { inferParamBFromIdOrName } from "../shared/model-param-b.js";
|
||||
|
||||
@@ -299,6 +301,7 @@ async function probeTool(
|
||||
model: OpenAIModel,
|
||||
apiKey: string,
|
||||
timeoutMs: number,
|
||||
complete: LlmRuntime["complete"],
|
||||
): Promise<ProbeResult> {
|
||||
const context: Context = {
|
||||
messages: [
|
||||
@@ -345,6 +348,7 @@ async function probeImage(
|
||||
model: OpenAIModel,
|
||||
apiKey: string,
|
||||
timeoutMs: number,
|
||||
complete: LlmRuntime["complete"],
|
||||
): Promise<ProbeResult> {
|
||||
const context: Context = {
|
||||
messages: [
|
||||
@@ -433,6 +437,8 @@ export async function scanOpenRouterModels(
|
||||
const providerFilter = normalizeProviderId(options.providerFilter ?? "");
|
||||
|
||||
const catalog = await fetchOpenRouterModels(fetchImpl, timeoutMs);
|
||||
const llmRuntime = createLlmRuntime();
|
||||
registerBuiltInApiProviders(llmRuntime.registry);
|
||||
const now = Date.now();
|
||||
|
||||
const filtered = catalog.filter((entry) => {
|
||||
@@ -504,9 +510,9 @@ export async function scanOpenRouterModels(
|
||||
reasoning: baseModel.reasoning,
|
||||
};
|
||||
|
||||
const toolResult = await probeTool(model, apiKey, timeoutMs);
|
||||
const toolResult = await probeTool(model, apiKey, timeoutMs, llmRuntime.complete);
|
||||
const imageResult = model.input?.includes("image")
|
||||
? await probeImage(ensureImageInput(model), apiKey, timeoutMs)
|
||||
? await probeImage(ensureImageInput(model), apiKey, timeoutMs, llmRuntime.complete)
|
||||
: { ok: false, latencyMs: null, skipped: true };
|
||||
|
||||
result = buildOpenRouterScanResult({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Live-sweeps discovered model profiles with optional provider/model filters and probes.
|
||||
import { writeSync } from "node:fs";
|
||||
import { defaultApiRegistry } from "@openclaw/ai/internal/runtime";
|
||||
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { type Api, completeSimple, type Model } from "openclaw/plugin-sdk/llm";
|
||||
@@ -347,6 +348,7 @@ async function ensureLiveProviderApisRegistered(params: {
|
||||
const providerConfig = params.config.models?.providers?.ollama;
|
||||
const providerBaseUrl = readConfiguredOllamaBaseUrl(providerConfig) || OLLAMA_DEFAULT_BASE_URL;
|
||||
ensureCustomApiRegistered(
|
||||
defaultApiRegistry,
|
||||
"ollama",
|
||||
createLiveOllamaRuntimeStreamFn({
|
||||
createConfiguredOllamaStreamFn,
|
||||
@@ -1436,6 +1438,7 @@ async function completeSimpleWithTimeout<TApi extends Api>(
|
||||
});
|
||||
try {
|
||||
const completionModel = prepareModelForSimpleCompletion({
|
||||
apiRegistry: defaultApiRegistry,
|
||||
model,
|
||||
cfg: activeLiveCompletionConfig,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createApiRegistry, createLlmRuntime } from "@openclaw/ai";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { bindModelLlmRuntime } from "../llm/model-runtime-binding.js";
|
||||
import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js";
|
||||
import { registerProviderStreamForModel } from "./provider-stream.js";
|
||||
|
||||
const { providerStream } = vi.hoisted(() => ({
|
||||
providerStream: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/provider-runtime.js", () => ({
|
||||
resolveProviderStreamFn: () => providerStream,
|
||||
}));
|
||||
|
||||
describe("provider stream lifecycle registration", () => {
|
||||
it("registers provider streams into the prepared model runtime", () => {
|
||||
providerStream.mockReturnValue(createAssistantMessageEventStream());
|
||||
const apiRegistry = createApiRegistry();
|
||||
const llmRuntime = createLlmRuntime(apiRegistry);
|
||||
const model = bindModelLlmRuntime(
|
||||
{
|
||||
api: "test-lifecycle-provider",
|
||||
provider: "test-provider",
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
baseUrl: "https://example.test",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1024,
|
||||
maxTokens: 512,
|
||||
},
|
||||
llmRuntime,
|
||||
);
|
||||
|
||||
expect(registerProviderStreamForModel({ model })).toBeTypeOf("function");
|
||||
expect(apiRegistry.getApiProvider("test-lifecycle-provider")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,9 @@
|
||||
* Resolves plugin-owned or transport-aware stream functions and registers the
|
||||
* model API once a concrete stream implementation exists.
|
||||
*/
|
||||
import type { ApiRegistry } from "@openclaw/ai";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { getModelLlmRuntime } from "../llm/model-runtime-binding.js";
|
||||
import type { Api, Model } from "../llm/types.js";
|
||||
import { resolveProviderStreamFn } from "../plugins/provider-runtime.js";
|
||||
import { ensureCustomApiRegistered } from "./custom-api-registry.js";
|
||||
@@ -23,7 +25,7 @@ export function registerProviderStreamForModel<TApi extends Api>(params: {
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
allowRuntimePluginLoad?: boolean;
|
||||
registerStream?: boolean;
|
||||
apiRegistry?: ApiRegistry;
|
||||
}): StreamFn | undefined {
|
||||
// Plugin stream factories may capture model headers, so construction is the
|
||||
// last safe boundary for providers that do not expose the host fetch seam.
|
||||
@@ -67,8 +69,9 @@ export function registerProviderStreamForModel<TApi extends Api>(params: {
|
||||
}
|
||||
// Register custom APIs only after a concrete stream exists, so later callers
|
||||
// can route by model.api without reloading provider runtime hooks.
|
||||
if (params.registerStream !== false) {
|
||||
ensureCustomApiRegistered(params.model.api, streamFn);
|
||||
const apiRegistry = params.apiRegistry ?? getModelLlmRuntime(params.model)?.registry;
|
||||
if (apiRegistry) {
|
||||
ensureCustomApiRegistered(apiRegistry, params.model.api, streamFn);
|
||||
}
|
||||
return streamFn;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cleanupSessionResources } from "@openclaw/ai/internal/runtime";
|
||||
import { streamSimple } from "../../llm/stream.js";
|
||||
import type { AssistantMessage, Model } from "../../llm/types.js";
|
||||
import type {
|
||||
Agent,
|
||||
@@ -36,6 +35,7 @@ import {
|
||||
type TurnStartEvent,
|
||||
} from "./extensions/index.js";
|
||||
import type { BashExecutionMessage, CustomMessage } from "./messages.js";
|
||||
import { getModelRegistryRuntime } from "./model-registry-runtime.js";
|
||||
import type { ModelRegistry } from "./model-registry.js";
|
||||
import type { PromptTemplate } from "./prompt-templates.js";
|
||||
import type { ResourceLoader } from "./resource-loader.js";
|
||||
@@ -183,7 +183,10 @@ export abstract class AgentSessionBase {
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
}> {
|
||||
if (this.agent.streamFn === streamSimple) {
|
||||
if (
|
||||
this.agent.streamFn ===
|
||||
getModelRegistryRuntime(this.sessionModelRegistry).llmRuntime.streamSimple
|
||||
) {
|
||||
return this.getRequiredRequestAuth(model);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { isContextOverflow } from "@openclaw/ai/internal/runtime";
|
||||
import { streamSimple } from "../../llm/stream.js";
|
||||
import type { AssistantMessage, Model } from "../../llm/types.js";
|
||||
import {
|
||||
calculateContextTokens,
|
||||
@@ -14,6 +13,7 @@ import { AgentSessionInspection } from "./agent-session-inspection.js";
|
||||
import { unwrapCoreResult } from "./agent-session-utils.js";
|
||||
import { formatNoModelSelectedMessage } from "./auth-guidance.js";
|
||||
import { preflightManualSessionCompaction } from "./manual-compaction-preflight.js";
|
||||
import { getModelRegistryRuntime } from "./model-registry-runtime.js";
|
||||
import { getLatestCompactionEntry, type CompactionEntry } from "./session-manager.js";
|
||||
import type { SettingsManager } from "./settings-manager.js";
|
||||
|
||||
@@ -109,7 +109,10 @@ export abstract class AgentSessionCompaction extends AgentSessionInspection {
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
if (this.agent.streamFn !== streamSimple) {
|
||||
if (
|
||||
this.agent.streamFn !==
|
||||
getModelRegistryRuntime(this.sessionModelRegistry).llmRuntime.streamSimple
|
||||
) {
|
||||
return this.getCompactionRequestAuth(model);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { basename, dirname } from "node:path";
|
||||
import { defaultApiRegistry } from "@openclaw/ai/internal/runtime";
|
||||
import { resetApiProviders } from "@openclaw/ai/providers";
|
||||
import { AgentSessionCompaction } from "./agent-session-compaction.js";
|
||||
import type { ExtensionBindings } from "./agent-session-types.js";
|
||||
import { ExtensionRunner, type ToolDefinition, wrapRegisteredTools } from "./extensions/index.js";
|
||||
@@ -401,8 +399,8 @@ export abstract class AgentSessionExtensions extends AgentSessionCompaction {
|
||||
await this.settingsManager.reload();
|
||||
this.agent.steeringMode = this.settingsManager.getSteeringMode();
|
||||
this.agent.followUpMode = this.settingsManager.getFollowUpMode();
|
||||
resetApiProviders(defaultApiRegistry);
|
||||
await this.sessionResourceLoader.reload();
|
||||
this.sessionModelRegistry.refresh();
|
||||
this.buildRuntime({
|
||||
activeToolNames: this.getActiveToolNames(),
|
||||
flagValues: previousFlagValues,
|
||||
|
||||
@@ -12,10 +12,6 @@ const streamMocks = vi.hoisted(() => ({
|
||||
streamSimple: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../llm/stream.js", () => ({
|
||||
streamSimple: streamMocks.streamSimple,
|
||||
}));
|
||||
|
||||
import type { AgentTool } from "../runtime/index.js";
|
||||
import type { AgentSessionEvent } from "./agent-session-types.js";
|
||||
import { AgentSession } from "./agent-session.js";
|
||||
@@ -172,6 +168,11 @@ async function createTestSession(
|
||||
retry: { enabled: false },
|
||||
});
|
||||
const sessionManager = options.sessionManager ?? SessionManager.inMemory();
|
||||
const modelRegistry = ModelRegistry.inMemory(authStorage);
|
||||
modelRegistry.registerProvider(model.provider, {
|
||||
api: model.api,
|
||||
streamSimple: streamMocks.streamSimple,
|
||||
});
|
||||
const result = await createAgentSession({
|
||||
model,
|
||||
noTools: "builtin",
|
||||
@@ -179,7 +180,7 @@ async function createTestSession(
|
||||
resourceLoader: options.resourceLoader ?? createResourceLoader(),
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
modelRegistry: ModelRegistry.inMemory(authStorage),
|
||||
modelRegistry,
|
||||
});
|
||||
sessions.push(result.session);
|
||||
return { ...result, settingsManager, sessionManager };
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
createApiRegistry,
|
||||
createLlmRuntime,
|
||||
type ApiRegistry,
|
||||
type LlmRuntime,
|
||||
} from "@openclaw/ai";
|
||||
import { getPublishedApiProviders } from "@openclaw/ai/internal/runtime";
|
||||
import { registerBuiltInApiProviders } from "@openclaw/ai/providers";
|
||||
import "../../llm/ai-transport-host.js";
|
||||
import { bindStreamLlmRuntime } from "../../llm/model-runtime-binding.js";
|
||||
|
||||
type ModelRegistryRuntime = {
|
||||
apiRegistry: ApiRegistry;
|
||||
llmRuntime: LlmRuntime;
|
||||
};
|
||||
|
||||
const modelRegistryRuntimes = new WeakMap<object, ModelRegistryRuntime>();
|
||||
|
||||
function resetApiRegistry(runtime: ModelRegistryRuntime): void {
|
||||
runtime.apiRegistry.clearApiProviders();
|
||||
registerBuiltInApiProviders(runtime.apiRegistry);
|
||||
// The Plugin SDK registry is a shipped compatibility facade. Snapshot it at
|
||||
// lifecycle publication so request-time routing never depends on mutable global state.
|
||||
for (const provider of getPublishedApiProviders()) {
|
||||
runtime.apiRegistry.registerApiProvider(provider);
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates the runtime facts owned by one model-registry lifecycle. */
|
||||
export function initializeModelRegistryRuntime(owner: object): void {
|
||||
const apiRegistry = createApiRegistry();
|
||||
const llmRuntime = createLlmRuntime(apiRegistry);
|
||||
const runtime = { apiRegistry, llmRuntime };
|
||||
bindStreamLlmRuntime(llmRuntime.streamSimple, llmRuntime);
|
||||
resetApiRegistry(runtime);
|
||||
modelRegistryRuntimes.set(owner, runtime);
|
||||
}
|
||||
|
||||
/** Returns the prepared runtime facts for one model-registry lifecycle. */
|
||||
export function getModelRegistryRuntime(owner: object): ModelRegistryRuntime {
|
||||
const runtime = modelRegistryRuntimes.get(owner);
|
||||
if (!runtime) {
|
||||
throw new Error("Model registry runtime is not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
export function resetModelRegistryRuntime(owner: object): void {
|
||||
resetApiRegistry(getModelRegistryRuntime(owner));
|
||||
}
|
||||
@@ -3,9 +3,16 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
defaultApiRegistry,
|
||||
getApiProvider,
|
||||
registerApiProvider,
|
||||
unregisterApiProviders,
|
||||
} from "@openclaw/ai/internal/runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { PLUGIN_MODEL_CATALOG_GENERATED_BY } from "../plugin-model-catalog.js";
|
||||
import { AuthStorage } from "./auth-storage.js";
|
||||
import { getModelRegistryRuntime } from "./model-registry-runtime.js";
|
||||
import { ModelRegistry, type ProviderConfigInput } from "./model-registry.js";
|
||||
|
||||
const PLUGIN_MODEL_CATALOG_FILE = "catalog.json";
|
||||
@@ -595,3 +602,58 @@ describe("ModelRegistry OAuth provider ownership", () => {
|
||||
).toBe("Anthropic (Claude Pro/Max)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModelRegistry API provider ownership", () => {
|
||||
it("keeps stream registrations isolated across registry refreshes", () => {
|
||||
const sessionA = ModelRegistry.inMemory(AuthStorage.inMemory());
|
||||
const sessionB = ModelRegistry.inMemory(AuthStorage.inMemory());
|
||||
const streamA = vi.fn(() => ({}) as never);
|
||||
const streamB = vi.fn(() => ({}) as never);
|
||||
|
||||
sessionA.registerProvider("session-a", {
|
||||
api: "test-session-api",
|
||||
streamSimple: streamA,
|
||||
});
|
||||
sessionB.registerProvider("session-b", {
|
||||
api: "test-session-api",
|
||||
streamSimple: streamB,
|
||||
});
|
||||
const runtimeA = getModelRegistryRuntime(sessionA);
|
||||
const runtimeB = getModelRegistryRuntime(sessionB);
|
||||
|
||||
expect(runtimeA.apiRegistry.getApiProvider("test-session-api")?.streamSimple).not.toBe(
|
||||
runtimeB.apiRegistry.getApiProvider("test-session-api")?.streamSimple,
|
||||
);
|
||||
expect(getApiProvider("test-session-api")).toBeUndefined();
|
||||
|
||||
sessionB.unregisterProvider("session-b");
|
||||
|
||||
expect(runtimeA.apiRegistry.getApiProvider("test-session-api")).toBeDefined();
|
||||
expect(runtimeB.apiRegistry.getApiProvider("test-session-api")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("imports published SDK providers without copying request-generated aliases", () => {
|
||||
const publishedSource = "plugin:test-published-api";
|
||||
const requestSource = "custom-api:test-request-api";
|
||||
const stream = vi.fn(() => ({}) as never);
|
||||
registerApiProvider(
|
||||
{ api: "test-published-api", stream, streamSimple: stream },
|
||||
publishedSource,
|
||||
);
|
||||
defaultApiRegistry.registerApiProvider(
|
||||
{ api: "test-request-api", stream, streamSimple: stream },
|
||||
requestSource,
|
||||
);
|
||||
|
||||
try {
|
||||
const session = ModelRegistry.inMemory(AuthStorage.inMemory());
|
||||
const runtime = getModelRegistryRuntime(session);
|
||||
|
||||
expect(runtime.apiRegistry.getApiProvider("test-published-api")).toBeDefined();
|
||||
expect(runtime.apiRegistry.getApiProvider("test-request-api")).toBeUndefined();
|
||||
} finally {
|
||||
unregisterApiProviders(publishedSource);
|
||||
defaultApiRegistry.unregisterApiProviders(requestSource);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { defaultApiRegistry, registerApiProvider } from "@openclaw/ai/internal/runtime";
|
||||
import { resetApiProviders } from "@openclaw/ai/providers";
|
||||
import { type Static, Type } from "typebox";
|
||||
import { Compile } from "typebox/compile";
|
||||
import type { TLocalizedValidationError } from "typebox/error";
|
||||
@@ -31,6 +29,11 @@ import {
|
||||
} from "../plugin-model-catalog.js";
|
||||
import { getAuthStorageOAuthProviderRegistry } from "./auth-storage-oauth-registry.js";
|
||||
import type { AuthStatus, AuthStorage } from "./auth-storage.js";
|
||||
import {
|
||||
getModelRegistryRuntime,
|
||||
initializeModelRegistryRuntime,
|
||||
resetModelRegistryRuntime,
|
||||
} from "./model-registry-runtime.js";
|
||||
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.js";
|
||||
import {
|
||||
clearConfigValueCache,
|
||||
@@ -322,6 +325,7 @@ export class ModelRegistry {
|
||||
options: ModelRegistryOptions = {},
|
||||
) {
|
||||
this.authStorage = authStorage;
|
||||
initializeModelRegistryRuntime(this);
|
||||
this.modelsJsonPath = modelsJsonPath;
|
||||
this.pluginMetadataSnapshot = resolveModelPluginMetadataSnapshot({
|
||||
...(options.pluginMetadataSnapshot
|
||||
@@ -354,8 +358,8 @@ export class ModelRegistry {
|
||||
this.modelRequestHeaders.clear();
|
||||
this.loadError = undefined;
|
||||
|
||||
// Ensure dynamic API/OAuth registrations are rebuilt from current provider state.
|
||||
resetApiProviders(defaultApiRegistry);
|
||||
// Rebuild this lifecycle's API/OAuth registrations from current provider state.
|
||||
resetModelRegistryRuntime(this);
|
||||
getAuthStorageOAuthProviderRegistry(this.authStorage).reset();
|
||||
|
||||
this.loadModels();
|
||||
@@ -859,7 +863,7 @@ export class ModelRegistry {
|
||||
|
||||
if (config.streamSimple) {
|
||||
const streamSimple = config.streamSimple;
|
||||
registerApiProvider(
|
||||
getModelRegistryRuntime(this).apiRegistry.registerApiProvider(
|
||||
{
|
||||
api: config.api!,
|
||||
stream: (model, context, options) =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createAssistantMessageEventStream, type AssistantMessage } from "opencl
|
||||
// session write-lock behavior.
|
||||
import { Type } from "typebox";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getStreamLlmRuntime } from "../../llm/model-runtime-binding.js";
|
||||
import type { Model, SimpleStreamOptions } from "../../llm/types.js";
|
||||
import { createUserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js";
|
||||
import { createTestUserTurnTranscriptTarget } from "../../sessions/user-turn-transcript.test-support.js";
|
||||
@@ -24,6 +25,7 @@ import { takeRuntimeUserTurnTranscriptContext } from "../../sessions/user-turn-t
|
||||
import { AuthStorage } from "./auth-storage.js";
|
||||
import { createExtensionRuntime } from "./extensions/loader.js";
|
||||
import type { LoadExtensionsResult, ToolDefinition } from "./extensions/types.js";
|
||||
import { getModelRegistryRuntime } from "./model-registry-runtime.js";
|
||||
import { ModelRegistry } from "./model-registry.js";
|
||||
import type { ResourceLoader } from "./resource-loader.js";
|
||||
import { createAgentSession } from "./sdk.js";
|
||||
@@ -44,6 +46,23 @@ const testModel: Model = {
|
||||
maxTokens: 1000,
|
||||
};
|
||||
|
||||
describe("createAgentSession runtime ownership", () => {
|
||||
it("binds the installed stream wrapper to the model-registry lifecycle", async () => {
|
||||
const modelRegistry = createTestModelRegistry();
|
||||
const { session } = await createAgentSession({
|
||||
model: testModel,
|
||||
resourceLoader: createEmptyResourceLoader(),
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settingsManager: SettingsManager.inMemory(),
|
||||
modelRegistry,
|
||||
});
|
||||
|
||||
expect(getStreamLlmRuntime(session.agent.streamFn)).toBe(
|
||||
getModelRegistryRuntime(modelRegistry).llmRuntime,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createModelWithoutBaseUrl(overrides: Partial<Model>): Model {
|
||||
const { baseUrl: _baseUrl, ...model } = { ...testModel, ...overrides };
|
||||
return model as unknown as Model;
|
||||
@@ -87,6 +106,17 @@ function createEmptyResourceLoader(): ResourceLoader {
|
||||
return createResourceLoaderWithHandlers(new Map());
|
||||
}
|
||||
|
||||
function createTestModelRegistry(authStorage = AuthStorage.inMemory()): ModelRegistry {
|
||||
const modelRegistry = ModelRegistry.inMemory(authStorage);
|
||||
for (const api of ["openai-responses", "bedrock-converse-stream"] as const) {
|
||||
modelRegistry.registerProvider(`test-${api}`, {
|
||||
api,
|
||||
streamSimple: streamMocks.streamSimple,
|
||||
});
|
||||
}
|
||||
return modelRegistry;
|
||||
}
|
||||
|
||||
function createResourceLoaderWithHandlers(
|
||||
handlers: Map<string, Array<(...args: unknown[]) => Promise<unknown>>>,
|
||||
): ResourceLoader {
|
||||
@@ -130,7 +160,7 @@ async function createSessionAndStreamModel(model: Model): Promise<SimpleStreamOp
|
||||
resourceLoader: createEmptyResourceLoader(),
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settingsManager: SettingsManager.inMemory(),
|
||||
modelRegistry: ModelRegistry.inMemory(AuthStorage.inMemory()),
|
||||
modelRegistry: createTestModelRegistry(),
|
||||
});
|
||||
|
||||
await session.agent.streamFn?.(
|
||||
@@ -735,7 +765,7 @@ describe("AgentSession retry behavior", () => {
|
||||
settingsManager: SettingsManager.inMemory({
|
||||
retry: retry ?? { baseDelayMs: 0, maxRetries: 1 },
|
||||
}),
|
||||
modelRegistry: ModelRegistry.inMemory(authStorage),
|
||||
modelRegistry: createTestModelRegistry(authStorage),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
resolveThinkingDefaultForModel,
|
||||
type ThinkingCatalogEntry,
|
||||
} from "../../auto-reply/thinking.js";
|
||||
import { streamSimple } from "../../llm/stream.js";
|
||||
import { bindStreamLlmRuntime } from "../../llm/model-runtime-binding.js";
|
||||
import type { Message, Model } from "../../llm/types.js";
|
||||
import { getAgentDir } from "../config.js";
|
||||
import {
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
ToolDefinition,
|
||||
} from "./extensions/index.js";
|
||||
import { convertToLlm } from "./messages.js";
|
||||
import { getModelRegistryRuntime } from "./model-registry-runtime.js";
|
||||
import { ModelRegistry } from "./model-registry.js";
|
||||
import { findInitialModel } from "./model-resolver.js";
|
||||
import { DefaultResourceLoader, type ResourceLoader } from "./resource-loader.js";
|
||||
@@ -293,6 +294,7 @@ export async function createAgentSession(
|
||||
if (!resourceLoader) {
|
||||
resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
|
||||
await resourceLoader.reload();
|
||||
modelRegistry.refresh();
|
||||
}
|
||||
|
||||
// Check if session has existing data to restore
|
||||
@@ -438,6 +440,7 @@ export async function createAgentSession(
|
||||
const runWithSessionWriteLock = async <T>(run: () => Promise<T> | T): Promise<T> =>
|
||||
options.withSessionWriteLock ? await options.withSessionWriteLock(run) : await run();
|
||||
|
||||
const modelRegistryRuntime = getModelRegistryRuntime(modelRegistry);
|
||||
const agent: Agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt: "",
|
||||
@@ -453,7 +456,7 @@ export async function createAgentSession(
|
||||
}
|
||||
const providerRetrySettings = settingsManager.getProviderRetrySettings();
|
||||
const attributionHeaders = getAttributionHeaders(modelResult, settingsManager);
|
||||
return streamSimple(modelResult, context, {
|
||||
return modelRegistryRuntime.llmRuntime.streamSimple(modelResult, context, {
|
||||
...optionsLocal,
|
||||
apiKey: auth.apiKey,
|
||||
timeoutMs: optionsLocal?.timeoutMs ?? providerRetrySettings.timeoutMs,
|
||||
@@ -506,6 +509,9 @@ export async function createAgentSession(
|
||||
thinkingBudgets: settingsManager.getThinkingBudgets(),
|
||||
maxRetryDelayMs: settingsManager.getProviderRetrySettings().maxRetryDelayMs,
|
||||
});
|
||||
if (agent.streamFn) {
|
||||
bindStreamLlmRuntime(agent.streamFn, modelRegistryRuntime.llmRuntime);
|
||||
}
|
||||
|
||||
// Restore messages if session has existing data
|
||||
if (hasExistingSession) {
|
||||
|
||||
@@ -31,6 +31,20 @@ vi.mock("../llm/stream.js", () => ({
|
||||
completeSimple: hoisted.completeMock,
|
||||
}));
|
||||
|
||||
vi.mock("./sessions/model-registry-runtime.js", () => ({
|
||||
getModelRegistryRuntime: () => {
|
||||
const apiRegistry = {};
|
||||
return {
|
||||
apiRegistry,
|
||||
llmRuntime: {
|
||||
registry: apiRegistry,
|
||||
completeSimple: (...args: unknown[]) => hoisted.completeMock(...args),
|
||||
streamSimple: vi.fn(),
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./embedded-agent-runner/model.js", () => ({
|
||||
resolveModel: hoisted.resolveModelMock,
|
||||
resolveModelAsync: hoisted.resolveModelAsyncMock,
|
||||
@@ -880,7 +894,11 @@ describe("completeWithPreparedSimpleCompletionModel", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(hoisted.prepareModelForSimpleCompletionMock).toHaveBeenCalledWith({ model, cfg });
|
||||
expect(hoisted.prepareModelForSimpleCompletionMock).toHaveBeenCalledWith({
|
||||
apiRegistry: expect.anything(),
|
||||
model,
|
||||
cfg,
|
||||
});
|
||||
expect(hoisted.completeMock).toHaveBeenCalledWith(
|
||||
preparedModel,
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { supportsOpenAIReasoningEffort } from "@openclaw/ai/internal/openai";
|
||||
import { defaultApiRegistry } from "@openclaw/ai/internal/runtime";
|
||||
import { resolveClaudeSonnet5ModelIdentity } from "@openclaw/llm-core";
|
||||
/**
|
||||
* Simple completion runtime preparation.
|
||||
@@ -8,6 +9,7 @@ import { resolveClaudeSonnet5ModelIdentity } from "@openclaw/llm-core";
|
||||
import type { ThinkLevel } from "../auto-reply/thinking.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { bindModelLlmRuntime, getModelLlmRuntime } from "../llm/model-runtime-binding.js";
|
||||
import { completeSimple } from "../llm/stream.js";
|
||||
import type {
|
||||
AssistantMessage,
|
||||
@@ -49,6 +51,7 @@ import { applyPreparedRuntimeAuthToModel } from "./provider-request-config.js";
|
||||
import { protectPreparedProviderRuntimeAuth } from "./provider-secret-egress.js";
|
||||
import { buildAgentRuntimeAuthPlan } from "./runtime-plan/auth.js";
|
||||
import { materializePreparedRuntimeModel } from "./runtime-plan/materialize-model.js";
|
||||
import { getModelRegistryRuntime } from "./sessions/model-registry-runtime.js";
|
||||
import { resolveSimpleCompletionModelResolverWorkspace } from "./simple-completion-scope.js";
|
||||
import { prepareModelForSimpleCompletion } from "./simple-completion-transport.js";
|
||||
import { resolveUtilityModelRefForAgent } from "./utility-model.js";
|
||||
@@ -445,11 +448,15 @@ export async function prepareSimpleCompletionModel(params: {
|
||||
})
|
||||
: fingerprintResolvedProviderAuth(auth)
|
||||
: undefined;
|
||||
const modelRuntime = getModelRegistryRuntime(resolved.modelRegistry);
|
||||
|
||||
return {
|
||||
model: applySecretRefHeaderSentinels(
|
||||
applyLocalNoAuthHeaderOverride(resolvedModel, resolvedAuth),
|
||||
params.cfg,
|
||||
model: bindModelLlmRuntime(
|
||||
applySecretRefHeaderSentinels(
|
||||
applyLocalNoAuthHeaderOverride(resolvedModel, resolvedAuth),
|
||||
params.cfg,
|
||||
),
|
||||
modelRuntime.llmRuntime,
|
||||
),
|
||||
auth: resolvedAuth,
|
||||
...(sourceAuthFingerprint ? { sourceAuthFingerprint } : {}),
|
||||
@@ -521,7 +528,17 @@ export async function completeWithPreparedSimpleCompletionModel(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
options?: SimpleCompletionModelOptions;
|
||||
}): Promise<AssistantMessage> {
|
||||
const completionModel = prepareModelForSimpleCompletion({ model: params.model, cfg: params.cfg });
|
||||
const runtime = getModelLlmRuntime(params.model);
|
||||
let completionModel = prepareModelForSimpleCompletion({
|
||||
// Direct SDK callers that did not use the preparation helper keep the shipped
|
||||
// process-default behavior; all prepared host paths carry their lifecycle owner.
|
||||
apiRegistry: runtime?.registry ?? defaultApiRegistry,
|
||||
model: params.model,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
if (runtime) {
|
||||
completionModel = bindModelLlmRuntime(completionModel, runtime);
|
||||
}
|
||||
const { reasoning: rawReasoning, ...options } = params.options ?? {};
|
||||
const reasoning = normalizeSimpleCompletionReasoning(rawReasoning, completionModel);
|
||||
return await completeSimple(completionModel, params.context, {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { registerApiProvider, unregisterApiProviders } from "@openclaw/ai/internal/runtime";
|
||||
import { createApiRegistry, type ApiRegistry } from "@openclaw/ai";
|
||||
// Simple completion transport tests cover provider-specific stream alias
|
||||
// selection before the generic completion helper invokes the LLM layer.
|
||||
import type { Model } from "openclaw/plugin-sdk/llm";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createMoonshotThinkingWrapper } from "../llm/providers/stream-wrappers/moonshot-thinking.js";
|
||||
import { mintSecretSentinel } from "../secrets/sentinel.js";
|
||||
@@ -17,7 +17,7 @@ const createOpenClawTransportStreamFnForModel = vi.fn();
|
||||
const createTransportAwareStreamFnForModel = vi.fn();
|
||||
const prepareTransportAwareSimpleModel = vi.fn();
|
||||
const resolveTransportAwareSimpleApi = vi.fn();
|
||||
const prepareGoogleSimpleCompletionModel = vi.fn((model: unknown) => model);
|
||||
const prepareGoogleSimpleCompletionModel = vi.fn((_registry: unknown, model: unknown) => model);
|
||||
const pluginStreamFn = vi.fn(() => "plugin-stream-result" as never);
|
||||
|
||||
vi.mock("./anthropic-vertex-stream.js", () => ({
|
||||
@@ -51,17 +51,31 @@ vi.mock("../plugins/provider-runtime.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
let prepareModelForSimpleCompletion: typeof import("./simple-completion-transport.js").prepareModelForSimpleCompletion;
|
||||
let prepareModelForSimpleCompletionImpl: typeof import("./simple-completion-transport.js").prepareModelForSimpleCompletion;
|
||||
let apiRegistry: ApiRegistry;
|
||||
const SIMPLE_COMPLETION_SOURCE_ID = "test:simple-completion-transport";
|
||||
|
||||
function prepareModelForSimpleCompletion(
|
||||
params: Omit<
|
||||
Parameters<
|
||||
typeof import("./simple-completion-transport.js").prepareModelForSimpleCompletion
|
||||
>[0],
|
||||
"apiRegistry"
|
||||
>,
|
||||
) {
|
||||
return prepareModelForSimpleCompletionImpl({ ...params, apiRegistry });
|
||||
}
|
||||
|
||||
describe("prepareModelForSimpleCompletion", () => {
|
||||
beforeAll(async () => {
|
||||
// Dynamic import lets the mocked transport/provider modules settle before
|
||||
// the unit under test captures custom stream registration helpers.
|
||||
({ prepareModelForSimpleCompletion } = await import("./simple-completion-transport.js"));
|
||||
({ prepareModelForSimpleCompletion: prepareModelForSimpleCompletionImpl } =
|
||||
await import("./simple-completion-transport.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
apiRegistry = createApiRegistry();
|
||||
createAnthropicVertexStreamFnForModel.mockReset();
|
||||
ensureCustomApiRegistered.mockReset();
|
||||
resolveProviderStreamFn.mockReset();
|
||||
@@ -81,18 +95,14 @@ describe("prepareModelForSimpleCompletion", () => {
|
||||
createTransportAwareStreamFnForModel.mockReturnValue(undefined);
|
||||
prepareTransportAwareSimpleModel.mockImplementation((model) => model);
|
||||
resolveTransportAwareSimpleApi.mockReturnValue(undefined);
|
||||
prepareGoogleSimpleCompletionModel.mockImplementation((model) => model);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unregisterApiProviders(SIMPLE_COMPLETION_SOURCE_ID);
|
||||
prepareGoogleSimpleCompletionModel.mockImplementation((_registry, model) => model);
|
||||
});
|
||||
|
||||
it("routes provider-owned simple-completion wrappers through an internal API alias", () => {
|
||||
const sourceApi = "moonshot-simple-source";
|
||||
const sourceResult = { source: true };
|
||||
let capturedApi: string | undefined;
|
||||
registerApiProvider(
|
||||
apiRegistry.registerApiProvider(
|
||||
{
|
||||
api: sourceApi,
|
||||
stream: () => sourceResult as never,
|
||||
@@ -137,7 +147,7 @@ describe("prepareModelForSimpleCompletion", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const registeredStream = ensureCustomApiRegistered.mock.calls.at(-1)?.[1];
|
||||
const registeredStream = ensureCustomApiRegistered.mock.calls.at(-1)?.[2];
|
||||
expect(registeredStream).toBeTypeOf("function");
|
||||
const stream = registeredStream(result, { messages: [] }, {});
|
||||
expect(stream).toBe(sourceResult);
|
||||
@@ -193,8 +203,12 @@ describe("prepareModelForSimpleCompletion", () => {
|
||||
...model,
|
||||
headers: { Authorization: `Bearer ${secret}` },
|
||||
});
|
||||
expect(ensureCustomApiRegistered).toHaveBeenCalledWith("ollama", expect.any(Function));
|
||||
const registeredStream = ensureCustomApiRegistered.mock.calls[0]?.[1] as StreamFn;
|
||||
expect(ensureCustomApiRegistered).toHaveBeenCalledWith(
|
||||
apiRegistry,
|
||||
"ollama",
|
||||
expect.any(Function),
|
||||
);
|
||||
const registeredStream = ensureCustomApiRegistered.mock.calls[0]?.[2] as StreamFn;
|
||||
void registeredStream(
|
||||
{ ...model, headers: { Authorization: `Bearer ${sentinel}` } } as never,
|
||||
{} as never,
|
||||
@@ -228,6 +242,7 @@ describe("prepareModelForSimpleCompletion", () => {
|
||||
|
||||
expect(createAnthropicVertexStreamFnForModel).toHaveBeenCalledWith(model);
|
||||
expect(ensureCustomApiRegistered).toHaveBeenCalledWith(
|
||||
apiRegistry,
|
||||
"openclaw-anthropic-vertex-simple:https%3A%2F%2Fus-central1-aiplatform.googleapis.com",
|
||||
"vertex-stream",
|
||||
);
|
||||
@@ -263,6 +278,7 @@ describe("prepareModelForSimpleCompletion", () => {
|
||||
expect(prepareTransportAwareSimpleModel).toHaveBeenCalledWith(model, { cfg: undefined });
|
||||
expect(buildTransportAwareSimpleStreamFn).toHaveBeenCalledWith(model, { cfg: undefined });
|
||||
expect(ensureCustomApiRegistered).toHaveBeenCalledWith(
|
||||
apiRegistry,
|
||||
"openclaw-openai-responses-transport",
|
||||
"transport-stream",
|
||||
);
|
||||
@@ -286,7 +302,7 @@ describe("prepareModelForSimpleCompletion", () => {
|
||||
maxTokens: 8192,
|
||||
headers: {},
|
||||
};
|
||||
prepareGoogleSimpleCompletionModel.mockImplementationOnce((m: unknown) => ({
|
||||
prepareGoogleSimpleCompletionModel.mockImplementationOnce((_registry: unknown, m: unknown) => ({
|
||||
...(m as Model<"google-generative-ai">),
|
||||
api: "openclaw-google-generative-ai-simple",
|
||||
}));
|
||||
@@ -295,7 +311,7 @@ describe("prepareModelForSimpleCompletion", () => {
|
||||
const result = prepareModelForSimpleCompletion({ model });
|
||||
|
||||
expect(prepareTransportAwareSimpleModel).toHaveBeenCalledWith(model, { cfg: undefined });
|
||||
expect(prepareGoogleSimpleCompletionModel).toHaveBeenCalledWith(model);
|
||||
expect(prepareGoogleSimpleCompletionModel).toHaveBeenCalledWith(apiRegistry, model);
|
||||
expect(buildTransportAwareSimpleStreamFn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
...model,
|
||||
@@ -330,6 +346,7 @@ describe("prepareModelForSimpleCompletion", () => {
|
||||
|
||||
expect(buildTransportAwareSimpleStreamFn).toHaveBeenCalledWith(model, { cfg: undefined });
|
||||
expect(ensureCustomApiRegistered).toHaveBeenCalledWith(
|
||||
apiRegistry,
|
||||
"openclaw-google-generative-ai-transport",
|
||||
"google-transport-stream",
|
||||
);
|
||||
@@ -380,6 +397,7 @@ describe("prepareModelForSimpleCompletion", () => {
|
||||
{ cfg: undefined },
|
||||
);
|
||||
expect(ensureCustomApiRegistered).toHaveBeenCalledWith(
|
||||
apiRegistry,
|
||||
"openclaw-openai-responses-transport",
|
||||
"codex-transport-stream",
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getApiProvider } from "@openclaw/ai/internal/runtime";
|
||||
import type { ApiRegistry } from "@openclaw/ai";
|
||||
/**
|
||||
* Simple completion transport preparation.
|
||||
*
|
||||
@@ -65,11 +65,15 @@ function resolveProviderSimpleCompletionApi(model: Model): Api {
|
||||
.join(":")}`;
|
||||
}
|
||||
|
||||
function applyProviderSimpleCompletionWrapper(model: Model, cfg?: OpenClawConfig): Model {
|
||||
function applyProviderSimpleCompletionWrapper(
|
||||
registry: ApiRegistry,
|
||||
model: Model,
|
||||
cfg?: OpenClawConfig,
|
||||
): Model {
|
||||
if (model.api.startsWith(PROVIDER_SIMPLE_COMPLETION_API_PREFIX)) {
|
||||
return model;
|
||||
}
|
||||
const sourceProvider = getApiProvider(model.api);
|
||||
const sourceProvider = registry.getApiProvider(model.api);
|
||||
if (!sourceProvider) {
|
||||
return model;
|
||||
}
|
||||
@@ -93,11 +97,12 @@ function applyProviderSimpleCompletionWrapper(model: Model, cfg?: OpenClawConfig
|
||||
}
|
||||
|
||||
const api = resolveProviderSimpleCompletionApi(model);
|
||||
ensureCustomApiRegistered(api, streamFn);
|
||||
ensureCustomApiRegistered(registry, api, streamFn);
|
||||
return { ...model, api };
|
||||
}
|
||||
|
||||
function prepareCodexSimpleTransportModel<TApi extends Api>(
|
||||
registry: ApiRegistry,
|
||||
model: Model<TApi>,
|
||||
cfg?: OpenClawConfig,
|
||||
): Model | undefined {
|
||||
@@ -117,7 +122,7 @@ function prepareCodexSimpleTransportModel<TApi extends Api>(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
ensureCustomApiRegistered(api, streamFn);
|
||||
ensureCustomApiRegistered(registry, api, streamFn);
|
||||
return {
|
||||
...transportModel,
|
||||
api,
|
||||
@@ -125,38 +130,46 @@ function prepareCodexSimpleTransportModel<TApi extends Api>(
|
||||
}
|
||||
|
||||
export function prepareModelForSimpleCompletion<TApi extends Api>(params: {
|
||||
apiRegistry: ApiRegistry;
|
||||
model: Model<TApi>;
|
||||
cfg?: OpenClawConfig;
|
||||
}): Model {
|
||||
const { model, cfg } = params;
|
||||
const { apiRegistry, model, cfg } = params;
|
||||
// Only provider-owned custom APIs need runtime stream registration here.
|
||||
if (!getApiProvider(model.api) && registerProviderStreamForModel({ model, cfg })) {
|
||||
return applyProviderSimpleCompletionWrapper(model, cfg);
|
||||
if (
|
||||
!apiRegistry.getApiProvider(model.api) &&
|
||||
registerProviderStreamForModel({ model, cfg, apiRegistry })
|
||||
) {
|
||||
return applyProviderSimpleCompletionWrapper(apiRegistry, model, cfg);
|
||||
}
|
||||
|
||||
const codexTransportModel = prepareCodexSimpleTransportModel(model, cfg);
|
||||
const codexTransportModel = prepareCodexSimpleTransportModel(apiRegistry, model, cfg);
|
||||
if (codexTransportModel) {
|
||||
return applyProviderSimpleCompletionWrapper(codexTransportModel, cfg);
|
||||
return applyProviderSimpleCompletionWrapper(apiRegistry, codexTransportModel, cfg);
|
||||
}
|
||||
|
||||
const transportAwareModel = prepareTransportAwareSimpleModel(model, { cfg });
|
||||
if (transportAwareModel !== model) {
|
||||
const streamFn = buildTransportAwareSimpleStreamFn(model, { cfg });
|
||||
if (streamFn) {
|
||||
ensureCustomApiRegistered(transportAwareModel.api, streamFn);
|
||||
return applyProviderSimpleCompletionWrapper(transportAwareModel, cfg);
|
||||
ensureCustomApiRegistered(apiRegistry, transportAwareModel.api, streamFn);
|
||||
return applyProviderSimpleCompletionWrapper(apiRegistry, transportAwareModel, cfg);
|
||||
}
|
||||
}
|
||||
|
||||
if (model.api === "google-generative-ai") {
|
||||
return applyProviderSimpleCompletionWrapper(prepareGoogleSimpleCompletionModel(model), cfg);
|
||||
return applyProviderSimpleCompletionWrapper(
|
||||
apiRegistry,
|
||||
prepareGoogleSimpleCompletionModel(apiRegistry, model),
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (model.provider === "anthropic-vertex") {
|
||||
const api = resolveAnthropicVertexSimpleApi(model.baseUrl);
|
||||
ensureCustomApiRegistered(api, createAnthropicVertexStreamFnForModel(model));
|
||||
return applyProviderSimpleCompletionWrapper({ ...model, api }, cfg);
|
||||
ensureCustomApiRegistered(apiRegistry, api, createAnthropicVertexStreamFnForModel(model));
|
||||
return applyProviderSimpleCompletionWrapper(apiRegistry, { ...model, api }, cfg);
|
||||
}
|
||||
|
||||
return applyProviderSimpleCompletionWrapper(model, cfg);
|
||||
return applyProviderSimpleCompletionWrapper(apiRegistry, model, cfg);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ import * as modelDiscovery from "../agent-model-discovery.js";
|
||||
import type { AuthProfileStore } from "../auth-profiles/types.js";
|
||||
import * as modelAuth from "../model-auth.js";
|
||||
import * as modelsConfig from "../models-config.js";
|
||||
import {
|
||||
getModelRegistryRuntime,
|
||||
initializeModelRegistryRuntime,
|
||||
} from "../sessions/model-registry-runtime.js";
|
||||
import * as pdfNativeProviders from "./pdf-native-providers.js";
|
||||
import * as pdfModelConfigModule from "./pdf-tool.model-config.js";
|
||||
import { resetPdfToolAuthEnv, withTempPdfAgentDir } from "./pdf-tool.test-support.js";
|
||||
@@ -19,14 +23,6 @@ import { resetPdfToolAuthEnv, withTempPdfAgentDir } from "./pdf-tool.test-suppor
|
||||
const completeMock = vi.hoisted(() => vi.fn());
|
||||
const registerProviderStreamForModelMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../llm/stream.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../llm/stream.js")>("../../llm/stream.js");
|
||||
return {
|
||||
...actual,
|
||||
complete: completeMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../provider-stream.js", () => ({
|
||||
registerProviderStreamForModel: registerProviderStreamForModelMock,
|
||||
}));
|
||||
@@ -148,7 +144,10 @@ async function stubPdfToolInfra(
|
||||
maxTokens: 8192,
|
||||
input: params?.input ?? ["text", "document"],
|
||||
}) as never;
|
||||
vi.spyOn(modelDiscovery, "discoverModels").mockReturnValue({ find } as never);
|
||||
const modelRegistry = { find };
|
||||
initializeModelRegistryRuntime(modelRegistry);
|
||||
getModelRegistryRuntime(modelRegistry).llmRuntime.complete = completeMock;
|
||||
vi.spyOn(modelDiscovery, "discoverModels").mockReturnValue(modelRegistry as never);
|
||||
|
||||
vi.spyOn(modelsConfig, "ensureOpenClawModelsJson").mockResolvedValue({
|
||||
agentDir,
|
||||
@@ -744,6 +743,7 @@ describe("createPdfTool", () => {
|
||||
expect(modelAuth.requireApiKey).not.toHaveBeenCalled();
|
||||
expect(setRuntimeApiKey).not.toHaveBeenCalled();
|
||||
expect(registerProviderStreamForModelMock).toHaveBeenCalledWith({
|
||||
apiRegistry: expect.anything(),
|
||||
model: expect.objectContaining({
|
||||
provider: "amazon-bedrock",
|
||||
api: "bedrock-converse-stream",
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { Type } from "typebox";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { complete } from "../../llm/stream.js";
|
||||
import type { Context } from "../../llm/types.js";
|
||||
import {
|
||||
classifyMediaReferenceSource,
|
||||
@@ -23,6 +22,7 @@ import { applySecretRefHeaderSentinels } from "../model-auth.js";
|
||||
import { getModelProviderRequestTransport } from "../provider-request-config.js";
|
||||
import { registerProviderStreamForModel } from "../provider-stream.js";
|
||||
import { optionalFiniteNumberSchema } from "../schema/typebox.js";
|
||||
import { getModelRegistryRuntime } from "../sessions/model-registry-runtime.js";
|
||||
import { readFiniteNumberParam, ToolInputError } from "./common.js";
|
||||
import { coerceImageModelConfig, type ImageModelConfig } from "./image-tool.helpers.js";
|
||||
import {
|
||||
@@ -245,10 +245,12 @@ async function runPdfPrompt(params: {
|
||||
|
||||
// PDF-only model selections may not have loaded their provider plugin yet.
|
||||
// Register before complete() so plugin-owned APIs resolve on first use.
|
||||
const modelRuntime = getModelRegistryRuntime(modelRegistry);
|
||||
registerProviderStreamForModel({
|
||||
model,
|
||||
cfg: effectiveCfg,
|
||||
agentDir: params.agentDir,
|
||||
apiRegistry: modelRuntime.apiRegistry,
|
||||
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
|
||||
});
|
||||
|
||||
@@ -266,7 +268,7 @@ async function runPdfPrompt(params: {
|
||||
images: [],
|
||||
}));
|
||||
const context = buildPdfExtractionContext(params.prompt, textOnlyExtractions, model);
|
||||
const message = await complete(model, context, {
|
||||
const message = await modelRuntime.llmRuntime.complete(model, context, {
|
||||
apiKey,
|
||||
maxTokens: resolvePdfToolMaxTokens(model.maxTokens),
|
||||
});
|
||||
@@ -275,7 +277,7 @@ async function runPdfPrompt(params: {
|
||||
}
|
||||
|
||||
const context = buildPdfExtractionContext(params.prompt, extractions, model);
|
||||
const message = await complete(model, context, {
|
||||
const message = await modelRuntime.llmRuntime.complete(model, context, {
|
||||
apiKey,
|
||||
maxTokens: resolvePdfToolMaxTokens(model.maxTokens),
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { resolveSimpleCompletionModelResolverWorkspace } from "../../agents/simp
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { onTrustedInternalDiagnosticEvent } from "../../infra/diagnostic-events.js";
|
||||
import { bindModelLlmRuntime } from "../../llm/model-runtime-binding.js";
|
||||
import type { AssistantMessage, Model, StreamFn, Usage } from "../../llm/types.js";
|
||||
import { createAssistantMessageEventStream } from "../../llm/utils/event-stream.js";
|
||||
import type { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js";
|
||||
@@ -24,6 +25,10 @@ import {
|
||||
} from "./inference-runtime.js";
|
||||
import { createWorkerToolCallStream } from "./inference-tool-call-stream.js";
|
||||
|
||||
vi.mock("../../agents/sessions/model-registry-runtime.js", () => ({
|
||||
getModelRegistryRuntime: (owner: unknown) => owner,
|
||||
}));
|
||||
|
||||
type Deps = {
|
||||
applyStreamPolicy: typeof applyExtraParamsToAgent;
|
||||
loadCatalog: typeof loadModelCatalog;
|
||||
@@ -172,7 +177,6 @@ function setup(entry: SessionEntry = sessionEntry) {
|
||||
authProfile?: string;
|
||||
catalogWorkspace?: string;
|
||||
prepareWorkspace?: string;
|
||||
registerStream?: boolean;
|
||||
} = {};
|
||||
const resolveModel = vi.fn<Deps["resolveModel"]>(
|
||||
async (_provider, _model, _dir, _cfg, options) => {
|
||||
@@ -185,8 +189,12 @@ function setup(entry: SessionEntry = sessionEntry) {
|
||||
modelParams.modelResolver,
|
||||
);
|
||||
await modelParams.modelResolver?.(PROVIDER, MODEL, modelParams.agentDir, modelParams.cfg, {});
|
||||
const apiRegistry = {};
|
||||
return {
|
||||
model: logicalModel,
|
||||
model: bindModelLlmRuntime(logicalModel, {
|
||||
registry: apiRegistry,
|
||||
streamSimple: fallbackStream,
|
||||
} as never),
|
||||
auth: {
|
||||
apiKey: AUTH_MARKER,
|
||||
profileId: PROFILE,
|
||||
@@ -201,8 +209,7 @@ function setup(entry: SessionEntry = sessionEntry) {
|
||||
const loadManifestSnapshot = vi.fn(
|
||||
() => ({ plugins: [] }) as unknown as ReturnType<Deps["loadManifestSnapshot"]>,
|
||||
);
|
||||
const resolveProviderStream = vi.fn<Deps["resolveProviderStream"]>((streamParams) => {
|
||||
scope.registerStream = streamParams.registerStream;
|
||||
const resolveProviderStream = vi.fn<Deps["resolveProviderStream"]>(() => {
|
||||
return stream;
|
||||
});
|
||||
const resolveStream = vi.fn<Deps["resolveStream"]>((streamParams) => {
|
||||
@@ -238,7 +245,6 @@ function setup(entry: SessionEntry = sessionEntry) {
|
||||
resolveProviderStream,
|
||||
resolveStream,
|
||||
applyStreamPolicy,
|
||||
stream: fallbackStream,
|
||||
wrapStream: vi.fn((streamFn: StreamFn) => streamFn),
|
||||
createTrace: vi.fn(() => ({ traceId: "1".repeat(32), spanId: "2".repeat(16) })),
|
||||
};
|
||||
@@ -364,7 +370,6 @@ describe("worker inference provider runtime", () => {
|
||||
authProfile: PROFILE,
|
||||
catalogWorkspace: WORKSPACE,
|
||||
prepareWorkspace: WORKSPACE,
|
||||
registerStream: false,
|
||||
});
|
||||
const [streamModel, streamContext, streamOptions] = runtime.stream.mock.calls[0] ?? [];
|
||||
expect(streamModel).toMatchObject({ baseUrl: ENDPOINT });
|
||||
|
||||
@@ -54,14 +54,13 @@ import {
|
||||
freezeDiagnosticTraceContext,
|
||||
type DiagnosticTraceContext,
|
||||
} from "../../infra/diagnostic-trace-context.js";
|
||||
import { streamSimple } from "../../llm/stream.js";
|
||||
import { getModelLlmRuntime } from "../../llm/model-runtime-binding.js";
|
||||
import type {
|
||||
AssistantMessage,
|
||||
AssistantMessageEvent,
|
||||
Context,
|
||||
Model,
|
||||
SimpleStreamOptions,
|
||||
StreamFn,
|
||||
Tool,
|
||||
Usage,
|
||||
} from "../../llm/types.js";
|
||||
@@ -110,7 +109,6 @@ type WorkerInferenceRuntimeDependencies = {
|
||||
resolveProviderStream: typeof registerProviderStreamForModel;
|
||||
resolveStream: typeof resolveEmbeddedAgentStreamFn;
|
||||
applyStreamPolicy: typeof applyExtraParamsToAgent;
|
||||
stream: StreamFn;
|
||||
wrapStream: typeof wrapStreamFnWithDiagnosticModelCallEvents;
|
||||
createTrace: typeof createDiagnosticTraceContextFromActiveScope;
|
||||
recordUsage: (params: WorkerInferenceUsageParams) => void;
|
||||
@@ -391,7 +389,6 @@ const DEFAULT_DEPENDENCIES: WorkerInferenceRuntimeDependencies = {
|
||||
resolveProviderStream: registerProviderStreamForModel,
|
||||
resolveStream: resolveEmbeddedAgentStreamFn,
|
||||
applyStreamPolicy: applyExtraParamsToAgent,
|
||||
stream: streamSimple as StreamFn,
|
||||
wrapStream: wrapStreamFnWithDiagnosticModelCallEvents,
|
||||
createTrace: createDiagnosticTraceContextFromActiveScope,
|
||||
recordUsage: emitWorkerInferenceUsage,
|
||||
@@ -635,6 +632,10 @@ export function createWorkerInferenceExecutor(
|
||||
model: approved.model,
|
||||
};
|
||||
const logicalModel = approved.prepared.model;
|
||||
const llmRuntime = getModelLlmRuntime(logicalModel);
|
||||
if (!llmRuntime) {
|
||||
throw new Error("Prepared worker model has no lifecycle runtime owner");
|
||||
}
|
||||
const providerModel =
|
||||
logicalModel.provider === "openai" && logicalModel.api === "openai-chatgpt-responses"
|
||||
? {
|
||||
@@ -647,12 +648,12 @@ export function createWorkerInferenceExecutor(
|
||||
cfg: config,
|
||||
agentDir: approved.agentDir,
|
||||
workspaceDir: approved.workspaceDir,
|
||||
registerStream: false,
|
||||
});
|
||||
const authValue = approved.prepared.auth.apiKey;
|
||||
const streamAgent = {
|
||||
streamFn: dependencies.resolveStream({
|
||||
currentStreamFn: dependencies.stream,
|
||||
llmRuntime,
|
||||
currentStreamFn: llmRuntime.streamSimple,
|
||||
...(providerStream ? { providerStreamFn: providerStream } : {}),
|
||||
sessionId: request.sessionId,
|
||||
signal,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { LlmRuntime } from "@openclaw/ai";
|
||||
import type { Model } from "./types.js";
|
||||
|
||||
const MODEL_LLM_RUNTIME = Symbol("openclaw.modelLlmRuntime");
|
||||
const streamLlmRuntimes = new WeakMap<object, LlmRuntime>();
|
||||
|
||||
type RuntimeBoundModel = Model & {
|
||||
[MODEL_LLM_RUNTIME]?: LlmRuntime;
|
||||
};
|
||||
|
||||
/** Carries the prepared lifecycle runtime without changing the serialized model shape. */
|
||||
export function bindModelLlmRuntime(model: Model, runtime: LlmRuntime): Model {
|
||||
const bound = { ...model } as RuntimeBoundModel;
|
||||
Object.defineProperty(bound, MODEL_LLM_RUNTIME, {
|
||||
value: runtime,
|
||||
enumerable: false,
|
||||
});
|
||||
return bound;
|
||||
}
|
||||
|
||||
export function getModelLlmRuntime(model: Model): LlmRuntime | undefined {
|
||||
return (model as RuntimeBoundModel)[MODEL_LLM_RUNTIME];
|
||||
}
|
||||
|
||||
/** Associates a prepared stream entry point with the runtime that owns it. */
|
||||
export function bindStreamLlmRuntime(streamFn: object, runtime: LlmRuntime): void {
|
||||
streamLlmRuntimes.set(streamFn, runtime);
|
||||
}
|
||||
|
||||
export function getStreamLlmRuntime(streamFn: object | undefined): LlmRuntime | undefined {
|
||||
return streamFn ? streamLlmRuntimes.get(streamFn) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createApiRegistry, createLlmRuntime } from "@openclaw/ai";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { bindModelLlmRuntime } from "./model-runtime-binding.js";
|
||||
import { streamSimple } from "./stream.js";
|
||||
import { createAssistantMessageEventStream } from "./utils/event-stream.js";
|
||||
|
||||
describe("LLM stream facade", () => {
|
||||
it("routes a prepared model through its lifecycle runtime", () => {
|
||||
const registry = createApiRegistry();
|
||||
const runtime = createLlmRuntime(registry);
|
||||
const expected = createAssistantMessageEventStream();
|
||||
const stream = vi.fn(() => expected);
|
||||
registry.registerApiProvider({
|
||||
api: "test-lifecycle-api",
|
||||
stream,
|
||||
streamSimple: stream,
|
||||
});
|
||||
const model = bindModelLlmRuntime(
|
||||
{
|
||||
api: "test-lifecycle-api",
|
||||
provider: "test-provider",
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
baseUrl: "https://example.test",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1024,
|
||||
maxTokens: 512,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(streamSimple(model, { messages: [] })).toBe(expected);
|
||||
expect(stream).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
+46
-2
@@ -2,10 +2,54 @@
|
||||
// This facade owns the process-default AI runtime wiring: it installs the
|
||||
// OpenClaw host policy ports and registers built-in providers exactly once,
|
||||
// before any caller imports the stream API.
|
||||
import { defaultApiRegistry } from "@openclaw/ai/internal/runtime";
|
||||
import { defaultApiRegistry, defaultLlmRuntime } from "@openclaw/ai/internal/runtime";
|
||||
import { registerBuiltInApiProviders } from "@openclaw/ai/providers";
|
||||
import { getModelLlmRuntime } from "./model-runtime-binding.js";
|
||||
import "./ai-transport-host.js";
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
AssistantMessageEventStreamContract,
|
||||
Context,
|
||||
Model,
|
||||
ProviderStreamOptions,
|
||||
SimpleStreamOptions,
|
||||
} from "./types.js";
|
||||
|
||||
registerBuiltInApiProviders(defaultApiRegistry);
|
||||
|
||||
export { complete, completeSimple, stream, streamSimple } from "@openclaw/ai/internal/runtime";
|
||||
function resolveRuntime(model: Model) {
|
||||
return getModelLlmRuntime(model) ?? defaultLlmRuntime;
|
||||
}
|
||||
|
||||
export function stream<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
options?: ProviderStreamOptions,
|
||||
): AssistantMessageEventStreamContract {
|
||||
return resolveRuntime(model).stream(model, context, options);
|
||||
}
|
||||
|
||||
export function complete<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
options?: ProviderStreamOptions,
|
||||
): Promise<AssistantMessage> {
|
||||
return resolveRuntime(model).complete(model, context, options);
|
||||
}
|
||||
|
||||
export function streamSimple<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStreamContract {
|
||||
return resolveRuntime(model).streamSimple(model, context, options);
|
||||
}
|
||||
|
||||
export function completeSimple<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
options?: SimpleStreamOptions,
|
||||
): Promise<AssistantMessage> {
|
||||
return resolveRuntime(model).completeSimple(model, context, options);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { ensureOpenClawModelsJson } from "../agents/models-config.js";
|
||||
import { resolveProviderModelMaterializationAuthMode } from "../agents/provider-model-route-auth.js";
|
||||
import { protectPreparedProviderRuntimeAuth } from "../agents/provider-secret-egress.js";
|
||||
import { providerUsesCredentialScopedModelMetadata } from "../agents/runtime-plan/credential-scoped-model.js";
|
||||
import { getModelRegistryRuntime } from "../agents/sessions/model-registry-runtime.js";
|
||||
import { bindModelLlmRuntime } from "../llm/model-runtime-binding.js";
|
||||
import type { Model } from "../llm/types.js";
|
||||
import { prepareProviderRuntimeAuth } from "../plugins/provider-runtime.runtime.js";
|
||||
import type { ImageDescriptionRequest } from "./types.js";
|
||||
@@ -60,6 +62,7 @@ async function prepareResolvedImageRuntime(
|
||||
modelRegistry: Awaited<ReturnType<typeof resolveModelAsync>>["modelRegistry"],
|
||||
): Promise<{ apiKey: string; model: Model }> {
|
||||
let model = resolvedModel;
|
||||
const modelRuntime = getModelRegistryRuntime(modelRegistry);
|
||||
const apiKeyInfo = await getApiKeyForModel({
|
||||
model,
|
||||
cfg: params.cfg,
|
||||
@@ -113,7 +116,13 @@ async function prepareResolvedImageRuntime(
|
||||
apiKeyInfo.mode === "aws-sdk" &&
|
||||
model.api === "bedrock-converse-stream"
|
||||
) {
|
||||
return { apiKey: "", model: applySecretRefHeaderSentinels(model, params.cfg) };
|
||||
return {
|
||||
apiKey: "",
|
||||
model: bindModelLlmRuntime(
|
||||
applySecretRefHeaderSentinels(model, params.cfg),
|
||||
modelRuntime.llmRuntime,
|
||||
),
|
||||
};
|
||||
}
|
||||
let apiKey = requireApiKey(apiKeyInfo, model.provider);
|
||||
const preparedAuth = protectPreparedProviderRuntimeAuth({
|
||||
@@ -142,7 +151,13 @@ async function prepareResolvedImageRuntime(
|
||||
model = { ...model, baseUrl: runtimeBaseUrl };
|
||||
}
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKey);
|
||||
return { apiKey, model: applySecretRefHeaderSentinels(model, params.cfg) };
|
||||
return {
|
||||
apiKey,
|
||||
model: bindModelLlmRuntime(
|
||||
applySecretRefHeaderSentinels(model, params.cfg),
|
||||
modelRuntime.llmRuntime,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveImageRuntime(
|
||||
|
||||
@@ -153,7 +153,23 @@ vi.mock("../plugins/provider-runtime.runtime.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../agents/embedded-agent-runner/model.js", () => ({
|
||||
resolveModelAsync: resolveModelAsyncMock,
|
||||
resolveModelAsync: async (...args: unknown[]) => {
|
||||
const result = await resolveModelAsyncMock(...args);
|
||||
const modelRegistry = (result?.modelRegistry ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
...result,
|
||||
modelRegistry: {
|
||||
...modelRegistry,
|
||||
llmRuntime: modelRegistry.llmRuntime ?? { complete: completeMock },
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../agents/sessions/model-registry-runtime.js", () => ({
|
||||
getModelRegistryRuntime: (owner: { llmRuntime?: unknown }) => ({
|
||||
llmRuntime: owner.llmRuntime ?? { complete: completeMock },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../plugin-sdk/provider-auth.js", () => ({
|
||||
@@ -1562,7 +1578,9 @@ describe("describeImageWithModel", () => {
|
||||
expect(resolveModelAsyncMock.mock.calls[2]?.[4]).toEqual(
|
||||
expect.objectContaining({
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
modelRegistry: expect.objectContaining({
|
||||
llmRuntime: expect.anything(),
|
||||
}),
|
||||
authProfileId: "github-copilot:backup",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -435,6 +435,8 @@ async function describeImagesWithModelInternal(
|
||||
});
|
||||
}
|
||||
|
||||
// Resolved models carry their lifecycle runtime, so registration targets that
|
||||
// registry before the built-in fallback reaches complete().
|
||||
const providerStreamFn = registerProviderStreamForModel({
|
||||
model,
|
||||
cfg: params.cfg,
|
||||
|
||||
Reference in New Issue
Block a user