diff --git a/docs/plugins/sdk-provider-plugins/model-catalogs.md b/docs/plugins/sdk-provider-plugins/model-catalogs.md index b92033a8e344..374648f2e08a 100644 --- a/docs/plugins/sdk-provider-plugins/model-catalogs.md +++ b/docs/plugins/sdk-provider-plugins/model-catalogs.md @@ -340,10 +340,33 @@ agent/workspace paths. If your auth flow also needs to patch `models.providers.*`, aliases, and the agent default model during onboarding, use the preset helpers from -`openclaw/plugin-sdk/provider-onboard`. The narrowest helpers are -`createDefaultModelPresetAppliers(...)`, +`openclaw/plugin-sdk/provider-onboard`. For registered connection-only setup, +use `createProviderConnectionPresetAppliers(...)` with a lazy `catalogModels` +supplier. Ordinary setup writes connection facts, aliases, and a missing +primary without copying the built-in catalog into saved configuration. +Explicit `models.mode: "replace"` evaluates the supplier and merges generated +rows behind authored rows. Each setup result owns its generated model data. + +Use `createDefaultModelsConnectionPresetAppliers(...)` when replace mode must +retain the existing required-default rule: add the supplied `defaultModels` +only when the configured provider lacks the selected `defaultModelId`. +`applyProviderConnectionConfig(...)` provides the catalog variant for auth +flows that resolve their endpoint or primary per invocation. These helpers +preserve authored rows, existing aliases and fallbacks. The auth flow still +owns any explicit default selection. + +The existing public `createDefaultModelPresetAppliers(...)`, `createDefaultModelsPresetAppliers(...)`, and -`createModelCatalogPresetAppliers(...)`. +`createModelCatalogPresetAppliers(...)` retain their catalog-seeding behavior +in ordinary mode. The latter two also accept lazy model suppliers. A provider +with published config helpers can share one preset descriptor between its +existing helper and its new registered setup helper; migrate the registration +without silently changing the published helper's contract. + +Independently published plugins must require the host release containing these +helpers in `openclaw.compat.pluginApi`. The core release version-sync tool +updates that range with the paired release; these imports do not work on an +older host that lacks the helpers. When a provider's native endpoint supports streamed usage blocks on the normal `openai-completions` transport, prefer the shared catalog helpers in diff --git a/extensions/cloudflare-ai-gateway/index.ts b/extensions/cloudflare-ai-gateway/index.ts index 5a1665aeee58..4b6c86d1c71b 100644 --- a/extensions/cloudflare-ai-gateway/index.ts +++ b/extensions/cloudflare-ai-gateway/index.ts @@ -17,7 +17,10 @@ import { upsertAuthProfileWithLockOrThrow } from "openclaw/plugin-sdk/provider-a import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { buildCloudflareAiGatewayCatalogProvider } from "./catalog-provider.js"; import { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "./models.js"; -import { applyCloudflareAiGatewayConfig, buildCloudflareAiGatewayConfigPatch } from "./onboard.js"; +import { + applyCloudflareAiGatewayConnectionConfig, + applyCloudflareAiGatewayProviderConnectionConfig, +} from "./onboard.js"; import { wrapCloudflareAiGatewayProviderStream } from "./stream-wrappers.js"; const PROVIDER_ID = "cloudflare-ai-gateway"; @@ -132,7 +135,7 @@ export default definePluginEntry({ ), }, ], - configPatch: buildCloudflareAiGatewayConfigPatch(metadata), + configPatch: applyCloudflareAiGatewayProviderConnectionConfig(ctx.config, metadata), defaultModel: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, }; }, @@ -195,7 +198,7 @@ export default definePluginEntry({ provider: PROVIDER_ID, mode: "api_key", }); - return applyCloudflareAiGatewayConfig(next, { accountId, gatewayId }); + return applyCloudflareAiGatewayConnectionConfig(next, { accountId, gatewayId }); }, }, ], diff --git a/extensions/cloudflare-ai-gateway/onboard.ts b/extensions/cloudflare-ai-gateway/onboard.ts index cbaf4618af43..21d0f3eac82c 100644 --- a/extensions/cloudflare-ai-gateway/onboard.ts +++ b/extensions/cloudflare-ai-gateway/onboard.ts @@ -5,6 +5,7 @@ import { applyAgentDefaultModelPrimary, applyProviderConfigWithDefaultModel, + applyProviderConnectionConfig, type OpenClawConfig, } from "openclaw/plugin-sdk/provider-onboard"; import { @@ -102,3 +103,29 @@ export function applyCloudflareAiGatewayConfig( CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, ); } + +/** Registered setup keeps authored rows and seeds the default only in replace mode. */ +export function applyCloudflareAiGatewayProviderConnectionConfig( + cfg: OpenClawConfig, + params: { accountId: string; gatewayId: string }, +): OpenClawConfig { + return applyProviderConnectionConfig(cfg, { + providerId: "cloudflare-ai-gateway", + api: "anthropic-messages", + baseUrl: resolveCloudflareAiGatewayBaseUrl(params), + catalogModels: () => [buildCloudflareAiGatewayModelDefinition()], + aliases: [ + { modelRef: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, alias: "Cloudflare AI Gateway" }, + ], + }); +} + +export function applyCloudflareAiGatewayConnectionConfig( + cfg: OpenClawConfig, + params: { accountId: string; gatewayId: string }, +): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyCloudflareAiGatewayProviderConnectionConfig(cfg, params), + CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/featherless/index.ts b/extensions/featherless/index.ts index 8a5e945a6926..71e34c8ecab3 100644 --- a/extensions/featherless/index.ts +++ b/extensions/featherless/index.ts @@ -11,7 +11,7 @@ import { normalizeModelCompat, } from "openclaw/plugin-sdk/provider-model-shared"; import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools"; -import { applyFeatherlessConfig, FEATHERLESS_DEFAULT_MODEL_REF } from "./onboard.js"; +import { applyFeatherlessConnectionConfig, FEATHERLESS_DEFAULT_MODEL_REF } from "./onboard.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; import { FEATHERLESS_BASE_URL, @@ -81,7 +81,7 @@ export default defineSingleProviderPluginEntry({ docsPath: "/providers/featherless", manifestAuth: { defaultModel: FEATHERLESS_DEFAULT_MODEL_REF, - applyConfig: applyFeatherlessConfig, + applyConfig: applyFeatherlessConnectionConfig, noteTitle: "Featherless AI", noteMessage: [ "Featherless AI serves open models through an OpenAI-compatible API.", diff --git a/extensions/featherless/onboard.ts b/extensions/featherless/onboard.ts index c5845d3d3d0b..cacd815ef6af 100644 --- a/extensions/featherless/onboard.ts +++ b/extensions/featherless/onboard.ts @@ -1,5 +1,8 @@ // Featherless onboarding applies the curated model catalog and default. -import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard"; +import { + createModelCatalogPresetAppliers, + createProviderConnectionPresetAppliers, +} from "openclaw/plugin-sdk/provider-onboard"; import { buildFeatherlessCatalogModels, FEATHERLESS_BASE_URL, @@ -8,13 +11,18 @@ import { export { FEATHERLESS_DEFAULT_MODEL_REF } from "./models.js"; -export const { applyConfig: applyFeatherlessConfig } = createModelCatalogPresetAppliers<[]>({ +const featherlessPreset = { primaryModelRef: FEATHERLESS_DEFAULT_MODEL_REF, resolveParams: () => ({ providerId: "featherless", api: "openai-completions", baseUrl: FEATHERLESS_BASE_URL, - catalogModels: buildFeatherlessCatalogModels(), + catalogModels: buildFeatherlessCatalogModels, aliases: [{ modelRef: FEATHERLESS_DEFAULT_MODEL_REF, alias: "Qwen3 32B" }], }), -}); +} satisfies Parameters>[0]; + +export const { applyConfig: applyFeatherlessConfig } = + createModelCatalogPresetAppliers(featherlessPreset); +export const { applyConfig: applyFeatherlessConnectionConfig } = + createProviderConnectionPresetAppliers(featherlessPreset); diff --git a/extensions/huggingface/index.ts b/extensions/huggingface/index.ts index 0d8a0f2770bf..39825f40139e 100644 --- a/extensions/huggingface/index.ts +++ b/extensions/huggingface/index.ts @@ -1,6 +1,6 @@ import { runLiveProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; -import { applyHuggingfaceConfig, HUGGINGFACE_DEFAULT_MODEL_REF } from "./onboard.js"; +import { applyHuggingfaceConnectionConfig, HUGGINGFACE_DEFAULT_MODEL_REF } from "./onboard.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; import { buildHuggingfaceProvider } from "./provider-catalog.js"; @@ -23,7 +23,7 @@ export default defineSingleProviderPluginEntry({ envVars: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"], manifestAuth: { defaultModel: HUGGINGFACE_DEFAULT_MODEL_REF, - applyConfig: applyHuggingfaceConfig, + applyConfig: applyHuggingfaceConnectionConfig, }, catalog: { run: async (ctx) => { diff --git a/extensions/huggingface/onboard.ts b/extensions/huggingface/onboard.ts index 3661caef0ebe..b003b345eed7 100644 --- a/extensions/huggingface/onboard.ts +++ b/extensions/huggingface/onboard.ts @@ -1,16 +1,24 @@ // Huggingface setup module handles plugin onboarding behavior. -import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard"; +import { + createModelCatalogPresetAppliers, + createProviderConnectionPresetAppliers, +} from "openclaw/plugin-sdk/provider-onboard"; import { HUGGINGFACE_BASE_URL, HUGGINGFACE_MODEL_CATALOG } from "./models.js"; export const HUGGINGFACE_DEFAULT_MODEL_REF = "huggingface/deepseek-ai/DeepSeek-R1"; -export const { applyConfig: applyHuggingfaceConfig } = createModelCatalogPresetAppliers<[]>({ +const huggingfacePreset = { primaryModelRef: HUGGINGFACE_DEFAULT_MODEL_REF, resolveParams: () => ({ providerId: "huggingface", api: "openai-completions", baseUrl: HUGGINGFACE_BASE_URL, - catalogModels: HUGGINGFACE_MODEL_CATALOG.map((model) => Object.assign({}, model)), + catalogModels: () => HUGGINGFACE_MODEL_CATALOG.map((model) => Object.assign({}, model)), aliases: [{ modelRef: HUGGINGFACE_DEFAULT_MODEL_REF, alias: "Hugging Face" }], }), -}); +} satisfies Parameters>[0]; + +export const { applyConfig: applyHuggingfaceConfig } = + createModelCatalogPresetAppliers(huggingfacePreset); +export const { applyConfig: applyHuggingfaceConnectionConfig } = + createProviderConnectionPresetAppliers(huggingfacePreset); diff --git a/extensions/longcat/index.ts b/extensions/longcat/index.ts index 9fafa85b02ca..fedc191274a9 100644 --- a/extensions/longcat/index.ts +++ b/extensions/longcat/index.ts @@ -3,7 +3,7 @@ import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-en import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared"; import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools"; import { LONGCAT_DEFAULT_MODEL_REF } from "./models.js"; -import { applyLongCatConfig } from "./onboard.js"; +import { applyLongCatConnectionConfig } from "./onboard.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; import { createLongCatThinkingWrapper } from "./stream.js"; @@ -20,7 +20,7 @@ export default defineSingleProviderPluginEntry({ aliases: ["meituan-longcat"], manifestAuth: { defaultModel: LONGCAT_DEFAULT_MODEL_REF, - applyConfig: applyLongCatConfig, + applyConfig: applyLongCatConnectionConfig, noteTitle: "LongCat", noteMessage: "Manage API keys at https://longcat.chat/platform/api_keys", }, diff --git a/extensions/longcat/onboard.ts b/extensions/longcat/onboard.ts index 9fe9b838642f..d3ec9a4f4f97 100644 --- a/extensions/longcat/onboard.ts +++ b/extensions/longcat/onboard.ts @@ -1,14 +1,21 @@ // LongCat setup module handles plugin onboarding behavior. -import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard"; +import { + createModelCatalogPresetAppliers, + createProviderConnectionPresetAppliers, +} from "openclaw/plugin-sdk/provider-onboard"; import { LONGCAT_BASE_URL, LONGCAT_DEFAULT_MODEL_REF, LONGCAT_MODEL_CATALOG } from "./models.js"; -export const { applyConfig: applyLongCatConfig } = createModelCatalogPresetAppliers<[]>({ +const longCatPreset = { primaryModelRef: LONGCAT_DEFAULT_MODEL_REF, resolveParams: () => ({ providerId: "longcat", api: "openai-completions", baseUrl: LONGCAT_BASE_URL, - catalogModels: LONGCAT_MODEL_CATALOG, + catalogModels: () => LONGCAT_MODEL_CATALOG, aliases: [{ modelRef: LONGCAT_DEFAULT_MODEL_REF, alias: "LongCat 2.0" }], }), -}); +} satisfies Parameters>[0]; + +export const { applyConfig: applyLongCatConfig } = createModelCatalogPresetAppliers(longCatPreset); +export const { applyConfig: applyLongCatConnectionConfig } = + createProviderConnectionPresetAppliers(longCatPreset); diff --git a/extensions/meta/index.ts b/extensions/meta/index.ts index 1455e66ad4ea..aed220998767 100644 --- a/extensions/meta/index.ts +++ b/extensions/meta/index.ts @@ -3,7 +3,7 @@ */ import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared"; -import { applyMetaConfig } from "./onboard.js"; +import { applyMetaConnectionConfig } from "./onboard.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; import { buildMetaProvider } from "./provider-catalog.js"; import { wrapMetaProviderStream } from "./stream.js"; @@ -20,7 +20,7 @@ export default defineSingleProviderPluginEntry({ label: "Meta", docsPath: "/providers/meta", manifestAuth: { - applyConfig: applyMetaConfig, + applyConfig: applyMetaConnectionConfig, noteMessage: "Meta provides Responses API inference.", noteTitle: "Meta", }, diff --git a/extensions/meta/onboard.ts b/extensions/meta/onboard.ts index f2d596437e2a..cc73f67bad1b 100644 --- a/extensions/meta/onboard.ts +++ b/extensions/meta/onboard.ts @@ -2,7 +2,10 @@ * Meta onboarding config helpers. */ import { readManifestProviderDefaultModelRef } from "openclaw/plugin-sdk/provider-catalog-shared"; -import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard"; +import { + createModelCatalogPresetAppliers, + createProviderConnectionPresetAppliers, +} from "openclaw/plugin-sdk/provider-onboard"; import { buildMetaCatalogModels, META_BASE_URL } from "./models.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; @@ -10,13 +13,17 @@ import manifest from "./openclaw.plugin.json" with { type: "json" }; export const META_DEFAULT_MODEL_REF = readManifestProviderDefaultModelRef(manifest, "meta")!; /** Applies Meta provider/catalog config and default model aliases. */ -export const { applyConfig: applyMetaConfig } = createModelCatalogPresetAppliers<[]>({ +const metaPreset = { primaryModelRef: META_DEFAULT_MODEL_REF, resolveParams: () => ({ providerId: "meta", api: "openai-responses", baseUrl: META_BASE_URL, - catalogModels: buildMetaCatalogModels(), + catalogModels: buildMetaCatalogModels, aliases: [{ modelRef: META_DEFAULT_MODEL_REF, alias: "Muse Spark 1.3" }], }), -}); +} satisfies Parameters>[0]; + +export const { applyConfig: applyMetaConfig } = createModelCatalogPresetAppliers(metaPreset); +export const { applyConfig: applyMetaConnectionConfig } = + createProviderConnectionPresetAppliers(metaPreset); diff --git a/extensions/mistral/index.ts b/extensions/mistral/index.ts index 9dff6c3c690a..47c481cdaff0 100644 --- a/extensions/mistral/index.ts +++ b/extensions/mistral/index.ts @@ -7,7 +7,7 @@ import { } from "./api.js"; import { mistralMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { mistralMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js"; -import { applyMistralConfig } from "./onboard.js"; +import { applyMistralConnectionConfig } from "./onboard.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; import { buildMistralRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js"; @@ -27,7 +27,7 @@ export default defineSingleProviderPluginEntry({ provider: { label: "Mistral", docsPath: "/providers/models", - manifestAuth: { applyConfig: applyMistralConfig }, + manifestAuth: { applyConfig: applyMistralConnectionConfig }, catalog: { discoveryMode: "strict", allowExplicitBaseUrl: true, diff --git a/extensions/mistral/onboard.ts b/extensions/mistral/onboard.ts index 7ff7fb3d418e..7f0d061d12ac 100644 --- a/extensions/mistral/onboard.ts +++ b/extensions/mistral/onboard.ts @@ -1,4 +1,7 @@ -import { createDefaultModelPresetAppliers } from "openclaw/plugin-sdk/provider-onboard"; +import { + createDefaultModelsPresetAppliers, + createDefaultModelsConnectionPresetAppliers, +} from "openclaw/plugin-sdk/provider-onboard"; import { buildMistralModelDefinition, MISTRAL_BASE_URL, @@ -6,15 +9,19 @@ import { MISTRAL_DEFAULT_MODEL_REF, } from "./model-definitions.js"; +const mistralPreset = { + primaryModelRef: MISTRAL_DEFAULT_MODEL_REF, + resolveParams: () => ({ + providerId: "mistral", + api: "openai-completions", + baseUrl: MISTRAL_BASE_URL, + defaultModels: () => [buildMistralModelDefinition()], + defaultModelId: MISTRAL_DEFAULT_MODEL_ID, + aliases: [{ modelRef: MISTRAL_DEFAULT_MODEL_REF, alias: "Mistral" }], + }), +} satisfies Parameters>[0]; + export const { applyConfig: applyMistralConfig, applyProviderConfig: applyMistralProviderConfig } = - createDefaultModelPresetAppliers<[]>({ - primaryModelRef: MISTRAL_DEFAULT_MODEL_REF, - resolveParams: () => ({ - providerId: "mistral", - api: "openai-completions", - baseUrl: MISTRAL_BASE_URL, - defaultModel: buildMistralModelDefinition(), - defaultModelId: MISTRAL_DEFAULT_MODEL_ID, - aliases: [{ modelRef: MISTRAL_DEFAULT_MODEL_REF, alias: "Mistral" }], - }), - }); + createDefaultModelsPresetAppliers(mistralPreset); +export const { applyConfig: applyMistralConnectionConfig } = + createDefaultModelsConnectionPresetAppliers(mistralPreset); diff --git a/extensions/nvidia/index.ts b/extensions/nvidia/index.ts index 7328c2de783e..255f9d9a6609 100644 --- a/extensions/nvidia/index.ts +++ b/extensions/nvidia/index.ts @@ -1,6 +1,6 @@ // Nvidia plugin entrypoint registers its OpenClaw integration. import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; -import { applyNvidiaConfig, NVIDIA_DEFAULT_MODEL_REF } from "./onboard.js"; +import { applyNvidiaConnectionConfig, NVIDIA_DEFAULT_MODEL_REF } from "./onboard.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; import { buildLiveNvidiaProvider, buildSelectableNvidiaProvider } from "./provider-catalog.js"; @@ -17,7 +17,7 @@ export default defineSingleProviderPluginEntry({ preserveLiteralProviderPrefix: true, manifestAuth: { defaultModel: NVIDIA_DEFAULT_MODEL_REF, - applyConfig: applyNvidiaConfig, + applyConfig: applyNvidiaConnectionConfig, }, catalog: { discoveryMode: "strict", diff --git a/extensions/nvidia/onboard.ts b/extensions/nvidia/onboard.ts index aa61331c5db4..96a372a0dc6c 100644 --- a/extensions/nvidia/onboard.ts +++ b/extensions/nvidia/onboard.ts @@ -1,21 +1,28 @@ // Nvidia setup module handles plugin onboarding behavior. -import { createDefaultModelsPresetAppliers } from "openclaw/plugin-sdk/provider-onboard"; +import { + createDefaultModelsPresetAppliers, + createDefaultModelsConnectionPresetAppliers, +} from "openclaw/plugin-sdk/provider-onboard"; import { buildSelectableNvidiaProvider, NVIDIA_DEFAULT_MODEL_ID } from "./provider-catalog.js"; export const NVIDIA_DEFAULT_MODEL_REF = NVIDIA_DEFAULT_MODEL_ID; +const nvidiaPreset = { + primaryModelRef: NVIDIA_DEFAULT_MODEL_REF, + resolveParams: () => { + const defaultProvider = buildSelectableNvidiaProvider(); + return { + providerId: "nvidia", + api: defaultProvider.api ?? "openai-completions", + baseUrl: defaultProvider.baseUrl, + defaultModels: () => defaultProvider.models ?? [], + defaultModelId: NVIDIA_DEFAULT_MODEL_ID, + aliases: [{ modelRef: NVIDIA_DEFAULT_MODEL_REF, alias: "NVIDIA" }], + }; + }, +} satisfies Parameters>[0]; + export const { applyConfig: applyNvidiaConfig, applyProviderConfig: applyNvidiaProviderConfig } = - createDefaultModelsPresetAppliers<[]>({ - primaryModelRef: NVIDIA_DEFAULT_MODEL_REF, - resolveParams: () => { - const defaultProvider = buildSelectableNvidiaProvider(); - return { - providerId: "nvidia", - api: defaultProvider.api ?? "openai-completions", - baseUrl: defaultProvider.baseUrl, - defaultModels: defaultProvider.models ?? [], - defaultModelId: NVIDIA_DEFAULT_MODEL_ID, - aliases: [{ modelRef: NVIDIA_DEFAULT_MODEL_REF, alias: "NVIDIA" }], - }; - }, - }); + createDefaultModelsPresetAppliers(nvidiaPreset); +export const { applyConfig: applyNvidiaConnectionConfig } = + createDefaultModelsConnectionPresetAppliers(nvidiaPreset); diff --git a/extensions/synthetic/index.ts b/extensions/synthetic/index.ts index 59380acba147..54eb7c9d1c86 100644 --- a/extensions/synthetic/index.ts +++ b/extensions/synthetic/index.ts @@ -1,6 +1,6 @@ // Synthetic plugin entrypoint registers its OpenClaw integration. import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; -import { applySyntheticConfig, SYNTHETIC_DEFAULT_MODEL_REF } from "./onboard.js"; +import { applySyntheticConnectionConfig, SYNTHETIC_DEFAULT_MODEL_REF } from "./onboard.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; import { buildSyntheticProvider, SYNTHETIC_MODEL_DISCOVERY } from "./provider-catalog.js"; @@ -16,7 +16,7 @@ export default defineSingleProviderPluginEntry({ docsPath: "/providers/synthetic", manifestAuth: { defaultModel: SYNTHETIC_DEFAULT_MODEL_REF, - applyConfig: applySyntheticConfig, + applyConfig: applySyntheticConnectionConfig, }, catalog: { discoveryMode: "strict", diff --git a/extensions/synthetic/onboard.ts b/extensions/synthetic/onboard.ts index e43d317409a7..a1f1726963f7 100644 --- a/extensions/synthetic/onboard.ts +++ b/extensions/synthetic/onboard.ts @@ -1,5 +1,8 @@ // Synthetic setup module handles plugin onboarding behavior. -import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard"; +import { + createModelCatalogPresetAppliers, + createProviderConnectionPresetAppliers, +} from "openclaw/plugin-sdk/provider-onboard"; import { buildSyntheticModelDefinition, SYNTHETIC_BASE_URL, @@ -9,16 +12,20 @@ import { export { SYNTHETIC_DEFAULT_MODEL_REF }; -export const { - applyConfig: applySyntheticConfig, - applyProviderConfig: applySyntheticProviderConfig, -} = createModelCatalogPresetAppliers<[]>({ +const syntheticPreset = { primaryModelRef: SYNTHETIC_DEFAULT_MODEL_REF, resolveParams: () => ({ providerId: "synthetic", api: "anthropic-messages", baseUrl: SYNTHETIC_BASE_URL, - catalogModels: SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition), + catalogModels: () => SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition), aliases: [{ modelRef: SYNTHETIC_DEFAULT_MODEL_REF, alias: "MiniMax M3" }], }), -}); +} satisfies Parameters>[0]; + +export const { + applyConfig: applySyntheticConfig, + applyProviderConfig: applySyntheticProviderConfig, +} = createModelCatalogPresetAppliers(syntheticPreset); +export const { applyConfig: applySyntheticConnectionConfig } = + createProviderConnectionPresetAppliers(syntheticPreset); diff --git a/extensions/together/index.ts b/extensions/together/index.ts index 6cc093b2158f..e3da77803c63 100644 --- a/extensions/together/index.ts +++ b/extensions/together/index.ts @@ -1,6 +1,6 @@ // Together plugin entrypoint registers its OpenClaw integration. import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; -import { applyTogetherConfig } from "./onboard.js"; +import { applyTogetherConnectionConfig } from "./onboard.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; import { buildTogetherVideoGenerationProvider } from "./video-generation-provider.js"; @@ -14,7 +14,7 @@ export default defineSingleProviderPluginEntry({ provider: { label: "Together", docsPath: "/providers/together", - manifestAuth: { applyConfig: applyTogetherConfig }, + manifestAuth: { applyConfig: applyTogetherConnectionConfig }, catalog: { liveModelDiscovery: true, discoveryMode: "strict" }, classifyFailoverReason: ({ errorMessage }) => /\bconcurrency limit\b.*\b(?:breached|reached)\b/i.test(errorMessage) diff --git a/extensions/together/onboard.ts b/extensions/together/onboard.ts index f58aeac6c924..857664840467 100644 --- a/extensions/together/onboard.ts +++ b/extensions/together/onboard.ts @@ -1,6 +1,9 @@ // Together setup module handles plugin onboarding behavior. import { readManifestProviderDefaultModelRef } from "openclaw/plugin-sdk/provider-catalog-shared"; -import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard"; +import { + createModelCatalogPresetAppliers, + createProviderConnectionPresetAppliers, +} from "openclaw/plugin-sdk/provider-onboard"; import { TOGETHER_BASE_URL, TOGETHER_MODEL_CATALOG } from "./models.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; @@ -9,13 +12,18 @@ export const TOGETHER_DEFAULT_MODEL_REF = readManifestProviderDefaultModelRef( "together", )!; -export const { applyConfig: applyTogetherConfig } = createModelCatalogPresetAppliers<[]>({ +const togetherPreset = { primaryModelRef: TOGETHER_DEFAULT_MODEL_REF, resolveParams: () => ({ providerId: "together", api: "openai-completions", baseUrl: TOGETHER_BASE_URL, - catalogModels: structuredClone(TOGETHER_MODEL_CATALOG), + catalogModels: () => structuredClone(TOGETHER_MODEL_CATALOG), aliases: [{ modelRef: TOGETHER_DEFAULT_MODEL_REF, alias: "Together AI" }], }), -}); +} satisfies Parameters>[0]; + +export const { applyConfig: applyTogetherConfig } = + createModelCatalogPresetAppliers(togetherPreset); +export const { applyConfig: applyTogetherConnectionConfig } = + createProviderConnectionPresetAppliers(togetherPreset); diff --git a/extensions/xiaomi/index.ts b/extensions/xiaomi/index.ts index 863f7339d6ad..a80b7daf5daf 100644 --- a/extensions/xiaomi/index.ts +++ b/extensions/xiaomi/index.ts @@ -26,7 +26,7 @@ import { } from "openclaw/plugin-sdk/provider-model-shared"; import { PROVIDER_LABELS } from "openclaw/plugin-sdk/provider-usage"; import { - applyXiaomiConfig, + applyXiaomiConnectionConfig, applyXiaomiTokenPlanConfig, XIAOMI_DEFAULT_MODEL_REF, XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_REF, @@ -299,7 +299,7 @@ function createPaygAuthMethod(): ProviderAuthMethod { promptMessage: "Enter Xiaomi MiMo API key (pay-as-you-go, sk-...)", expectedKind: "payg", defaultModel: XIAOMI_DEFAULT_MODEL_REF, - applyConfig: applyXiaomiConfig, + applyConfig: applyXiaomiConnectionConfig, }), runNonInteractive: async (ctx) => await runXiaomiApiKeyAuthNonInteractive(ctx, { @@ -308,7 +308,7 @@ function createPaygAuthMethod(): ProviderAuthMethod { flagName: PAYG_FLAG_NAME, envVar: PAYG_ENV_VAR, expectedKind: "payg", - applyConfig: applyXiaomiConfig, + applyConfig: applyXiaomiConnectionConfig, }), }; } diff --git a/extensions/xiaomi/onboard.ts b/extensions/xiaomi/onboard.ts index c60a98415e61..7d520612360e 100644 --- a/extensions/xiaomi/onboard.ts +++ b/extensions/xiaomi/onboard.ts @@ -1,6 +1,7 @@ // Xiaomi setup module handles plugin onboarding behavior. import { createDefaultModelsPresetAppliers, + createDefaultModelsConnectionPresetAppliers, type OpenClawConfig, } from "openclaw/plugin-sdk/provider-onboard"; import { @@ -17,21 +18,25 @@ import { export const XIAOMI_DEFAULT_MODEL_REF = `${XIAOMI_PROVIDER_ID}/${XIAOMI_DEFAULT_MODEL_ID}`; export const XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_REF = `${XIAOMI_TOKEN_PLAN_PROVIDER_ID}/${XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_ID}`; +const xiaomiPreset = { + primaryModelRef: XIAOMI_DEFAULT_MODEL_REF, + resolveParams: () => { + const defaultProvider = buildXiaomiProvider(); + return { + providerId: XIAOMI_PROVIDER_ID, + api: defaultProvider.api ?? "openai-completions", + baseUrl: defaultProvider.baseUrl, + defaultModels: () => defaultProvider.models ?? [], + defaultModelId: XIAOMI_DEFAULT_MODEL_ID, + aliases: [{ modelRef: XIAOMI_DEFAULT_MODEL_REF, alias: "Xiaomi" }], + }; + }, +} satisfies Parameters>[0]; + export const { applyConfig: applyXiaomiConfig, applyProviderConfig: applyXiaomiProviderConfig } = - createDefaultModelsPresetAppliers<[]>({ - primaryModelRef: XIAOMI_DEFAULT_MODEL_REF, - resolveParams: () => { - const defaultProvider = buildXiaomiProvider(); - return { - providerId: XIAOMI_PROVIDER_ID, - api: defaultProvider.api ?? "openai-completions", - baseUrl: defaultProvider.baseUrl, - defaultModels: defaultProvider.models ?? [], - defaultModelId: XIAOMI_DEFAULT_MODEL_ID, - aliases: [{ modelRef: XIAOMI_DEFAULT_MODEL_REF, alias: "Xiaomi" }], - }; - }, - }); + createDefaultModelsPresetAppliers(xiaomiPreset); +export const { applyConfig: applyXiaomiConnectionConfig } = + createDefaultModelsConnectionPresetAppliers(xiaomiPreset); const xiaomiTokenPlanPresetAppliers = createDefaultModelsPresetAppliers<[]>({ primaryModelRef: XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_REF, diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index 8bba60c04283..43b8835ee817 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -34,7 +34,11 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coer import { detectZaiEndpoint, type ZaiEndpointId } from "./detect.js"; import { zaiMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { buildZaiModelDefinition, resolveZaiBaseUrl } from "./model-definitions.js"; -import { applyZaiConfig, applyZaiProviderConfig, resolveZaiModelId } from "./onboard.js"; +import { + applyZaiConnectionConfig, + applyZaiProviderConnectionConfig, + resolveZaiModelId, +} from "./onboard.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; import { resolveThinkingProfile, resolveZaiReasoningEffort } from "./provider-policy-api.js"; @@ -169,7 +173,7 @@ async function runZaiApiKeyAuth( endpoint?: ZaiEndpointId, ): Promise<{ profiles: Array<{ profileId: string; credential: ReturnType }>; - configPatch: ReturnType; + configPatch: ReturnType; defaultModel: string; notes?: string[]; }> { @@ -226,7 +230,7 @@ async function runZaiApiKeyAuth( ), }, ], - configPatch: applyZaiProviderConfig(ctx.config, preset), + configPatch: applyZaiProviderConnectionConfig(ctx.config, preset), defaultModel: `zai/${resolveZaiModelId(preset)}`, ...(detected?.note ? { notes: [detected.note] } : {}), }; @@ -272,7 +276,7 @@ async function runZaiApiKeyAuthNonInteractive( provider: PROVIDER_ID, mode: "api_key", }); - return applyZaiConfig(next, { + return applyZaiConnectionConfig(next, { ...(nextEndpoint ? { endpoint: nextEndpoint } : {}), ...(modelIdOverride ? { modelId: modelIdOverride } : {}), }); diff --git a/extensions/zai/onboard.ts b/extensions/zai/onboard.ts index 3fadf4666b4c..33fb38c6866d 100644 --- a/extensions/zai/onboard.ts +++ b/extensions/zai/onboard.ts @@ -1,6 +1,7 @@ // Zai setup module handles plugin onboarding behavior. import { applyProviderConfigWithModelCatalogPreset, + applyProviderConnectionConfig, type OpenClawConfig, } from "openclaw/plugin-sdk/provider-onboard"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -42,15 +43,16 @@ function applyZaiPreset( cfg: OpenClawConfig, params?: { endpoint?: string; modelId?: string }, primaryModelRef?: string, + applyPreset = applyProviderConfigWithModelCatalogPreset, ): OpenClawConfig { const baseUrl = resolveZaiPresetBaseUrl(cfg, params?.endpoint); const modelId = resolveZaiModelId({ ...params, baseUrl }); const modelRef = `zai/${modelId}`; - return applyProviderConfigWithModelCatalogPreset(cfg, { + return applyPreset(cfg, { providerId: "zai", api: "openai-completions", baseUrl, - catalogModels: buildZaiCatalogModels(), + catalogModels: buildZaiCatalogModels, aliases: [{ modelRef, alias: "GLM" }], primaryModelRef, }); @@ -72,3 +74,19 @@ export function applyZaiConfig( const modelRef = modelId === ZAI_DEFAULT_MODEL_ID ? ZAI_DEFAULT_MODEL_REF : `zai/${modelId}`; return applyZaiPreset(cfg, params, modelRef); } + +export function applyZaiProviderConnectionConfig( + cfg: OpenClawConfig, + params?: { endpoint?: string; modelId?: string }, +): OpenClawConfig { + return applyZaiPreset(cfg, params, undefined, applyProviderConnectionConfig); +} + +export function applyZaiConnectionConfig( + cfg: OpenClawConfig, + params?: { endpoint?: string; modelId?: string }, +): OpenClawConfig { + const baseUrl = resolveZaiPresetBaseUrl(cfg, params?.endpoint); + const modelId = resolveZaiModelId({ ...params, baseUrl }); + return applyZaiPreset(cfg, params, `zai/${modelId}`, applyProviderConnectionConfig); +} diff --git a/src/plugin-sdk/provider-onboard.connection.test.ts b/src/plugin-sdk/provider-onboard.connection.test.ts new file mode 100644 index 000000000000..212dfbc31f3e --- /dev/null +++ b/src/plugin-sdk/provider-onboard.connection.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import { + createDefaultModelsConnectionPresetAppliers, + createDefaultModelsPresetAppliers, + createModelCatalogPresetAppliers, + createProviderConnectionPresetAppliers, + type ModelDefinitionConfig, + type OpenClawConfig, +} from "./provider-onboard.js"; + +function model(id: string): ModelDefinitionConfig { + return { + id, + name: id, + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 1024, + }; +} + +function preset(models: () => ModelDefinitionConfig[]) { + return { + primaryModelRef: "fixture/default", + resolveParams: () => ({ + providerId: "fixture", + api: "openai-completions" as const, + baseUrl: "https://fixture.invalid/v1", + catalogModels: models, + defaultModels: models, + defaultModelId: "default", + aliases: [{ modelRef: "fixture/default", alias: "Default" }], + }), + }; +} + +describe.each([ + ["catalog", createProviderConnectionPresetAppliers<[]>], + ["default models", createDefaultModelsConnectionPresetAppliers<[]>], +] as const)("connection-only %s setup", (_name, create) => { + it.each([undefined, "merge"] as const)( + "writes a connection without needing catalog data in %s mode", + (mode) => { + const appliers = create( + preset(() => { + throw new Error("Catalog data is unavailable"); + }), + ); + + const result = appliers.applyConfig(mode ? { models: { mode } } : {}); + + expect(result.models?.providers?.fixture).toEqual({ + api: "openai-completions", + baseUrl: "https://fixture.invalid/v1", + models: [], + }); + expect(result.agents?.defaults?.model).toEqual({ primary: "fixture/default" }); + expect(result.agents?.defaults?.models).toEqual({ + "fixture/default": { alias: "Default" }, + }); + }, + ); + + it("keeps authored native-named and unique rows, defaults, fallbacks and aliases", () => { + const authoredDefault = { ...model("default"), name: "Authored", contextWindow: 32768 }; + const authoredUnique = model("private-choice"); + const config: OpenClawConfig = { + models: { + mode: "merge", + providers: { + fixture: { + baseUrl: "https://fixture.invalid/v1", + models: [authoredDefault, authoredUnique], + }, + }, + }, + agents: { + defaults: { + model: { primary: "other/chosen", fallbacks: ["other/backup", "fixture/private-choice"] }, + models: { "fixture/default": { alias: "Authored alias", params: { temperature: 0.3 } } }, + }, + }, + }; + + const result = create(preset(() => [model("default"), model("extra")])).applyConfig(config); + + expect(result.models?.providers?.fixture?.models).toEqual([authoredDefault, authoredUnique]); + expect(result.agents?.defaults?.model).toEqual(config.agents?.defaults?.model); + expect(result.agents?.defaults?.models).toEqual(config.agents?.defaults?.models); + expect(config.models?.providers?.fixture?.models).toEqual([authoredDefault, authoredUnique]); + }); + + it("keeps provider-only setup from changing the primary", () => { + const result = create(preset(() => [model("default")])).applyProviderConfig({}); + + expect(result.models?.providers?.fixture?.models).toEqual([]); + expect(result.agents?.defaults?.model).toBeUndefined(); + }); + + it("owns generated replace rows independently from the catalog and other setup calls", () => { + const catalog = [model("default")]; + const appliers = create(preset(() => catalog)); + const first = appliers.applyConfig({ models: { mode: "replace" } }); + const firstModel = first.models!.providers!.fixture!.models[0]!; + firstModel.cost.input = 91; + firstModel.input.push("image"); + + const second = appliers.applyConfig({ models: { mode: "replace" } }); + + expect(catalog[0]!.cost.input).toBe(1); + expect(catalog[0]!.input).toEqual(["text"]); + expect(second.models?.providers?.fixture?.models[0]?.cost.input).toBe(1); + expect(second.models?.providers?.fixture?.models[0]?.input).toEqual(["text"]); + }); + + it("preserves a resolver's intentional no-op", () => { + const config: OpenClawConfig = { agents: { defaults: { model: { fallbacks: [] } } } }; + const result = create({ + primaryModelRef: "fixture/default", + resolveParams: () => null, + }).applyConfig(config); + + expect(result).toBe(config); + }); +}); + +it("keeps catalog and default-model membership rules distinct in replace mode", () => { + const authored = { ...model("default"), name: "Authored" }; + const config: OpenClawConfig = { + models: { + mode: "replace", + providers: { fixture: { baseUrl: "https://fixture.invalid/v1", models: [authored] } }, + }, + }; + const params = preset(() => [model("default"), model("extra")]); + + const catalog = createProviderConnectionPresetAppliers(params).applyConfig(config); + const defaults = createDefaultModelsConnectionPresetAppliers(params).applyConfig(config); + + expect(catalog.models?.providers?.fixture?.models).toEqual([authored, model("extra")]); + expect(defaults.models?.providers?.fixture?.models).toEqual([authored]); +}); + +it("still adds required defaults in replace mode when the authored rows omit the default", () => { + const authored = model("private-choice"); + const result = createDefaultModelsConnectionPresetAppliers( + preset(() => [model("default"), model("extra")]), + ).applyConfig({ + models: { + mode: "replace", + providers: { fixture: { baseUrl: "https://fixture.invalid/v1", models: [authored] } }, + }, + }); + + expect(result.models?.providers?.fixture?.models).toEqual([ + authored, + model("default"), + model("extra"), + ]); +}); + +it.each([createModelCatalogPresetAppliers<[]>, createDefaultModelsPresetAppliers<[]>])( + "preserves the existing public catalog-seeding helper behavior", + (create) => { + const result = create(preset(() => [model("default")])).applyConfig({}); + + expect(result.models?.providers?.fixture?.models).toEqual([model("default")]); + }, +); diff --git a/src/plugin-sdk/provider-onboard.ts b/src/plugin-sdk/provider-onboard.ts index 9ca71f523228..2eb768046387 100644 --- a/src/plugin-sdk/provider-onboard.ts +++ b/src/plugin-sdk/provider-onboard.ts @@ -49,6 +49,19 @@ export type ProviderOnboardPresetAppliers = { applyConfig: (cfg: OpenClawConfig, ...args: TArgs) => OpenClawConfig; }; +type ProviderPresetModels = ModelDefinitionConfig[] | (() => ModelDefinitionConfig[]); + +function resolvePresetModels(models: ProviderPresetModels): ModelDefinitionConfig[] { + return typeof models === "function" ? models() : models; +} + +function resolveConnectionModels( + cfg: OpenClawConfig, + models: ProviderPresetModels, +): ModelDefinitionConfig[] { + return cfg.models?.mode === "replace" ? structuredClone(resolvePresetModels(models)) : []; +} + function extractAgentDefaultModelFallbacks(model: unknown): string[] | undefined { if (!model || typeof model !== "object") { return undefined; @@ -547,7 +560,7 @@ export function applyProviderConfigWithDefaultModelsPreset( providerId: string; api: ModelApi; baseUrl: string; - defaultModels: ModelDefinitionConfig[]; + defaultModels: ProviderPresetModels; defaultModelId?: string; aliases?: readonly AgentModelAliasEntry[]; primaryModelRef?: string; @@ -558,7 +571,7 @@ export function applyProviderConfigWithDefaultModelsPreset( providerId: params.providerId, api: params.api, baseUrl: params.baseUrl, - defaultModels: params.defaultModels, + defaultModels: resolvePresetModels(params.defaultModels), defaultModelId: params.defaultModelId, }); return completeProviderPreset(cfg, next, params.primaryModelRef); @@ -582,6 +595,20 @@ export function createDefaultModelsPresetAppliers(param }); } +/** Build connection-only setup appliers while retaining the default-model merge rule in replace mode. */ +export function createDefaultModelsConnectionPresetAppliers( + params: Parameters>[0], +): ProviderOnboardPresetAppliers { + return createProviderPresetAppliers({ + ...params, + applyPreset: (cfg, preset: Parameters[1]) => + applyProviderConfigWithDefaultModelsPreset(cfg, { + ...preset, + defaultModels: resolveConnectionModels(cfg, preset.defaultModels), + }), + }); +} + /** Merge a provider config with a catalog while preserving existing model entries first. */ export function applyProviderConfigWithModelCatalog( cfg: OpenClawConfig, @@ -621,7 +648,7 @@ export function applyProviderConfigWithModelCatalogPreset( providerId: string; api: ModelApi; baseUrl: string; - catalogModels: ModelDefinitionConfig[]; + catalogModels: ProviderPresetModels; aliases?: readonly AgentModelAliasEntry[]; primaryModelRef?: string; }, @@ -631,7 +658,7 @@ export function applyProviderConfigWithModelCatalogPreset( providerId: params.providerId, api: params.api, baseUrl: params.baseUrl, - catalogModels: params.catalogModels, + catalogModels: resolvePresetModels(params.catalogModels), }); return completeProviderPreset(cfg, next, params.primaryModelRef); } @@ -654,6 +681,24 @@ export function createModelCatalogPresetAppliers(params }); } +/** Apply connection facts and aliases, seeding the supplied catalog only in explicit replace mode. */ +export function applyProviderConnectionConfig( + cfg: OpenClawConfig, + params: Parameters[1], +): OpenClawConfig { + return applyProviderConfigWithModelCatalogPreset(cfg, { + ...params, + catalogModels: resolveConnectionModels(cfg, params.catalogModels), + }); +} + +/** Build registered setup appliers without changing the catalog-seeding public helper contract. */ +export function createProviderConnectionPresetAppliers( + params: Parameters>[0], +): ProviderOnboardPresetAppliers { + return createProviderPresetAppliers({ ...params, applyPreset: applyProviderConnectionConfig }); +} + /** Ensure static per-model config includes a provider model ref after onboarding. */ export function ensureModelAllowlistEntry(params: { cfg: OpenClawConfig;