refactor: decouple bundled plugin sdk surfaces

This commit is contained in:
Peter Steinberger
2026-03-29 21:20:24 +01:00
parent 5d4c4bb850
commit 35233bae96
13 changed files with 109 additions and 17 deletions

View File

@@ -270,6 +270,13 @@ my-plugin/
`./runtime-api.ts`. The SDK path is the external contract only.
</Warning>
<Warning>
Extension production code should also avoid `openclaw/plugin-sdk/<other-plugin>`
imports. If a helper is truly shared, promote it to a neutral SDK subpath
such as `openclaw/plugin-sdk/speech`, `.../provider-model-shared`, or another
capability-oriented surface instead of coupling two plugins together.
</Warning>
## Related
- [Entry Points](/plugins/sdk-entrypoints) — `definePluginEntry` and `defineChannelPluginEntry` options

View File

@@ -5,13 +5,13 @@ import type {
SpeechProviderOverrides,
SpeechProviderPlugin,
SpeechVoiceOption,
} from "openclaw/plugin-sdk/speech-core";
} from "openclaw/plugin-sdk/speech";
import {
normalizeApplyTextNormalization,
normalizeLanguageCode,
normalizeSeed,
requireInRange,
} from "openclaw/plugin-sdk/speech-core";
} from "openclaw/plugin-sdk/speech";
import { elevenLabsTTS } from "./tts.js";
const DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io";

View File

@@ -3,7 +3,7 @@ import {
normalizeLanguageCode,
normalizeSeed,
requireInRange,
} from "openclaw/plugin-sdk/speech-core";
} from "openclaw/plugin-sdk/speech";
const DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io";

View File

@@ -1,10 +1,10 @@
import * as imageGenerationCore from "openclaw/plugin-sdk/image-generation-core";
import * as providerAuthRuntime from "openclaw/plugin-sdk/provider-auth-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildGoogleImageGenerationProvider } from "./image-generation-provider.js";
import { __testing as geminiWebSearchTesting } from "./src/gemini-web-search-provider.js";
function mockGoogleApiKeyAuth() {
vi.spyOn(imageGenerationCore, "resolveApiKeyForProvider").mockResolvedValue({
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "google-test-key",
source: "env",
mode: "api-key",
@@ -48,7 +48,7 @@ describe("Google image-generation provider", () => {
});
it("generates image buffers from the Gemini generateContent API", async () => {
vi.spyOn(imageGenerationCore, "resolveApiKeyForProvider").mockResolvedValue({
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "google-test-key",
source: "env",
mode: "api-key",
@@ -118,7 +118,7 @@ describe("Google image-generation provider", () => {
});
it("accepts OAuth JSON auth and inline_data responses", async () => {
vi.spyOn(imageGenerationCore, "resolveApiKeyForProvider").mockResolvedValue({
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: JSON.stringify({ token: "oauth-token" }),
source: "profile",
mode: "token",

View File

@@ -1,5 +1,5 @@
import type { ImageGenerationProvider } from "openclaw/plugin-sdk/image-generation";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/image-generation-core";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
normalizeBaseUrl,

View File

@@ -1,5 +1,5 @@
import { format } from "node:util";
import { redactSensitiveText } from "openclaw/plugin-sdk/diagnostics-otel";
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
import type { RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
import { getMatrixRuntime } from "../../runtime.js";

View File

@@ -5,13 +5,13 @@ import {
TRUSTED_CLIENT_TOKEN,
generateSecMsGecToken,
} from "node-edge-tts/dist/drm.js";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/llm-task";
import { isVoiceCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
import type {
SpeechProviderConfig,
SpeechProviderPlugin,
SpeechVoiceOption,
} from "openclaw/plugin-sdk/speech-core";
} from "openclaw/plugin-sdk/speech";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { edgeTTS, inferEdgeExtension } from "./tts.js";
const DEFAULT_EDGE_VOICE = "en-US-MichelleNeural";

View File

@@ -4,7 +4,7 @@ import type {
SpeechProviderConfig,
SpeechProviderOverrides,
SpeechProviderPlugin,
} from "openclaw/plugin-sdk/speech-core";
} from "openclaw/plugin-sdk/speech";
import {
DEFAULT_OPENAI_BASE_URL,
isValidOpenAIModel,

View File

@@ -1,16 +1,17 @@
import { isMiniMaxModernModelId } from "openclaw/plugin-sdk/minimax";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { matchesExactOrPrefix } from "openclaw/plugin-sdk/provider-model-shared";
import { applyOpencodeZenConfig, OPENCODE_ZEN_DEFAULT_MODEL } from "./api.js";
const PROVIDER_ID = "opencode";
const MINIMAX_MODERN_MODEL_MATCHERS = ["minimax-m2.7"] as const;
function isModernOpencodeModel(modelId: string): boolean {
const lower = modelId.trim().toLowerCase();
if (lower.endsWith("-free") || lower === "alpha-glm-4.7") {
return false;
}
return !isMiniMaxModernModelId(lower);
return !matchesExactOrPrefix(lower, MINIMAX_MODERN_MODEL_MATCHERS);
}
export default definePluginEntry({

View File

@@ -1,14 +1,34 @@
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { applyXaiModelCompat } from "openclaw/plugin-sdk/xai";
import { applyModelCompatPatch } from "openclaw/plugin-sdk/provider-model-shared";
import type { ModelCompatConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { XAI_UNSUPPORTED_SCHEMA_KEYWORDS } from "openclaw/plugin-sdk/provider-tools";
import { applyVeniceConfig, VENICE_DEFAULT_MODEL_REF } from "./onboard.js";
import { buildVeniceProvider } from "./provider-catalog.js";
const PROVIDER_ID = "venice";
const XAI_TOOL_SCHEMA_PROFILE = "xai";
const HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING = "html-entities";
function isXaiBackedVeniceModel(modelId: string): boolean {
return modelId.trim().toLowerCase().includes("grok");
}
function resolveXaiCompatPatch(): ModelCompatConfig {
return {
toolSchemaProfile: XAI_TOOL_SCHEMA_PROFILE,
unsupportedToolSchemaKeywords: Array.from(XAI_UNSUPPORTED_SCHEMA_KEYWORDS),
nativeWebSearchTool: true,
toolCallArgumentsEncoding: HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING,
};
}
function applyXaiCompat<T extends { compat?: unknown }>(model: T): T {
return applyModelCompatPatch(
model as T & { compat?: ModelCompatConfig },
resolveXaiCompatPatch(),
) as T;
}
export default defineSingleProviderPluginEntry({
id: PROVIDER_ID,
name: "Venice Provider",
@@ -42,6 +62,6 @@ export default defineSingleProviderPluginEntry({
buildProvider: buildVeniceProvider,
},
normalizeResolvedModel: ({ modelId, model }) =>
isXaiBackedVeniceModel(modelId) ? applyXaiModelCompat(model) : undefined,
isXaiBackedVeniceModel(modelId) ? applyXaiCompat(model) : undefined,
},
});

View File

@@ -13,6 +13,11 @@ const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const REPO_ROOT = resolve(ROOT_DIR, "..");
const ALLOWED_EXTENSION_PUBLIC_SURFACES = new Set(GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES);
ALLOWED_EXTENSION_PUBLIC_SURFACES.add("test-api.js");
const BUNDLED_EXTENSION_IDS = new Set(
readdirSync(resolve(REPO_ROOT, "extensions"), { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name !== "shared")
.map((entry) => entry.name),
);
const GUARDED_CHANNEL_EXTENSIONS = new Set([
"bluebubbles",
"discord",
@@ -455,6 +460,27 @@ function expectNoSiblingExtensionPrivateSrcImports(file: string, imports: string
}
}
function expectNoCrossPluginSdkFacadeImports(file: string, imports: string[]): void {
const normalizedFile = file.replaceAll("\\", "/");
const currentExtensionId =
normalizedFile.match(new RegExp(`/${BUNDLED_PLUGIN_ROOT_DIR}/([^/]+)/`))?.[1] ?? null;
if (!currentExtensionId) {
return;
}
for (const specifier of imports) {
if (!specifier.startsWith("openclaw/plugin-sdk/")) {
continue;
}
const targetSubpath = specifier.slice("openclaw/plugin-sdk/".length);
if (!BUNDLED_EXTENSION_IDS.has(targetSubpath) || targetSubpath === currentExtensionId) {
continue;
}
expect.fail(
`${file} should not import another bundled plugin facade, got ${specifier}. Promote shared helpers to a neutral plugin-sdk subpath instead.`,
);
}
}
describe("channel import guardrails", () => {
it("keeps channel helper modules off their own SDK barrels", () => {
for (const source of SAME_CHANNEL_SDK_GUARDS) {
@@ -522,6 +548,12 @@ describe("channel import guardrails", () => {
}
});
it("keeps extension production files off other bundled plugin sdk facades", () => {
for (const file of collectExtensionSourceFiles()) {
expectNoCrossPluginSdkFacadeImports(file, getSourceAnalysis(file).importSpecifiers);
}
});
it("keeps core extension imports limited to approved public surfaces", () => {
for (const file of collectCoreSourceFiles()) {
expectOnlyApprovedExtensionSeams(file, getSourceAnalysis(file).extensionImports);

View File

@@ -1 +1,2 @@
export { createSubsystemLogger } from "../logging/subsystem.js";
export { redactSensitiveText } from "../logging/redact.js";

View File

@@ -1,9 +1,40 @@
// Public speech helpers for bundled or third-party plugins.
//
// Keep this surface neutral. Provider plugins should not need to know about the
// bundled `speech-core` plugin id just to consume shared speech types/helpers.
export { parseTtsDirectives } from "../tts/directives.js";
export type { SpeechProviderPlugin } from "../plugins/types.js";
export type {
SpeechDirectiveTokenParseContext,
SpeechDirectiveTokenParseResult,
SpeechListVoicesRequest,
SpeechModelOverridePolicy,
SpeechProviderConfig,
SpeechProviderConfiguredContext,
SpeechProviderResolveConfigContext,
SpeechProviderResolveTalkConfigContext,
SpeechProviderResolveTalkOverridesContext,
SpeechProviderOverrides,
SpeechSynthesisRequest,
SpeechTelephonySynthesisRequest,
SpeechVoiceOption,
TtsDirectiveOverrides,
TtsDirectiveParseResult,
} from "../tts/provider-types.js";
export {
scheduleCleanup,
summarizeText,
normalizeApplyTextNormalization,
normalizeLanguageCode,
normalizeSeed,
requireInRange,
} from "../tts/tts-core.js";
export { parseTtsDirectives } from "../tts/directives.js";
export {
canonicalizeSpeechProviderId,
getSpeechProvider,
listSpeechProviders,
normalizeSpeechProviderId,
} from "../tts/provider-registry.js";
export { normalizeTtsAutoMode, TTS_AUTO_MODES } from "../tts/tts-auto-mode.js";