diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index c48fc64af79..15baedc0b5c 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -96,6 +96,7 @@ Those belong in your plugin code and `package.json`. "modelPrefixes": ["router-"] }, "cliBackends": ["openrouter-cli"], + "syntheticAuthRefs": ["openrouter-cli"], "providerAuthEnvVars": { "openrouter": ["OPENROUTER_API_KEY"] }, @@ -153,6 +154,7 @@ Those belong in your plugin code and `package.json`. | `providers` | No | `string[]` | Provider ids owned by this plugin. | | `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. | | `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. | +| `syntheticAuthRefs` | No | `string[]` | Provider or CLI backend refs whose plugin-owned synthetic auth hook should be probed during cold model discovery before runtime loads. | | `commandAliases` | No | `object[]` | Command names owned by this plugin that should produce plugin-aware config and CLI diagnostics before runtime loads. | | `providerAuthEnvVars` | No | `Record` | Cheap provider-auth env metadata that OpenClaw can inspect without loading plugin code. | | `providerAuthAliases` | No | `Record` | Provider ids that should reuse another provider id for auth lookup, for example a coding provider that shares the base provider API key and auth profiles. | @@ -599,6 +601,10 @@ See [Configuration reference](/gateway/configuration) for the full `plugins.*` s - `providerAuthAliases` lets provider variants reuse another provider's auth env vars, auth profiles, config-backed auth, and API-key onboarding choice without hardcoding that relationship in core. +- `syntheticAuthRefs` is the cheap metadata path for provider-owned synthetic + auth hooks that must be visible to cold model discovery before the runtime + registry exists. Only list refs whose runtime provider or CLI backend actually + implements `resolveSyntheticAuth`. - `channelEnvVars` is the cheap metadata path for shell-env fallback, setup prompts, and similar channel surfaces that should not boot plugin runtime just to inspect env names. diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index ec0e3cacd23..95bbd2c2a27 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -6,6 +6,7 @@ "modelPrefixes": ["claude-"] }, "cliBackends": ["claude-cli"], + "syntheticAuthRefs": ["claude-cli"], "providerAuthEnvVars": { "anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] }, diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json index e853f4e267c..1217e0707c2 100644 --- a/extensions/ollama/openclaw.plugin.json +++ b/extensions/ollama/openclaw.plugin.json @@ -3,6 +3,7 @@ "enabledByDefault": true, "providers": ["ollama"], "providerDiscoveryEntry": "./provider-discovery.ts", + "syntheticAuthRefs": ["ollama"], "providerAuthEnvVars": { "ollama": ["OLLAMA_API_KEY"] }, diff --git a/extensions/xai/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json index 25eb9735e3a..12208ad1094 100644 --- a/extensions/xai/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -2,6 +2,7 @@ "id": "xai", "enabledByDefault": true, "providers": ["xai"], + "syntheticAuthRefs": ["xai"], "providerAuthEnvVars": { "xai": ["XAI_API_KEY"] }, diff --git a/src/agents/pi-embedded-runner-extraparams-moonshot.test.ts b/src/agents/pi-embedded-runner-extraparams-moonshot.test.ts index 23cdd18c79d..666dec3218f 100644 --- a/src/agents/pi-embedded-runner-extraparams-moonshot.test.ts +++ b/src/agents/pi-embedded-runner-extraparams-moonshot.test.ts @@ -17,17 +17,6 @@ beforeEach(() => { }); return createMoonshotThinkingWrapper(params.context.streamFn, thinkingType); } - if (params.provider === "ollama") { - const modelId = params.context.model?.id ?? params.context.modelId; - if (typeof modelId === "string" && /^kimi-k2\.5(?::|$)/i.test(modelId)) { - const thinkingType = resolveMoonshotThinkingType({ - configuredThinking: params.context.extraParams?.thinking, - thinkingLevel: params.context.thinkingLevel, - }); - return createMoonshotThinkingWrapper(params.context.streamFn, thinkingType); - } - return params.context.streamFn; - } return params.context.streamFn; }, }); @@ -37,7 +26,7 @@ afterEach(() => { extraParamsTesting.resetProviderRuntimeDepsForTest(); }); -describe("applyExtraParamsToAgent Moonshot and Ollama Kimi", () => { +describe("applyExtraParamsToAgent Moonshot", () => { it("maps thinkingLevel=off to Moonshot thinking.type=disabled", () => { const payload = runExtraParamsPayloadCase({ provider: "moonshot", @@ -94,41 +83,4 @@ describe("applyExtraParamsToAgent Moonshot and Ollama Kimi", () => { expect(payload.thinking).toEqual({ type: "disabled" }); }); - - it("applies Moonshot payload compatibility to Ollama Kimi cloud models", () => { - const payload = runExtraParamsPayloadCase({ - provider: "ollama", - modelId: "kimi-k2.5:cloud", - thinkingLevel: "low", - payload: { tool_choice: "required" }, - }); - - expect(payload.thinking).toEqual({ type: "enabled" }); - expect(payload.tool_choice).toBe("auto"); - }); - - it("maps thinkingLevel=off for Ollama Kimi cloud models through Moonshot compatibility", () => { - const payload = runExtraParamsPayloadCase({ - provider: "ollama", - modelId: "kimi-k2.5:cloud", - thinkingLevel: "off", - }); - - expect(payload.thinking).toEqual({ type: "disabled" }); - }); - - it("disables thinking instead of broadening pinned Ollama Kimi cloud tool_choice", () => { - const payload = runExtraParamsPayloadCase({ - provider: "ollama", - modelId: "kimi-k2.5:cloud", - thinkingLevel: "low", - payload: { tool_choice: { type: "function", function: { name: "read" } } }, - }); - - expect(payload.thinking).toEqual({ type: "disabled" }); - expect(payload.tool_choice).toEqual({ - type: "function", - function: { name: "read" }, - }); - }); }); diff --git a/src/agents/pi-embedded-runner/extra-params.ollama.test.ts b/src/agents/pi-embedded-runner/extra-params.provider-runtime.test.ts similarity index 81% rename from src/agents/pi-embedded-runner/extra-params.ollama.test.ts rename to src/agents/pi-embedded-runner/extra-params.provider-runtime.test.ts index 7a29bc62e4e..14964041050 100644 --- a/src/agents/pi-embedded-runner/extra-params.ollama.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.provider-runtime.test.ts @@ -19,7 +19,7 @@ beforeEach(() => { extraParamsTesting.setProviderRuntimeDepsForTest({ prepareProviderExtraParams: ({ context }) => context.extraParams, wrapProviderStreamFn: ({ provider, context }) => { - if (provider !== "ollama" || context.thinkingLevel !== "off") { + if (provider !== "local-provider" || context.thinkingLevel !== "off") { return context.streamFn; } const baseStreamFn = context.streamFn; @@ -44,19 +44,19 @@ afterEach(() => { extraParamsTesting.resetProviderRuntimeDepsForTest(); }); -describe("extra-params: Ollama plugin handoff", () => { +describe("extra-params: provider runtime handoff", () => { it("passes thinking-off intent through the provider runtime wrapper seam", () => { const payload = runExtraParamsCase({ - applyProvider: "ollama", - applyModelId: "qwen3.5:9b", + applyProvider: "local-provider", + applyModelId: "local-model:9b", model: { - api: "ollama", - provider: "ollama", - id: "qwen3.5:9b", + api: "openai-completions", + provider: "local-provider", + id: "local-model:9b", } as unknown as Model<"openai-completions">, thinkingLevel: "off", payload: { - model: "qwen3.5:9b", + model: "local-model:9b", messages: [], stream: true, options: { @@ -70,7 +70,7 @@ describe("extra-params: Ollama plugin handoff", () => { expect((payload.options as Record).think).toBeUndefined(); }); - it("does not apply the plugin wrapper for non-ollama providers", () => { + it("does not apply the plugin wrapper for other providers", () => { const payload = runExtraParamsCase({ applyProvider: "openai", applyModelId: "gpt-5.4", @@ -91,16 +91,16 @@ describe("extra-params: Ollama plugin handoff", () => { it("does not apply the plugin wrapper when thinkingLevel is not off", () => { const payload = runExtraParamsCase({ - applyProvider: "ollama", - applyModelId: "qwen3.5:9b", + applyProvider: "local-provider", + applyModelId: "local-model:9b", model: { - api: "ollama", - provider: "ollama", - id: "qwen3.5:9b", + api: "openai-completions", + provider: "local-provider", + id: "local-model:9b", } as unknown as Model<"openai-completions">, thinkingLevel: "high", payload: { - model: "qwen3.5:9b", + model: "local-model:9b", messages: [], stream: true, options: { diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 2b1d2c94af4..71b371ccb98 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -382,6 +382,7 @@ describe("loadPluginManifestRegistry", () => { providerAuthEnvVars: { openai: ["OPENAI_API_KEY"], }, + syntheticAuthRefs: ["openai-cli"], providerAuthAliases: { "openai-codex": "openai", }, @@ -407,6 +408,7 @@ describe("loadPluginManifestRegistry", () => { expect(registry.plugins[0]?.providerAuthEnvVars).toEqual({ openai: ["OPENAI_API_KEY"], }); + expect(registry.plugins[0]?.syntheticAuthRefs).toEqual(["openai-cli"]); expect(registry.plugins[0]?.providerAuthAliases).toEqual({ "openai-codex": "openai", }); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index c3bca9edd3c..e7a2cd7716a 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -86,6 +86,7 @@ export type PluginManifestRecord = { providerDiscoverySource?: string; modelSupport?: PluginManifestModelSupport; cliBackends: string[]; + syntheticAuthRefs?: string[]; commandAliases?: PluginManifestCommandAlias[]; providerAuthEnvVars?: Record; providerAuthAliases?: Record; @@ -328,6 +329,7 @@ function buildRecord(params: { : undefined, modelSupport: params.manifest.modelSupport, cliBackends: params.manifest.cliBackends ?? [], + syntheticAuthRefs: params.manifest.syntheticAuthRefs ?? [], commandAliases: params.manifest.commandAliases, providerAuthEnvVars: params.manifest.providerAuthEnvVars, providerAuthAliases: params.manifest.providerAuthAliases, @@ -398,6 +400,7 @@ function buildBundleRecord(params: { channels: [], providers: [], cliBackends: [], + syntheticAuthRefs: [], skills: params.manifest.skills ?? [], settingsFiles: params.manifest.settingsFiles ?? [], hooks: params.manifest.hooks ?? [], diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index f8117aa5cfc..c5b79a1b4c2 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -163,6 +163,11 @@ export type PluginManifest = { modelSupport?: PluginManifestModelSupport; /** Cheap startup activation lookup for plugin-owned CLI inference backends. */ cliBackends?: string[]; + /** + * Provider or CLI backend refs whose plugin-owned synthetic auth hook should + * be probed during cold model discovery before the runtime registry exists. + */ + syntheticAuthRefs?: string[]; /** * Plugin-owned command aliases that should resolve to this plugin during * config diagnostics before runtime loads. @@ -701,6 +706,7 @@ export function loadPluginManifest( const providerDiscoveryEntry = normalizeOptionalString(raw.providerDiscoveryEntry); const modelSupport = normalizeManifestModelSupport(raw.modelSupport); const cliBackends = normalizeTrimmedStringList(raw.cliBackends); + const syntheticAuthRefs = normalizeTrimmedStringList(raw.syntheticAuthRefs); const commandAliases = normalizeManifestCommandAliases(raw.commandAliases); const providerAuthEnvVars = normalizeStringListRecord(raw.providerAuthEnvVars); const providerAuthAliases = normalizeStringRecord(raw.providerAuthAliases); @@ -735,6 +741,7 @@ export function loadPluginManifest( providerDiscoveryEntry, modelSupport, cliBackends, + syntheticAuthRefs, commandAliases, providerAuthEnvVars, providerAuthAliases, diff --git a/src/plugins/synthetic-auth.runtime.test.ts b/src/plugins/synthetic-auth.runtime.test.ts new file mode 100644 index 00000000000..cd3bf8a16f1 --- /dev/null +++ b/src/plugins/synthetic-auth.runtime.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getPluginRegistryState = vi.hoisted(() => vi.fn()); +const loadPluginManifestRegistry = vi.hoisted(() => vi.fn()); + +vi.mock("./runtime-state.js", () => ({ + getPluginRegistryState, +})); + +vi.mock("./manifest-registry.js", () => ({ + loadPluginManifestRegistry, +})); + +import { resolveRuntimeSyntheticAuthProviderRefs } from "./synthetic-auth.runtime.js"; + +describe("synthetic auth runtime refs", () => { + beforeEach(() => { + getPluginRegistryState.mockReset(); + loadPluginManifestRegistry.mockReset().mockReturnValue({ plugins: [] }); + }); + + it("uses manifest-owned synthetic auth refs before the runtime registry exists", () => { + loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { syntheticAuthRefs: [" local-provider ", "local-provider", "local-cli"] }, + { syntheticAuthRefs: ["remote-provider"] }, + { syntheticAuthRefs: [] }, + ], + }); + + expect(resolveRuntimeSyntheticAuthProviderRefs()).toEqual([ + "local-provider", + "local-cli", + "remote-provider", + ]); + expect(loadPluginManifestRegistry).toHaveBeenCalledWith({ cache: true }); + }); + + it("prefers the active runtime registry when plugins are already loaded", () => { + getPluginRegistryState.mockReturnValue({ + activeRegistry: { + providers: [ + { + provider: { + id: "runtime-provider", + resolveSyntheticAuth: () => undefined, + }, + }, + { + provider: { + id: "plain-provider", + }, + }, + ], + cliBackends: [ + { + backend: { + id: "runtime-cli", + resolveSyntheticAuth: () => undefined, + }, + }, + ], + }, + }); + + expect(resolveRuntimeSyntheticAuthProviderRefs()).toEqual(["runtime-provider", "runtime-cli"]); + expect(loadPluginManifestRegistry).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/synthetic-auth.runtime.ts b/src/plugins/synthetic-auth.runtime.ts index 0f3ed10a66e..cbc563e8c98 100644 --- a/src/plugins/synthetic-auth.runtime.ts +++ b/src/plugins/synthetic-auth.runtime.ts @@ -1,6 +1,6 @@ import { normalizeProviderId } from "../agents/provider-id.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; import { getPluginRegistryState } from "./runtime-state.js"; -const BUNDLED_SYNTHETIC_AUTH_PROVIDER_REFS = ["claude-cli", "ollama", "xai"] as const; function uniqueProviderRefs(values: readonly string[]): string[] { const seen = new Set(); @@ -17,6 +17,14 @@ function uniqueProviderRefs(values: readonly string[]): string[] { return next; } +function resolveManifestSyntheticAuthProviderRefs(): string[] { + return uniqueProviderRefs( + loadPluginManifestRegistry({ cache: true }).plugins.flatMap( + (plugin) => plugin.syntheticAuthRefs ?? [], + ), + ); +} + export function resolveRuntimeSyntheticAuthProviderRefs(): string[] { const registry = getPluginRegistryState()?.activeRegistry; if (registry) { @@ -37,5 +45,5 @@ export function resolveRuntimeSyntheticAuthProviderRefs(): string[] { .map((entry) => entry.backend.id), ]); } - return uniqueProviderRefs(BUNDLED_SYNTHETIC_AUTH_PROVIDER_REFS); + return resolveManifestSyntheticAuthProviderRefs(); }