refactor(providers): unify lifecycle-safe manifest registration (#114381)

* refactor(providers): canonicalize manifest-driven registration

* fix(zai): eliminate type-only SDK import side effects
This commit is contained in:
Peter Steinberger
2026-07-27 03:20:02 -04:00
committed by GitHub
parent 65cce37480
commit eec2c0cc26
20 changed files with 892 additions and 825 deletions

View File

@@ -137,6 +137,132 @@ describe("defineSingleProviderPluginEntry", () => {
]);
});
it("preserves manifest-owned onboarding scope and assistant metadata", () => {
const manifest = createProviderManifest();
const entry = defineSingleProviderPluginEntry({
id: "demo",
name: "Demo Provider",
description: "Demo provider plugin",
manifest: {
...manifest,
providerAuthChoices: [
{
...manifest.providerAuthChoices[0]!,
assistantPriority: 4,
assistantVisibility: "manual-only",
onboardingFeatured: true,
onboardingScopes: ["text-inference", "music-generation"],
},
],
},
provider: { label: "Demo", docsPath: "/providers/demo", catalog: {} },
});
expect(capturePluginRegistration(entry).providers[0]?.auth[0]?.wizard).toMatchObject({
assistantPriority: 4,
assistantVisibility: "manual-only",
onboardingFeatured: true,
onboardingScopes: ["text-inference", "music-generation"],
});
});
it("creates registration-scoped provider state for provider factories", () => {
let registrations = 0;
const registrationApis: unknown[] = [];
const entry = defineSingleProviderPluginEntry({
id: "demo",
name: "Demo Provider",
description: "Demo provider plugin",
manifest: createProviderManifest(),
provider(api) {
registrationApis.push(api);
const registration = ++registrations;
return {
label: "Demo",
docsPath: "/providers/demo",
catalog: {},
normalizeModelId: ({ modelId }) => `${modelId}:${registration}`,
};
},
});
const firstRegistration = capturePluginRegistration(entry);
const secondRegistration = capturePluginRegistration(entry);
const first = firstRegistration.providers[0];
const second = secondRegistration.providers[0];
expect(first?.normalizeModelId?.({ provider: "demo", modelId: "example" })).toBe("example:1");
expect(second?.normalizeModelId?.({ provider: "demo", modelId: "example" })).toBe("example:2");
expect(registrationApis).toEqual([firstRegistration.api, secondRegistration.api]);
expect(registrationApis[0]).not.toBe(registrationApis[1]);
expect(registrations).toBe(2);
});
it("merges manifest onboarding metadata with provider-owned wizard model policies", () => {
const manifest = createProviderManifest();
const entry = defineSingleProviderPluginEntry({
id: "demo",
name: "Demo Provider",
description: "Demo provider plugin",
manifest: {
...manifest,
providerAuthChoices: [
{
...manifest.providerAuthChoices[0]!,
assistantPriority: 4,
assistantVisibility: "manual-only",
onboardingFeatured: true,
onboardingScopes: ["text-inference", "music-generation"],
},
],
},
provider: {
label: "Demo",
docsPath: "/providers/demo",
manifestAuth: {
wizard: {
modelAllowlist: { allowedKeys: ["demo/default"], loadCatalog: true },
modelSelection: { promptWhenAuthChoiceProvided: true, allowKeepCurrent: false },
},
},
catalog: {},
},
});
expect(capturePluginRegistration(entry).providers[0]?.auth[0]?.wizard).toMatchObject({
choiceId: "demo-api-key",
choiceLabel: "Demo API key",
choiceHint: "Manifest-owned key",
groupId: "demo-group",
groupLabel: "Demo providers",
groupHint: "Manifest-owned setup",
assistantPriority: 4,
assistantVisibility: "manual-only",
onboardingFeatured: true,
onboardingScopes: ["text-inference", "music-generation"],
methodId: "api-key",
modelAllowlist: { allowedKeys: ["demo/default"], loadCatalog: true },
modelSelection: { promptWhenAuthChoiceProvided: true, allowKeepCurrent: false },
});
});
it("allows provider-owned manifest auth to disable the onboarding wizard", () => {
const entry = defineSingleProviderPluginEntry({
id: "demo",
name: "Demo Provider",
description: "Demo provider plugin",
manifest: createProviderManifest(),
provider: {
label: "Demo",
docsPath: "/providers/demo",
manifestAuth: { wizard: false },
catalog: {},
},
});
expect(capturePluginRegistration(entry).providers[0]?.auth[0]?.wizard).toBeUndefined();
});
it("honors explicit base URLs and provider-owned manifest auth overrides", async () => {
const entry = defineSingleProviderPluginEntry({
id: "demo",

View File

@@ -42,11 +42,30 @@ import {
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 PluginManifestProviderAuthChoice[];
providerAuthChoices?: readonly SingleProviderPluginManifestAuthChoice[];
modelCatalog?: {
providers?: Readonly<Record<string, unknown>>;
discovery?: Readonly<Record<string, unknown>>;
@@ -126,6 +145,48 @@ export type SingleProviderPluginCatalogOptions =
/**
* 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.
@@ -158,47 +219,9 @@ export type SingleProviderPluginOptions = {
* Primary provider registration. Extra provider fields are forwarded after
* the helper-owned id/auth/catalog fields are normalized.
*/
provider?: {
/**
* 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 auth methods appended after generated API-key methods.
*/
extraAuth?: ProviderAuthMethod[];
/**
* Live/static catalog implementation for this provider.
*/
catalog: SingleProviderPluginCatalogOptions;
} & Omit<
ProviderPlugin,
"id" | "label" | "docsPath" | "aliases" | "envVars" | "auth" | "catalog" | "staticCatalog"
>;
provider?:
| SingleProviderPluginDefinition
| ((api: OpenClawPluginApi) => SingleProviderPluginDefinition);
/**
* Optional hook for registering companion capabilities with the same plugin entry.
*/
@@ -223,6 +246,14 @@ function resolveManifestProviderAuth(params: {
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,
@@ -235,16 +266,28 @@ function resolveManifestProviderAuth(params: {
envVar,
promptMessage: `Enter ${choice.choiceLabel}`,
...(defaultModel ? { defaultModel } : {}),
wizard: {
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 } : {}),
methodId: choice.method,
},
...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,
},
},
];
}
@@ -263,6 +306,13 @@ function resolveWizardSetup(params: {
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)
@@ -271,6 +321,7 @@ function resolveWizardSetup(params: {
methodId,
...(wizard.onboardingScopes ? { onboardingScopes: wizard.onboardingScopes } : {}),
...(wizard.modelAllowlist ? { modelAllowlist: wizard.modelAllowlist } : {}),
...(wizard.modelSelection ? { modelSelection: wizard.modelSelection } : {}),
};
}
@@ -320,7 +371,9 @@ export function defineSingleProviderPluginEntry(options: SingleProviderPluginOpt
...(options.kind ? { kind: options.kind } : {}),
...(options.configSchema ? { configSchema: options.configSchema } : {}),
register(api) {
const provider = options.provider;
// 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 (