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

@@ -2,13 +2,12 @@
* Arcee AI provider plugin entry. It supports direct Arcee auth and OpenRouter
* routing while normalizing OpenRouter model ids and base URLs.
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { buildOpenAICompatibleLiveModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import {
readConfiguredProviderCatalogEntries,
type ProviderCatalogContext,
} from "openclaw/plugin-sdk/provider-catalog-shared";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
import {
applyArceeConfig,
@@ -30,50 +29,6 @@ const ARCEE_WIZARD_GROUP = {
groupHint: "Direct API or OpenRouter",
} as const;
function buildArceeAuthMethods() {
return [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "arcee-platform",
label: "Arcee AI API key",
hint: "Direct access to Arcee platform",
optionKey: "arceeaiApiKey",
flagName: "--arceeai-api-key",
envVar: "ARCEEAI_API_KEY",
promptMessage: "Enter Arcee AI API key",
defaultModel: ARCEE_DEFAULT_MODEL_REF,
expectedProviders: [PROVIDER_ID],
applyConfig: (cfg) => applyArceeConfig(cfg),
wizard: {
choiceId: "arceeai-api-key",
choiceLabel: "Arcee AI API key",
choiceHint: "Direct (chat.arcee.ai)",
...ARCEE_WIZARD_GROUP,
},
}),
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "openrouter",
label: "OpenRouter API key",
hint: "Access Arcee models via OpenRouter",
optionKey: "openrouterApiKey",
flagName: "--openrouter-api-key",
envVar: "OPENROUTER_API_KEY",
promptMessage: "Enter OpenRouter API key",
profileId: "openrouter:default",
defaultModel: ARCEE_OPENROUTER_DEFAULT_MODEL_REF,
expectedProviders: [PROVIDER_ID, "openrouter"],
applyConfig: (cfg) => applyArceeOpenRouterConfig(cfg),
wizard: {
choiceId: "arceeai-openrouter",
choiceLabel: "OpenRouter API key",
choiceHint: "Via OpenRouter (openrouter.ai)",
...ARCEE_WIZARD_GROUP,
},
}),
];
}
async function resolveArceeCatalog(ctx: ProviderCatalogContext) {
const directAuth = ctx.resolveProviderApiKey(PROVIDER_ID);
if (directAuth.apiKey) {
@@ -114,45 +69,77 @@ function normalizeArceeResolvedModel<T extends { baseUrl?: string; id: string }>
}
/** Provider entry for Arcee direct and OpenRouter-backed models. */
export default definePluginEntry({
export default defineSingleProviderPluginEntry({
id: PROVIDER_ID,
name: "Arcee AI Provider",
description: "Bundled Arcee AI provider plugin",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: "Arcee AI",
docsPath: "/providers/arcee",
envVars: ["ARCEEAI_API_KEY", "OPENROUTER_API_KEY"],
auth: buildArceeAuthMethods(),
catalog: {
run: resolveArceeCatalog,
provider: {
label: "Arcee AI",
docsPath: "/providers/arcee",
envVars: ["ARCEEAI_API_KEY", "OPENROUTER_API_KEY"],
auth: [
{
methodId: "arcee-platform",
label: "Arcee AI API key",
hint: "Direct access to Arcee platform",
optionKey: "arceeaiApiKey",
flagName: "--arceeai-api-key",
envVar: "ARCEEAI_API_KEY",
promptMessage: "Enter Arcee AI API key",
defaultModel: ARCEE_DEFAULT_MODEL_REF,
applyConfig: applyArceeConfig,
wizard: {
choiceId: "arceeai-api-key",
choiceLabel: "Arcee AI API key",
choiceHint: "Direct (chat.arcee.ai)",
...ARCEE_WIZARD_GROUP,
},
},
staticCatalog: {
run: async () => ({ provider: buildArceeProvider() }),
{
methodId: "openrouter",
label: "OpenRouter API key",
hint: "Access Arcee models via OpenRouter",
optionKey: "openrouterApiKey",
flagName: "--openrouter-api-key",
envVar: "OPENROUTER_API_KEY",
promptMessage: "Enter OpenRouter API key",
profileId: "openrouter:default",
defaultModel: ARCEE_OPENROUTER_DEFAULT_MODEL_REF,
expectedProviders: [PROVIDER_ID, "openrouter"],
applyConfig: applyArceeOpenRouterConfig,
wizard: {
choiceId: "arceeai-openrouter",
choiceLabel: "OpenRouter API key",
choiceHint: "Via OpenRouter (openrouter.ai)",
...ARCEE_WIZARD_GROUP,
},
},
augmentModelCatalog: ({ config }) =>
readConfiguredProviderCatalogEntries({
config,
providerId: PROVIDER_ID,
}),
normalizeConfig: ({ providerConfig }) => {
const normalizedBaseUrl = normalizeArceeOpenRouterBaseUrl(providerConfig.baseUrl);
return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl
? { ...providerConfig, baseUrl: normalizedBaseUrl }
: undefined;
},
normalizeResolvedModel: ({ model }) => normalizeArceeResolvedModel(model),
normalizeTransport: ({ api: apiLocal, baseUrl }) => {
const normalizedBaseUrl = normalizeArceeOpenRouterBaseUrl(baseUrl);
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
? {
api: apiLocal,
baseUrl: normalizedBaseUrl,
}
: undefined;
},
...buildProviderReplayFamilyHooks({ family: "openai-compatible" }),
});
],
catalog: {
run: resolveArceeCatalog,
staticRun: async () => ({ provider: buildArceeProvider() }),
},
augmentModelCatalog: ({ config }) =>
readConfiguredProviderCatalogEntries({
config,
providerId: PROVIDER_ID,
}),
normalizeConfig: ({ providerConfig }) => {
const normalizedBaseUrl = normalizeArceeOpenRouterBaseUrl(providerConfig.baseUrl);
return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl
? { ...providerConfig, baseUrl: normalizedBaseUrl }
: undefined;
},
normalizeResolvedModel: ({ model }) => normalizeArceeResolvedModel(model),
normalizeTransport: ({ api: apiLocal, baseUrl }) => {
const normalizedBaseUrl = normalizeArceeOpenRouterBaseUrl(baseUrl);
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
? {
api: apiLocal,
baseUrl: normalizedBaseUrl,
}
: undefined;
},
...buildProviderReplayFamilyHooks({ family: "openai-compatible" }),
},
});

View File

@@ -6,8 +6,21 @@ import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-ru
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
import { BYTEPLUS_CODING_MODEL_CATALOG, BYTEPLUS_MODEL_CATALOG } from "./models.js";
import { BYTEPLUS_PROVIDER_CATALOG_ENTRIES } from "./provider-catalog.js";
describe("byteplus plugin", () => {
it("preserves both provider-owned static catalogs and paired ordering", async () => {
const provider = await registerSingleProviderPlugin(plugin);
expect(provider.catalog?.order).toBe("paired");
expect(provider.staticCatalog?.order).toBe("paired");
expect(await provider.staticCatalog?.run({} as never)).toEqual({
providers: Object.fromEntries(
BYTEPLUS_PROVIDER_CATALOG_ENTRIES.map(({ id, buildProvider }) => [id, buildProvider()]),
),
});
});
it("augments the catalog with bundled standard and plan models", async () => {
const provider = await registerSingleProviderPlugin(plugin);
expect(provider.auth?.[0]?.starterModel).toBe("byteplus-plan/ark-code-latest");

View File

@@ -1,10 +1,9 @@
/**
* BytePlus provider plugin entrypoint for model and video generation providers.
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { buildOpenAICompatibleLiveModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { readManifestProviderDefaultModelRef } from "openclaw/plugin-sdk/provider-catalog-shared";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { ensureModelAllowlistEntry } from "openclaw/plugin-sdk/provider-onboard";
import manifest from "./openclaw.plugin.json" with { type: "json" };
import { BYTEPLUS_PROVIDER_CATALOG_ENTRIES } from "./provider-catalog.js";
@@ -13,90 +12,65 @@ import { buildBytePlusVideoGenerationProvider } from "./video-generation-provide
const PROVIDER_ID = "byteplus";
const BYTEPLUS_DEFAULT_MODEL_REF = readManifestProviderDefaultModelRef(manifest, "byteplus-plan")!;
export default definePluginEntry({
export default defineSingleProviderPluginEntry({
id: PROVIDER_ID,
name: "BytePlus Provider",
description: "Bundled BytePlus provider plugin",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: "BytePlus",
docsPath: "/concepts/model-providers#byteplus-international",
envVars: ["BYTEPLUS_API_KEY"],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "BytePlus API key",
hint: "API key",
optionKey: "byteplusApiKey",
flagName: "--byteplus-api-key",
envVar: "BYTEPLUS_API_KEY",
promptMessage: "Enter BytePlus API key",
defaultModel: BYTEPLUS_DEFAULT_MODEL_REF,
expectedProviders: ["byteplus"],
applyConfig: (cfg) =>
ensureModelAllowlistEntry({
cfg,
modelRef: BYTEPLUS_DEFAULT_MODEL_REF,
}),
wizard: {
choiceId: "byteplus-api-key",
choiceLabel: "BytePlus API key",
groupId: "byteplus",
groupLabel: "BytePlus",
groupHint: "API key",
},
}),
],
catalog: {
order: "paired",
run: async (ctx) => {
const auth = ctx.resolveProviderApiKey(PROVIDER_ID);
const apiKey = auth.apiKey;
if (!apiKey) {
return null;
}
return {
providers: Object.fromEntries(
await Promise.all(
BYTEPLUS_PROVIDER_CATALOG_ENTRIES.map(
async ({ id, buildProvider }) =>
[
id,
await buildOpenAICompatibleLiveModelProviderConfig({
providerId: id,
providerConfig: buildProvider(),
apiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
] as const,
),
manifest,
provider: {
label: "BytePlus",
docsPath: "/concepts/model-providers#byteplus-international",
manifestAuth: {
defaultModel: BYTEPLUS_DEFAULT_MODEL_REF,
applyConfig: (cfg) =>
ensureModelAllowlistEntry({ cfg, modelRef: BYTEPLUS_DEFAULT_MODEL_REF }),
},
catalog: {
order: "paired",
run: async (ctx) => {
const auth = ctx.resolveProviderApiKey(PROVIDER_ID);
const apiKey = auth.apiKey;
if (!apiKey) {
return null;
}
return {
providers: Object.fromEntries(
await Promise.all(
BYTEPLUS_PROVIDER_CATALOG_ENTRIES.map(
async ({ id, buildProvider }) =>
[
id,
await buildOpenAICompatibleLiveModelProviderConfig({
providerId: id,
providerConfig: buildProvider(),
apiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
] as const,
),
),
};
},
},
staticCatalog: {
order: "paired",
run: async () => ({
providers: Object.fromEntries(
BYTEPLUS_PROVIDER_CATALOG_ENTRIES.map(({ id, buildProvider }) => [id, buildProvider()]),
),
}),
};
},
augmentModelCatalog: () =>
BYTEPLUS_PROVIDER_CATALOG_ENTRIES.flatMap(({ id: provider, models }) =>
models.map((entry) => ({
provider,
id: entry.id,
name: entry.name,
reasoning: entry.reasoning,
input: [...entry.input],
contextWindow: entry.contextWindow,
})),
staticRun: async () => ({
providers: Object.fromEntries(
BYTEPLUS_PROVIDER_CATALOG_ENTRIES.map(({ id, buildProvider }) => [id, buildProvider()]),
),
});
}),
},
augmentModelCatalog: () =>
BYTEPLUS_PROVIDER_CATALOG_ENTRIES.flatMap(({ id: provider, models }) =>
models.map((entry) => ({
provider,
id: entry.id,
name: entry.name,
reasoning: entry.reasoning,
input: [...entry.input],
contextWindow: entry.contextWindow,
})),
),
},
register(api) {
api.registerVideoGenerationProvider(buildBytePlusVideoGenerationProvider());
},
});

View File

@@ -454,6 +454,38 @@ describe("ClawRouter plugin", () => {
expect(provider?.resolveDynamicModel?.(context as never)).toBeUndefined();
});
it("keeps discovered models isolated to their plugin registration", async () => {
providerAuthRuntimeMocks.resolveApiKeyForProvider.mockResolvedValue({
apiKey: "resolved-proxy-key",
mode: "api-key",
source: "auth profile",
});
vi.stubGlobal(
"fetch",
vi.fn(async () => Response.json(LIVE_CATALOG)),
);
const first = await registerSingleProviderPlugin(plugin);
const second = await registerSingleProviderPlugin(plugin);
const context = {
config: { models: {} },
agentDir: "/agent",
workspaceDir: "/workspace",
provider: "clawrouter",
modelId: "openai/gpt-5.5",
modelRegistry: { find: vi.fn(() => null) },
authProfileId: "clawrouter-profile",
authProfileMode: "api_key",
};
await first.prepareDynamicModel?.(context as never);
expect(first.resolveDynamicModel?.(context as never)).toMatchObject({
id: "openai/gpt-5.5",
});
expect(second.resolveDynamicModel?.(context as never)).toBeUndefined();
expect(second.preferRuntimeResolvedModel?.(context as never)).toBe(false);
});
it("keeps the previous dynamic model snapshot while rebuilding", async () => {
providerAuthRuntimeMocks.resolveApiKeyForProvider
.mockResolvedValueOnce({ apiKey: "decoy-token" })

View File

@@ -1,13 +1,12 @@
// ClawRouter plugin entrypoint registers credential-scoped model routing and quota reporting.
import {
definePluginEntry,
type ProviderAuthMethod,
type ProviderResolveDynamicModelContext,
type ProviderRuntimeModel,
import type {
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools";
import manifest from "./openclaw.plugin.json" with { type: "json" };
import {
buildClawRouterProviderConfig,
normalizeClawRouterApiBaseUrl,
@@ -37,32 +36,6 @@ const perplexityTools = {
inspectToolSchemas: inspectPerplexityToolSchemas,
};
function buildApiKeyAuth(): ProviderAuthMethod {
return createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "ClawRouter proxy key",
hint: "Credential-scoped access to approved models and budgets",
optionKey: "clawrouterApiKey",
flagName: "--clawrouter-api-key",
envVar: ENV_VAR,
promptMessage: "Enter ClawRouter proxy key",
noteTitle: "ClawRouter",
noteMessage: [
"Use the proxy key issued by your ClawRouter administrator.",
"OpenClaw discovers only the models granted to that key.",
].join("\n"),
wizard: {
choiceId: "clawrouter-api-key",
choiceLabel: "ClawRouter proxy key",
choiceHint: "Approved models through one managed key",
groupId: PROVIDER_ID,
groupLabel: "ClawRouter",
groupHint: "Managed model access and quotas",
},
});
}
function configuredBaseUrl(
config: { models?: { providers?: Record<string, { baseUrl?: unknown }> } } | null | undefined,
): string | undefined {
@@ -116,19 +89,25 @@ function resolveToolFamily(modelId: string) {
return openAiTools;
}
export default definePluginEntry({
export default defineSingleProviderPluginEntry({
id: PROVIDER_ID,
name: "ClawRouter",
description: "Managed multi-provider model routing and quotas",
register(api) {
manifest,
provider() {
const dynamicModels = new Map<string, Map<string, ProviderRuntimeModel>>();
api.registerProvider({
id: PROVIDER_ID,
return {
label: "ClawRouter",
docsPath: "/providers/clawrouter",
envVars: [ENV_VAR],
auth: [buildApiKeyAuth()],
manifestAuth: {
hint: "Credential-scoped access to approved models and budgets",
noteTitle: "ClawRouter",
noteMessage: [
"Use the proxy key issued by your ClawRouter administrator.",
"OpenClaw discovers only the models granted to that key.",
].join("\n"),
},
catalog: {
order: "simple",
run: async (ctx) => {
@@ -249,6 +228,6 @@ export default definePluginEntry({
baseUrl: configuredBaseUrl(ctx.config),
timeoutMs: ctx.timeoutMs,
}),
});
};
},
});

View File

@@ -5,6 +5,7 @@ import {
} from "openclaw/plugin-sdk/llm";
// Groq tests cover index plugin behavior.
import { capturePluginRegistration } from "openclaw/plugin-sdk/plugin-test-runtime";
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
import { describe, expect, it } from "vitest";
import { resolveGroqReasoningCompatPatch } from "./api.js";
import plugin from "./index.js";
@@ -330,7 +331,7 @@ describe("groq provider compat", () => {
});
});
it("registers Groq model and media providers", () => {
it("registers Groq model and media providers", async () => {
const captured = capturePluginRegistration(plugin);
const [provider] = captured.providers;
if (!provider) {
@@ -353,6 +354,13 @@ describe("groq provider compat", () => {
groupId: "groq",
},
});
expect(await provider.staticCatalog?.run({} as never)).toEqual({
provider: buildManifestModelProviderConfig({
providerId: "groq",
catalog: manifest.modelCatalog.providers.groq,
}),
});
expect(captured.modelCatalogProviders.map((entry) => entry.kinds)).toEqual([["text"]]);
expect(captured.mediaUnderstandingProviders).toHaveLength(1);
const [mediaProvider] = captured.mediaUnderstandingProviders;
if (!mediaProvider) {

View File

@@ -4,27 +4,13 @@ import {
streamSimple,
type AssistantMessageEvent,
} from "openclaw/plugin-sdk/llm";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { buildOpenAICompatibleProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import {
buildManifestModelProviderConfig,
readManifestProviderDefaultModelRef,
} from "openclaw/plugin-sdk/provider-catalog-shared";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { groqMediaUnderstandingProvider } from "./media-understanding-provider.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
const GROQ_DEFAULT_MODEL_REF = readManifestProviderDefaultModelRef(manifest, "groq")!;
const GROQ_OVERSIZED_RECOVERY_MODEL_ID = "llama-3.3-70b-versatile";
const GROQ_FALLBACK_MAX_TOKENS = 1_024;
function buildGroqCatalogProvider() {
return buildManifestModelProviderConfig({
providerId: "groq",
catalog: manifest.modelCatalog.providers.groq,
});
}
function hasWireMaxTokens(value: unknown): boolean {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
@@ -147,61 +133,27 @@ function wrapGroqOversizedRequestRecovery(
};
}
export default definePluginEntry({
export default defineSingleProviderPluginEntry({
id: "groq",
name: "Groq Provider",
description: "Bundled Groq provider plugin",
manifest,
provider: {
label: "Groq",
docsPath: "/providers/groq",
catalog: { liveModelDiscovery: true },
wrapStreamFn: (ctx) =>
wrapGroqOversizedRequestRecovery(
ctx.streamFn,
// Older compatible hosts omit this provenance. Only a known discovered default
// is safe to replace; an unknown value could be a user-configured cap.
ctx.modelId === GROQ_OVERSIZED_RECOVERY_MODEL_ID &&
!hasExplicitMaxTokens(ctx.extraParams) &&
!hasExplicitMaxTokens(ctx.model?.params) &&
ctx.model?.maxTokensSource === "discovered",
),
},
register(api) {
api.registerProvider({
id: "groq",
label: "Groq",
docsPath: "/providers/groq",
envVars: ["GROQ_API_KEY"],
auth: [
createProviderApiKeyAuthMethod({
providerId: "groq",
methodId: "api-key",
label: "Groq API key",
hint: "Fast OpenAI-compatible inference",
optionKey: "groqApiKey",
flagName: "--groq-api-key",
envVar: "GROQ_API_KEY",
promptMessage: "Enter Groq API key",
defaultModel: GROQ_DEFAULT_MODEL_REF,
wizard: {
choiceId: "groq-api-key",
choiceLabel: "Groq API key",
choiceHint: "Fast OpenAI-compatible inference",
groupId: "groq",
groupLabel: "Groq",
groupHint: "Fast OpenAI-compatible inference",
},
}),
],
catalog: {
order: "simple",
run: (ctx) =>
buildOpenAICompatibleProviderCatalog({
ctx,
providerId: "groq",
buildProvider: buildGroqCatalogProvider,
}),
},
staticCatalog: {
order: "simple",
run: async () => ({ provider: buildGroqCatalogProvider() }),
},
wrapStreamFn: (ctx) =>
wrapGroqOversizedRequestRecovery(
ctx.streamFn,
// Older compatible hosts omit this provenance. Only a known discovered default
// is safe to replace; an unknown value could be a user-configured cap.
ctx.modelId === GROQ_OVERSIZED_RECOVERY_MODEL_ID &&
!hasExplicitMaxTokens(ctx.extraParams) &&
!hasExplicitMaxTokens(ctx.model?.params) &&
ctx.model?.maxTokensSource === "discovered",
),
});
api.registerMediaUnderstandingProvider(groqMediaUnderstandingProvider);
},
});

View File

@@ -1,10 +1,10 @@
// Kimi Coding plugin entrypoint registers its OpenClaw integration.
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import type { SecretInput } from "openclaw/plugin-sdk/secret-input";
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { applyKimiCodeConfig, KIMI_CODING_MODEL_REF } from "./onboard.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
import { buildKimiCodingProvider, normalizeKimiCodingModelId } from "./provider-catalog.js";
import { isKimiK3ModelId, resolveThinkingProfile } from "./provider-policy-api.js";
import { KIMI_REPLAY_POLICY } from "./replay-policy.js";
@@ -26,87 +26,70 @@ function findExplicitProviderConfig(
);
return isRecord(match?.[1]) ? match[1] : undefined;
}
export default definePluginEntry({
export default defineSingleProviderPluginEntry({
id: PLUGIN_ID,
name: "Kimi Provider",
description: "Bundled Kimi provider plugin",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: "Kimi",
aliases: ["kimi-code", "kimi-coding"],
docsPath: "/providers/moonshot",
envVars: ["KIMI_API_KEY", "KIMICODE_API_KEY"],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "Kimi Code API key (subscription)",
hint: "Kimi Code membership · https://www.kimi.com/membership/pricing",
optionKey: "kimiCodeApiKey",
flagName: "--kimi-code-api-key",
envVar: "KIMI_API_KEY",
promptMessage: "Enter Kimi API key",
defaultModel: KIMI_CODING_MODEL_REF,
expectedProviders: ["kimi", "kimi-code", "kimi-coding"],
applyConfig: (cfg) => applyKimiCodeConfig(cfg),
noteMessage: [
"Kimi uses a dedicated coding endpoint and API key.",
"Get your API key at: https://www.kimi.com/code/console",
].join("\n"),
noteTitle: "Kimi",
wizard: {
choiceId: "kimi-code-api-key",
choiceLabel: "Kimi Code API key (subscription)",
groupId: "moonshot",
groupLabel: "Moonshot AI (Kimi)",
groupHint: "Kimi Code membership · https://www.kimi.com/membership/pricing",
manifest,
provider: {
id: PROVIDER_ID,
label: "Kimi",
aliases: ["kimi-code", "kimi-coding"],
docsPath: "/providers/moonshot",
envVars: ["KIMI_API_KEY", "KIMICODE_API_KEY"],
manifestAuth: {
promptMessage: "Enter Kimi API key",
defaultModel: KIMI_CODING_MODEL_REF,
expectedProviders: ["kimi", "kimi-code", "kimi-coding"],
applyConfig: applyKimiCodeConfig,
noteMessage: [
"Kimi uses a dedicated coding endpoint and API key.",
"Get your API key at: https://www.kimi.com/code/console",
].join("\n"),
noteTitle: "Kimi",
},
catalog: {
order: "simple",
run: async (ctx) => {
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
if (!apiKey) {
return null;
}
const explicitProvider = findExplicitProviderConfig(
ctx.config.models?.providers as Record<string, unknown> | undefined,
PROVIDER_ID,
);
const builtInProvider = buildKimiCodingProvider();
const explicitBaseUrl = normalizeOptionalString(explicitProvider?.baseUrl) ?? "";
const explicitHeaders = isRecord(explicitProvider?.headers)
? (explicitProvider.headers as Record<string, SecretInput>)
: undefined;
return {
provider: {
...builtInProvider,
...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}),
...(explicitHeaders
? {
headers: {
...builtInProvider.headers,
...explicitHeaders,
},
}
: {}),
apiKey,
},
}),
],
catalog: {
order: "simple",
run: async (ctx) => {
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
if (!apiKey) {
return null;
}
const explicitProvider = findExplicitProviderConfig(
ctx.config.models?.providers as Record<string, unknown> | undefined,
PROVIDER_ID,
);
const builtInProvider = buildKimiCodingProvider();
const explicitBaseUrl = normalizeOptionalString(explicitProvider?.baseUrl) ?? "";
const explicitHeaders = isRecord(explicitProvider?.headers)
? (explicitProvider.headers as Record<string, SecretInput>)
: undefined;
return {
provider: {
...builtInProvider,
...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}),
...(explicitHeaders
? {
headers: {
...builtInProvider.headers,
...explicitHeaders,
},
}
: {}),
apiKey,
},
};
},
};
},
buildReplayPolicy: () => KIMI_REPLAY_POLICY,
normalizeResolvedModel: ({ model }) => {
const normalizedId = normalizeKimiCodingModelId(model.id);
return normalizedId === model.id ? undefined : { ...model, id: normalizedId };
},
normalizeModelId: ({ modelId }) => normalizeKimiCodingModelId(modelId),
resolveThinkingProfile,
wrapSimpleCompletionStreamFn: (ctx) =>
isKimiK3ModelId(ctx.modelId) ? wrapKimiProviderStream(ctx) : ctx.streamFn,
wrapStreamFn: wrapKimiProviderStream,
});
},
buildReplayPolicy: () => KIMI_REPLAY_POLICY,
normalizeResolvedModel: ({ model }) => {
const normalizedId = normalizeKimiCodingModelId(model.id);
return normalizedId === model.id ? undefined : { ...model, id: normalizedId };
},
normalizeModelId: ({ modelId }) => normalizeKimiCodingModelId(modelId),
resolveThinkingProfile,
wrapSimpleCompletionStreamFn: (ctx) =>
isKimiK3ModelId(ctx.modelId) ? wrapKimiProviderStream(ctx) : ctx.streamFn,
wrapStreamFn: wrapKimiProviderStream,
},
});

View File

@@ -78,6 +78,20 @@ describe("opencode-go provider plugin", () => {
clearLiveCatalogCacheForTests();
});
it("registers only the Go auth choice from its own provider manifest", async () => {
const provider = await registerSingleProviderPlugin(plugin);
expect(provider.id).toBe("opencode-go");
expect(provider.envVars).toEqual(["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"]);
expect(provider.auth.map((method) => method.id)).toEqual(["api-key"]);
expect(provider.auth.map((method) => method.wizard?.choiceId)).toEqual(["opencode-go"]);
expect(provider.auth[0]?.wizard).toMatchObject({
choiceLabel: "OpenCode Go catalog",
groupId: "opencode",
groupHint: "Shared API key for Zen + Go catalogs",
});
});
it("registers image media understanding through the OpenCode Go plugin", async () => {
const { mediaProviders } = await registerProviderPlugin({
plugin,

View File

@@ -1,9 +1,9 @@
// Opencode Go plugin entrypoint registers its OpenClaw integration.
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
import { applyOpencodeGoConfig, OPENCODE_GO_DEFAULT_MODEL_REF } from "./api.js";
import { opencodeGoMediaUnderstandingProvider } from "./media-understanding-provider.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
import {
buildOpencodeGoLiveProviderConfig,
buildStaticOpencodeGoProviderConfig,
@@ -18,12 +18,6 @@ import { createOpencodeGoWrapper } from "./stream.js";
const PROVIDER_ID = "opencode-go";
const OPENCODE_SHARED_PROFILE_IDS = ["opencode:default", "opencode-go:default"] as const;
const OPENCODE_SHARED_HINT = "Shared API key for Zen + Go catalogs";
const OPENCODE_SHARED_WIZARD_GROUP = {
groupId: "opencode",
groupLabel: "OpenCode",
groupHint: OPENCODE_SHARED_HINT,
} as const;
type OpencodeGoCatalogAuth = {
apiKey?: string;
discoveryApiKey?: string;
@@ -44,103 +38,90 @@ function resolveOpencodeGoCatalogAuth(
return hasCatalogAuth(sharedOpencodeAuth) ? sharedOpencodeAuth : undefined;
}
export default definePluginEntry({
export default defineSingleProviderPluginEntry({
id: PROVIDER_ID,
name: "OpenCode Go Provider",
description: "Bundled OpenCode Go provider plugin",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: "OpenCode Go",
docsPath: "/providers/models",
envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "OpenCode Go catalog",
hint: OPENCODE_SHARED_HINT,
optionKey: "opencodeGoApiKey",
flagName: "--opencode-go-api-key",
envVar: "OPENCODE_API_KEY",
promptMessage: "Enter OpenCode API key",
profileIds: [...OPENCODE_SHARED_PROFILE_IDS],
defaultModel: OPENCODE_GO_DEFAULT_MODEL_REF,
applyConfig: (cfg) => applyOpencodeGoConfig(cfg),
expectedProviders: ["opencode", "opencode-go"],
noteMessage: [
"OpenCode uses one API key across the Zen and Go catalogs.",
"Go focuses on Kimi, GLM, and MiniMax coding models.",
"Get your API key at: https://opencode.ai/auth",
].join("\n"),
noteTitle: "OpenCode",
wizard: {
choiceId: "opencode-go",
choiceLabel: "OpenCode Go catalog",
...OPENCODE_SHARED_WIZARD_GROUP,
},
}),
],
normalizeConfig: ({ providerConfig }) => {
const normalizedBaseUrl = normalizeOpencodeGoBaseUrl({
api: providerConfig.api,
baseUrl: providerConfig.baseUrl,
});
return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl
? { ...providerConfig, baseUrl: normalizedBaseUrl }
: undefined;
},
normalizeResolvedModel: ({ model }) => {
const normalizedBaseUrl = normalizeOpencodeGoBaseUrl({
api: model.api,
baseUrl: model.baseUrl,
});
const baseUrlNormalized =
normalizedBaseUrl && normalizedBaseUrl !== model.baseUrl
? { ...model, baseUrl: normalizedBaseUrl }
: model;
const modelNormalized = normalizeOpencodeGoResolvedModel(baseUrlNormalized);
if (modelNormalized) {
return modelNormalized;
manifest,
provider: {
label: "OpenCode Go",
docsPath: "/providers/models",
envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
manifestAuth: {
hint: OPENCODE_SHARED_HINT,
promptMessage: "Enter OpenCode API key",
profileIds: [...OPENCODE_SHARED_PROFILE_IDS],
defaultModel: OPENCODE_GO_DEFAULT_MODEL_REF,
applyConfig: applyOpencodeGoConfig,
expectedProviders: ["opencode", "opencode-go"],
noteMessage: [
"OpenCode uses one API key across the Zen and Go catalogs.",
"Go focuses on Kimi, GLM, and MiniMax coding models.",
"Get your API key at: https://opencode.ai/auth",
].join("\n"),
noteTitle: "OpenCode",
},
normalizeConfig: ({ providerConfig }) => {
const normalizedBaseUrl = normalizeOpencodeGoBaseUrl({
api: providerConfig.api,
baseUrl: providerConfig.baseUrl,
});
return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl
? { ...providerConfig, baseUrl: normalizedBaseUrl }
: undefined;
},
normalizeResolvedModel: ({ model }) => {
const normalizedBaseUrl = normalizeOpencodeGoBaseUrl({
api: model.api,
baseUrl: model.baseUrl,
});
const baseUrlNormalized =
normalizedBaseUrl && normalizedBaseUrl !== model.baseUrl
? { ...model, baseUrl: normalizedBaseUrl }
: model;
const modelNormalized = normalizeOpencodeGoResolvedModel(baseUrlNormalized);
if (modelNormalized) {
return modelNormalized;
}
return baseUrlNormalized !== model ? baseUrlNormalized : undefined;
},
normalizeTransport: ({ api: apiLocal, baseUrl }) => {
const normalizedBaseUrl = normalizeOpencodeGoBaseUrl({ api: apiLocal, baseUrl });
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
? {
api: apiLocal,
baseUrl: normalizedBaseUrl,
}
: undefined;
},
resolveDynamicModel: ({ modelId }) => resolveOpencodeGoModel(modelId),
catalog: {
order: "simple",
run: async (ctx) => {
const auth = resolveOpencodeGoCatalogAuth(ctx.resolveProviderApiKey);
if (!auth) {
return null;
}
return baseUrlNormalized !== model ? baseUrlNormalized : undefined;
},
normalizeTransport: ({ api: apiLocal, baseUrl }) => {
const normalizedBaseUrl = normalizeOpencodeGoBaseUrl({ api: apiLocal, baseUrl });
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
? {
api: apiLocal,
baseUrl: normalizedBaseUrl,
}
: undefined;
},
resolveDynamicModel: ({ modelId }) => resolveOpencodeGoModel(modelId),
catalog: {
order: "simple",
run: async (ctx) => {
const auth = resolveOpencodeGoCatalogAuth(ctx.resolveProviderApiKey);
if (!auth) {
return null;
}
if (!auth.discoveryApiKey) {
return {
provider: buildStaticOpencodeGoProviderConfig(auth.apiKey),
};
}
if (!auth.discoveryApiKey) {
return {
provider: await buildOpencodeGoLiveProviderConfig({
apiKey: auth.apiKey ?? auth.discoveryApiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
provider: buildStaticOpencodeGoProviderConfig(auth.apiKey),
};
},
}
return {
provider: await buildOpencodeGoLiveProviderConfig({
apiKey: auth.apiKey ?? auth.discoveryApiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
};
},
augmentModelCatalog: () => listOpencodeGoModelCatalogEntries(),
...buildProviderReplayFamilyHooks({ family: "passthrough-gemini" }),
resolveThinkingProfile,
wrapStreamFn: (ctx) => createOpencodeGoWrapper(ctx.streamFn, ctx.thinkingLevel),
isModernModelRef: () => true,
});
},
augmentModelCatalog: () => listOpencodeGoModelCatalogEntries(),
...buildProviderReplayFamilyHooks({ family: "passthrough-gemini" }),
resolveThinkingProfile,
wrapStreamFn: (ctx) => createOpencodeGoWrapper(ctx.streamFn, ctx.thinkingLevel),
isModernModelRef: () => true,
},
register(api) {
api.registerMediaUnderstandingProvider(opencodeGoMediaUnderstandingProvider);
},
});

View File

@@ -44,6 +44,21 @@ describe("opencode provider plugin", () => {
beforeEach(() => {
clearLiveCatalogCacheForTests();
});
it("registers only the Zen auth choice from its own provider manifest", async () => {
const provider = await registerSingleProviderPlugin(plugin);
expect(provider.id).toBe("opencode");
expect(provider.envVars).toEqual(["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"]);
expect(provider.auth.map((method) => method.id)).toEqual(["api-key"]);
expect(provider.auth.map((method) => method.wizard?.choiceId)).toEqual(["opencode-zen"]);
expect(provider.auth[0]?.wizard).toMatchObject({
choiceLabel: "OpenCode Zen catalog",
groupId: "opencode",
groupHint: "Shared API key for Zen + Go catalogs",
});
});
it("registers image media understanding through the OpenCode plugin", async () => {
const { mediaProviders } = await registerProviderPlugin({
plugin,

View File

@@ -1,6 +1,5 @@
// Opencode plugin entrypoint registers its OpenClaw integration.
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import {
buildProviderReplayFamilyHooks,
matchesExactOrPrefix,
@@ -8,6 +7,7 @@ import {
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { applyOpencodeZenConfig, OPENCODE_ZEN_DEFAULT_MODEL } from "./api.js";
import { opencodeMediaUnderstandingProvider } from "./media-understanding-provider.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
import {
buildOpencodeZenLiveProviderConfig,
buildStaticOpencodeZenProviderConfig,
@@ -22,12 +22,6 @@ const PROVIDER_ID = "opencode";
const MINIMAX_MODERN_MODEL_MATCHERS = ["minimax-m2.7"] as const;
const OPENCODE_SHARED_PROFILE_IDS = ["opencode:default", "opencode-go:default"] as const;
const OPENCODE_SHARED_HINT = "Shared API key for Zen + Go catalogs";
const OPENCODE_SHARED_WIZARD_GROUP = {
groupId: "opencode",
groupLabel: "OpenCode",
groupHint: OPENCODE_SHARED_HINT,
} as const;
type OpencodeZenCatalogAuth = {
apiKey?: string;
discoveryApiKey?: string;
@@ -56,103 +50,85 @@ function isModernOpencodeModel(modelId: string): boolean {
return !matchesExactOrPrefix(lower, MINIMAX_MODERN_MODEL_MATCHERS);
}
export default definePluginEntry({
export default defineSingleProviderPluginEntry({
id: PROVIDER_ID,
name: "OpenCode Zen Provider",
description: "Bundled OpenCode Zen provider plugin",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: "OpenCode Zen",
docsPath: "/providers/models",
envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "OpenCode Zen catalog",
hint: OPENCODE_SHARED_HINT,
optionKey: "opencodeZenApiKey",
flagName: "--opencode-zen-api-key",
envVar: "OPENCODE_API_KEY",
promptMessage: "Enter OpenCode API key",
profileIds: [...OPENCODE_SHARED_PROFILE_IDS],
defaultModel: OPENCODE_ZEN_DEFAULT_MODEL,
applyConfig: (cfg) => applyOpencodeZenConfig(cfg),
expectedProviders: ["opencode", "opencode-go"],
noteMessage: [
"OpenCode uses one API key across the Zen and Go catalogs.",
"Zen provides access to Claude, GPT, Gemini, and more models.",
"Get your API key at: https://opencode.ai/auth",
"Choose the Zen catalog when you want the curated multi-model proxy.",
].join("\n"),
noteTitle: "OpenCode",
wizard: {
choiceId: "opencode-zen",
choiceLabel: "OpenCode Zen catalog",
...OPENCODE_SHARED_WIZARD_GROUP,
},
}),
],
normalizeConfig: ({ providerConfig }) => {
const normalizedBaseUrl = normalizeOpencodeZenBaseUrl({
api: providerConfig.api,
baseUrl: providerConfig.baseUrl,
});
return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl
? { ...providerConfig, baseUrl: normalizedBaseUrl }
: undefined;
},
normalizeResolvedModel: ({ model }) => {
const normalizedBaseUrl = normalizeOpencodeZenBaseUrl({
api: model.api,
baseUrl: model.baseUrl,
});
return normalizedBaseUrl && normalizedBaseUrl !== model.baseUrl
? { ...model, baseUrl: normalizedBaseUrl }
: undefined;
},
normalizeTransport: ({ api: apiLocal, baseUrl }) => {
const normalizedBaseUrl = normalizeOpencodeZenBaseUrl({ api: apiLocal, baseUrl });
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
? {
api: apiLocal,
baseUrl: normalizedBaseUrl,
}
: undefined;
},
resolveDynamicModel: ({ modelId }) => resolveOpencodeZenModel(modelId),
staticCatalog: {
order: "simple",
run: async () => ({
provider: buildStaticOpencodeZenProviderConfig(),
}),
},
catalog: {
order: "simple",
run: async (ctx) => {
const auth = resolveOpencodeZenCatalogAuth(ctx.resolveProviderApiKey);
if (!auth) {
return null;
}
if (!auth.discoveryApiKey) {
return {
provider: buildStaticOpencodeZenProviderConfig(auth.apiKey),
};
manifest,
provider: {
label: "OpenCode Zen",
docsPath: "/providers/models",
envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
manifestAuth: {
hint: OPENCODE_SHARED_HINT,
promptMessage: "Enter OpenCode API key",
profileIds: [...OPENCODE_SHARED_PROFILE_IDS],
defaultModel: OPENCODE_ZEN_DEFAULT_MODEL,
applyConfig: applyOpencodeZenConfig,
expectedProviders: ["opencode", "opencode-go"],
noteMessage: [
"OpenCode uses one API key across the Zen and Go catalogs.",
"Zen provides access to Claude, GPT, Gemini, and more models.",
"Get your API key at: https://opencode.ai/auth",
"Choose the Zen catalog when you want the curated multi-model proxy.",
].join("\n"),
noteTitle: "OpenCode",
},
normalizeConfig: ({ providerConfig }) => {
const normalizedBaseUrl = normalizeOpencodeZenBaseUrl({
api: providerConfig.api,
baseUrl: providerConfig.baseUrl,
});
return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl
? { ...providerConfig, baseUrl: normalizedBaseUrl }
: undefined;
},
normalizeResolvedModel: ({ model }) => {
const normalizedBaseUrl = normalizeOpencodeZenBaseUrl({
api: model.api,
baseUrl: model.baseUrl,
});
return normalizedBaseUrl && normalizedBaseUrl !== model.baseUrl
? { ...model, baseUrl: normalizedBaseUrl }
: undefined;
},
normalizeTransport: ({ api: apiLocal, baseUrl }) => {
const normalizedBaseUrl = normalizeOpencodeZenBaseUrl({ api: apiLocal, baseUrl });
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
? {
api: apiLocal,
baseUrl: normalizedBaseUrl,
}
: undefined;
},
resolveDynamicModel: ({ modelId }) => resolveOpencodeZenModel(modelId),
catalog: {
order: "simple",
run: async (ctx) => {
const auth = resolveOpencodeZenCatalogAuth(ctx.resolveProviderApiKey);
if (!auth) {
return null;
}
if (!auth.discoveryApiKey) {
return {
provider: await buildOpencodeZenLiveProviderConfig({
apiKey: auth.apiKey ?? auth.discoveryApiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
provider: buildStaticOpencodeZenProviderConfig(auth.apiKey),
};
},
}
return {
provider: await buildOpencodeZenLiveProviderConfig({
apiKey: auth.apiKey ?? auth.discoveryApiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
};
},
augmentModelCatalog: () => listOpencodeZenModelCatalogEntries(),
...buildProviderReplayFamilyHooks({ family: "passthrough-gemini" }),
isModernModelRef: ({ modelId }) => isModernOpencodeModel(modelId),
resolveThinkingProfile: resolveOpencodeThinkingProfile,
});
staticRun: async () => ({ provider: buildStaticOpencodeZenProviderConfig() }),
},
augmentModelCatalog: () => listOpencodeZenModelCatalogEntries(),
...buildProviderReplayFamilyHooks({ family: "passthrough-gemini" }),
isModernModelRef: ({ modelId }) => isModernOpencodeModel(modelId),
resolveThinkingProfile: resolveOpencodeThinkingProfile,
},
register(api) {
api.registerMediaUnderstandingProvider(opencodeMediaUnderstandingProvider);
registerOpenCodeSessionCatalog(api);
},

View File

@@ -144,7 +144,14 @@ describe("openrouter provider hooks", () => {
id: "openrouter",
name: "OpenRouter Provider",
});
const modelCatalogProvider = expectUnifiedModelCatalogProviderRegistration({
const textModelCatalogProvider = expectUnifiedModelCatalogProviderRegistration({
plugin: openrouterPlugin,
pluginId: "openrouter",
pluginName: "OpenRouter Provider",
provider: "openrouter",
kind: "text",
});
const videoModelCatalogProvider = expectUnifiedModelCatalogProviderRegistration({
plugin: openrouterPlugin,
pluginId: "openrouter",
pluginName: "OpenRouter Provider",
@@ -162,7 +169,9 @@ describe("openrouter provider hooks", () => {
expect(imageProviders.map((provider) => provider.id)).toEqual(["openrouter"]);
expect(musicProviders.map((provider) => provider.id)).toEqual(["openrouter"]);
expect(videoProviders.map((provider) => provider.id)).toEqual(["openrouter"]);
expect(modelCatalogProvider.liveCatalog).toBeTypeOf("function");
expect(textModelCatalogProvider.staticCatalog).toBeTypeOf("function");
expect(textModelCatalogProvider.liveCatalog).toBeTypeOf("function");
expect(videoModelCatalogProvider.liveCatalog).toBeTypeOf("function");
});
it("registers OAuth and API-key auth methods", async () => {

View File

@@ -1,12 +1,11 @@
// Openrouter plugin entrypoint registers its OpenClaw integration.
import {
definePluginEntry,
type ProviderReplayPolicy,
type ProviderReplayPolicyContext,
type ProviderResolveDynamicModelContext,
type ProviderRuntimeModel,
import type {
ProviderReplayPolicy,
ProviderReplayPolicyContext,
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import {
buildProviderReplayFamilyHooks,
DEFAULT_CONTEXT_TOKENS,
@@ -22,6 +21,7 @@ import { isOpenRouterMistralModelId, normalizeOpenRouterApiModelId } from "./mod
import { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
import { createOpenRouterOAuthAuthMethod } from "./oauth.js";
import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
import {
buildOpenrouterLiveProvider,
buildOpenrouterProvider,
@@ -228,11 +228,12 @@ function resolveOpenRouterFusionPromptContribution(
return lines.length > 2 ? { dynamicSuffix: lines.join("\n") } : undefined;
}
export default definePluginEntry({
export default defineSingleProviderPluginEntry({
id: "openrouter",
name: "OpenRouter Provider",
description: "Bundled OpenRouter provider plugin",
register(api) {
manifest,
provider() {
function buildDynamicOpenRouterModel(
ctx: ProviderResolveDynamicModelContext,
): ProviderRuntimeModel {
@@ -281,35 +282,15 @@ export default definePluginEntry({
return base;
}
api.registerProvider({
id: PROVIDER_ID,
return {
label: "OpenRouter",
docsPath: "/providers/models",
envVars: ["OPENROUTER_API_KEY"],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "OpenRouter API key",
hint: "API key",
optionKey: "openrouterApiKey",
flagName: "--openrouter-api-key",
envVar: "OPENROUTER_API_KEY",
promptMessage: "Enter OpenRouter API key",
defaultModel: OPENROUTER_DEFAULT_MODEL_REF,
expectedProviders: ["openrouter"],
applyConfig: (cfg) => applyOpenrouterConfig(cfg),
wizard: {
choiceId: "openrouter-api-key",
choiceLabel: "OpenRouter API key",
groupId: "openrouter",
groupLabel: "OpenRouter",
groupHint: "OAuth or API key",
onboardingScopes: ["text-inference", "music-generation"],
},
}),
createOpenRouterOAuthAuthMethod(),
],
manifestAuth: {
hint: "API key",
defaultModel: OPENROUTER_DEFAULT_MODEL_REF,
applyConfig: applyOpenrouterConfig,
},
extraAuth: [createOpenRouterOAuthAuthMethod()],
catalog: {
order: "simple",
run: async (ctx) => {
@@ -325,10 +306,7 @@ export default definePluginEntry({
}),
};
},
},
staticCatalog: {
order: "simple",
run: async () => ({
staticRun: async () => ({
provider: buildOpenrouterProvider(),
}),
},
@@ -375,7 +353,9 @@ export default definePluginEntry({
timeoutMs: ctx.timeoutMs,
fetchFn: ctx.fetchFn,
}),
});
};
},
register(api) {
api.registerMediaUnderstandingProvider(openrouterMediaUnderstandingProvider);
api.registerImageGenerationProvider(buildOpenRouterImageGenerationProvider());
api.registerMusicGenerationProvider(buildOpenRouterMusicGenerationProvider());

View File

@@ -6,8 +6,21 @@ import { describe, expect, it } from "vitest";
import { VOLCENGINE_UNSUPPORTED_TOOL_SCHEMA_KEYWORDS } from "./api.js";
import plugin from "./index.js";
import { DOUBAO_CODING_MODEL_CATALOG, DOUBAO_MODEL_CATALOG } from "./models.js";
import { VOLCENGINE_PROVIDER_CATALOG_ENTRIES } from "./provider-catalog.js";
describe("volcengine plugin", () => {
it("preserves both provider-owned static catalogs and paired ordering", async () => {
const provider = await registerSingleProviderPlugin(plugin);
expect(provider.catalog?.order).toBe("paired");
expect(provider.staticCatalog?.order).toBe("paired");
expect(await provider.staticCatalog?.run({} as never)).toEqual({
providers: Object.fromEntries(
VOLCENGINE_PROVIDER_CATALOG_ENTRIES.map(({ id, buildProvider }) => [id, buildProvider()]),
),
});
});
it("augments the catalog with bundled standard and plan models", async () => {
const provider = await registerSingleProviderPlugin(plugin);
expect(provider.auth?.[0]?.starterModel).toBe("volcengine-plan/ark-code-latest");

View File

@@ -1,8 +1,7 @@
// Volcengine plugin entrypoint registers its OpenClaw integration.
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { buildOpenAICompatibleLiveModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { readManifestProviderDefaultModelRef } from "openclaw/plugin-sdk/provider-catalog-shared";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { ensureModelAllowlistEntry } from "openclaw/plugin-sdk/provider-onboard";
import { applyVolcengineToolSchemaCompat } from "./api.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
@@ -15,95 +14,67 @@ const VOLCENGINE_DEFAULT_MODEL_REF = readManifestProviderDefaultModelRef(
"volcengine-plan",
)!;
export default definePluginEntry({
export default defineSingleProviderPluginEntry({
id: PROVIDER_ID,
name: "Volcengine Provider",
description: "Bundled Volcengine provider plugin",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: "Volcengine",
docsPath: "/concepts/model-providers#volcano-engine-doubao",
envVars: ["VOLCANO_ENGINE_API_KEY"],
hookAliases: ["volcengine-plan"],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "Volcano Engine API key",
hint: "API key",
optionKey: "volcengineApiKey",
flagName: "--volcengine-api-key",
envVar: "VOLCANO_ENGINE_API_KEY",
promptMessage: "Enter Volcano Engine API key",
defaultModel: VOLCENGINE_DEFAULT_MODEL_REF,
expectedProviders: ["volcengine"],
applyConfig: (cfg) =>
ensureModelAllowlistEntry({
cfg,
modelRef: VOLCENGINE_DEFAULT_MODEL_REF,
}),
wizard: {
choiceId: "volcengine-api-key",
choiceLabel: "Volcano Engine API key",
groupId: "volcengine",
groupLabel: "Volcano Engine",
groupHint: "API key",
},
}),
],
catalog: {
order: "paired",
run: async (ctx) => {
const auth = ctx.resolveProviderApiKey(PROVIDER_ID);
const apiKey = auth.apiKey;
if (!apiKey) {
return null;
}
return {
providers: Object.fromEntries(
await Promise.all(
VOLCENGINE_PROVIDER_CATALOG_ENTRIES.map(
async ({ id, buildProvider }) =>
[
id,
await buildOpenAICompatibleLiveModelProviderConfig({
providerId: id,
providerConfig: buildProvider(),
apiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
] as const,
),
manifest,
provider: {
label: "Volcengine",
docsPath: "/concepts/model-providers#volcano-engine-doubao",
hookAliases: ["volcengine-plan"],
manifestAuth: {
defaultModel: VOLCENGINE_DEFAULT_MODEL_REF,
applyConfig: (cfg) =>
ensureModelAllowlistEntry({ cfg, modelRef: VOLCENGINE_DEFAULT_MODEL_REF }),
},
catalog: {
order: "paired",
run: async (ctx) => {
const auth = ctx.resolveProviderApiKey(PROVIDER_ID);
const apiKey = auth.apiKey;
if (!apiKey) {
return null;
}
return {
providers: Object.fromEntries(
await Promise.all(
VOLCENGINE_PROVIDER_CATALOG_ENTRIES.map(
async ({ id, buildProvider }) =>
[
id,
await buildOpenAICompatibleLiveModelProviderConfig({
providerId: id,
providerConfig: buildProvider(),
apiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
] as const,
),
),
};
},
},
staticCatalog: {
order: "paired",
run: async () => ({
providers: Object.fromEntries(
VOLCENGINE_PROVIDER_CATALOG_ENTRIES.map(({ id, buildProvider }) => [
id,
buildProvider(),
]),
),
}),
};
},
augmentModelCatalog: () =>
VOLCENGINE_PROVIDER_CATALOG_ENTRIES.flatMap(({ id: provider, models }) =>
models.map((entry) => ({
provider,
id: entry.id,
name: entry.name,
reasoning: entry.reasoning,
input: [...entry.input],
contextWindow: entry.contextWindow,
})),
staticRun: async () => ({
providers: Object.fromEntries(
VOLCENGINE_PROVIDER_CATALOG_ENTRIES.map(({ id, buildProvider }) => [id, buildProvider()]),
),
normalizeResolvedModel: ({ model }) => applyVolcengineToolSchemaCompat(model),
});
}),
},
augmentModelCatalog: () =>
VOLCENGINE_PROVIDER_CATALOG_ENTRIES.flatMap(({ id: provider, models }) =>
models.map((entry) => ({
provider,
id: entry.id,
name: entry.name,
reasoning: entry.reasoning,
input: [...entry.input],
contextWindow: entry.contextWindow,
})),
),
normalizeResolvedModel: ({ model }) => applyVolcengineToolSchemaCompat(model),
},
register(api) {
api.registerSpeechProvider(buildVolcengineSpeechProvider());
},
});

View File

@@ -5,9 +5,11 @@ import path from "node:path";
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import type { Context, Model } from "openclaw/plugin-sdk/llm";
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
import { buildOpenAICompletionsParams } from "openclaw/plugin-sdk/provider-transport-runtime";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
function createGlm47Template() {
return {
@@ -49,6 +51,26 @@ function expectModelFields(
}
describe("zai provider plugin", () => {
it("preserves all regional auth choices and the exact manifest-owned static catalog", async () => {
const provider = await registerSingleProviderPlugin(plugin);
expect(provider.aliases).toEqual(["z-ai", "z.ai"]);
expect(provider.envVars).toEqual(["ZAI_API_KEY", "Z_AI_API_KEY"]);
expect(provider.auth.map((method) => method.id)).toEqual([
"api-key",
"coding-global",
"coding-cn",
"global",
"cn",
]);
expect(await provider.staticCatalog?.run({} as never)).toEqual({
provider: buildManifestModelProviderConfig({
providerId: "zai",
catalog: manifest.modelCatalog.providers.zai,
}),
});
});
it("owns replay policy for OpenAI-compatible Z.ai transports", async () => {
const provider = await registerSingleProviderPlugin(plugin);

View File

@@ -2,13 +2,12 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
definePluginEntry,
type ProviderAuthContext,
type ProviderAuthMethod,
type ProviderAuthMethodNonInteractiveContext,
type ProviderResolveDynamicModelContext,
type ProviderWrapStreamFnContext,
import type {
ProviderAuthContext,
ProviderAuthMethod,
ProviderAuthMethodNonInteractiveContext,
ProviderResolveDynamicModelContext,
ProviderWrapStreamFnContext,
} from "openclaw/plugin-sdk/plugin-entry";
import {
applyAuthProfileConfig,
@@ -20,8 +19,7 @@ import {
upsertAuthProfileWithLock,
validateApiKeyInput,
} from "openclaw/plugin-sdk/provider-auth-api-key";
import { buildOpenAICompatibleProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import {
buildProviderReplayFamilyHooks,
resolveFamilyForwardCompatModel,
@@ -45,13 +43,6 @@ const GLM5_TEMPLATE_MODEL_ID = "glm-4.7";
const PROFILE_ID = "zai:default";
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
function buildZaiCatalogProvider() {
return buildManifestModelProviderConfig({
providerId: PROVIDER_ID,
catalog: manifest.modelCatalog.providers.zai,
});
}
function resolveDeprecatedPiAgentAuthPath(env: NodeJS.ProcessEnv): string {
const home = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
return path.join(home, ".pi", "agent", "auth.json");
@@ -340,101 +331,89 @@ function buildZaiApiKeyMethod(params: {
};
}
export default definePluginEntry({
export default defineSingleProviderPluginEntry({
id: PROVIDER_ID,
name: "Z.AI Provider",
description: "Bundled Z.AI provider plugin",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: "Z.AI",
aliases: ["z-ai", "z.ai"],
docsPath: "/providers/models",
envVars: ["ZAI_API_KEY", "Z_AI_API_KEY"],
auth: [
buildZaiApiKeyMethod({
id: "api-key",
choiceId: "zai-api-key",
choiceLabel: "Z.AI API key",
}),
buildZaiApiKeyMethod({
id: "coding-global",
choiceId: "zai-coding-global",
choiceLabel: "Coding-Plan-Global",
choiceHint: "GLM Coding Plan Global (api.z.ai)",
endpoint: "coding-global",
}),
buildZaiApiKeyMethod({
id: "coding-cn",
choiceId: "zai-coding-cn",
choiceLabel: "Coding-Plan-CN",
choiceHint: "GLM Coding Plan CN (open.bigmodel.cn)",
endpoint: "coding-cn",
}),
buildZaiApiKeyMethod({
id: "global",
choiceId: "zai-global",
choiceLabel: "Global",
choiceHint: "Z.AI Global (api.z.ai)",
endpoint: "global",
}),
buildZaiApiKeyMethod({
id: "cn",
choiceId: "zai-cn",
choiceLabel: "CN",
choiceHint: "Z.AI CN (open.bigmodel.cn)",
endpoint: "cn",
}),
],
catalog: {
order: "simple",
run: (ctx) =>
buildOpenAICompatibleProviderCatalog({
ctx,
providerId: PROVIDER_ID,
buildProvider: buildZaiCatalogProvider,
allowExplicitBaseUrl: true,
}),
},
staticCatalog: {
order: "simple",
run: async () => ({ provider: buildZaiCatalogProvider() }),
},
resolveDynamicModel: (ctx) => resolveGlm5ForwardCompatModel(ctx),
matchesContextOverflowError: ({ errorMessage }) =>
/\b(?:tokens? in request more than max tokens? allowed|prompt exceeds max(?:imum)? length)\b/i.test(
errorMessage,
),
...buildProviderReplayFamilyHooks({
family: "openai-compatible",
dropReasoningFromHistory: false,
manifest,
provider: {
label: "Z.AI",
aliases: ["z-ai", "z.ai"],
docsPath: "/providers/models",
envVars: ["ZAI_API_KEY", "Z_AI_API_KEY"],
auth: [],
extraAuth: [
buildZaiApiKeyMethod({
id: "api-key",
choiceId: "zai-api-key",
choiceLabel: "Z.AI API key",
}),
prepareExtraParams: (ctx) => defaultToolStreamExtraParams(ctx.extraParams),
wrapStreamFn: (ctx) => wrapZaiStreamFn(ctx),
resolveThinkingProfile,
isModernModelRef: ({ modelId }) => {
const lower = normalizeLowercaseStringOrEmpty(modelId);
return (
lower.startsWith("glm-5") ||
lower.startsWith("glm-4.7") ||
lower.startsWith("glm-4.7-flash") ||
lower.startsWith("glm-4.7-flashx")
);
},
resolveUsageAuth: async (ctx) => {
const apiKey = ctx.resolveApiKeyFromConfigAndStore({
providerIds: [PROVIDER_ID, "z-ai"],
envDirect: [ctx.env.ZAI_API_KEY, ctx.env.Z_AI_API_KEY],
});
if (apiKey) {
return { token: apiKey };
}
const legacyToken = resolveDeprecatedPiAgentAccessToken(ctx.env, ["z-ai", PROVIDER_ID]);
return legacyToken ? { token: legacyToken } : null;
},
fetchUsageSnapshot: async (ctx) => await fetchZaiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn),
isCacheTtlEligible: () => true,
});
buildZaiApiKeyMethod({
id: "coding-global",
choiceId: "zai-coding-global",
choiceLabel: "Coding-Plan-Global",
choiceHint: "GLM Coding Plan Global (api.z.ai)",
endpoint: "coding-global",
}),
buildZaiApiKeyMethod({
id: "coding-cn",
choiceId: "zai-coding-cn",
choiceLabel: "Coding-Plan-CN",
choiceHint: "GLM Coding Plan CN (open.bigmodel.cn)",
endpoint: "coding-cn",
}),
buildZaiApiKeyMethod({
id: "global",
choiceId: "zai-global",
choiceLabel: "Global",
choiceHint: "Z.AI Global (api.z.ai)",
endpoint: "global",
}),
buildZaiApiKeyMethod({
id: "cn",
choiceId: "zai-cn",
choiceLabel: "CN",
choiceHint: "Z.AI CN (open.bigmodel.cn)",
endpoint: "cn",
}),
],
catalog: { allowExplicitBaseUrl: true, liveModelDiscovery: true },
resolveDynamicModel: (ctx) => resolveGlm5ForwardCompatModel(ctx),
matchesContextOverflowError: ({ errorMessage }) =>
/\b(?:tokens? in request more than max tokens? allowed|prompt exceeds max(?:imum)? length)\b/i.test(
errorMessage,
),
...buildProviderReplayFamilyHooks({
family: "openai-compatible",
dropReasoningFromHistory: false,
}),
prepareExtraParams: (ctx) => defaultToolStreamExtraParams(ctx.extraParams),
wrapStreamFn: (ctx) => wrapZaiStreamFn(ctx),
resolveThinkingProfile,
isModernModelRef: ({ modelId }) => {
const lower = normalizeLowercaseStringOrEmpty(modelId);
return (
lower.startsWith("glm-5") ||
lower.startsWith("glm-4.7") ||
lower.startsWith("glm-4.7-flash") ||
lower.startsWith("glm-4.7-flashx")
);
},
resolveUsageAuth: async (ctx) => {
const apiKey = ctx.resolveApiKeyFromConfigAndStore({
providerIds: [PROVIDER_ID, "z-ai"],
envDirect: [ctx.env.ZAI_API_KEY, ctx.env.Z_AI_API_KEY],
});
if (apiKey) {
return { token: apiKey };
}
const legacyToken = resolveDeprecatedPiAgentAccessToken(ctx.env, ["z-ai", PROVIDER_ID]);
return legacyToken ? { token: legacyToken } : null;
},
fetchUsageSnapshot: async (ctx) => await fetchZaiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn),
isCacheTtlEligible: () => true,
},
register(api) {
api.registerMediaUnderstandingProvider(zaiMediaUnderstandingProvider);
},
});

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 (