refactor: share provider selection runtime helper

This commit is contained in:
Peter Steinberger
2026-04-24 01:42:02 +01:00
parent 3caaba79bc
commit 958afeb397
12 changed files with 308 additions and 158 deletions

View File

@@ -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

View File

@@ -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` |

View File

@@ -1,92 +0,0 @@
import { resolveProviderRawConfig, selectConfiguredOrAutoProvider } from "./provider-selection.js";
type AutoSelectableProvider = {
id: string;
autoSelectOrder?: number;
};
export type ResolvedConfiguredProvider<TProvider, TConfig> =
| {
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<string, Record<string, unknown> | undefined>;
cfg: TFullConfig | undefined;
cfgForResolve: TFullConfig;
getConfiguredProvider: (providerId: string | undefined) => TProvider | undefined;
listProviders: () => Iterable<TProvider>;
resolveProviderConfig: (params: {
provider: TProvider;
cfg: TFullConfig;
rawConfig: Record<string, unknown>;
}) => TConfig;
isProviderConfigured: (params: {
provider: TProvider;
cfg: TFullConfig | undefined;
providerConfig: TConfig;
}) => boolean;
}): ResolvedConfiguredProvider<TProvider, TConfig> {
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,
};
}

View File

@@ -1,61 +0,0 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
type AutoSelectableProvider = {
autoSelectOrder?: number;
};
export function selectConfiguredOrAutoProvider<TProvider extends AutoSelectableProvider>(params: {
configuredProviderId?: string;
getConfiguredProvider: (providerId: string | undefined) => TProvider | undefined;
listProviders: () => Iterable<TProvider>;
}): {
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<string, Record<string, unknown> | undefined>;
}): Record<string, unknown> {
const canonicalProviderConfig =
params.providerConfigs?.[params.providerId] &&
typeof params.providerConfigs[params.providerId] === "object"
? (params.providerConfigs[params.providerId] as Record<string, unknown>)
: undefined;
const selectedProviderConfig =
params.configuredProviderId &&
params.providerConfigs?.[params.configuredProviderId] &&
typeof params.providerConfigs[params.configuredProviderId] === "object"
? (params.providerConfigs[params.configuredProviderId] as Record<string, unknown>)
: undefined;
return {
...canonicalProviderConfig,
...selectedProviderConfig,
};
}

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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"

View File

@@ -77,6 +77,9 @@ export const pluginSdkDocMetadata = {
"provider-onboard": {
category: "provider",
},
"provider-selection-runtime": {
category: "provider",
},
opencode: {
category: "provider",
},

View File

@@ -242,6 +242,7 @@
"provider-auth-api-key",
"provider-auth-result",
"provider-auth-login",
"provider-selection-runtime",
"plugin-entry",
"provider-catalog-shared",
"provider-entry",

View File

@@ -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",
});
});
});

View File

@@ -0,0 +1,208 @@
import { normalizeOptionalString } from "../shared/string-coerce.js";
export type AutoSelectableProvider = {
id: string;
autoSelectOrder?: number;
};
export type ProviderSelection<TProvider> = {
configuredProviderId?: string;
missingConfiguredProvider: boolean;
provider: TProvider | undefined;
};
export type ResolvedConfiguredProvider<TProvider, TConfig> =
| {
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<TProvider extends AutoSelectableProvider>(params: {
configuredProviderId?: string;
getConfiguredProvider: (providerId: string | undefined) => TProvider | undefined;
listProviders: () => Iterable<TProvider>;
}): ProviderSelection<TProvider> {
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<string, Record<string, unknown> | undefined>;
}): Record<string, unknown> {
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<string, Record<string, unknown> | undefined>;
cfg: TFullConfig | undefined;
cfgForResolve: TFullConfig;
getConfiguredProvider: (providerId: string | undefined) => TProvider | undefined;
listProviders: () => Iterable<TProvider>;
resolveProviderConfig: (params: {
provider: TProvider;
cfg: TFullConfig;
rawConfig: Record<string, unknown>;
}) => TConfig;
isProviderConfigured: (params: {
provider: TProvider;
cfg: TFullConfig | undefined;
providerConfig: TConfig;
}) => boolean;
}): ResolvedConfiguredProvider<TProvider, TConfig> {
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<TProvider extends AutoSelectableProvider>(
left: TProvider,
right: TProvider,
): number {
return (
(left.autoSelectOrder ?? Number.MAX_SAFE_INTEGER) -
(right.autoSelectOrder ?? Number.MAX_SAFE_INTEGER)
);
}
function readProviderConfig(
providerConfigs: Record<string, Record<string, unknown> | undefined> | undefined,
providerId: string | undefined,
): Record<string, unknown> | 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<string, Record<string, unknown> | undefined>;
cfg: TFullConfig | undefined;
cfgForResolve: TFullConfig;
provider: TProvider;
resolveProviderConfig: (params: {
provider: TProvider;
cfg: TFullConfig;
rawConfig: Record<string, unknown>;
}) => TConfig;
isProviderConfigured: (params: {
provider: TProvider;
cfg: TFullConfig | undefined;
providerConfig: TConfig;
}) => boolean;
}): ResolvedConfiguredProvider<TProvider, TConfig> {
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,
};
}