Merge branch 'main' into fix/tts-tool-no-channel-hang

This commit is contained in:
Hiago Silva 2026-03-16 09:18:45 -03:00 committed by GitHub
commit 4632b642be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 698 additions and 295 deletions

View File

@ -1,10 +1,5 @@
import {
buildOllamaProvider,
emptyPluginConfigSchema,
ensureOllamaModelPulled,
OLLAMA_DEFAULT_BASE_URL,
promptAndConfigureOllama,
configureOllamaNonInteractive,
type OpenClawPluginApi,
type ProviderAuthContext,
type ProviderAuthMethodNonInteractiveContext,
@ -12,10 +7,15 @@ import {
type ProviderDiscoveryContext,
} from "openclaw/plugin-sdk/core";
import { resolveOllamaApiBase } from "../../src/agents/models-config.providers.discovery.js";
import { OLLAMA_DEFAULT_BASE_URL } from "../../src/agents/ollama-defaults.js";
const PROVIDER_ID = "ollama";
const DEFAULT_API_KEY = "ollama-local";
async function loadProviderSetup() {
return await import("openclaw/plugin-sdk/provider-setup");
}
const ollamaPlugin = {
id: "ollama",
name: "Ollama Provider",
@ -34,7 +34,8 @@ const ollamaPlugin = {
hint: "Cloud and local open models",
kind: "custom",
run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
const result = await promptAndConfigureOllama({
const providerSetup = await loadProviderSetup();
const result = await providerSetup.promptAndConfigureOllama({
cfg: ctx.config,
prompter: ctx.prompter,
});
@ -53,12 +54,14 @@ const ollamaPlugin = {
defaultModel: `ollama/${result.defaultModelId}`,
};
},
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
configureOllamaNonInteractive({
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => {
const providerSetup = await loadProviderSetup();
return await providerSetup.configureOllamaNonInteractive({
nextConfig: ctx.config,
opts: ctx.opts,
runtime: ctx.runtime,
}),
});
},
},
],
discovery: {
@ -81,7 +84,8 @@ const ollamaPlugin = {
};
}
const provider = await buildOllamaProvider(explicit?.baseUrl, {
const providerSetup = await loadProviderSetup();
const provider = await providerSetup.buildOllamaProvider(explicit?.baseUrl, {
quiet: !ollamaKey && !explicit,
});
if (provider.models.length === 0 && !ollamaKey && !explicit?.apiKey) {
@ -115,7 +119,8 @@ const ollamaPlugin = {
if (!model.startsWith("ollama/")) {
return;
}
await ensureOllamaModelPulled({ config, prompter });
const providerSetup = await loadProviderSetup();
await providerSetup.ensureOllamaModelPulled({ config, prompter });
},
});
},

View File

@ -1,5 +1,5 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { registerSandboxBackend } from "openclaw/plugin-sdk/core";
import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox";
import {
createOpenShellSandboxBackendFactory,
createOpenShellSandboxBackendManager,

View File

@ -11,13 +11,13 @@ import type {
SandboxBackendHandle,
SandboxBackendManager,
SshSandboxSession,
} from "openclaw/plugin-sdk/core";
} from "openclaw/plugin-sdk/sandbox";
import {
createRemoteShellSandboxFsBridge,
disposeSshSandboxSession,
resolvePreferredOpenClawTmpDir,
runSshSandboxCommand,
} from "openclaw/plugin-sdk/core";
} from "openclaw/plugin-sdk/sandbox";
import {
buildExecRemoteCommand,
buildRemoteCommand,

View File

@ -4,10 +4,10 @@ import {
runPluginCommandWithTimeout,
shellEscape,
type SshSandboxSession,
} from "openclaw/plugin-sdk/core";
} from "openclaw/plugin-sdk/sandbox";
import type { ResolvedOpenShellPluginConfig } from "./config.js";
export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/core";
export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/sandbox";
export type OpenShellExecContext = {
config: ResolvedOpenShellPluginConfig;

View File

@ -5,7 +5,7 @@ import type {
SandboxFsBridge,
SandboxFsStat,
SandboxResolvedPath,
} from "openclaw/plugin-sdk/core";
} from "openclaw/plugin-sdk/sandbox";
import type { OpenShellSandboxBackend } from "./backend.js";
import { movePathWithCopyFallback } from "./mirror.js";

View File

@ -3,7 +3,7 @@ import {
type RemoteShellSandboxHandle,
type SandboxContext,
type SandboxFsBridge,
} from "openclaw/plugin-sdk/core";
} from "openclaw/plugin-sdk/sandbox";
export function createOpenShellRemoteFsBridge(params: {
sandbox: SandboxContext;

View File

@ -1,15 +1,20 @@
import {
buildSglangProvider,
configureOpenAICompatibleSelfHostedProviderNonInteractive,
discoverOpenAICompatibleSelfHostedProvider,
emptyPluginConfigSchema,
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth,
type OpenClawPluginApi,
type ProviderAuthMethodNonInteractiveContext,
} from "openclaw/plugin-sdk/core";
import {
SGLANG_DEFAULT_API_KEY_ENV_VAR,
SGLANG_DEFAULT_BASE_URL,
SGLANG_MODEL_PLACEHOLDER,
SGLANG_PROVIDER_LABEL,
} from "../../src/agents/sglang-defaults.js";
const PROVIDER_ID = "sglang";
const DEFAULT_BASE_URL = "http://127.0.0.1:30000/v1";
async function loadProviderSetup() {
return await import("openclaw/plugin-sdk/provider-setup");
}
const sglangPlugin = {
id: "sglang",
@ -25,38 +30,44 @@ const sglangPlugin = {
auth: [
{
id: "custom",
label: "SGLang",
label: SGLANG_PROVIDER_LABEL,
hint: "Fast self-hosted OpenAI-compatible server",
kind: "custom",
run: async (ctx) =>
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
run: async (ctx) => {
const providerSetup = await loadProviderSetup();
return await providerSetup.promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
cfg: ctx.config,
prompter: ctx.prompter,
providerId: PROVIDER_ID,
providerLabel: "SGLang",
defaultBaseUrl: DEFAULT_BASE_URL,
defaultApiKeyEnvVar: "SGLANG_API_KEY",
modelPlaceholder: "Qwen/Qwen3-8B",
}),
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
configureOpenAICompatibleSelfHostedProviderNonInteractive({
providerLabel: SGLANG_PROVIDER_LABEL,
defaultBaseUrl: SGLANG_DEFAULT_BASE_URL,
defaultApiKeyEnvVar: SGLANG_DEFAULT_API_KEY_ENV_VAR,
modelPlaceholder: SGLANG_MODEL_PLACEHOLDER,
});
},
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => {
const providerSetup = await loadProviderSetup();
return await providerSetup.configureOpenAICompatibleSelfHostedProviderNonInteractive({
ctx,
providerId: PROVIDER_ID,
providerLabel: "SGLang",
defaultBaseUrl: DEFAULT_BASE_URL,
defaultApiKeyEnvVar: "SGLANG_API_KEY",
modelPlaceholder: "Qwen/Qwen3-8B",
}),
providerLabel: SGLANG_PROVIDER_LABEL,
defaultBaseUrl: SGLANG_DEFAULT_BASE_URL,
defaultApiKeyEnvVar: SGLANG_DEFAULT_API_KEY_ENV_VAR,
modelPlaceholder: SGLANG_MODEL_PLACEHOLDER,
});
},
},
],
discovery: {
order: "late",
run: async (ctx) =>
discoverOpenAICompatibleSelfHostedProvider({
run: async (ctx) => {
const providerSetup = await loadProviderSetup();
return await providerSetup.discoverOpenAICompatibleSelfHostedProvider({
ctx,
providerId: PROVIDER_ID,
buildProvider: buildSglangProvider,
}),
buildProvider: providerSetup.buildSglangProvider,
});
},
},
wizard: {
setup: {

View File

@ -335,6 +335,29 @@ describe("dispatchTelegramMessage draft streaming", () => {
);
});
it("keeps fallback replies silent after an error reply is skipped", async () => {
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
dispatcherOptions.onSkip?.(
{ text: "oops", isError: true },
{ kind: "final", reason: "empty" },
);
return { queuedFinal: false };
});
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({
context: createContext(),
telegramCfg: { silentErrorReplies: true },
});
expect(deliverReplies).toHaveBeenLastCalledWith(
expect.objectContaining({
silent: true,
replies: [expect.objectContaining({ text: expect.any(String) })],
}),
);
});
it("keeps block streaming enabled when session reasoning level is on", async () => {
loadSessionStore.mockReturnValue({
s1: { reasoningLevel: "on" },

View File

@ -515,6 +515,7 @@ export const dispatchTelegramMessage = async ({
});
let queuedFinal = false;
let hadErrorReplyFailureOrSkip = false;
if (statusReactionController) {
void statusReactionController.setThinking();
@ -541,6 +542,9 @@ export const dispatchTelegramMessage = async ({
...prefixOptions,
typingCallbacks,
deliver: async (payload, info) => {
if (payload.isError === true) {
hadErrorReplyFailureOrSkip = true;
}
if (info.kind === "final") {
// Assistant callbacks are fire-and-forget; ensure queued boundary
// rotations/partials are applied before final delivery mapping.
@ -654,7 +658,10 @@ export const dispatchTelegramMessage = async ({
await flushBufferedFinalAnswer();
}
},
onSkip: (_payload, info) => {
onSkip: (payload, info) => {
if (payload.isError === true) {
hadErrorReplyFailureOrSkip = true;
}
if (info.reason !== "silent") {
deliveryState.markNonSilentSkip();
}
@ -811,7 +818,7 @@ export const dispatchTelegramMessage = async ({
const result = await deliverReplies({
replies: [{ text: fallbackText }],
...deliveryBaseOptions,
silent: silentErrorReplies && dispatchError != null,
silent: silentErrorReplies && (dispatchError != null || hadErrorReplyFailureOrSkip),
});
sentFallback = result.delivered;
}

View File

@ -1,15 +1,20 @@
import {
buildVllmProvider,
configureOpenAICompatibleSelfHostedProviderNonInteractive,
discoverOpenAICompatibleSelfHostedProvider,
emptyPluginConfigSchema,
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth,
type OpenClawPluginApi,
type ProviderAuthMethodNonInteractiveContext,
} from "openclaw/plugin-sdk/core";
import {
VLLM_DEFAULT_API_KEY_ENV_VAR,
VLLM_DEFAULT_BASE_URL,
VLLM_MODEL_PLACEHOLDER,
VLLM_PROVIDER_LABEL,
} from "../../src/agents/vllm-defaults.js";
const PROVIDER_ID = "vllm";
const DEFAULT_BASE_URL = "http://127.0.0.1:8000/v1";
async function loadProviderSetup() {
return await import("openclaw/plugin-sdk/provider-setup");
}
const vllmPlugin = {
id: "vllm",
@ -25,38 +30,44 @@ const vllmPlugin = {
auth: [
{
id: "custom",
label: "vLLM",
label: VLLM_PROVIDER_LABEL,
hint: "Local/self-hosted OpenAI-compatible server",
kind: "custom",
run: async (ctx) =>
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
run: async (ctx) => {
const providerSetup = await loadProviderSetup();
return await providerSetup.promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
cfg: ctx.config,
prompter: ctx.prompter,
providerId: PROVIDER_ID,
providerLabel: "vLLM",
defaultBaseUrl: DEFAULT_BASE_URL,
defaultApiKeyEnvVar: "VLLM_API_KEY",
modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct",
}),
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
configureOpenAICompatibleSelfHostedProviderNonInteractive({
providerLabel: VLLM_PROVIDER_LABEL,
defaultBaseUrl: VLLM_DEFAULT_BASE_URL,
defaultApiKeyEnvVar: VLLM_DEFAULT_API_KEY_ENV_VAR,
modelPlaceholder: VLLM_MODEL_PLACEHOLDER,
});
},
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => {
const providerSetup = await loadProviderSetup();
return await providerSetup.configureOpenAICompatibleSelfHostedProviderNonInteractive({
ctx,
providerId: PROVIDER_ID,
providerLabel: "vLLM",
defaultBaseUrl: DEFAULT_BASE_URL,
defaultApiKeyEnvVar: "VLLM_API_KEY",
modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct",
}),
providerLabel: VLLM_PROVIDER_LABEL,
defaultBaseUrl: VLLM_DEFAULT_BASE_URL,
defaultApiKeyEnvVar: VLLM_DEFAULT_API_KEY_ENV_VAR,
modelPlaceholder: VLLM_MODEL_PLACEHOLDER,
});
},
},
],
discovery: {
order: "late",
run: async (ctx) =>
discoverOpenAICompatibleSelfHostedProvider({
run: async (ctx) => {
const providerSetup = await loadProviderSetup();
return await providerSetup.discoverOpenAICompatibleSelfHostedProvider({
ctx,
providerId: PROVIDER_ID,
buildProvider: buildVllmProvider,
}),
buildProvider: providerSetup.buildVllmProvider,
});
},
},
wizard: {
setup: {

View File

@ -1,6 +1,7 @@
#!/usr/bin/env node
import module from "node:module";
import { fileURLToPath } from "node:url";
const MIN_NODE_MAJOR = 22;
const MIN_NODE_MINOR = 12;
@ -47,6 +48,20 @@ if (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) {
const isModuleNotFoundError = (err) =>
err && typeof err === "object" && "code" in err && err.code === "ERR_MODULE_NOT_FOUND";
const isDirectModuleNotFoundError = (err, specifier) => {
if (!isModuleNotFoundError(err)) {
return false;
}
const expectedUrl = new URL(specifier, import.meta.url);
if ("url" in err && err.url === expectedUrl.href) {
return true;
}
const message = "message" in err && typeof err.message === "string" ? err.message : "";
return message.includes(fileURLToPath(expectedUrl));
};
const installProcessWarningFilter = async () => {
// Keep bootstrap warnings consistent with the TypeScript runtime.
for (const specifier of ["./dist/warning-filter.js", "./dist/warning-filter.mjs"]) {
@ -57,7 +72,7 @@ const installProcessWarningFilter = async () => {
return;
}
} catch (err) {
if (isModuleNotFoundError(err)) {
if (isDirectModuleNotFoundError(err, specifier)) {
continue;
}
throw err;
@ -72,8 +87,8 @@ const tryImport = async (specifier) => {
await import(specifier);
return true;
} catch (err) {
// Only swallow missing-module errors; rethrow real runtime errors.
if (isModuleNotFoundError(err)) {
// Only swallow direct entry misses; rethrow transitive resolution failures.
if (isDirectModuleNotFoundError(err, specifier)) {
return false;
}
throw err;

View File

@ -50,6 +50,14 @@
"types": "./dist/plugin-sdk/compat.d.ts",
"default": "./dist/plugin-sdk/compat.js"
},
"./plugin-sdk/provider-setup": {
"types": "./dist/plugin-sdk/provider-setup.d.ts",
"default": "./dist/plugin-sdk/provider-setup.js"
},
"./plugin-sdk/sandbox": {
"types": "./dist/plugin-sdk/sandbox.d.ts",
"default": "./dist/plugin-sdk/sandbox.js"
},
"./plugin-sdk/routing": {
"types": "./dist/plugin-sdk/routing.d.ts",
"default": "./dist/plugin-sdk/routing.js"

View File

@ -0,0 +1,56 @@
import { withFileLock } from "../../infra/file-lock.js";
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION } from "./constants.js";
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js";
function coerceAuthProfileStore(raw: unknown): AuthProfileStore {
const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
const profiles =
record.profiles && typeof record.profiles === "object" && !Array.isArray(record.profiles)
? { ...(record.profiles as Record<string, AuthProfileCredential>) }
: {};
const order =
record.order && typeof record.order === "object" && !Array.isArray(record.order)
? (record.order as Record<string, string[]>)
: undefined;
const lastGood =
record.lastGood && typeof record.lastGood === "object" && !Array.isArray(record.lastGood)
? (record.lastGood as Record<string, string>)
: undefined;
const usageStats =
record.usageStats && typeof record.usageStats === "object" && !Array.isArray(record.usageStats)
? (record.usageStats as Record<string, ProfileUsageStats>)
: undefined;
return {
version:
typeof record.version === "number" && Number.isFinite(record.version)
? record.version
: AUTH_STORE_VERSION,
profiles,
...(order ? { order } : {}),
...(lastGood ? { lastGood } : {}),
...(usageStats ? { usageStats } : {}),
};
}
export async function upsertAuthProfileWithLock(params: {
profileId: string;
credential: AuthProfileCredential;
agentDir?: string;
}): Promise<AuthProfileStore | null> {
const authPath = resolveAuthStorePath(params.agentDir);
ensureAuthStoreFile(authPath);
try {
return await withFileLock(authPath, AUTH_STORE_LOCK_OPTIONS, async () => {
const store = coerceAuthProfileStore(loadJsonFile(authPath));
store.profiles[params.profileId] = params.credential;
saveJsonFile(authPath, store);
return store;
});
} catch {
return null;
}
}

View File

@ -18,8 +18,15 @@ import {
resolveOllamaApiBase,
type OllamaTagsResponse,
} from "./ollama-models.js";
import {
SELF_HOSTED_DEFAULT_CONTEXT_WINDOW,
SELF_HOSTED_DEFAULT_COST,
SELF_HOSTED_DEFAULT_MAX_TOKENS,
} from "./self-hosted-provider-defaults.js";
import { SGLANG_DEFAULT_BASE_URL, SGLANG_PROVIDER_LABEL } from "./sglang-defaults.js";
import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js";
import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js";
import { VLLM_DEFAULT_BASE_URL, VLLM_PROVIDER_LABEL } from "./vllm-defaults.js";
export { resolveOllamaApiBase } from "./ollama-models.js";
@ -31,19 +38,6 @@ const log = createSubsystemLogger("agents/model-providers");
const OLLAMA_SHOW_CONCURRENCY = 8;
const OLLAMA_SHOW_MAX_MODELS = 200;
const OPENAI_COMPAT_LOCAL_DEFAULT_CONTEXT_WINDOW = 128000;
const OPENAI_COMPAT_LOCAL_DEFAULT_MAX_TOKENS = 8192;
const OPENAI_COMPAT_LOCAL_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const SGLANG_BASE_URL = "http://127.0.0.1:30000/v1";
const VLLM_BASE_URL = "http://127.0.0.1:8000/v1";
type OpenAICompatModelsResponse = {
data?: Array<{
id?: string;
@ -140,9 +134,9 @@ async function discoverOpenAICompatibleLocalModels(params: {
name: modelId,
reasoning: isReasoningModelHeuristic(modelId),
input: ["text"],
cost: OPENAI_COMPAT_LOCAL_DEFAULT_COST,
contextWindow: params.contextWindow ?? OPENAI_COMPAT_LOCAL_DEFAULT_CONTEXT_WINDOW,
maxTokens: params.maxTokens ?? OPENAI_COMPAT_LOCAL_DEFAULT_MAX_TOKENS,
cost: SELF_HOSTED_DEFAULT_COST,
contextWindow: params.contextWindow ?? SELF_HOSTED_DEFAULT_CONTEXT_WINDOW,
maxTokens: params.maxTokens ?? SELF_HOSTED_DEFAULT_MAX_TOKENS,
} satisfies ModelDefinitionConfig;
});
} catch (error) {
@ -197,11 +191,11 @@ export async function buildVllmProvider(params?: {
baseUrl?: string;
apiKey?: string;
}): Promise<ProviderConfig> {
const baseUrl = (params?.baseUrl?.trim() || VLLM_BASE_URL).replace(/\/+$/, "");
const baseUrl = (params?.baseUrl?.trim() || VLLM_DEFAULT_BASE_URL).replace(/\/+$/, "");
const models = await discoverOpenAICompatibleLocalModels({
baseUrl,
apiKey: params?.apiKey,
label: "vLLM",
label: VLLM_PROVIDER_LABEL,
});
return {
baseUrl,
@ -214,11 +208,11 @@ export async function buildSglangProvider(params?: {
baseUrl?: string;
apiKey?: string;
}): Promise<ProviderConfig> {
const baseUrl = (params?.baseUrl?.trim() || SGLANG_BASE_URL).replace(/\/+$/, "");
const baseUrl = (params?.baseUrl?.trim() || SGLANG_DEFAULT_BASE_URL).replace(/\/+$/, "");
const models = await discoverOpenAICompatibleLocalModels({
baseUrl,
apiKey: params?.apiKey,
label: "SGLang",
label: SGLANG_PROVIDER_LABEL,
});
return {
baseUrl,

View File

@ -0,0 +1 @@
export const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434";

View File

@ -1,7 +1,6 @@
import type { ModelDefinitionConfig } from "../config/types.models.js";
import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js";
import { OLLAMA_DEFAULT_BASE_URL } from "./ollama-defaults.js";
export const OLLAMA_DEFAULT_BASE_URL = OLLAMA_NATIVE_BASE_URL;
export const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000;
export const OLLAMA_DEFAULT_MAX_TOKENS = 8192;
export const OLLAMA_DEFAULT_COST = {

View File

@ -10,6 +10,7 @@ import type {
import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { isNonSecretApiKeyMarker } from "./model-auth-markers.js";
import { OLLAMA_DEFAULT_BASE_URL } from "./ollama-defaults.js";
import {
buildAssistantMessage as buildStreamAssistantMessage,
buildStreamErrorAssistantMessage,
@ -18,7 +19,7 @@ import {
const log = createSubsystemLogger("ollama-stream");
export const OLLAMA_NATIVE_BASE_URL = "http://127.0.0.1:11434";
export const OLLAMA_NATIVE_BASE_URL = OLLAMA_DEFAULT_BASE_URL;
export function resolveOllamaBaseUrlForRun(params: {
modelBaseUrl?: string;

View File

@ -0,0 +1,8 @@
export const SELF_HOSTED_DEFAULT_CONTEXT_WINDOW = 128000;
export const SELF_HOSTED_DEFAULT_MAX_TOKENS = 8192;
export const SELF_HOSTED_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};

View File

@ -0,0 +1,4 @@
export const SGLANG_DEFAULT_BASE_URL = "http://127.0.0.1:30000/v1";
export const SGLANG_PROVIDER_LABEL = "SGLang";
export const SGLANG_DEFAULT_API_KEY_ENV_VAR = "SGLANG_API_KEY";
export const SGLANG_MODEL_PLACEHOLDER = "Qwen/Qwen3-8B";

View File

@ -0,0 +1,4 @@
export const VLLM_DEFAULT_BASE_URL = "http://127.0.0.1:8000/v1";
export const VLLM_PROVIDER_LABEL = "vLLM";
export const VLLM_DEFAULT_API_KEY_ENV_VAR = "VLLM_API_KEY";
export const VLLM_MODEL_PLACEHOLDER = "meta-llama/Meta-Llama-3-8B-Instruct";

View File

@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import {
filterMessagingToolMediaDuplicates,
shouldSuppressMessagingToolReplies,
@ -153,4 +155,18 @@ describe("shouldSuppressMessagingToolReplies", () => {
}),
).toBe(true);
});
it("suppresses telegram replies even when the active plugin registry omits telegram", () => {
setActivePluginRegistry(createTestRegistry([]));
expect(
shouldSuppressMessagingToolReplies({
messageProvider: "telegram",
originatingTo: "telegram:group:-100123:topic:77",
messagingToolSentTargets: [
{ tool: "message", provider: "telegram", to: "-100123", threadId: "77" },
],
}),
).toBe(true);
});
});

View File

@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it } from "vitest";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { parseExplicitTargetForChannel } from "./target-parsing.js";
describe("parseExplicitTargetForChannel", () => {
beforeEach(() => {
setActivePluginRegistry(createTestRegistry([]));
});
it("parses bundled Telegram targets without an active Telegram registry entry", () => {
expect(parseExplicitTargetForChannel("telegram", "telegram:group:-100123:topic:77")).toEqual({
to: "-100123",
threadId: 77,
chatType: "group",
});
expect(parseExplicitTargetForChannel("telegram", "-100123")).toEqual({
to: "-100123",
chatType: "group",
});
});
it("parses registered non-bundled channel targets via the active plugin contract", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "msteams",
source: "test",
plugin: {
id: "msteams",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams",
docsPath: "/channels/msteams",
blurb: "test stub",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
messaging: {
parseExplicitTarget: ({ raw }: { raw: string }) => ({
to: raw.trim().toUpperCase(),
chatType: "direct" as const,
}),
},
},
},
]),
);
expect(parseExplicitTargetForChannel("msteams", "team-room")).toEqual({
to: "TEAM-ROOM",
chatType: "direct",
});
});
});

View File

@ -1,4 +1,7 @@
import { parseDiscordTarget } from "../../../extensions/discord/src/targets.js";
import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js";
import type { ChatType } from "../chat-type.js";
import { normalizeChatChannelId } from "../registry.js";
import { getChannelPlugin, normalizeChannelId } from "./registry.js";
export type ParsedChannelExplicitTarget = {
@ -11,10 +14,28 @@ function parseWithPlugin(
rawChannel: string,
rawTarget: string,
): ParsedChannelExplicitTarget | null {
const channel = normalizeChannelId(rawChannel);
const channel = normalizeChatChannelId(rawChannel) ?? normalizeChannelId(rawChannel);
if (!channel) {
return null;
}
if (channel === "telegram") {
const target = parseTelegramTarget(rawTarget);
return {
to: target.chatId,
...(target.messageThreadId != null ? { threadId: target.messageThreadId } : {}),
...(target.chatType === "unknown" ? {} : { chatType: target.chatType }),
};
}
if (channel === "discord") {
const target = parseDiscordTarget(rawTarget, { defaultKind: "channel" });
if (!target) {
return null;
}
return {
to: target.id,
chatType: target.kind === "user" ? "direct" : "channel",
};
}
return getChannelPlugin(channel)?.messaging?.parseExplicitTarget?.({ raw: rawTarget }) ?? null;
}

View File

@ -1,5 +1,6 @@
import type { Command } from "commander";
import JSON5 from "json5";
import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js";
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
import { formatConfigIssueLines, normalizeConfigIssues } from "../config/issue-format.js";
import { CONFIG_PATH } from "../config/paths.js";
@ -20,7 +21,6 @@ type ConfigSetParseOpts = {
const OLLAMA_API_KEY_PATH: PathSegment[] = ["models", "providers", "ollama", "apiKey"];
const OLLAMA_PROVIDER_PATH: PathSegment[] = ["models", "providers", "ollama"];
const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434";
function isIndexSegment(raw: string): boolean {
return /^[0-9]+$/.test(raw);

View File

@ -0,0 +1,73 @@
import type { OpenClawConfig } from "../config/config.js";
export function applyAuthProfileConfig(
cfg: OpenClawConfig,
params: {
profileId: string;
provider: string;
mode: "api_key" | "oauth" | "token";
email?: string;
preferProfileFirst?: boolean;
},
): OpenClawConfig {
const normalizedProvider = params.provider.toLowerCase();
const profiles = {
...cfg.auth?.profiles,
[params.profileId]: {
provider: params.provider,
mode: params.mode,
...(params.email ? { email: params.email } : {}),
},
};
const configuredProviderProfiles = Object.entries(cfg.auth?.profiles ?? {})
.filter(([, profile]) => profile.provider.toLowerCase() === normalizedProvider)
.map(([profileId, profile]) => ({ profileId, mode: profile.mode }));
// Maintain `auth.order` when it already exists. Additionally, if we detect
// mixed auth modes for the same provider (e.g. legacy oauth + newly selected
// api_key), create an explicit order to keep the newly selected profile first.
const existingProviderOrder = cfg.auth?.order?.[params.provider];
const preferProfileFirst = params.preferProfileFirst ?? true;
const reorderedProviderOrder =
existingProviderOrder && preferProfileFirst
? [
params.profileId,
...existingProviderOrder.filter((profileId) => profileId !== params.profileId),
]
: existingProviderOrder;
const hasMixedConfiguredModes = configuredProviderProfiles.some(
({ profileId, mode }) => profileId !== params.profileId && mode !== params.mode,
);
const derivedProviderOrder =
existingProviderOrder === undefined && preferProfileFirst && hasMixedConfiguredModes
? [
params.profileId,
...configuredProviderProfiles
.map(({ profileId }) => profileId)
.filter((profileId) => profileId !== params.profileId),
]
: undefined;
const order =
existingProviderOrder !== undefined
? {
...cfg.auth?.order,
[params.provider]: reorderedProviderOrder?.includes(params.profileId)
? reorderedProviderOrder
: [...(reorderedProviderOrder ?? []), params.profileId],
}
: derivedProviderOrder
? {
...cfg.auth?.order,
[params.provider]: derivedProviderOrder,
}
: cfg.auth?.order;
return {
...cfg,
auth: {
...cfg.auth,
profiles,
...(order ? { order } : {}),
},
};
}

View File

@ -1,6 +1,6 @@
import { upsertAuthProfileWithLock } from "../agents/auth-profiles.js";
import { upsertAuthProfileWithLock } from "../agents/auth-profiles/upsert-with-lock.js";
import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js";
import {
OLLAMA_DEFAULT_BASE_URL,
buildOllamaModelDefinition,
enrichOllamaModelsWithContext,
fetchOllamaModels,
@ -15,7 +15,7 @@ import { applyAgentDefaultModelPrimary } from "./onboard-auth.config-shared.js";
import { openUrl } from "./onboard-helpers.js";
import type { OnboardMode, OnboardOptions } from "./onboard-types.js";
export { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-models.js";
export { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js";
export const OLLAMA_DEFAULT_MODEL = "glm-4.7-flash";
const OLLAMA_SUGGESTED_MODELS_LOCAL = ["glm-4.7-flash"];

View File

@ -84,6 +84,7 @@ import {
MODELSTUDIO_GLOBAL_BASE_URL,
MODELSTUDIO_DEFAULT_MODEL_REF,
} from "./onboard-auth.models.js";
export { applyAuthProfileConfig } from "./auth-profile-config.js";
function mergeProviderModels<T extends { id: string }>(
existingProvider: Record<string, unknown> | undefined,
@ -484,78 +485,6 @@ export function applyKilocodeConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(next, KILOCODE_DEFAULT_MODEL_REF);
}
export function applyAuthProfileConfig(
cfg: OpenClawConfig,
params: {
profileId: string;
provider: string;
mode: "api_key" | "oauth" | "token";
email?: string;
preferProfileFirst?: boolean;
},
): OpenClawConfig {
const normalizedProvider = params.provider.toLowerCase();
const profiles = {
...cfg.auth?.profiles,
[params.profileId]: {
provider: params.provider,
mode: params.mode,
...(params.email ? { email: params.email } : {}),
},
};
const configuredProviderProfiles = Object.entries(cfg.auth?.profiles ?? {})
.filter(([, profile]) => profile.provider.toLowerCase() === normalizedProvider)
.map(([profileId, profile]) => ({ profileId, mode: profile.mode }));
// Maintain `auth.order` when it already exists. Additionally, if we detect
// mixed auth modes for the same provider (e.g. legacy oauth + newly selected
// api_key), create an explicit order to keep the newly selected profile first.
const existingProviderOrder = cfg.auth?.order?.[params.provider];
const preferProfileFirst = params.preferProfileFirst ?? true;
const reorderedProviderOrder =
existingProviderOrder && preferProfileFirst
? [
params.profileId,
...existingProviderOrder.filter((profileId) => profileId !== params.profileId),
]
: existingProviderOrder;
const hasMixedConfiguredModes = configuredProviderProfiles.some(
({ profileId, mode }) => profileId !== params.profileId && mode !== params.mode,
);
const derivedProviderOrder =
existingProviderOrder === undefined && preferProfileFirst && hasMixedConfiguredModes
? [
params.profileId,
...configuredProviderProfiles
.map(({ profileId }) => profileId)
.filter((profileId) => profileId !== params.profileId),
]
: undefined;
const order =
existingProviderOrder !== undefined
? {
...cfg.auth?.order,
[params.provider]: reorderedProviderOrder?.includes(params.profileId)
? reorderedProviderOrder
: [...(reorderedProviderOrder ?? []), params.profileId],
}
: derivedProviderOrder
? {
...cfg.auth?.order,
[params.provider]: derivedProviderOrder,
}
: cfg.auth?.order;
return {
...cfg,
auth: {
...cfg.auth,
profiles,
...(order ? { order } : {}),
},
};
}
export function applyQianfanProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[QIANFAN_DEFAULT_MODEL_REF] = {

View File

@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { CONTEXT_WINDOW_HARD_MIN_TOKENS } from "../agents/context-window-guard.js";
import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-models.js";
import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js";
import type { OpenClawConfig } from "../config/config.js";
import { defaultRuntime } from "../runtime.js";
import {

View File

@ -1,7 +1,7 @@
import { CONTEXT_WINDOW_HARD_MIN_TOKENS } from "../agents/context-window-guard.js";
import { DEFAULT_PROVIDER } from "../agents/defaults.js";
import { buildModelAliasIndex, modelKey } from "../agents/model-selection.js";
import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-models.js";
import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js";
import type { OpenClawConfig } from "../config/config.js";
import type { ModelProviderConfig } from "../config/types.models.js";
import { isSecretRef, type SecretInput } from "../config/types.secrets.js";

View File

@ -1,5 +1,10 @@
import { upsertAuthProfileWithLock } from "../agents/auth-profiles.js";
import type { ApiKeyCredential, AuthProfileCredential } from "../agents/auth-profiles/types.js";
import { upsertAuthProfileWithLock } from "../agents/auth-profiles/upsert-with-lock.js";
import {
SELF_HOSTED_DEFAULT_CONTEXT_WINDOW,
SELF_HOSTED_DEFAULT_COST,
SELF_HOSTED_DEFAULT_MAX_TOKENS,
} from "../agents/self-hosted-provider-defaults.js";
import type { OpenClawConfig } from "../config/config.js";
import type {
ProviderDiscoveryContext,
@ -8,16 +13,13 @@ import type {
ProviderNonInteractiveApiKeyResult,
} from "../plugins/types.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { applyAuthProfileConfig } from "./onboard-auth.js";
import { applyAuthProfileConfig } from "./auth-profile-config.js";
export const SELF_HOSTED_DEFAULT_CONTEXT_WINDOW = 128000;
export const SELF_HOSTED_DEFAULT_MAX_TOKENS = 8192;
export const SELF_HOSTED_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
export {
SELF_HOSTED_DEFAULT_CONTEXT_WINDOW,
SELF_HOSTED_DEFAULT_COST,
SELF_HOSTED_DEFAULT_MAX_TOKENS,
} from "../agents/self-hosted-provider-defaults.js";
export function applyProviderDefaultModel(cfg: OpenClawConfig, modelRef: string): OpenClawConfig {
const existingModel = cfg.agents?.defaults?.model;

View File

@ -1,14 +1,20 @@
import {
VLLM_DEFAULT_API_KEY_ENV_VAR,
VLLM_DEFAULT_BASE_URL,
VLLM_MODEL_PLACEHOLDER,
VLLM_PROVIDER_LABEL,
} from "../agents/vllm-defaults.js";
import type { OpenClawConfig } from "../config/config.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
applyProviderDefaultModel,
promptAndConfigureOpenAICompatibleSelfHostedProvider,
SELF_HOSTED_DEFAULT_CONTEXT_WINDOW,
SELF_HOSTED_DEFAULT_COST,
SELF_HOSTED_DEFAULT_MAX_TOKENS,
promptAndConfigureOpenAICompatibleSelfHostedProvider,
} from "./self-hosted-provider-setup.js";
export const VLLM_DEFAULT_BASE_URL = "http://127.0.0.1:8000/v1";
export { VLLM_DEFAULT_BASE_URL } from "../agents/vllm-defaults.js";
export const VLLM_DEFAULT_CONTEXT_WINDOW = SELF_HOSTED_DEFAULT_CONTEXT_WINDOW;
export const VLLM_DEFAULT_MAX_TOKENS = SELF_HOSTED_DEFAULT_MAX_TOKENS;
export const VLLM_DEFAULT_COST = SELF_HOSTED_DEFAULT_COST;
@ -21,10 +27,10 @@ export async function promptAndConfigureVllm(params: {
cfg: params.cfg,
prompter: params.prompter,
providerId: "vllm",
providerLabel: "vLLM",
providerLabel: VLLM_PROVIDER_LABEL,
defaultBaseUrl: VLLM_DEFAULT_BASE_URL,
defaultApiKeyEnvVar: "VLLM_API_KEY",
modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct",
defaultApiKeyEnvVar: VLLM_DEFAULT_API_KEY_ENV_VAR,
modelPlaceholder: VLLM_MODEL_PLACEHOLDER,
});
return {
config: result.config,

View File

@ -1,6 +1,7 @@
import "./isolated-agent.mocks.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import * as modelSelection from "../agents/model-selection.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import type { CliDeps } from "../cli/deps.js";
@ -72,6 +73,7 @@ async function runTelegramAnnounceTurn(params: {
describe("runCronIsolatedAgentTurn", () => {
beforeEach(() => {
vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue(undefined);
setupIsolatedAgentTurnMocks({ fast: true });
});

View File

@ -1,6 +1,7 @@
import "./isolated-agent.mocks.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { loadModelCatalog } from "../agents/model-catalog.js";
import * as modelSelection from "../agents/model-selection.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test-helpers.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
@ -125,6 +126,7 @@ async function expectInvalidModel(home: string, model: string) {
describe("cron model formatting and precedence edge cases", () => {
beforeEach(() => {
vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue(undefined);
vi.mocked(runEmbeddedPiAgent).mockClear();
vi.mocked(loadModelCatalog).mockResolvedValue([]);
});

View File

@ -1,6 +1,7 @@
import "./isolated-agent.mocks.js";
import fs from "node:fs/promises";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as modelSelection from "../agents/model-selection.js";
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import type { CliDeps } from "../cli/deps.js";
import {
@ -261,6 +262,7 @@ async function assertExplicitTelegramTargetDelivery(params: {
describe("runCronIsolatedAgentTurn", () => {
beforeEach(() => {
vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue(undefined);
setupIsolatedAgentTurnMocks();
});

View File

@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { loadModelCatalog } from "../agents/model-catalog.js";
import * as modelSelection from "../agents/model-selection.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import type { CliDeps } from "../cli/deps.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
@ -15,6 +16,10 @@ import {
} from "./isolated-agent.test-harness.js";
import type { CronJob } from "./types.js";
let resolveThinkingDefaultSpy: ReturnType<
typeof vi.spyOn<typeof modelSelection, "resolveThinkingDefault">
>;
function makeDeps(): CliDeps {
return {
sendMessageSlack: vi.fn(),
@ -163,6 +168,9 @@ async function runStoredOverrideAndExpectModel(params: {
describe("runCronIsolatedAgentTurn", () => {
beforeEach(() => {
resolveThinkingDefaultSpy = vi
.spyOn(modelSelection, "resolveThinkingDefault")
.mockReturnValue(undefined);
vi.mocked(runEmbeddedPiAgent).mockClear();
vi.mocked(loadModelCatalog).mockResolvedValue([]);
});
@ -503,16 +511,9 @@ describe("runCronIsolatedAgentTurn", () => {
});
});
it("defaults thinking to low for reasoning-capable models", async () => {
it("passes through the resolved default thinking level", async () => {
await withTempHome(async (home) => {
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
{
id: "claude-opus-4-5",
name: "Opus 4.5",
provider: "anthropic",
reasoning: true,
},
]);
resolveThinkingDefaultSpy.mockReturnValueOnce("low");
await runCronTurn(home, {
jobPayload: DEFAULT_AGENT_TURN_PAYLOAD,

View File

@ -171,6 +171,27 @@ async function resolveCronDeliveryContext(params: {
deliveryContract: IsolatedDeliveryContract;
}) {
const deliveryPlan = resolveCronDeliveryPlan(params.job);
if (!deliveryPlan.requested) {
const resolvedDelivery = {
ok: false as const,
channel: undefined,
to: undefined,
accountId: undefined,
threadId: undefined,
mode: "implicit" as const,
error: new Error("cron delivery not requested"),
};
return {
deliveryPlan,
deliveryRequested: false,
resolvedDelivery,
toolPolicy: resolveCronToolPolicy({
deliveryRequested: false,
resolvedDelivery,
deliveryContract: params.deliveryContract,
}),
};
}
const resolvedDelivery = await resolveDeliveryTarget(params.cfg, params.agentId, {
channel: deliveryPlan.channel ?? "last",
to: deliveryPlan.to,

View File

@ -59,6 +59,13 @@ vi.mock("../infra/device-identity.js", () => ({
}));
vi.mock("./session-utils.js", () => ({
loadSessionEntry: vi.fn((sessionKey: string) => buildSessionLookup(sessionKey)),
migrateAndPruneGatewaySessionStoreKey: vi.fn(
({ key, store }: { key: string; store: Record<string, unknown> }) => ({
target: { canonicalKey: key, storeKeys: [key] },
primaryKey: key,
entry: store[key],
}),
),
pruneLegacyStoreKeys: vi.fn(),
resolveGatewaySessionStoreTarget: vi.fn(({ key }: { key: string }) => ({
canonicalKey: key,

View File

@ -12,12 +12,23 @@ import { withEnvAsync } from "../test-utils/env.js";
import { clearMediaUnderstandingBinaryCacheForTests } from "./runner.js";
import { createSafeAudioFixtureBuffer } from "./runner.test-utils.js";
vi.mock("../agents/model-auth.js", () => ({
resolveApiKeyForProvider: vi.fn(async () => ({
const resolveApiKeyForProviderMock = vi.hoisted(() =>
vi.fn(async () => ({
apiKey: "test-key", // pragma: allowlist secret
source: "test",
mode: "api-key",
})),
);
const hasAvailableAuthForProviderMock = vi.hoisted(() =>
vi.fn(async (...args: unknown[]) => {
const resolved = await resolveApiKeyForProviderMock(...args);
return Boolean(resolved?.apiKey);
}),
);
vi.mock("../agents/model-auth.js", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
hasAvailableAuthForProvider: hasAvailableAuthForProviderMock,
requireApiKey: (auth: { apiKey?: string; mode?: string }, provider: string) => {
if (auth?.apiKey) {
return auth.apiKey;
@ -247,6 +258,7 @@ describe("applyMediaUnderstanding", () => {
source: "test",
mode: "api-key",
});
hasAvailableAuthForProviderMock.mockClear();
mockedFetchRemoteMedia.mockClear();
mockedRunExec.mockReset();
mockedFetchRemoteMedia.mockResolvedValue({

View File

@ -30,27 +30,6 @@ export type {
ProviderAuthMethod,
ProviderAuthResult,
} from "../plugins/types.js";
export type {
CreateSandboxBackendParams,
RemoteShellSandboxHandle,
RunSshSandboxCommandParams,
SandboxBackendCommandParams,
SandboxBackendCommandResult,
SandboxBackendExecSpec,
SandboxBackendFactory,
SandboxFsBridge,
SandboxFsStat,
SandboxBackendHandle,
SandboxBackendId,
SandboxBackendManager,
SandboxBackendRegistration,
SandboxBackendRuntimeInfo,
SandboxContext,
SandboxResolvedPath,
SandboxSshConfig,
SshSandboxSession,
SshSandboxSettings,
} from "../agents/sandbox.js";
export type { OpenClawConfig } from "../config/config.js";
export type { GatewayRequestHandlerOptions } from "../gateway/server-methods/types.js";
export type {
@ -66,58 +45,7 @@ export type {
} from "./channel-plugin-common.js";
export { emptyPluginConfigSchema } from "./channel-plugin-common.js";
export {
buildExecRemoteCommand,
buildRemoteCommand,
buildSshSandboxArgv,
createRemoteShellSandboxFsBridge,
createSshSandboxSessionFromConfigText,
createSshSandboxSessionFromSettings,
disposeSshSandboxSession,
getSandboxBackendFactory,
getSandboxBackendManager,
registerSandboxBackend,
runSshSandboxCommand,
shellEscape,
uploadDirectoryToSshTarget,
requireSandboxBackendFactory,
} from "../agents/sandbox.js";
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
export {
applyProviderDefaultModel,
configureOpenAICompatibleSelfHostedProviderNonInteractive,
discoverOpenAICompatibleSelfHostedProvider,
promptAndConfigureOpenAICompatibleSelfHostedProvider,
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth,
SELF_HOSTED_DEFAULT_CONTEXT_WINDOW,
SELF_HOSTED_DEFAULT_COST,
SELF_HOSTED_DEFAULT_MAX_TOKENS,
} from "../commands/self-hosted-provider-setup.js";
export {
OLLAMA_DEFAULT_BASE_URL,
OLLAMA_DEFAULT_MODEL,
configureOllamaNonInteractive,
ensureOllamaModelPulled,
promptAndConfigureOllama,
} from "../commands/ollama-setup.js";
export {
VLLM_DEFAULT_BASE_URL,
VLLM_DEFAULT_CONTEXT_WINDOW,
VLLM_DEFAULT_COST,
VLLM_DEFAULT_MAX_TOKENS,
promptAndConfigureVllm,
} from "../commands/vllm-setup.js";
export {
buildOllamaProvider,
buildSglangProvider,
buildVllmProvider,
} from "../agents/models-config.providers.discovery.js";
export {
approveDevicePairing,
listDevicePairing,
rejectDevicePairing,
} from "../infra/device-pairing.js";
export {
DEFAULT_SECRET_FILE_MAX_BYTES,
loadSecretFileSync,
@ -126,13 +54,6 @@ export {
} from "../infra/secret-file.js";
export type { SecretFileReadOptions, SecretFileReadResult } from "../infra/secret-file.js";
export {
runPluginCommandWithTimeout,
type PluginCommandRunOptions,
type PluginCommandRunResult,
} from "./run-command.js";
export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
export { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
export type { GatewayBindUrlResult } from "../shared/gateway-bind-url.js";

View File

@ -0,0 +1,37 @@
export type {
OpenClawPluginApi,
ProviderAuthContext,
ProviderAuthMethodNonInteractiveContext,
ProviderAuthResult,
ProviderDiscoveryContext,
} from "../plugins/types.js";
export {
applyProviderDefaultModel,
configureOpenAICompatibleSelfHostedProviderNonInteractive,
discoverOpenAICompatibleSelfHostedProvider,
promptAndConfigureOpenAICompatibleSelfHostedProvider,
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth,
SELF_HOSTED_DEFAULT_CONTEXT_WINDOW,
SELF_HOSTED_DEFAULT_COST,
SELF_HOSTED_DEFAULT_MAX_TOKENS,
} from "../commands/self-hosted-provider-setup.js";
export {
OLLAMA_DEFAULT_BASE_URL,
OLLAMA_DEFAULT_MODEL,
configureOllamaNonInteractive,
ensureOllamaModelPulled,
promptAndConfigureOllama,
} from "../commands/ollama-setup.js";
export {
VLLM_DEFAULT_BASE_URL,
VLLM_DEFAULT_CONTEXT_WINDOW,
VLLM_DEFAULT_COST,
VLLM_DEFAULT_MAX_TOKENS,
promptAndConfigureVllm,
} from "../commands/vllm-setup.js";
export {
buildOllamaProvider,
buildSglangProvider,
buildVllmProvider,
} from "../agents/models-config.providers.discovery.js";

45
src/plugin-sdk/sandbox.ts Normal file
View File

@ -0,0 +1,45 @@
export type {
CreateSandboxBackendParams,
RemoteShellSandboxHandle,
RunSshSandboxCommandParams,
SandboxBackendCommandParams,
SandboxBackendCommandResult,
SandboxBackendExecSpec,
SandboxBackendFactory,
SandboxFsBridge,
SandboxFsStat,
SandboxBackendHandle,
SandboxBackendId,
SandboxBackendManager,
SandboxBackendRegistration,
SandboxBackendRuntimeInfo,
SandboxContext,
SandboxResolvedPath,
SandboxSshConfig,
SshSandboxSession,
SshSandboxSettings,
} from "../agents/sandbox.js";
export {
buildExecRemoteCommand,
buildRemoteCommand,
buildSshSandboxArgv,
createRemoteShellSandboxFsBridge,
createSshSandboxSessionFromConfigText,
createSshSandboxSessionFromSettings,
disposeSshSandboxSession,
getSandboxBackendFactory,
getSandboxBackendManager,
registerSandboxBackend,
requireSandboxBackendFactory,
runSshSandboxCommand,
shellEscape,
uploadDirectoryToSshTarget,
} from "../agents/sandbox.js";
export {
runPluginCommandWithTimeout,
type PluginCommandRunOptions,
type PluginCommandRunResult,
} from "./run-command.js";
export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";

View File

@ -11,6 +11,8 @@ import * as imessageSdk from "openclaw/plugin-sdk/imessage";
import * as lineSdk from "openclaw/plugin-sdk/line";
import * as msteamsSdk from "openclaw/plugin-sdk/msteams";
import * as nostrSdk from "openclaw/plugin-sdk/nostr";
import * as providerSetupSdk from "openclaw/plugin-sdk/provider-setup";
import * as sandboxSdk from "openclaw/plugin-sdk/sandbox";
import * as signalSdk from "openclaw/plugin-sdk/signal";
import * as slackSdk from "openclaw/plugin-sdk/slack";
import * as telegramSdk from "openclaw/plugin-sdk/telegram";
@ -46,6 +48,24 @@ describe("plugin-sdk subpath exports", () => {
expect(typeof coreSdk.resolveThreadSessionKeys).toBe("function");
expect(typeof coreSdk.runPassiveAccountLifecycle).toBe("function");
expect(typeof coreSdk.createLoggerBackedRuntime).toBe("function");
expect("registerSandboxBackend" in asExports(coreSdk)).toBe(false);
expect("promptAndConfigureOpenAICompatibleSelfHostedProviderAuth" in asExports(coreSdk)).toBe(
false,
);
});
it("exports provider setup helpers from the dedicated subpath", () => {
expect(typeof providerSetupSdk.buildVllmProvider).toBe("function");
expect(typeof providerSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe("function");
expect(typeof providerSetupSdk.promptAndConfigureOpenAICompatibleSelfHostedProviderAuth).toBe(
"function",
);
});
it("exports sandbox helpers from the dedicated subpath", () => {
expect(typeof sandboxSdk.registerSandboxBackend).toBe("function");
expect(typeof sandboxSdk.runPluginCommandWithTimeout).toBe("function");
expect(typeof sandboxSdk.createRemoteShellSandboxFsBridge).toBe("function");
});
it("exports shared core types used by bundled channels", () => {

View File

@ -136,6 +136,22 @@ describe("registerPluginCommand", () => {
});
});
it("resolves Telegram topic command bindings without a Telegram registry entry", () => {
expect(
__testing.resolveBindingConversationFromCommand({
channel: "telegram",
from: "telegram:group:-100123",
to: "telegram:group:-100123:topic:77",
accountId: "default",
}),
).toEqual({
channel: "telegram",
accountId: "default",
conversationId: "-100123",
threadId: 77,
});
});
it("does not resolve binding conversations for unsupported command channels", () => {
expect(
__testing.resolveBindingConversationFromCommand({

View File

@ -22,8 +22,8 @@ vi.mock("../../../extensions/github-copilot/token.js", async () => {
};
});
vi.mock("openclaw/plugin-sdk/core", async () => {
const actual = await vi.importActual<object>("openclaw/plugin-sdk/core");
vi.mock("openclaw/plugin-sdk/provider-setup", async () => {
const actual = await vi.importActual<object>("openclaw/plugin-sdk/provider-setup");
return {
...actual,
buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args),

View File

@ -2800,6 +2800,7 @@ module.exports = {
it("preserves runtime reflection semantics when runtime is lazily initialized", () => {
useNoBundledPlugins();
const stateDir = makeTempDir();
const plugin = writePlugin({
id: "runtime-introspection",
filename: "runtime-introspection.cjs",
@ -2818,12 +2819,17 @@ module.exports = {
} };`,
});
const registry = loadRegistryFromSinglePlugin({
plugin,
pluginConfig: {
allow: ["runtime-introspection"],
},
});
const registry = withEnv({ OPENCLAW_STATE_DIR: stateDir }, () =>
loadRegistryFromSinglePlugin({
plugin,
pluginConfig: {
allow: ["runtime-introspection"],
},
options: {
onlyPluginIds: ["runtime-introspection"],
},
}),
);
const record = registry.plugins.find((entry) => entry.id === "runtime-introspection");
expect(record?.status).toBe("loaded");

View File

@ -60,6 +60,21 @@ export type PluginLoadOptions = {
const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 128;
const registryCache = new Map<string, PluginRegistry>();
const openAllowlistWarningCache = new Set<string>();
const LAZY_RUNTIME_REFLECTION_KEYS = [
"version",
"config",
"subagent",
"system",
"media",
"tts",
"stt",
"tools",
"channel",
"events",
"logging",
"state",
"modelAuth",
] as const satisfies readonly (keyof PluginRuntime)[];
export function clearPluginLoaderCache(): void {
registryCache.clear();
@ -870,6 +885,22 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
resolvedRuntime ??= resolveCreatePluginRuntime()(options.runtimeOptions);
return resolvedRuntime;
};
const lazyRuntimeReflectionKeySet = new Set<PropertyKey>(LAZY_RUNTIME_REFLECTION_KEYS);
const resolveLazyRuntimeDescriptor = (prop: PropertyKey): PropertyDescriptor | undefined => {
if (!lazyRuntimeReflectionKeySet.has(prop)) {
return Reflect.getOwnPropertyDescriptor(resolveRuntime() as object, prop);
}
return {
configurable: true,
enumerable: true,
get() {
return Reflect.get(resolveRuntime() as object, prop);
},
set(value: unknown) {
Reflect.set(resolveRuntime() as object, prop, value);
},
};
};
const runtime = new Proxy({} as PluginRuntime, {
get(_target, prop, receiver) {
return Reflect.get(resolveRuntime(), prop, receiver);
@ -878,13 +909,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
return Reflect.set(resolveRuntime(), prop, value, receiver);
},
has(_target, prop) {
return Reflect.has(resolveRuntime(), prop);
return lazyRuntimeReflectionKeySet.has(prop) || Reflect.has(resolveRuntime(), prop);
},
ownKeys() {
return Reflect.ownKeys(resolveRuntime() as object);
return [...LAZY_RUNTIME_REFLECTION_KEYS];
},
getOwnPropertyDescriptor(_target, prop) {
return Reflect.getOwnPropertyDescriptor(resolveRuntime() as object, prop);
return resolveLazyRuntimeDescriptor(prop);
},
defineProperty(_target, prop, attributes) {
return Reflect.defineProperty(resolveRuntime() as object, prop, attributes);

View File

@ -58,6 +58,7 @@ describe("provider-runtime", () => {
});
it("matches providers by alias for runtime hook lookup", () => {
resolveOwningPluginIdsForProviderMock.mockReturnValue(["openrouter"]);
resolvePluginProvidersMock.mockReturnValue([
{
id: "openrouter",
@ -77,13 +78,35 @@ describe("provider-runtime", () => {
);
expect(resolvePluginProvidersMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["openrouter"],
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
}),
);
});
it("skips plugin loading when the provider has no owning plugin", () => {
const plugin = resolveProviderRuntimePlugin({ provider: "anthropic" });
expect(plugin).toBeUndefined();
expect(resolveOwningPluginIdsForProviderMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "anthropic",
}),
);
expect(resolvePluginProvidersMock).not.toHaveBeenCalled();
});
it("dispatches runtime hooks for the matched provider", async () => {
resolveOwningPluginIdsForProviderMock.mockImplementation((params: { provider?: string }) => {
if (params.provider === "demo") {
return ["demo"];
}
if (params.provider === "openai") {
return ["openai"];
}
return undefined;
});
const prepareDynamicModel = vi.fn(async () => undefined);
const prepareRuntimeAuth = vi.fn(async () => ({
apiKey: "runtime-token",

View File

@ -54,14 +54,18 @@ export function resolveProviderRuntimePlugin(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderPlugin | undefined {
const owningPluginIds = resolveOwningPluginIdsForProvider({
provider: params.provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
if (!owningPluginIds || owningPluginIds.length === 0) {
return undefined;
}
return resolveProviderPluginsForHooks({
...params,
onlyPluginIds: resolveOwningPluginIdsForProvider({
provider: params.provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}),
onlyPluginIds: owningPluginIds,
}).find((plugin) => matchesProviderId(plugin, params.provider));
}