diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 07efd0d53e0..ca136446e37 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -64c5f94fe0234da8ae2312ab30694ebc5675091fadebac92c106210f45a66e91 plugin-sdk-api-baseline.json -fd00bb4cd8f1e32503f94e8542db95235ec641eb62ae45d6d4b653d9ff60cb09 plugin-sdk-api-baseline.jsonl +1d2767b688414ac41305e88c830858c00947e2d7c713f1a25d86f38cd577620e plugin-sdk-api-baseline.json +e5167477ab6aa2e67bd4361048cf5f6f8fd1cb7ee570544c634d14417f890674 plugin-sdk-api-baseline.jsonl diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index a72d6d8f724..5db3f80ca14 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -273,6 +273,7 @@ Current bundled provider examples: | `plugin-sdk/provider-auth-api-key` | Provider API-key setup helpers | API-key onboarding/profile-write helpers | | `plugin-sdk/provider-auth-result` | Provider auth-result helpers | Standard OAuth auth-result builder | | `plugin-sdk/provider-auth-login` | Provider interactive login helpers | Shared interactive login helpers | + | `plugin-sdk/provider-selection-runtime` | Provider selection helpers | Configured-or-auto provider selection and raw provider config merging | | `plugin-sdk/provider-env-vars` | Provider env-var helpers | Provider auth env-var lookup helpers | | `plugin-sdk/provider-model-shared` | Shared provider model/replay helpers | `ProviderReplayFamily`, `buildProviderReplayFamilyHooks`, `normalizeModelCompat`, shared replay-policy builders, provider-endpoint helpers, and model-id normalization helpers | | `plugin-sdk/provider-catalog-shared` | Shared provider catalog helpers | `findCatalogTemplate`, `buildSingleProviderApiKeyCatalog`, `supportsNativeStreamingUsageCompat`, `applyProviderNativeStreamingUsageCompat` | diff --git a/extensions/voice-call/src/provider-runtime-resolution.ts b/extensions/voice-call/src/provider-runtime-resolution.ts deleted file mode 100644 index 971f87bafea..00000000000 --- a/extensions/voice-call/src/provider-runtime-resolution.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { resolveProviderRawConfig, selectConfiguredOrAutoProvider } from "./provider-selection.js"; - -type AutoSelectableProvider = { - id: string; - autoSelectOrder?: number; -}; - -export type ResolvedConfiguredProvider = - | { - ok: true; - configuredProviderId?: string; - provider: TProvider; - providerConfig: TConfig; - } - | { - ok: false; - code: "missing-configured-provider" | "no-registered-provider" | "provider-not-configured"; - configuredProviderId?: string; - provider?: TProvider; - }; - -export function resolveConfiguredCapabilityProvider< - TConfig, - TFullConfig, - TProvider extends AutoSelectableProvider, ->(params: { - configuredProviderId?: string; - providerConfigs?: Record | undefined>; - cfg: TFullConfig | undefined; - cfgForResolve: TFullConfig; - getConfiguredProvider: (providerId: string | undefined) => TProvider | undefined; - listProviders: () => Iterable; - resolveProviderConfig: (params: { - provider: TProvider; - cfg: TFullConfig; - rawConfig: Record; - }) => TConfig; - isProviderConfigured: (params: { - provider: TProvider; - cfg: TFullConfig | undefined; - providerConfig: TConfig; - }) => boolean; -}): ResolvedConfiguredProvider { - const selection = selectConfiguredOrAutoProvider({ - configuredProviderId: params.configuredProviderId, - getConfiguredProvider: params.getConfiguredProvider, - listProviders: params.listProviders, - }); - if (selection.missingConfiguredProvider) { - return { - ok: false, - code: "missing-configured-provider", - configuredProviderId: selection.configuredProviderId, - }; - } - - const provider = selection.provider; - if (!provider) { - return { - ok: false, - code: "no-registered-provider", - configuredProviderId: selection.configuredProviderId, - }; - } - - const rawProviderConfig = resolveProviderRawConfig({ - providerId: provider.id, - configuredProviderId: selection.configuredProviderId, - providerConfigs: params.providerConfigs, - }); - const providerConfig = params.resolveProviderConfig({ - provider, - cfg: params.cfgForResolve, - rawConfig: rawProviderConfig, - }); - - if (!params.isProviderConfigured({ provider, cfg: params.cfg, providerConfig })) { - return { - ok: false, - code: "provider-not-configured", - configuredProviderId: selection.configuredProviderId, - provider, - }; - } - - return { - ok: true, - configuredProviderId: selection.configuredProviderId, - provider, - providerConfig, - }; -} diff --git a/extensions/voice-call/src/provider-selection.ts b/extensions/voice-call/src/provider-selection.ts deleted file mode 100644 index eec5cadfdf0..00000000000 --- a/extensions/voice-call/src/provider-selection.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; - -type AutoSelectableProvider = { - autoSelectOrder?: number; -}; - -export function selectConfiguredOrAutoProvider(params: { - configuredProviderId?: string; - getConfiguredProvider: (providerId: string | undefined) => TProvider | undefined; - listProviders: () => Iterable; -}): { - configuredProviderId?: string; - missingConfiguredProvider: boolean; - provider: TProvider | undefined; -} { - const configuredProviderId = normalizeOptionalString(params.configuredProviderId); - const configuredProvider = params.getConfiguredProvider(configuredProviderId); - - if (configuredProviderId && !configuredProvider) { - return { - configuredProviderId, - missingConfiguredProvider: true, - provider: undefined, - }; - } - - return { - configuredProviderId, - missingConfiguredProvider: false, - provider: - configuredProvider ?? - [...params.listProviders()].toSorted( - (left, right) => - (left.autoSelectOrder ?? Number.MAX_SAFE_INTEGER) - - (right.autoSelectOrder ?? Number.MAX_SAFE_INTEGER), - )[0], - }; -} - -export function resolveProviderRawConfig(params: { - providerId: string; - configuredProviderId?: string; - providerConfigs?: Record | undefined>; -}): Record { - const canonicalProviderConfig = - params.providerConfigs?.[params.providerId] && - typeof params.providerConfigs[params.providerId] === "object" - ? (params.providerConfigs[params.providerId] as Record) - : undefined; - const selectedProviderConfig = - params.configuredProviderId && - params.providerConfigs?.[params.configuredProviderId] && - typeof params.providerConfigs[params.configuredProviderId] === "object" - ? (params.providerConfigs[params.configuredProviderId] as Record) - : undefined; - - return { - ...canonicalProviderConfig, - ...selectedProviderConfig, - }; -} diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index 46d071212ff..d9fdbea8d0c 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { resolveConfiguredCapabilityProvider } from "openclaw/plugin-sdk/provider-selection-runtime"; import type { RealtimeVoiceProviderConfig, RealtimeVoiceProviderPlugin, @@ -8,7 +9,6 @@ import type { VoiceCallConfig } from "./config.js"; import { resolveVoiceCallConfig, validateProviderConfig } from "./config.js"; import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js"; import { CallManager } from "./manager.js"; -import { resolveConfiguredCapabilityProvider } from "./provider-runtime-resolution.js"; import type { VoiceCallProvider } from "./providers/base.js"; import type { TwilioProvider } from "./providers/twilio.js"; import type { TelephonyTtsRuntime } from "./telephony-tts.js"; diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index 7d1e4389a4b..6e95b9e6dc4 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -191,7 +191,7 @@ describe("VoiceCallWebhookServer realtime transcription provider selection", () const server = new VoiceCallWebhookServer(config, manager, provider); try { await server.start(); - expect(mocks.getRealtimeTranscriptionProvider).toHaveBeenCalledWith(undefined, null); + expect(mocks.getRealtimeTranscriptionProvider).not.toHaveBeenCalled(); expect(mocks.listRealtimeTranscriptionProviders).toHaveBeenCalledWith(null); expect(server.getMediaStreamHandler()).toBeTruthy(); } finally { diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index 179ab87face..f611e54226c 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -1,6 +1,7 @@ import http from "node:http"; import { URL } from "node:url"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveConfiguredCapabilityProvider } from "openclaw/plugin-sdk/provider-selection-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { createWebhookInFlightLimiter, @@ -18,7 +19,6 @@ import { getHeader } from "./http-headers.js"; import type { CallManager } from "./manager.js"; import type { MediaStreamConfig } from "./media-stream.js"; import { MediaStreamHandler } from "./media-stream.js"; -import { resolveConfiguredCapabilityProvider } from "./provider-runtime-resolution.js"; import type { VoiceCallProvider } from "./providers/base.js"; import { isProviderStatusTerminal } from "./providers/shared/call-status.js"; import type { TwilioProvider } from "./providers/twilio.js"; diff --git a/package.json b/package.json index 2e8e84fbac8..afde2107f07 100644 --- a/package.json +++ b/package.json @@ -1025,6 +1025,10 @@ "types": "./dist/plugin-sdk/provider-auth-login.d.ts", "default": "./dist/plugin-sdk/provider-auth-login.js" }, + "./plugin-sdk/provider-selection-runtime": { + "types": "./dist/plugin-sdk/provider-selection-runtime.d.ts", + "default": "./dist/plugin-sdk/provider-selection-runtime.js" + }, "./plugin-sdk/plugin-entry": { "types": "./dist/plugin-sdk/plugin-entry.d.ts", "default": "./dist/plugin-sdk/plugin-entry.js" diff --git a/scripts/lib/plugin-sdk-doc-metadata.ts b/scripts/lib/plugin-sdk-doc-metadata.ts index 96dad22d235..82f0170f710 100644 --- a/scripts/lib/plugin-sdk-doc-metadata.ts +++ b/scripts/lib/plugin-sdk-doc-metadata.ts @@ -77,6 +77,9 @@ export const pluginSdkDocMetadata = { "provider-onboard": { category: "provider", }, + "provider-selection-runtime": { + category: "provider", + }, opencode: { category: "provider", }, diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index c38d1d7d159..8034760065f 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -242,6 +242,7 @@ "provider-auth-api-key", "provider-auth-result", "provider-auth-login", + "provider-selection-runtime", "plugin-entry", "provider-catalog-shared", "provider-entry", diff --git a/src/plugin-sdk/provider-selection-runtime.test.ts b/src/plugin-sdk/provider-selection-runtime.test.ts new file mode 100644 index 00000000000..f3dc44a2c46 --- /dev/null +++ b/src/plugin-sdk/provider-selection-runtime.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + resolveConfiguredCapabilityProvider, + resolveProviderRawConfig, + selectConfiguredOrAutoProvider, + type AutoSelectableProvider, +} from "./provider-selection-runtime.js"; + +type TestProvider = AutoSelectableProvider & { + configured?: boolean; +}; + +describe("plugin-sdk provider-selection-runtime", () => { + const providers: TestProvider[] = [ + { id: "first", autoSelectOrder: 1 }, + { id: "second", autoSelectOrder: 2, configured: true }, + ]; + + it("selects an explicit provider when it exists", () => { + const selection = selectConfiguredOrAutoProvider({ + configuredProviderId: " second ", + getConfiguredProvider: (providerId) => providers.find((entry) => entry.id === providerId), + listProviders: () => providers, + }); + + expect(selection).toEqual({ + configuredProviderId: "second", + missingConfiguredProvider: false, + provider: providers[1], + }); + }); + + it("reports a missing explicit provider", () => { + const resolution = resolveConfiguredCapabilityProvider({ + configuredProviderId: "missing", + cfg: {}, + cfgForResolve: {}, + getConfiguredProvider: (providerId) => providers.find((entry) => entry.id === providerId), + listProviders: () => providers, + resolveProviderConfig: ({ rawConfig }) => rawConfig, + isProviderConfigured: ({ provider }) => provider.configured === true, + }); + + expect(resolution).toEqual({ + ok: false, + code: "missing-configured-provider", + configuredProviderId: "missing", + }); + }); + + it("auto-selects the first configured provider by order", () => { + const resolution = resolveConfiguredCapabilityProvider({ + cfg: {}, + cfgForResolve: {}, + getConfiguredProvider: (providerId) => providers.find((entry) => entry.id === providerId), + listProviders: () => providers, + resolveProviderConfig: ({ provider, rawConfig }) => ({ + ...rawConfig, + providerId: provider.id, + }), + isProviderConfigured: ({ providerConfig }) => providerConfig.providerId === "second", + }); + + expect(resolution).toMatchObject({ + ok: true, + provider: providers[1], + providerConfig: { providerId: "second" }, + }); + }); + + it("merges canonical and selected provider config", () => { + expect( + resolveProviderRawConfig({ + providerId: "canonical", + configuredProviderId: "alias", + providerConfigs: { + canonical: { apiKey: "default", model: "base" }, + alias: { model: "alias-model" }, + }, + }), + ).toEqual({ + apiKey: "default", + model: "alias-model", + }); + }); +}); diff --git a/src/plugin-sdk/provider-selection-runtime.ts b/src/plugin-sdk/provider-selection-runtime.ts new file mode 100644 index 00000000000..be04af0f3a8 --- /dev/null +++ b/src/plugin-sdk/provider-selection-runtime.ts @@ -0,0 +1,208 @@ +import { normalizeOptionalString } from "../shared/string-coerce.js"; + +export type AutoSelectableProvider = { + id: string; + autoSelectOrder?: number; +}; + +export type ProviderSelection = { + configuredProviderId?: string; + missingConfiguredProvider: boolean; + provider: TProvider | undefined; +}; + +export type ResolvedConfiguredProvider = + | { + ok: true; + configuredProviderId?: string; + provider: TProvider; + providerConfig: TConfig; + } + | { + ok: false; + code: "missing-configured-provider" | "no-registered-provider" | "provider-not-configured"; + configuredProviderId?: string; + provider?: TProvider; + }; + +export function selectConfiguredOrAutoProvider(params: { + configuredProviderId?: string; + getConfiguredProvider: (providerId: string | undefined) => TProvider | undefined; + listProviders: () => Iterable; +}): ProviderSelection { + const configuredProviderId = normalizeOptionalString(params.configuredProviderId); + const configuredProvider = configuredProviderId + ? params.getConfiguredProvider(configuredProviderId) + : undefined; + + if (configuredProviderId && !configuredProvider) { + return { + configuredProviderId, + missingConfiguredProvider: true, + provider: undefined, + }; + } + + return { + configuredProviderId, + missingConfiguredProvider: false, + provider: + configuredProvider ?? [...params.listProviders()].toSorted(compareProviderAutoSelectOrder)[0], + }; +} + +export function resolveProviderRawConfig(params: { + providerId: string; + configuredProviderId?: string; + providerConfigs?: Record | undefined>; +}): Record { + const canonicalProviderConfig = readProviderConfig(params.providerConfigs, params.providerId); + const selectedProviderConfig = readProviderConfig( + params.providerConfigs, + params.configuredProviderId, + ); + + return { + ...canonicalProviderConfig, + ...selectedProviderConfig, + }; +} + +export function resolveConfiguredCapabilityProvider< + TConfig, + TFullConfig, + TProvider extends AutoSelectableProvider, +>(params: { + configuredProviderId?: string; + providerConfigs?: Record | undefined>; + cfg: TFullConfig | undefined; + cfgForResolve: TFullConfig; + getConfiguredProvider: (providerId: string | undefined) => TProvider | undefined; + listProviders: () => Iterable; + resolveProviderConfig: (params: { + provider: TProvider; + cfg: TFullConfig; + rawConfig: Record; + }) => TConfig; + isProviderConfigured: (params: { + provider: TProvider; + cfg: TFullConfig | undefined; + providerConfig: TConfig; + }) => boolean; +}): ResolvedConfiguredProvider { + const configuredProviderId = normalizeOptionalString(params.configuredProviderId); + if (configuredProviderId) { + const provider = params.getConfiguredProvider(configuredProviderId); + if (!provider) { + return { + ok: false, + code: "missing-configured-provider", + configuredProviderId, + }; + } + + return resolveProviderCandidate({ + ...params, + configuredProviderId, + provider, + }); + } + + const providers = [...params.listProviders()].toSorted(compareProviderAutoSelectOrder); + if (providers.length === 0) { + return { + ok: false, + code: "no-registered-provider", + }; + } + + let firstUnconfigured: TProvider | undefined; + for (const provider of providers) { + const resolution = resolveProviderCandidate({ + ...params, + provider, + }); + if (resolution.ok) { + return resolution; + } + firstUnconfigured ??= provider; + } + + return { + ok: false, + code: "provider-not-configured", + provider: firstUnconfigured, + }; +} + +function compareProviderAutoSelectOrder( + left: TProvider, + right: TProvider, +): number { + return ( + (left.autoSelectOrder ?? Number.MAX_SAFE_INTEGER) - + (right.autoSelectOrder ?? Number.MAX_SAFE_INTEGER) + ); +} + +function readProviderConfig( + providerConfigs: Record | undefined> | undefined, + providerId: string | undefined, +): Record | undefined { + if (!providerId) { + return undefined; + } + const providerConfig = providerConfigs?.[providerId]; + return providerConfig && typeof providerConfig === "object" ? providerConfig : undefined; +} + +function resolveProviderCandidate< + TConfig, + TFullConfig, + TProvider extends AutoSelectableProvider, +>(params: { + configuredProviderId?: string; + providerConfigs?: Record | undefined>; + cfg: TFullConfig | undefined; + cfgForResolve: TFullConfig; + provider: TProvider; + resolveProviderConfig: (params: { + provider: TProvider; + cfg: TFullConfig; + rawConfig: Record; + }) => TConfig; + isProviderConfigured: (params: { + provider: TProvider; + cfg: TFullConfig | undefined; + providerConfig: TConfig; + }) => boolean; +}): ResolvedConfiguredProvider { + const rawProviderConfig = resolveProviderRawConfig({ + providerId: params.provider.id, + configuredProviderId: params.configuredProviderId, + providerConfigs: params.providerConfigs, + }); + const providerConfig = params.resolveProviderConfig({ + provider: params.provider, + cfg: params.cfgForResolve, + rawConfig: rawProviderConfig, + }); + + if ( + !params.isProviderConfigured({ provider: params.provider, cfg: params.cfg, providerConfig }) + ) { + return { + ok: false, + code: "provider-not-configured", + configuredProviderId: params.configuredProviderId, + provider: params.provider, + }; + } + + return { + ok: true, + configuredProviderId: params.configuredProviderId, + provider: params.provider, + providerConfig, + }; +}