refactor: move synthetic auth refs to manifests

This commit is contained in:
Peter Steinberger
2026-04-18 19:52:23 +01:00
parent ebfab7bf84
commit 796f272f7d
11 changed files with 116 additions and 66 deletions

View File

@@ -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<string, string[]>` | Cheap provider-auth env metadata that OpenClaw can inspect without loading plugin code. |
| `providerAuthAliases` | No | `Record<string, string>` | 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.

View File

@@ -6,6 +6,7 @@
"modelPrefixes": ["claude-"]
},
"cliBackends": ["claude-cli"],
"syntheticAuthRefs": ["claude-cli"],
"providerAuthEnvVars": {
"anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]
},

View File

@@ -3,6 +3,7 @@
"enabledByDefault": true,
"providers": ["ollama"],
"providerDiscoveryEntry": "./provider-discovery.ts",
"syntheticAuthRefs": ["ollama"],
"providerAuthEnvVars": {
"ollama": ["OLLAMA_API_KEY"]
},

View File

@@ -2,6 +2,7 @@
"id": "xai",
"enabledByDefault": true,
"providers": ["xai"],
"syntheticAuthRefs": ["xai"],
"providerAuthEnvVars": {
"xai": ["XAI_API_KEY"]
},

View File

@@ -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" },
});
});
});

View File

@@ -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<string, unknown>).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: {

View File

@@ -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",
});

View File

@@ -86,6 +86,7 @@ export type PluginManifestRecord = {
providerDiscoverySource?: string;
modelSupport?: PluginManifestModelSupport;
cliBackends: string[];
syntheticAuthRefs?: string[];
commandAliases?: PluginManifestCommandAlias[];
providerAuthEnvVars?: Record<string, string[]>;
providerAuthAliases?: Record<string, string>;
@@ -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 ?? [],

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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<string>();
@@ -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();
}