From eec2c0cc26c7e937e1594de1d90a13e0cc743cc3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 03:20:02 -0400 Subject: [PATCH] refactor(providers): unify lifecycle-safe manifest registration (#114381) * refactor(providers): canonicalize manifest-driven registration * fix(zai): eliminate type-only SDK import side effects --- extensions/arcee/index.ts | 149 +++++++++----------- extensions/byteplus/index.test.ts | 13 ++ extensions/byteplus/index.ts | 130 +++++++---------- extensions/clawrouter/index.test.ts | 32 +++++ extensions/clawrouter/index.ts | 57 +++----- extensions/groq/index.test.ts | 10 +- extensions/groq/index.ts | 84 +++-------- extensions/kimi-coding/index.ts | 141 +++++++++---------- extensions/opencode-go/index.test.ts | 14 ++ extensions/opencode-go/index.ts | 177 +++++++++++------------ extensions/opencode/index.test.ts | 15 ++ extensions/opencode/index.ts | 172 ++++++++++------------- extensions/openrouter/index.test.ts | 13 +- extensions/openrouter/index.ts | 62 +++------ extensions/volcengine/index.test.ts | 13 ++ extensions/volcengine/index.ts | 137 +++++++----------- extensions/zai/index.test.ts | 22 +++ extensions/zai/index.ts | 193 ++++++++++++-------------- src/plugin-sdk/provider-entry.test.ts | 126 +++++++++++++++++ src/plugin-sdk/provider-entry.ts | 157 ++++++++++++++------- 20 files changed, 892 insertions(+), 825 deletions(-) diff --git a/extensions/arcee/index.ts b/extensions/arcee/index.ts index db5789115fb7..fe8264b3b1ab 100644 --- a/extensions/arcee/index.ts +++ b/extensions/arcee/index.ts @@ -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 } /** 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" }), }, }); diff --git a/extensions/byteplus/index.test.ts b/extensions/byteplus/index.test.ts index 8fb7fc8a8f2c..635256948296 100644 --- a/extensions/byteplus/index.test.ts +++ b/extensions/byteplus/index.test.ts @@ -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"); diff --git a/extensions/byteplus/index.ts b/extensions/byteplus/index.ts index 9d34b4b43aab..c58d0803b98f 100644 --- a/extensions/byteplus/index.ts +++ b/extensions/byteplus/index.ts @@ -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()); }, }); diff --git a/extensions/clawrouter/index.test.ts b/extensions/clawrouter/index.test.ts index 5828b5f4d637..2bfabb0a903c 100644 --- a/extensions/clawrouter/index.test.ts +++ b/extensions/clawrouter/index.test.ts @@ -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" }) diff --git a/extensions/clawrouter/index.ts b/extensions/clawrouter/index.ts index 07a5b14bcc5d..eef086f6158e 100644 --- a/extensions/clawrouter/index.ts +++ b/extensions/clawrouter/index.ts @@ -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 } } | 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>(); - 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, }), - }); + }; }, }); diff --git a/extensions/groq/index.test.ts b/extensions/groq/index.test.ts index f4dfecf485d6..36ea4ad878f3 100644 --- a/extensions/groq/index.test.ts +++ b/extensions/groq/index.test.ts @@ -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) { diff --git a/extensions/groq/index.ts b/extensions/groq/index.ts index 961c9cddbf0c..cc659dce6bf2 100644 --- a/extensions/groq/index.ts +++ b/extensions/groq/index.ts @@ -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); }, }); diff --git a/extensions/kimi-coding/index.ts b/extensions/kimi-coding/index.ts index 8dbe3221580a..1f163e0d3c5c 100644 --- a/extensions/kimi-coding/index.ts +++ b/extensions/kimi-coding/index.ts @@ -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 | undefined, + PROVIDER_ID, + ); + const builtInProvider = buildKimiCodingProvider(); + const explicitBaseUrl = normalizeOptionalString(explicitProvider?.baseUrl) ?? ""; + const explicitHeaders = isRecord(explicitProvider?.headers) + ? (explicitProvider.headers as Record) + : 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 | undefined, - PROVIDER_ID, - ); - const builtInProvider = buildKimiCodingProvider(); - const explicitBaseUrl = normalizeOptionalString(explicitProvider?.baseUrl) ?? ""; - const explicitHeaders = isRecord(explicitProvider?.headers) - ? (explicitProvider.headers as Record) - : 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, }, }); diff --git a/extensions/opencode-go/index.test.ts b/extensions/opencode-go/index.test.ts index e989decc56a9..29b65fd78ef1 100644 --- a/extensions/opencode-go/index.test.ts +++ b/extensions/opencode-go/index.test.ts @@ -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, diff --git a/extensions/opencode-go/index.ts b/extensions/opencode-go/index.ts index 1fbced7f7b2c..bca0731cb50d 100644 --- a/extensions/opencode-go/index.ts +++ b/extensions/opencode-go/index.ts @@ -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); }, }); diff --git a/extensions/opencode/index.test.ts b/extensions/opencode/index.test.ts index d19694c2b97b..a184ae3d0a24 100644 --- a/extensions/opencode/index.test.ts +++ b/extensions/opencode/index.test.ts @@ -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, diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts index 0f1bc0e29594..16aa63e333c5 100644 --- a/extensions/opencode/index.ts +++ b/extensions/opencode/index.ts @@ -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); }, diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts index bedaa9bcb52a..7c925700916e 100644 --- a/extensions/openrouter/index.test.ts +++ b/extensions/openrouter/index.test.ts @@ -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 () => { diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index bebf95c8c482..da87aac9891f 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -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()); diff --git a/extensions/volcengine/index.test.ts b/extensions/volcengine/index.test.ts index ffba88c4538e..481437b30faf 100644 --- a/extensions/volcengine/index.test.ts +++ b/extensions/volcengine/index.test.ts @@ -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"); diff --git a/extensions/volcengine/index.ts b/extensions/volcengine/index.ts index af169638398f..1c27663e3212 100644 --- a/extensions/volcengine/index.ts +++ b/extensions/volcengine/index.ts @@ -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()); }, }); diff --git a/extensions/zai/index.test.ts b/extensions/zai/index.test.ts index 56da5f4a4618..46890ff68527 100644 --- a/extensions/zai/index.test.ts +++ b/extensions/zai/index.test.ts @@ -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); diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index c4864272542b..3100574fe355 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -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[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); }, }); diff --git a/src/plugin-sdk/provider-entry.test.ts b/src/plugin-sdk/provider-entry.test.ts index 6a47f23b2a98..bff1055a4ed4 100644 --- a/src/plugin-sdk/provider-entry.test.ts +++ b/src/plugin-sdk/provider-entry.test.ts @@ -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", diff --git a/src/plugin-sdk/provider-entry.ts b/src/plugin-sdk/provider-entry.ts index 2000f6bbabd0..95eb745c504e 100644 --- a/src/plugin-sdk/provider-entry.ts +++ b/src/plugin-sdk/provider-entry.ts @@ -42,11 +42,30 @@ import { type ApiKeyAuthMethodOptions = Parameters[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[]; }; - providerAuthChoices?: readonly PluginManifestProviderAuthChoice[]; + providerAuthChoices?: readonly SingleProviderPluginManifestAuthChoice[]; modelCatalog?: { providers?: Readonly>; discovery?: Readonly>; @@ -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[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 (