mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 13:31:52 +00:00
* refactor(providers): canonicalize manifest-driven registration * fix(zai): eliminate type-only SDK import side effects
546 lines
19 KiB
TypeScript
546 lines
19 KiB
TypeScript
// Provider entry contracts define provider plugin hooks, model catalogs, and runtime adapters.
|
|
import type { UnifiedModelCatalogEntry } from "@openclaw/model-catalog-core/model-catalog-types";
|
|
import {
|
|
normalizeStringEntries,
|
|
uniqueStrings,
|
|
} from "../../packages/normalization-core/src/string-normalization.js";
|
|
import type {
|
|
PluginManifestProviderAuthChoice,
|
|
PluginManifestSetupProvider,
|
|
} from "../plugins/manifest-types.js";
|
|
import { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js";
|
|
import { projectProviderCatalogResultToUnifiedTextRows } from "../plugins/provider-catalog-unified-text.js";
|
|
import type {
|
|
ProviderPlugin,
|
|
ProviderCatalogContext,
|
|
ProviderCatalogResult,
|
|
ProviderAuthMethod,
|
|
ProviderPluginCatalog,
|
|
UnifiedModelCatalogProviderContext,
|
|
ProviderPluginWizardSetup,
|
|
} from "../plugins/types.js";
|
|
import {
|
|
copyArrayEntries,
|
|
isRecordWithoutThrowing,
|
|
readRecordValue,
|
|
} from "../shared/safe-record.js";
|
|
import { definePluginEntry } from "./plugin-entry.js";
|
|
import type {
|
|
OpenClawPluginApi,
|
|
OpenClawPluginConfigSchema,
|
|
OpenClawPluginDefinition,
|
|
} from "./plugin-entry.js";
|
|
import {
|
|
buildOpenAICompatibleProviderCatalog,
|
|
type OpenAICompatibleModelDiscoveryOptions,
|
|
} from "./provider-catalog-live-runtime.js";
|
|
import {
|
|
buildManifestModelProviderConfig,
|
|
buildSingleProviderApiKeyCatalog,
|
|
readManifestProviderDefaultModelRef,
|
|
} from "./provider-catalog-shared.js";
|
|
|
|
type ApiKeyAuthMethodOptions = Parameters<typeof createProviderApiKeyAuthMethod>[0];
|
|
|
|
type SingleProviderPluginManifestAuthChoice = Pick<
|
|
PluginManifestProviderAuthChoice,
|
|
| "provider"
|
|
| "method"
|
|
| "choiceId"
|
|
| "choiceLabel"
|
|
| "choiceHint"
|
|
| "groupId"
|
|
| "groupLabel"
|
|
| "groupHint"
|
|
| "optionKey"
|
|
| "cliFlag"
|
|
| "assistantPriority"
|
|
| "onboardingFeatured"
|
|
> & {
|
|
assistantVisibility?: string;
|
|
onboardingScopes?: readonly string[];
|
|
};
|
|
|
|
type SingleProviderPluginManifest = {
|
|
setup?: {
|
|
providers?: readonly Pick<PluginManifestSetupProvider, "id" | "envVars">[];
|
|
};
|
|
providerAuthChoices?: readonly SingleProviderPluginManifestAuthChoice[];
|
|
modelCatalog?: {
|
|
providers?: Readonly<Record<string, unknown>>;
|
|
discovery?: Readonly<Record<string, unknown>>;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* API-key auth options for single-provider plugins, with provider id filled in by the entry helper.
|
|
*/
|
|
export type SingleProviderPluginApiKeyAuthOptions = Omit<
|
|
ApiKeyAuthMethodOptions,
|
|
"providerId" | "expectedProviders" | "wizard"
|
|
> & {
|
|
/**
|
|
* Provider ids this auth method is allowed to satisfy; defaults to the single
|
|
* provider id declared by the plugin entry.
|
|
*/
|
|
expectedProviders?: string[];
|
|
/**
|
|
* Wizard metadata for setup flows, or `false` when the method should be
|
|
* registered without an onboarding choice.
|
|
*/
|
|
wizard?: false | ProviderPluginWizardSetup;
|
|
};
|
|
|
|
type ManifestProviderAuthOptions = Omit<
|
|
SingleProviderPluginApiKeyAuthOptions,
|
|
"methodId" | "label" | "optionKey" | "flagName" | "envVar" | "promptMessage"
|
|
> & {
|
|
promptMessage?: string;
|
|
};
|
|
|
|
/**
|
|
* Catalog configuration accepted by the single-provider entry helper.
|
|
*/
|
|
export type SingleProviderPluginCatalogOptions =
|
|
| {
|
|
/**
|
|
* Builds the live provider catalog through the shared API-key catalog path.
|
|
*/
|
|
buildProvider?: Parameters<typeof buildSingleProviderApiKeyCatalog>[0]["buildProvider"];
|
|
/**
|
|
* Builds a static catalog for cheap model discovery before credentials are resolved.
|
|
*/
|
|
buildStaticProvider?: Parameters<typeof buildSingleProviderApiKeyCatalog>[0]["buildProvider"];
|
|
/**
|
|
* Allows operator-configured base URLs to override the provider catalog base URL.
|
|
*/
|
|
allowExplicitBaseUrl?: boolean;
|
|
/**
|
|
* Discovers text/chat models from the provider's OpenAI-compatible model-list endpoint.
|
|
*/
|
|
liveModelDiscovery?: true | OpenAICompatibleModelDiscoveryOptions;
|
|
run?: never;
|
|
order?: never;
|
|
staticRun?: never;
|
|
}
|
|
| {
|
|
/**
|
|
* Runs a fully custom provider catalog implementation.
|
|
*/
|
|
run: ProviderPluginCatalog["run"];
|
|
/**
|
|
* Optional static variant for custom catalog implementations.
|
|
*/
|
|
staticRun?: ProviderPluginCatalog["run"];
|
|
/**
|
|
* Catalog ordering contract forwarded to the core provider registry.
|
|
*/
|
|
order?: ProviderPluginCatalog["order"];
|
|
buildProvider?: never;
|
|
buildStaticProvider?: never;
|
|
allowExplicitBaseUrl?: never;
|
|
liveModelDiscovery?: never;
|
|
};
|
|
|
|
/**
|
|
* Defines one provider plugin plus optional extra registration hooks.
|
|
*/
|
|
type SingleProviderPluginDefinition = {
|
|
/**
|
|
* Provider id override when the runtime provider id differs from the plugin id.
|
|
*/
|
|
id?: string;
|
|
/**
|
|
* Human-readable provider label.
|
|
*/
|
|
label: string;
|
|
/**
|
|
* Documentation route used by provider setup and diagnostics.
|
|
*/
|
|
docsPath: string;
|
|
/**
|
|
* Alternate provider ids accepted by routing and configuration lookups.
|
|
*/
|
|
aliases?: string[];
|
|
/**
|
|
* Explicit environment variables advertised for credentials.
|
|
*/
|
|
envVars?: string[];
|
|
/**
|
|
* API-key auth methods converted through the shared provider auth helper.
|
|
*/
|
|
auth?: SingleProviderPluginApiKeyAuthOptions[];
|
|
/**
|
|
* Provider-owned behavior layered over manifest-derived API-key auth.
|
|
*/
|
|
manifestAuth?: ManifestProviderAuthOptions;
|
|
/**
|
|
* Non-API-key or provider-owned auth methods appended after generated methods.
|
|
*/
|
|
extraAuth?: ProviderAuthMethod[];
|
|
/**
|
|
* Live/static catalog implementation for this provider.
|
|
*/
|
|
catalog: SingleProviderPluginCatalogOptions;
|
|
} & Omit<
|
|
ProviderPlugin,
|
|
"id" | "label" | "docsPath" | "aliases" | "envVars" | "auth" | "catalog" | "staticCatalog"
|
|
>;
|
|
|
|
export type SingleProviderPluginOptions = {
|
|
/**
|
|
* Plugin id and default provider id when `provider.id` is omitted.
|
|
*/
|
|
id: string;
|
|
/**
|
|
* Display name registered for the plugin entry.
|
|
*/
|
|
name: string;
|
|
/**
|
|
* Short plugin description surfaced by plugin registries and setup flows.
|
|
*/
|
|
description: string;
|
|
/**
|
|
* Plugin-owned metadata used to derive API-key auth and model catalogs
|
|
* without repeating the manifest in the runtime entry.
|
|
*/
|
|
manifest?: SingleProviderPluginManifest;
|
|
/**
|
|
* @deprecated Declare exclusive plugin kind in `openclaw.plugin.json` via
|
|
* manifest `kind`. Runtime-entry `kind` remains only as a compatibility
|
|
* fallback for older plugins.
|
|
*/
|
|
kind?: OpenClawPluginDefinition["kind"];
|
|
/**
|
|
* Optional plugin configuration schema or lazy schema factory.
|
|
*/
|
|
configSchema?: OpenClawPluginConfigSchema | (() => OpenClawPluginConfigSchema);
|
|
/**
|
|
* Primary provider registration. Extra provider fields are forwarded after
|
|
* the helper-owned id/auth/catalog fields are normalized.
|
|
*/
|
|
provider?:
|
|
| SingleProviderPluginDefinition
|
|
| ((api: OpenClawPluginApi) => SingleProviderPluginDefinition);
|
|
/**
|
|
* Optional hook for registering companion capabilities with the same plugin entry.
|
|
*/
|
|
register?: (api: OpenClawPluginApi) => void;
|
|
};
|
|
|
|
function resolveManifestProviderAuth(params: {
|
|
manifest: SingleProviderPluginManifest | undefined;
|
|
providerId: string;
|
|
providerLabel: string;
|
|
overrides?: ManifestProviderAuthOptions;
|
|
}): SingleProviderPluginApiKeyAuthOptions[] {
|
|
const choice = params.manifest?.providerAuthChoices?.find(
|
|
(entry) => entry.provider === params.providerId && entry.method === "api-key",
|
|
);
|
|
if (!choice) {
|
|
return [];
|
|
}
|
|
const envVar = params.manifest?.setup?.providers?.find((entry) => entry.id === params.providerId)
|
|
?.envVars?.[0];
|
|
if (!choice.choiceLabel || !choice.optionKey || !choice.cliFlag?.startsWith("--") || !envVar) {
|
|
throw new Error(`Incomplete manifest API-key auth for provider "${params.providerId}"`);
|
|
}
|
|
const defaultModel = readManifestProviderDefaultModelRef(params.manifest, params.providerId);
|
|
const assistantVisibility =
|
|
choice.assistantVisibility === "visible" || choice.assistantVisibility === "manual-only"
|
|
? choice.assistantVisibility
|
|
: undefined;
|
|
const onboardingScopes = choice.onboardingScopes?.filter(
|
|
(scope): scope is NonNullable<ProviderPluginWizardSetup["onboardingScopes"]>[number] =>
|
|
scope === "text-inference" || scope === "image-generation" || scope === "music-generation",
|
|
);
|
|
return [
|
|
{
|
|
methodId: choice.method,
|
|
label: choice.choiceLabel,
|
|
...(choice.choiceHint || choice.groupHint
|
|
? { hint: choice.choiceHint ?? choice.groupHint }
|
|
: {}),
|
|
optionKey: choice.optionKey,
|
|
flagName: choice.cliFlag as `--${string}`,
|
|
envVar,
|
|
promptMessage: `Enter ${choice.choiceLabel}`,
|
|
...(defaultModel ? { defaultModel } : {}),
|
|
...params.overrides,
|
|
wizard:
|
|
params.overrides?.wizard === false
|
|
? false
|
|
: {
|
|
choiceId: choice.choiceId,
|
|
choiceLabel: choice.choiceLabel,
|
|
groupId: choice.groupId ?? params.providerId,
|
|
groupLabel: choice.groupLabel ?? params.providerLabel,
|
|
...(choice.choiceHint ? { choiceHint: choice.choiceHint } : {}),
|
|
...(choice.groupHint ? { groupHint: choice.groupHint } : {}),
|
|
...(choice.assistantPriority !== undefined
|
|
? { assistantPriority: choice.assistantPriority }
|
|
: {}),
|
|
...(assistantVisibility ? { assistantVisibility } : {}),
|
|
...(choice.onboardingFeatured !== undefined
|
|
? { onboardingFeatured: choice.onboardingFeatured }
|
|
: {}),
|
|
...(onboardingScopes?.length ? { onboardingScopes } : {}),
|
|
methodId: choice.method,
|
|
...params.overrides?.wizard,
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
function resolveWizardSetup(params: {
|
|
providerId: string;
|
|
providerLabel: string;
|
|
auth: SingleProviderPluginApiKeyAuthOptions;
|
|
}): ProviderPluginWizardSetup | undefined {
|
|
if (params.auth.wizard === false) {
|
|
return undefined;
|
|
}
|
|
const wizard = params.auth.wizard ?? {};
|
|
const methodId = params.auth.methodId.trim();
|
|
return {
|
|
choiceId: wizard.choiceId ?? `${params.providerId}-${methodId}`,
|
|
choiceLabel: wizard.choiceLabel ?? params.auth.label,
|
|
...(wizard.choiceHint ? { choiceHint: wizard.choiceHint } : {}),
|
|
...(wizard.assistantPriority !== undefined
|
|
? { assistantPriority: wizard.assistantPriority }
|
|
: {}),
|
|
...(wizard.assistantVisibility ? { assistantVisibility: wizard.assistantVisibility } : {}),
|
|
...(wizard.onboardingFeatured !== undefined
|
|
? { onboardingFeatured: wizard.onboardingFeatured }
|
|
: {}),
|
|
groupId: wizard.groupId ?? params.providerId,
|
|
groupLabel: wizard.groupLabel ?? params.providerLabel,
|
|
...((wizard.groupHint ?? params.auth.hint)
|
|
? { groupHint: wizard.groupHint ?? params.auth.hint }
|
|
: {}),
|
|
methodId,
|
|
...(wizard.onboardingScopes ? { onboardingScopes: wizard.onboardingScopes } : {}),
|
|
...(wizard.modelAllowlist ? { modelAllowlist: wizard.modelAllowlist } : {}),
|
|
...(wizard.modelSelection ? { modelSelection: wizard.modelSelection } : {}),
|
|
};
|
|
}
|
|
|
|
function copyProviderAuthOptions(value: unknown): SingleProviderPluginApiKeyAuthOptions[] {
|
|
return copyArrayEntries(value).filter(
|
|
isRecordWithoutThrowing,
|
|
) as SingleProviderPluginApiKeyAuthOptions[];
|
|
}
|
|
|
|
function copyProviderAuthMethods(value: unknown): ProviderAuthMethod[] {
|
|
return copyArrayEntries(value).filter(isRecordWithoutThrowing) as ProviderAuthMethod[];
|
|
}
|
|
|
|
function resolveEnvVars(params: {
|
|
envVars?: unknown;
|
|
auth?: SingleProviderPluginApiKeyAuthOptions[];
|
|
}): string[] | undefined {
|
|
const combined = normalizeStringEntries([
|
|
...copyArrayEntries(params.envVars),
|
|
...(params.auth ?? []).map((entry) => readRecordValue(entry, "envVar")).filter(Boolean),
|
|
]);
|
|
return combined.length > 0 ? uniqueStrings(combined) : undefined;
|
|
}
|
|
|
|
async function runUnifiedTextCatalog(params: {
|
|
providerId: string;
|
|
catalog: ProviderPluginCatalog;
|
|
ctx: UnifiedModelCatalogProviderContext;
|
|
source: UnifiedModelCatalogEntry["source"];
|
|
}): Promise<UnifiedModelCatalogEntry[]> {
|
|
const result = await params.catalog.run(params.ctx);
|
|
return projectProviderCatalogResultToUnifiedTextRows({
|
|
providerId: params.providerId,
|
|
result,
|
|
source: params.source,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Builds a plugin entry for providers whose runtime exports exactly one primary model provider.
|
|
*/
|
|
export function defineSingleProviderPluginEntry(options: SingleProviderPluginOptions) {
|
|
return definePluginEntry({
|
|
id: options.id,
|
|
name: options.name,
|
|
description: options.description,
|
|
...(options.kind ? { kind: options.kind } : {}),
|
|
...(options.configSchema ? { configSchema: options.configSchema } : {}),
|
|
register(api) {
|
|
// Factories keep caches and other provider state owned by each plugin registration.
|
|
const provider =
|
|
typeof options.provider === "function" ? options.provider(api) : options.provider;
|
|
if (provider) {
|
|
const providerId = provider.id ?? options.id;
|
|
if (
|
|
!("run" in provider.catalog) &&
|
|
!provider.catalog.buildProvider &&
|
|
!options.manifest?.modelCatalog?.providers?.[providerId]
|
|
) {
|
|
throw new Error(`Missing modelCatalog.providers.${providerId}`);
|
|
}
|
|
const providerAuth = copyProviderAuthOptions(
|
|
provider.auth ??
|
|
resolveManifestProviderAuth({
|
|
manifest: options.manifest,
|
|
providerId,
|
|
providerLabel: provider.label,
|
|
overrides: provider.manifestAuth,
|
|
}),
|
|
);
|
|
const acceptedProviderAuth: SingleProviderPluginApiKeyAuthOptions[] = [];
|
|
const auth = providerAuth.flatMap((entry) => {
|
|
try {
|
|
const { wizard: _wizard, ...authParams } = entry;
|
|
const wizard = resolveWizardSetup({
|
|
providerId,
|
|
providerLabel: provider.label,
|
|
auth: entry,
|
|
});
|
|
const method = createProviderApiKeyAuthMethod({
|
|
...authParams,
|
|
providerId,
|
|
expectedProviders: entry.expectedProviders ?? [providerId],
|
|
...(wizard ? { wizard } : {}),
|
|
});
|
|
acceptedProviderAuth.push(entry);
|
|
return [method];
|
|
} catch {
|
|
// Fuzzed or partially unreadable auth rows should not prevent the
|
|
// provider from registering its remaining healthy auth methods.
|
|
return [];
|
|
}
|
|
});
|
|
const envVars = resolveEnvVars({
|
|
envVars: provider.envVars,
|
|
auth: acceptedProviderAuth,
|
|
});
|
|
auth.push(...copyProviderAuthMethods(provider.extraAuth));
|
|
let catalog: ProviderPluginCatalog;
|
|
if ("run" in provider.catalog) {
|
|
const catalogRun = provider.catalog.run;
|
|
catalog = {
|
|
order: provider.catalog.order ?? "simple",
|
|
run: catalogRun!,
|
|
};
|
|
} else {
|
|
const buildProvider =
|
|
provider.catalog.buildProvider ??
|
|
(() =>
|
|
buildManifestModelProviderConfig({
|
|
providerId,
|
|
catalog: options.manifest?.modelCatalog?.providers?.[providerId],
|
|
}));
|
|
catalog = {
|
|
order: "simple",
|
|
run: (ctx: ProviderCatalogContext): Promise<ProviderCatalogResult> =>
|
|
provider.catalog.liveModelDiscovery
|
|
? buildOpenAICompatibleProviderCatalog({
|
|
ctx,
|
|
providerId,
|
|
buildProvider,
|
|
...(provider.catalog.allowExplicitBaseUrl
|
|
? { allowExplicitBaseUrl: true }
|
|
: {}),
|
|
...(provider.catalog.liveModelDiscovery === true
|
|
? {}
|
|
: { modelDiscovery: provider.catalog.liveModelDiscovery }),
|
|
})
|
|
: buildSingleProviderApiKeyCatalog({
|
|
ctx,
|
|
providerId,
|
|
buildProvider,
|
|
...(provider.catalog.allowExplicitBaseUrl
|
|
? { allowExplicitBaseUrl: true }
|
|
: {}),
|
|
}),
|
|
};
|
|
}
|
|
const manifestStaticProvider =
|
|
"run" in provider.catalog
|
|
? undefined
|
|
: (provider.catalog.buildStaticProvider ??
|
|
(provider.catalog.buildProvider
|
|
? undefined
|
|
: () =>
|
|
buildManifestModelProviderConfig({
|
|
providerId,
|
|
catalog: options.manifest?.modelCatalog?.providers?.[providerId],
|
|
})));
|
|
const staticCatalog: ProviderPluginCatalog | undefined =
|
|
"run" in provider.catalog
|
|
? provider.catalog.staticRun
|
|
? {
|
|
order: provider.catalog.order ?? "simple",
|
|
run: provider.catalog.staticRun,
|
|
}
|
|
: undefined
|
|
: manifestStaticProvider
|
|
? {
|
|
order: "simple",
|
|
run: async () => ({
|
|
provider: await manifestStaticProvider(),
|
|
}),
|
|
}
|
|
: undefined;
|
|
api.registerProvider({
|
|
id: providerId,
|
|
label: provider.label,
|
|
docsPath: provider.docsPath,
|
|
...(provider.aliases ? { aliases: provider.aliases } : {}),
|
|
...(envVars ? { envVars } : {}),
|
|
auth,
|
|
catalog,
|
|
...(staticCatalog ? { staticCatalog } : {}),
|
|
// Preserve additional provider capabilities while keeping helper-owned
|
|
// auth/catalog/id fields canonical.
|
|
...Object.fromEntries(
|
|
Object.entries(provider).filter(
|
|
([key]) =>
|
|
![
|
|
"id",
|
|
"label",
|
|
"docsPath",
|
|
"aliases",
|
|
"envVars",
|
|
"auth",
|
|
"manifestAuth",
|
|
"extraAuth",
|
|
"catalog",
|
|
"staticCatalog",
|
|
].includes(key),
|
|
),
|
|
),
|
|
});
|
|
api.registerModelCatalogProvider({
|
|
provider: providerId,
|
|
kinds: ["text"],
|
|
...(staticCatalog
|
|
? {
|
|
staticCatalog: (ctx: UnifiedModelCatalogProviderContext) =>
|
|
runUnifiedTextCatalog({
|
|
providerId,
|
|
catalog: staticCatalog,
|
|
ctx,
|
|
source: "static",
|
|
}),
|
|
}
|
|
: {}),
|
|
liveCatalog: (ctx: UnifiedModelCatalogProviderContext) =>
|
|
runUnifiedTextCatalog({
|
|
providerId,
|
|
catalog,
|
|
ctx,
|
|
source: "live",
|
|
}),
|
|
});
|
|
}
|
|
options.register?.(api);
|
|
},
|
|
});
|
|
}
|