From 8b438a308b82a52b055dd7cceccfd18b98512424 Mon Sep 17 00:00:00 2001 From: ImJarvis by LukeF <92253590+ImLukeF@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:44:10 +1100 Subject: [PATCH 01/10] fix(telegram): keep silent error fallback replies quiet --- .../telegram/src/bot-message-dispatch.test.ts | 23 +++++++++++++++++++ .../telegram/src/bot-message-dispatch.ts | 11 +++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 64fe301658a..ea1c098e7b6 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -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" }, diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 61fc9f92fbf..9b603393450 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -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; } From ccba943738e02833fb28721a1144d5cc97d34aa9 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 16 Mar 2026 17:16:48 +0530 Subject: [PATCH 02/10] test(gateway): restore agent request route mock --- src/gateway/server-node-events.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index 07425808cea..a5a7578ddbc 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -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 }) => ({ + target: { canonicalKey: key, storeKeys: [key] }, + primaryKey: key, + entry: store[key], + }), + ), pruneLegacyStoreKeys: vi.fn(), resolveGatewaySessionStoreTarget: vi.fn(({ key }: { key: string }) => ({ canonicalKey: key, From d6aa9b516e5903d99e584fc3cf6c1665dce3434f Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 11:39:15 +0000 Subject: [PATCH 03/10] Cron: isolate active-model delivery tests --- ...onse-has-heartbeat-ok-but-includes.test.ts | 2 ++ .../isolated-agent.model-formatting.test.ts | 2 ++ ...p-recipient-besteffortdeliver-true.test.ts | 2 ++ ....uses-last-non-empty-agent-text-as.test.ts | 19 +++++++++-------- src/cron/isolated-agent/run.ts | 21 +++++++++++++++++++ 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts index 5678b75e4f7..6316f394b9c 100644 --- a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts +++ b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts @@ -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 }); }); diff --git a/src/cron/isolated-agent.model-formatting.test.ts b/src/cron/isolated-agent.model-formatting.test.ts index b09a9db5ea1..c783247e6f3 100644 --- a/src/cron/isolated-agent.model-formatting.test.ts +++ b/src/cron/isolated-agent.model-formatting.test.ts @@ -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([]); }); diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index 5abbb453f35..639c207459e 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -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(); }); diff --git a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts index 2a4b786f99c..5830029d9e7 100644 --- a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts +++ b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts @@ -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 +>; + 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, diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 8a074338da7..9f3f28584e3 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -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, From 5e4851ae2bccf9e1f4691b761b6e183a114c4964 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 11:39:20 +0000 Subject: [PATCH 04/10] Tests: align media auth fixture with selection checks --- src/media-understanding/apply.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index 10e5da610cc..b979a2ac4ad 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -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({ From 4c8853122aa34c035bf2e788d61ab1e7a2d295ac Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 11:39:26 +0000 Subject: [PATCH 05/10] Plugins: preserve lazy runtime provider resolution --- src/plugins/loader.test.ts | 18 +++++++++----- src/plugins/loader.ts | 37 +++++++++++++++++++++++++--- src/plugins/provider-runtime.test.ts | 23 +++++++++++++++++ src/plugins/provider-runtime.ts | 16 +++++++----- 4 files changed, 79 insertions(+), 15 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 740db382095..5c5b0ee4717 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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"); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index da9bcd3e993..2fff62b0b95 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -60,6 +60,21 @@ export type PluginLoadOptions = { const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 128; const registryCache = new Map(); const openAllowlistWarningCache = new Set(); +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(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); diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 266abe24556..dec7be0b53d 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -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", diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 189b5ccef0c..3d1bd77f6d9 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -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)); } From 092afc850dc6e26615b74c3d3d6740b169e19944 Mon Sep 17 00:00:00 2001 From: huntharo Date: Mon, 16 Mar 2026 07:51:44 -0400 Subject: [PATCH 06/10] Bootstrap: report nested entry import misses --- openclaw.mjs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/openclaw.mjs b/openclaw.mjs index 248db52ea44..099c7f6a406 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -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; From 55f6d2d1adfd2a20037009d62d9509ba1a8854d1 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 16 Mar 2026 17:24:37 +0530 Subject: [PATCH 07/10] fix(channels): parse bundled targets without plugin registry --- src/channels/plugins/target-parsing.test.ts | 59 +++++++++++++++++++++ src/channels/plugins/target-parsing.ts | 23 +++++++- 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 src/channels/plugins/target-parsing.test.ts diff --git a/src/channels/plugins/target-parsing.test.ts b/src/channels/plugins/target-parsing.test.ts new file mode 100644 index 00000000000..a1c5d278fde --- /dev/null +++ b/src/channels/plugins/target-parsing.test.ts @@ -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", + }); + }); +}); diff --git a/src/channels/plugins/target-parsing.ts b/src/channels/plugins/target-parsing.ts index 5d7fd6e28da..beea68adca3 100644 --- a/src/channels/plugins/target-parsing.ts +++ b/src/channels/plugins/target-parsing.ts @@ -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; } From e78b51baea9ecc240aca52e41478cd4e85377468 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 16 Mar 2026 17:24:44 +0530 Subject: [PATCH 08/10] test(telegram): cover shared parsing without registry --- src/auto-reply/reply/reply-payloads.test.ts | 16 ++++++++++++++++ src/plugins/commands.test.ts | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/auto-reply/reply/reply-payloads.test.ts b/src/auto-reply/reply/reply-payloads.test.ts index 614fcd37951..8664eec5c72 100644 --- a/src/auto-reply/reply/reply-payloads.test.ts +++ b/src/auto-reply/reply/reply-payloads.test.ts @@ -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); + }); }); diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 6f371305a81..d95a98b18d9 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -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({ From 9fc6c1929aaff1196797e78b45c7c9532fff5dca Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 12:04:32 +0000 Subject: [PATCH 09/10] Plugin SDK: split setup and sandbox subpaths --- extensions/ollama/index.ts | 27 ++++--- extensions/openshell/index.ts | 2 +- extensions/openshell/src/backend.ts | 4 +- extensions/openshell/src/cli.ts | 4 +- extensions/openshell/src/fs-bridge.ts | 2 +- extensions/openshell/src/remote-fs-bridge.ts | 2 +- extensions/sglang/index.ts | 59 ++++++++------ extensions/vllm/index.ts | 59 ++++++++------ package.json | 8 ++ src/agents/ollama-defaults.ts | 1 + src/agents/sglang-defaults.ts | 4 + src/agents/vllm-defaults.ts | 4 + src/plugin-sdk/core.ts | 79 ------------------- src/plugin-sdk/provider-setup.ts | 37 +++++++++ src/plugin-sdk/sandbox.ts | 45 +++++++++++ src/plugin-sdk/subpaths.test.ts | 20 +++++ .../contracts/discovery.contract.test.ts | 4 +- 17 files changed, 214 insertions(+), 147 deletions(-) create mode 100644 src/agents/ollama-defaults.ts create mode 100644 src/agents/sglang-defaults.ts create mode 100644 src/agents/vllm-defaults.ts create mode 100644 src/plugin-sdk/provider-setup.ts create mode 100644 src/plugin-sdk/sandbox.ts diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 0609c597dc4..f19c4cfbc0a 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -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 => { - 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 }); }, }); }, diff --git a/extensions/openshell/index.ts b/extensions/openshell/index.ts index 910abe31b44..35e00cf0f52 100644 --- a/extensions/openshell/index.ts +++ b/extensions/openshell/index.ts @@ -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, diff --git a/extensions/openshell/src/backend.ts b/extensions/openshell/src/backend.ts index d87b1c92af8..847e8022e24 100644 --- a/extensions/openshell/src/backend.ts +++ b/extensions/openshell/src/backend.ts @@ -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, diff --git a/extensions/openshell/src/cli.ts b/extensions/openshell/src/cli.ts index 411166520e7..a35b6aba69f 100644 --- a/extensions/openshell/src/cli.ts +++ b/extensions/openshell/src/cli.ts @@ -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; diff --git a/extensions/openshell/src/fs-bridge.ts b/extensions/openshell/src/fs-bridge.ts index 00257e81be4..195e8ec555e 100644 --- a/extensions/openshell/src/fs-bridge.ts +++ b/extensions/openshell/src/fs-bridge.ts @@ -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"; diff --git a/extensions/openshell/src/remote-fs-bridge.ts b/extensions/openshell/src/remote-fs-bridge.ts index 9cc1ddf704d..eeee51b7ee6 100644 --- a/extensions/openshell/src/remote-fs-bridge.ts +++ b/extensions/openshell/src/remote-fs-bridge.ts @@ -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; diff --git a/extensions/sglang/index.ts b/extensions/sglang/index.ts index 5672034ad9c..38ecf508b07 100644 --- a/extensions/sglang/index.ts +++ b/extensions/sglang/index.ts @@ -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: { diff --git a/extensions/vllm/index.ts b/extensions/vllm/index.ts index 571692c6585..8ab11b9f1bc 100644 --- a/extensions/vllm/index.ts +++ b/extensions/vllm/index.ts @@ -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: { diff --git a/package.json b/package.json index ebaa3607ad1..4f1c502b586 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/agents/ollama-defaults.ts b/src/agents/ollama-defaults.ts new file mode 100644 index 00000000000..434efeb8dcb --- /dev/null +++ b/src/agents/ollama-defaults.ts @@ -0,0 +1 @@ +export const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434"; diff --git a/src/agents/sglang-defaults.ts b/src/agents/sglang-defaults.ts new file mode 100644 index 00000000000..d91355a8257 --- /dev/null +++ b/src/agents/sglang-defaults.ts @@ -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"; diff --git a/src/agents/vllm-defaults.ts b/src/agents/vllm-defaults.ts new file mode 100644 index 00000000000..3f2498221f0 --- /dev/null +++ b/src/agents/vllm-defaults.ts @@ -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"; diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 1e33cafe4e9..0c521f84122 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -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"; diff --git a/src/plugin-sdk/provider-setup.ts b/src/plugin-sdk/provider-setup.ts new file mode 100644 index 00000000000..6569c36a324 --- /dev/null +++ b/src/plugin-sdk/provider-setup.ts @@ -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"; diff --git a/src/plugin-sdk/sandbox.ts b/src/plugin-sdk/sandbox.ts new file mode 100644 index 00000000000..245fa9f6b83 --- /dev/null +++ b/src/plugin-sdk/sandbox.ts @@ -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"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 856bacbca10..813d3e28e1e 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -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", () => { diff --git a/src/plugins/contracts/discovery.contract.test.ts b/src/plugins/contracts/discovery.contract.test.ts index 9ca5f1184e6..a7576acdd5c 100644 --- a/src/plugins/contracts/discovery.contract.test.ts +++ b/src/plugins/contracts/discovery.contract.test.ts @@ -22,8 +22,8 @@ vi.mock("../../../extensions/github-copilot/token.js", async () => { }; }); -vi.mock("openclaw/plugin-sdk/core", async () => { - const actual = await vi.importActual("openclaw/plugin-sdk/core"); +vi.mock("openclaw/plugin-sdk/provider-setup", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/provider-setup"); return { ...actual, buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args), From c08f2aa21a6e336b303355297b8bbce08c0fa4a3 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 12:04:41 +0000 Subject: [PATCH 10/10] Providers: centralize setup defaults and helper boundaries --- src/agents/auth-profiles/upsert-with-lock.ts | 56 ++++++++++++++ .../models-config.providers.discovery.ts | 34 ++++----- src/agents/ollama-models.ts | 3 +- src/agents/ollama-stream.ts | 3 +- src/agents/self-hosted-provider-defaults.ts | 8 ++ src/cli/config-cli.ts | 2 +- src/commands/auth-profile-config.ts | 73 +++++++++++++++++++ src/commands/ollama-setup.ts | 6 +- src/commands/onboard-auth.config-core.ts | 73 +------------------ src/commands/onboard-custom.test.ts | 2 +- src/commands/onboard-custom.ts | 2 +- src/commands/self-hosted-provider-setup.ts | 22 +++--- src/commands/vllm-setup.ts | 16 ++-- 13 files changed, 184 insertions(+), 116 deletions(-) create mode 100644 src/agents/auth-profiles/upsert-with-lock.ts create mode 100644 src/agents/self-hosted-provider-defaults.ts create mode 100644 src/commands/auth-profile-config.ts diff --git a/src/agents/auth-profiles/upsert-with-lock.ts b/src/agents/auth-profiles/upsert-with-lock.ts new file mode 100644 index 00000000000..965798da940 --- /dev/null +++ b/src/agents/auth-profiles/upsert-with-lock.ts @@ -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) : {}; + const profiles = + record.profiles && typeof record.profiles === "object" && !Array.isArray(record.profiles) + ? { ...(record.profiles as Record) } + : {}; + const order = + record.order && typeof record.order === "object" && !Array.isArray(record.order) + ? (record.order as Record) + : undefined; + const lastGood = + record.lastGood && typeof record.lastGood === "object" && !Array.isArray(record.lastGood) + ? (record.lastGood as Record) + : undefined; + const usageStats = + record.usageStats && typeof record.usageStats === "object" && !Array.isArray(record.usageStats) + ? (record.usageStats as Record) + : 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 { + 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; + } +} diff --git a/src/agents/models-config.providers.discovery.ts b/src/agents/models-config.providers.discovery.ts index a6d99afa89f..01dfb28e469 100644 --- a/src/agents/models-config.providers.discovery.ts +++ b/src/agents/models-config.providers.discovery.ts @@ -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 { - 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 { - 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, diff --git a/src/agents/ollama-models.ts b/src/agents/ollama-models.ts index 20406b3a80e..ee0fcfde447 100644 --- a/src/agents/ollama-models.ts +++ b/src/agents/ollama-models.ts @@ -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 = { diff --git a/src/agents/ollama-stream.ts b/src/agents/ollama-stream.ts index 70a2ef33cf1..f332ad1fd83 100644 --- a/src/agents/ollama-stream.ts +++ b/src/agents/ollama-stream.ts @@ -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; diff --git a/src/agents/self-hosted-provider-defaults.ts b/src/agents/self-hosted-provider-defaults.ts new file mode 100644 index 00000000000..da9dcc4b1d6 --- /dev/null +++ b/src/agents/self-hosted-provider-defaults.ts @@ -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, +}; diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 4793ff6bea6..0469952d322 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -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); diff --git a/src/commands/auth-profile-config.ts b/src/commands/auth-profile-config.ts new file mode 100644 index 00000000000..797135b87b2 --- /dev/null +++ b/src/commands/auth-profile-config.ts @@ -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 } : {}), + }, + }; +} diff --git a/src/commands/ollama-setup.ts b/src/commands/ollama-setup.ts index 060724061bd..4557f606bb6 100644 --- a/src/commands/ollama-setup.ts +++ b/src/commands/ollama-setup.ts @@ -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"]; diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index 0afd59c3910..c939a2cb99d 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -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( existingProvider: Record | 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] = { diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index 6d78766853a..cf86da64211 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -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 { diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index 874018a74ea..9de8e3f85cf 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -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"; diff --git a/src/commands/self-hosted-provider-setup.ts b/src/commands/self-hosted-provider-setup.ts index c067d797f15..ec2d8c683e3 100644 --- a/src/commands/self-hosted-provider-setup.ts +++ b/src/commands/self-hosted-provider-setup.ts @@ -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; diff --git a/src/commands/vllm-setup.ts b/src/commands/vllm-setup.ts index 4d8657306e6..4c44587c06e 100644 --- a/src/commands/vllm-setup.ts +++ b/src/commands/vllm-setup.ts @@ -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,