diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index 26e57b1169b..eb5f05c3c59 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -270,6 +270,13 @@ my-plugin/ `./runtime-api.ts`. The SDK path is the external contract only. + + Extension production code should also avoid `openclaw/plugin-sdk/` + 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. + + ## Related - [Entry Points](/plugins/sdk-entrypoints) — `definePluginEntry` and `defineChannelPluginEntry` options diff --git a/extensions/elevenlabs/speech-provider.ts b/extensions/elevenlabs/speech-provider.ts index f6ce0e1436b..6bc47072e98 100644 --- a/extensions/elevenlabs/speech-provider.ts +++ b/extensions/elevenlabs/speech-provider.ts @@ -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"; diff --git a/extensions/elevenlabs/tts.ts b/extensions/elevenlabs/tts.ts index dff127efbbc..ef69c1c6587 100644 --- a/extensions/elevenlabs/tts.ts +++ b/extensions/elevenlabs/tts.ts @@ -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"; diff --git a/extensions/google/image-generation-provider.test.ts b/extensions/google/image-generation-provider.test.ts index 75716fe1382..16d480d6d0a 100644 --- a/extensions/google/image-generation-provider.test.ts +++ b/extensions/google/image-generation-provider.test.ts @@ -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", diff --git a/extensions/google/image-generation-provider.ts b/extensions/google/image-generation-provider.ts index 33616abc4b8..ae3898d3b32 100644 --- a/extensions/google/image-generation-provider.ts +++ b/extensions/google/image-generation-provider.ts @@ -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, diff --git a/extensions/matrix/src/matrix/sdk/logger.ts b/extensions/matrix/src/matrix/sdk/logger.ts index 758b0c1e85e..91765122163 100644 --- a/extensions/matrix/src/matrix/sdk/logger.ts +++ b/extensions/matrix/src/matrix/sdk/logger.ts @@ -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"; diff --git a/extensions/microsoft/speech-provider.ts b/extensions/microsoft/speech-provider.ts index d4fe6dc74d0..9e0d6db57fa 100644 --- a/extensions/microsoft/speech-provider.ts +++ b/extensions/microsoft/speech-provider.ts @@ -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"; diff --git a/extensions/openai/speech-provider.ts b/extensions/openai/speech-provider.ts index 42a9f835e66..686a669a876 100644 --- a/extensions/openai/speech-provider.ts +++ b/extensions/openai/speech-provider.ts @@ -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, diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts index 6be8e688c9a..779c346cb46 100644 --- a/extensions/opencode/index.ts +++ b/extensions/opencode/index.ts @@ -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({ diff --git a/extensions/venice/index.ts b/extensions/venice/index.ts index 76edef80b80..6c46ee66ea4 100644 --- a/extensions/venice/index.ts +++ b/extensions/venice/index.ts @@ -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(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, }, }); diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index 2290da560f0..c6c9d4e0289 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -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); diff --git a/src/plugin-sdk/logging-core.ts b/src/plugin-sdk/logging-core.ts index 023c41f73bd..e9a9ec93abf 100644 --- a/src/plugin-sdk/logging-core.ts +++ b/src/plugin-sdk/logging-core.ts @@ -1 +1,2 @@ export { createSubsystemLogger } from "../logging/subsystem.js"; +export { redactSensitiveText } from "../logging/redact.js"; diff --git a/src/plugin-sdk/speech.ts b/src/plugin-sdk/speech.ts index 9033a70af8f..8628da81d16 100644 --- a/src/plugin-sdk/speech.ts +++ b/src/plugin-sdk/speech.ts @@ -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";