From eaae63d288c8ef07cfe99b62c48cfa642ed5f598 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 12:50:25 +0100 Subject: [PATCH] refactor: keep plugin sdk owner seams explicit --- docs/plugins/sdk-subpaths.md | 7 +- .../bluebubbles/src/attachments.test.ts | 2 +- .../src/test-support/monitor-test-support.ts | 3 +- extensions/matrix/src/cli.test.ts | 2 +- .../mattermost/slash-http.send-config.test.ts | 3 +- extensions/opencode-go/index.ts | 26 +++++- extensions/opencode/index.ts | 25 +++++- extensions/telegram/src/command-ui.ts | 31 ++++++- src/plugin-sdk/entrypoints.ts | 28 +++++++ ...in-sdk-package-contract-guardrails.test.ts | 81 +++++++++++++++++++ 10 files changed, 192 insertions(+), 16 deletions(-) diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 59f3e77421c..5f1e871b505 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -87,6 +87,8 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | Subpath | Key exports | | --- | --- | | `plugin-sdk/provider-entry` | `defineSingleProviderPluginEntry` | + | `plugin-sdk/lmstudio` | Supported LM Studio provider facade for setup, catalog discovery, and runtime model preparation | + | `plugin-sdk/lmstudio-runtime` | Supported LM Studio runtime facade for local server defaults, model discovery, request headers, and loaded-model helpers | | `plugin-sdk/provider-setup` | Curated local/self-hosted provider setup helpers | | `plugin-sdk/self-hosted-provider-setup` | Focused OpenAI-compatible self-hosted provider setup helpers | | `plugin-sdk/cli-backend` | CLI backend defaults + watchdog constants | @@ -150,6 +152,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | --- | --- | | `plugin-sdk/runtime` | Broad runtime/logging/backup/plugin-install helpers | | `plugin-sdk/runtime-env` | Narrow runtime env, logger, timeout, retry, and backoff helpers | + | `plugin-sdk/browser-config` | Supported browser config facade for normalized profile/defaults, CDP URL parsing, and browser-control auth helpers | | `plugin-sdk/channel-runtime-context` | Generic channel runtime-context registration and lookup helpers | | `plugin-sdk/runtime-store` | `createPluginRuntimeStore` | | `plugin-sdk/plugin-runtime` | Shared plugin command/hook/http/interactive helpers | @@ -273,8 +276,8 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | Matrix | `plugin-sdk/matrix`, `plugin-sdk/matrix-helper`, `plugin-sdk/matrix-runtime-heavy`, `plugin-sdk/matrix-runtime-shared`, `plugin-sdk/matrix-runtime-surface`, `plugin-sdk/matrix-surface`, `plugin-sdk/matrix-thread-bindings` | Bundled Matrix helper/runtime surface | | Line | `plugin-sdk/line`, `plugin-sdk/line-core`, `plugin-sdk/line-runtime`, `plugin-sdk/line-surface` | Bundled LINE helper/runtime surface | | IRC | `plugin-sdk/irc`, `plugin-sdk/irc-surface` | Bundled IRC helper surface | - | Channel-specific helpers | `plugin-sdk/googlechat`, `plugin-sdk/googlechat-runtime-shared`, `plugin-sdk/zalouser`, `plugin-sdk/bluebubbles`, `plugin-sdk/bluebubbles-policy`, `plugin-sdk/mattermost`, `plugin-sdk/mattermost-policy`, `plugin-sdk/feishu`, `plugin-sdk/feishu-conversation`, `plugin-sdk/feishu-setup`, `plugin-sdk/msteams`, `plugin-sdk/nextcloud-talk`, `plugin-sdk/nostr`, `plugin-sdk/tlon`, `plugin-sdk/twitch`, `plugin-sdk/zalo`, `plugin-sdk/zalo-setup` | Deprecated bundled channel compatibility/helper seams. New plugins should import generic SDK subpaths or plugin-local barrels. | - | Auth/plugin-specific helpers | `plugin-sdk/github-copilot-login`, `plugin-sdk/github-copilot-token`, `plugin-sdk/diagnostics-otel`, `plugin-sdk/diagnostics-prometheus`, `plugin-sdk/diffs`, `plugin-sdk/llm-task`, `plugin-sdk/memory-core`, `plugin-sdk/memory-lancedb`, `plugin-sdk/thread-ownership`, `plugin-sdk/voice-call` | Bundled feature/plugin helper seams; `plugin-sdk/github-copilot-token` currently exports `DEFAULT_COPILOT_API_BASE_URL`, `deriveCopilotApiBaseUrlFromToken`, and `resolveCopilotApiToken` | + | Channel-specific helpers | `plugin-sdk/googlechat`, `plugin-sdk/googlechat-runtime-shared`, `plugin-sdk/zalouser`, `plugin-sdk/bluebubbles`, `plugin-sdk/bluebubbles-policy`, `plugin-sdk/mattermost`, `plugin-sdk/mattermost-policy`, `plugin-sdk/feishu`, `plugin-sdk/feishu-conversation`, `plugin-sdk/feishu-setup`, `plugin-sdk/msteams`, `plugin-sdk/nextcloud-talk`, `plugin-sdk/nostr`, `plugin-sdk/telegram-command-ui`, `plugin-sdk/tlon`, `plugin-sdk/twitch`, `plugin-sdk/zalo`, `plugin-sdk/zalo-setup` | Deprecated bundled channel compatibility/helper seams. New plugins should import generic SDK subpaths or plugin-local barrels. | + | Auth/plugin-specific helpers | `plugin-sdk/github-copilot-login`, `plugin-sdk/github-copilot-token`, `plugin-sdk/diagnostics-otel`, `plugin-sdk/diagnostics-prometheus`, `plugin-sdk/diffs`, `plugin-sdk/llm-task`, `plugin-sdk/memory-core`, `plugin-sdk/memory-lancedb`, `plugin-sdk/opencode`, `plugin-sdk/thread-ownership`, `plugin-sdk/voice-call` | Bundled feature/plugin helper seams; `plugin-sdk/github-copilot-token` currently exports `DEFAULT_COPILOT_API_BASE_URL`, `deriveCopilotApiBaseUrlFromToken`, and `resolveCopilotApiToken` | diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 503b9641f31..d4c09c48fb8 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -1,4 +1,4 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import "./test-mocks.js"; import { diff --git a/extensions/bluebubbles/src/test-support/monitor-test-support.ts b/extensions/bluebubbles/src/test-support/monitor-test-support.ts index 03ee6b2343d..b8f575ac8fa 100644 --- a/extensions/bluebubbles/src/test-support/monitor-test-support.ts +++ b/extensions/bluebubbles/src/test-support/monitor-test-support.ts @@ -1,4 +1,5 @@ -import type { HistoryEntry, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import { vi } from "vitest"; import { createPluginRuntimeMock } from "../../../../test/helpers/plugins/plugin-runtime-mock.js"; import { _resetBlueBubblesInboundDedupForTest } from "../inbound-dedupe.js"; diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts index 90108bf213f..ce70ab86c9f 100644 --- a/extensions/matrix/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; -import { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix-runtime-shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { registerMatrixCli, resetMatrixCliStateForTests } from "./cli.js"; +import { formatZonedTimestamp } from "./runtime-api.js"; import type { CoreConfig } from "./types.js"; const bootstrapMatrixVerificationMock = vi.fn(); diff --git a/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts b/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts index 9b97205d2f2..3f7866f2991 100644 --- a/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts +++ b/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts @@ -1,6 +1,7 @@ import { ServerResponse, type IncomingMessage } from "node:http"; import { PassThrough } from "node:stream"; -import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/mattermost"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ResolvedMattermostAccount } from "./accounts.js"; diff --git a/extensions/opencode-go/index.ts b/extensions/opencode-go/index.ts index 6a2af044ec2..1adf4c05ebe 100644 --- a/extensions/opencode-go/index.ts +++ b/extensions/opencode-go/index.ts @@ -1,5 +1,5 @@ -import { createOpencodeCatalogApiKeyAuthMethod } from "openclaw/plugin-sdk/opencode"; import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import { PASSTHROUGH_GEMINI_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared"; import { applyOpencodeGoConfig, OPENCODE_GO_DEFAULT_MODEL_REF } from "./api.js"; import { opencodeGoMediaUnderstandingProvider } from "./media-understanding-provider.js"; @@ -11,6 +11,14 @@ import { import { createOpencodeGoDeepSeekV4Wrapper } from "./stream.js"; const PROVIDER_ID = "opencode-go"; +const OPENCODE_SHARED_PROFILE_IDS = ["opencode:default", "opencode-go:default"] as const; +const OPENCODE_SHARED_HINT = "Shared API key for Zen + Go catalogs"; +const OPENCODE_SHARED_WIZARD_GROUP = { + groupId: "opencode", + groupLabel: "OpenCode", + groupHint: OPENCODE_SHARED_HINT, +} as const; + export default definePluginEntry({ id: PROVIDER_ID, name: "OpenCode Go Provider", @@ -22,20 +30,30 @@ export default definePluginEntry({ docsPath: "/providers/models", envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], auth: [ - createOpencodeCatalogApiKeyAuthMethod({ + createProviderApiKeyAuthMethod({ providerId: PROVIDER_ID, + methodId: "api-key", label: "OpenCode Go catalog", + hint: OPENCODE_SHARED_HINT, optionKey: "opencodeGoApiKey", flagName: "--opencode-go-api-key", + envVar: "OPENCODE_API_KEY", + promptMessage: "Enter OpenCode API key", + profileIds: [...OPENCODE_SHARED_PROFILE_IDS], defaultModel: OPENCODE_GO_DEFAULT_MODEL_REF, applyConfig: (cfg) => applyOpencodeGoConfig(cfg), + expectedProviders: ["opencode", "opencode-go"], noteMessage: [ "OpenCode uses one API key across the Zen and Go catalogs.", "Go focuses on Kimi, GLM, and MiniMax coding models.", "Get your API key at: https://opencode.ai/auth", ].join("\n"), - choiceId: "opencode-go", - choiceLabel: "OpenCode Go catalog", + noteTitle: "OpenCode", + wizard: { + choiceId: "opencode-go", + choiceLabel: "OpenCode Go catalog", + ...OPENCODE_SHARED_WIZARD_GROUP, + }, }), ], normalizeConfig: ({ providerConfig }) => { diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts index 1e97b81e160..a7eb3466998 100644 --- a/extensions/opencode/index.ts +++ b/extensions/opencode/index.ts @@ -1,5 +1,5 @@ -import { createOpencodeCatalogApiKeyAuthMethod } from "openclaw/plugin-sdk/opencode"; import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import { matchesExactOrPrefix, PASSTHROUGH_GEMINI_REPLAY_HOOKS, @@ -10,6 +10,13 @@ import { opencodeMediaUnderstandingProvider } from "./media-understanding-provid const PROVIDER_ID = "opencode"; const MINIMAX_MODERN_MODEL_MATCHERS = ["minimax-m2.7"] as const; +const OPENCODE_SHARED_PROFILE_IDS = ["opencode:default", "opencode-go:default"] as const; +const OPENCODE_SHARED_HINT = "Shared API key for Zen + Go catalogs"; +const OPENCODE_SHARED_WIZARD_GROUP = { + groupId: "opencode", + groupLabel: "OpenCode", + groupHint: OPENCODE_SHARED_HINT, +} as const; function isModernOpencodeModel(modelId: string): boolean { const lower = normalizeLowercaseStringOrEmpty(modelId); @@ -30,21 +37,31 @@ export default definePluginEntry({ docsPath: "/providers/models", envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], auth: [ - createOpencodeCatalogApiKeyAuthMethod({ + createProviderApiKeyAuthMethod({ providerId: PROVIDER_ID, + methodId: "api-key", label: "OpenCode Zen catalog", + hint: OPENCODE_SHARED_HINT, optionKey: "opencodeZenApiKey", flagName: "--opencode-zen-api-key", + envVar: "OPENCODE_API_KEY", + promptMessage: "Enter OpenCode API key", + profileIds: [...OPENCODE_SHARED_PROFILE_IDS], defaultModel: OPENCODE_ZEN_DEFAULT_MODEL, applyConfig: (cfg) => applyOpencodeZenConfig(cfg), + expectedProviders: ["opencode", "opencode-go"], noteMessage: [ "OpenCode uses one API key across the Zen and Go catalogs.", "Zen provides access to Claude, GPT, Gemini, and more models.", "Get your API key at: https://opencode.ai/auth", "Choose the Zen catalog when you want the curated multi-model proxy.", ].join("\n"), - choiceId: "opencode-zen", - choiceLabel: "OpenCode Zen catalog", + noteTitle: "OpenCode", + wizard: { + choiceId: "opencode-zen", + choiceLabel: "OpenCode Zen catalog", + ...OPENCODE_SHARED_WIZARD_GROUP, + }, }), ], ...PASSTHROUGH_GEMINI_REPLAY_HOOKS, diff --git a/extensions/telegram/src/command-ui.ts b/extensions/telegram/src/command-ui.ts index d039608ffa7..c8c3d455feb 100644 --- a/extensions/telegram/src/command-ui.ts +++ b/extensions/telegram/src/command-ui.ts @@ -1,5 +1,4 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; -import { buildCommandsPaginationKeyboard } from "openclaw/plugin-sdk/telegram-command-ui"; import { buildBrowseProvidersButton, buildModelsKeyboard, @@ -7,7 +6,35 @@ import { type ProviderInfo, } from "./model-buttons.js"; -export { buildCommandsPaginationKeyboard }; +export function buildCommandsPaginationKeyboard( + currentPage: number, + totalPages: number, + agentId?: string, +): Array> { + const buttons: Array<{ text: string; callback_data: string }> = []; + const suffix = agentId ? `:${agentId}` : ""; + + if (currentPage > 1) { + buttons.push({ + text: "◀ Prev", + callback_data: `commands_page_${currentPage - 1}${suffix}`, + }); + } + + buttons.push({ + text: `${currentPage}/${totalPages}`, + callback_data: `commands_page_noop${suffix}`, + }); + + if (currentPage < totalPages) { + buttons.push({ + text: "Next ▶", + callback_data: `commands_page_${currentPage + 1}${suffix}`, + }); + } + + return [buttons]; +} export function buildTelegramModelsMenuButtons(params: { providers: ProviderInfo[] }) { return buildProviderKeyboard(params.providers); diff --git a/src/plugin-sdk/entrypoints.ts b/src/plugin-sdk/entrypoints.ts index 66b10a01fdb..7fee9ef60a6 100644 --- a/src/plugin-sdk/entrypoints.ts +++ b/src/plugin-sdk/entrypoints.ts @@ -47,6 +47,8 @@ export const reservedBundledPluginSdkEntrypoints = [ "msteams", "nextcloud-talk", "nostr", + "opencode", + "telegram-command-ui", "thread-ownership", "tlon", "twitch", @@ -64,6 +66,32 @@ export const supportedBundledFacadeSdkEntrypoints = [ "tts-runtime", ] as const; +export const publicPluginOwnedSdkEntrypoints = [ + "browser-config", + "image-generation-core", + "memory-core-host-engine-embeddings", + "memory-core-host-engine-foundation", + "memory-core-host-engine-qmd", + "memory-core-host-engine-storage", + "memory-core-host-events", + "memory-core-host-multimodal", + "memory-core-host-query", + "memory-core-host-runtime-cli", + "memory-core-host-runtime-core", + "memory-core-host-runtime-files", + "memory-core-host-secret", + "memory-core-host-status", + "memory-host-core", + "memory-host-events", + "memory-host-files", + "memory-host-markdown", + "memory-host-search", + "memory-host-status", + "speech-core", + "telegram-command-config", + "video-generation-core", +] as const; + /** Map every SDK entrypoint name to its source file path inside the repo. */ export function buildPluginSdkEntrySources(entries: readonly string[] = pluginSdkEntrypoints) { return Object.fromEntries(entries.map((entry) => [entry, `src/plugin-sdk/${entry}.ts`])); diff --git a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts index 3daf7ed58e6..627dec2bfdb 100644 --- a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts @@ -4,12 +4,14 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { pluginSdkEntrypoints, + publicPluginOwnedSdkEntrypoints, reservedBundledPluginSdkEntrypoints, supportedBundledFacadeSdkEntrypoints, } from "../../plugin-sdk/entrypoints.js"; const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const REPO_ROOT = resolve(ROOT_DIR, ".."); +const SDK_SUBPATH_DOC_FILE = "docs/plugins/sdk-subpaths.md"; const PUBLIC_CONTRACT_REFERENCE_FILES = [ "docs/plugins/architecture.md", "src/plugins/contracts/plugin-sdk-subpaths.test.ts", @@ -57,6 +59,48 @@ function collectPluginSdkSubpathReferences() { return references; } +function collectDocumentedSdkSubpaths(): Set { + const source = readFileSync(resolve(REPO_ROOT, SDK_SUBPATH_DOC_FILE), "utf8"); + return new Set( + [...source.matchAll(/`plugin-sdk\/([a-z0-9][a-z0-9-]*)`/g)] + .map((match) => match[1]) + .filter((subpath): subpath is string => Boolean(subpath)), + ); +} + +function collectBundledPluginIds(): string[] { + return readdirSync(resolve(REPO_ROOT, "extensions"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .toSorted((a, b) => b.length - a.length || a.localeCompare(b)); +} + +function collectPluginOwnedSdkEntrypoints(): string[] { + const pluginIds = collectBundledPluginIds(); + return pluginSdkEntrypoints + .filter((entrypoint) => + pluginIds.some( + (pluginId) => entrypoint === pluginId || entrypoint.startsWith(`${pluginId}-`), + ), + ) + .toSorted(); +} + +function collectClassificationOverlaps(classifications: Record) { + const seen = new Map(); + for (const [classification, entrypoints] of Object.entries(classifications)) { + for (const entrypoint of entrypoints) { + const current = seen.get(entrypoint) ?? []; + current.push(classification); + seen.set(entrypoint, current); + } + } + return [...seen.entries()] + .filter(([, matches]) => matches.length > 1) + .map(([entrypoint, matches]) => `${entrypoint}: ${matches.toSorted().join(", ")}`) + .toSorted(); +} + function collectBundledFacadeSdkEntrypoints(): string[] { const entrypoints: string[] = []; for (const entrypoint of pluginSdkEntrypoints) { @@ -224,6 +268,43 @@ describe("plugin-sdk package contract guardrails", () => { }); }); + it("keeps plugin-owned SDK subpaths explicitly classified and documented", () => { + const entrypoints = new Set(pluginSdkEntrypoints); + const reserved = new Set(reservedBundledPluginSdkEntrypoints); + const supported = new Set(supportedBundledFacadeSdkEntrypoints); + const publicOwned = new Set(publicPluginOwnedSdkEntrypoints); + const documented = collectDocumentedSdkSubpaths(); + const pluginOwnedEntrypoints = collectPluginOwnedSdkEntrypoints(); + const classified = new Set([...reserved, ...supported, ...publicOwned]); + + const unknownPublicOwned = [...publicOwned].filter( + (entrypoint) => !entrypoints.has(entrypoint), + ); + const classificationOverlaps = collectClassificationOverlaps({ + reserved: reservedBundledPluginSdkEntrypoints, + supported: supportedBundledFacadeSdkEntrypoints, + publicOwned: publicPluginOwnedSdkEntrypoints, + }); + const unclassifiedPluginOwned = pluginOwnedEntrypoints.filter( + (entrypoint) => !classified.has(entrypoint), + ); + const undocumentedPluginOwned = pluginOwnedEntrypoints.filter( + (entrypoint) => !documented.has(entrypoint), + ); + + expect({ + unknownPublicOwned, + classificationOverlaps, + unclassifiedPluginOwned, + undocumentedPluginOwned, + }).toEqual({ + unknownPublicOwned: [], + classificationOverlaps: [], + unclassifiedPluginOwned: [], + undocumentedPluginOwned: [], + }); + }); + it("keeps curated public plugin-sdk references on exported built subpaths", () => { const entrypoints = new Set(pluginSdkEntrypoints); const exports = new Set(collectPluginSdkPackageExports());