feat: add Fireworks provider and simplify plugin setup loading

This commit is contained in:
Peter Steinberger 2026-04-05 07:42:15 +01:00
parent f842f518cd
commit d655a8bc76
No known key found for this signature in database
62 changed files with 1096 additions and 880 deletions

View File

@ -11,6 +11,11 @@ Docs: https://docs.openclaw.ai
### Changes
- Memory/dreaming (experimental): add weighted short-term recall promotion, managed dreaming modes (`off|core|rem|deep`), a `/dreaming` command, Dreams UI, multilingual conceptual tagging, and doctor/status repair support so durable memory promotion can run in the background with less manual setup. (#60569, #60697)
- Channels/context visibility: add configurable `contextVisibility` per channel (`all`, `allowlist`, `allowlist_quote`) so supplemental quote, thread, and fetched history context can be filtered by sender allowlists instead of always passing through as received.
- Matrix/exec approvals: add Matrix-native exec approval prompts with account-scoped approvers, channel-or-DM delivery, and room-thread aware resolution handling. (#58635) Thanks @gumadeiras.
- Providers/StepFun: add the bundled StepFun provider plugin with standard and Step Plan endpoints, China/global onboarding choices, `step-3.5-flash` on both catalogs, and `step-3.5-flash-2603` currently exposed on Step Plan. (#60032) Thanks @hengm3467.
- Providers/Fireworks: add a bundled Fireworks AI provider plugin with `FIREWORKS_API_KEY` onboarding, Fire Pass Kimi defaults, and dynamic Fireworks model-id support.
- Providers/config: add `models.providers.*.request` overrides for headers and auth on model-provider paths, and full request transport overrides for media provider HTTP paths.
- MiniMax/TTS: add a bundled MiniMax speech provider backed by the T2A v2 API so speech synthesis can run through MiniMax-native voices and auth. (#55921) Thanks @duncanita.
- Control UI/skills: add ClawHub search, detail, and install flows directly in the Skills panel. (#60134) Thanks @samzong.
- Providers/Ollama: add a bundled Ollama Web Search provider for key-free `web_search` via your configured Ollama host and `ollama signin`. (#59318) Thanks @BruceMacD.

View File

@ -0,0 +1,69 @@
---
summary: "Fireworks setup (auth + model selection)"
read_when:
- You want to use Fireworks with OpenClaw
- You need the Fireworks API key env var or default model id
---
# Fireworks
[Fireworks](https://fireworks.ai) exposes open-weight and routed models through an OpenAI-compatible API. OpenClaw now includes a bundled Fireworks provider plugin.
- Provider: `fireworks`
- Auth: `FIREWORKS_API_KEY`
- API: OpenAI-compatible chat/completions
- Base URL: `https://api.fireworks.ai/inference/v1`
- Default model: `fireworks/accounts/fireworks/routers/kimi-k2p5-turbo`
## Quick start
Set up Fireworks auth through onboarding:
```bash
openclaw onboard --auth-choice fireworks-api-key
```
This stores your Fireworks key in OpenClaw config and sets the Fire Pass starter model as the default.
## Non-interactive example
```bash
openclaw onboard --non-interactive \
--mode local \
--auth-choice fireworks-api-key \
--fireworks-api-key "$FIREWORKS_API_KEY" \
--skip-health \
--accept-risk
```
## Environment note
If the Gateway runs outside your interactive shell, make sure `FIREWORKS_API_KEY`
is available to that process too. A key sitting only in `~/.profile` will not
help a launchd/systemd daemon unless that environment is imported there as well.
## Built-in catalog
| Model ref | Name | Input | Context | Max output | Notes |
| ------------------------------------------------------ | --------------------------- | ---------- | ------- | ---------- | ------------------------------------------ |
| `fireworks/accounts/fireworks/routers/kimi-k2p5-turbo` | Kimi K2.5 Turbo (Fire Pass) | text,image | 256,000 | 256,000 | Default bundled starter model on Fireworks |
## Custom Fireworks model ids
OpenClaw accepts dynamic Fireworks model ids too. Use the exact model or router id shown by Fireworks and prefix it with `fireworks/`.
Example:
```json5
{
agents: {
defaults: {
model: {
primary: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
},
},
},
}
```
If Fireworks publishes a newer model such as a fresh Qwen or Gemma release, you can switch to it directly by using its Fireworks model id without waiting for a bundled catalog update.

View File

@ -32,6 +32,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [Chutes](/providers/chutes)
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
- [DeepSeek](/providers/deepseek)
- [Fireworks](/providers/fireworks)
- [GitHub Copilot](/providers/github-copilot)
- [GLM models](/providers/glm)
- [Google (Gemini)](/providers/google)

View File

@ -29,6 +29,7 @@ model as `provider/model`.
- [BytePlus (International)](/concepts/model-providers#byteplus-international)
- [Chutes](/providers/chutes)
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
- [Fireworks](/providers/fireworks)
- [GLM models](/providers/glm)
- [MiniMax](/providers/minimax)
- [Mistral](/providers/mistral)

View File

@ -0,0 +1,115 @@
import type { ModelRegistry } from "@mariozechner/pi-coding-agent";
import type {
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it } from "vitest";
import { resolveProviderPluginChoice } from "../../src/plugins/provider-auth-choice.runtime.js";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
import fireworksPlugin from "./index.js";
import {
FIREWORKS_BASE_URL,
FIREWORKS_DEFAULT_CONTEXT_WINDOW,
FIREWORKS_DEFAULT_MAX_TOKENS,
FIREWORKS_DEFAULT_MODEL_ID,
} from "./provider-catalog.js";
function createDynamicContext(params: {
provider: string;
modelId: string;
models: ProviderRuntimeModel[];
}): ProviderResolveDynamicModelContext {
return {
provider: params.provider,
modelId: params.modelId,
modelRegistry: {
find(providerId: string, modelId: string) {
return (
params.models.find(
(model) =>
model.provider === providerId && model.id.toLowerCase() === modelId.toLowerCase(),
) ?? null
);
},
} as ModelRegistry,
};
}
describe("fireworks provider plugin", () => {
it("registers Fireworks with api-key auth wizard metadata", async () => {
const provider = await registerSingleProviderPlugin(fireworksPlugin);
const resolved = resolveProviderPluginChoice({
providers: [provider],
choice: "fireworks-api-key",
});
expect(provider.id).toBe("fireworks");
expect(provider.label).toBe("Fireworks");
expect(provider.aliases).toEqual(["fireworks-ai"]);
expect(provider.envVars).toEqual(["FIREWORKS_API_KEY"]);
expect(provider.auth).toHaveLength(1);
expect(resolved?.provider.id).toBe("fireworks");
expect(resolved?.method.id).toBe("api-key");
});
it("builds the Fireworks Fire Pass starter catalog", async () => {
const provider = await registerSingleProviderPlugin(fireworksPlugin);
const catalog = await provider.catalog?.run({
config: {},
env: {},
resolveProviderApiKey: () => ({ apiKey: "test-key" }),
resolveProviderAuth: () => ({
apiKey: "test-key",
mode: "api_key",
source: "env",
}),
} as never);
expect(catalog && "provider" in catalog).toBe(true);
if (!catalog || !("provider" in catalog)) {
throw new Error("expected single-provider catalog");
}
expect(catalog.provider.api).toBe("openai-completions");
expect(catalog.provider.baseUrl).toBe(FIREWORKS_BASE_URL);
expect(catalog.provider.models?.map((model) => model.id)).toEqual([FIREWORKS_DEFAULT_MODEL_ID]);
expect(catalog.provider.models?.[0]).toMatchObject({
reasoning: true,
input: ["text", "image"],
contextWindow: FIREWORKS_DEFAULT_CONTEXT_WINDOW,
maxTokens: FIREWORKS_DEFAULT_MAX_TOKENS,
});
});
it("resolves forward-compat Fireworks model ids from the default template", async () => {
const provider = await registerSingleProviderPlugin(fireworksPlugin);
const resolved = provider.resolveDynamicModel?.(
createDynamicContext({
provider: "fireworks",
modelId: "accounts/fireworks/models/qwen3.6-plus",
models: [
{
id: FIREWORKS_DEFAULT_MODEL_ID,
name: FIREWORKS_DEFAULT_MODEL_ID,
provider: "fireworks",
api: "openai-completions",
baseUrl: FIREWORKS_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: FIREWORKS_DEFAULT_CONTEXT_WINDOW,
maxTokens: FIREWORKS_DEFAULT_MAX_TOKENS,
},
],
}),
);
expect(resolved).toMatchObject({
provider: "fireworks",
id: "accounts/fireworks/models/qwen3.6-plus",
api: "openai-completions",
baseUrl: FIREWORKS_BASE_URL,
reasoning: true,
});
});
});

View File

@ -0,0 +1,88 @@
import type {
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import {
buildProviderReplayFamilyHooks,
cloneFirstTemplateModel,
DEFAULT_CONTEXT_TOKENS,
normalizeModelCompat,
} from "openclaw/plugin-sdk/provider-model-shared";
import { applyFireworksConfig, FIREWORKS_DEFAULT_MODEL_REF } from "./onboard.js";
import {
buildFireworksProvider,
FIREWORKS_BASE_URL,
FIREWORKS_DEFAULT_CONTEXT_WINDOW,
FIREWORKS_DEFAULT_MAX_TOKENS,
FIREWORKS_DEFAULT_MODEL_ID,
} from "./provider-catalog.js";
const PROVIDER_ID = "fireworks";
const OPENAI_COMPATIBLE_REPLAY_HOOKS = buildProviderReplayFamilyHooks({
family: "openai-compatible",
});
function resolveFireworksDynamicModel(
ctx: ProviderResolveDynamicModelContext,
): ProviderRuntimeModel | undefined {
const modelId = ctx.modelId.trim();
if (!modelId) {
return undefined;
}
return (
cloneFirstTemplateModel({
providerId: PROVIDER_ID,
modelId,
templateIds: [FIREWORKS_DEFAULT_MODEL_ID],
ctx,
patch: {
provider: PROVIDER_ID,
},
}) ??
normalizeModelCompat({
id: modelId,
name: modelId,
provider: PROVIDER_ID,
api: "openai-completions",
baseUrl: FIREWORKS_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: FIREWORKS_DEFAULT_CONTEXT_WINDOW,
maxTokens: FIREWORKS_DEFAULT_MAX_TOKENS || DEFAULT_CONTEXT_TOKENS,
} as ProviderRuntimeModel)
);
}
export default defineSingleProviderPluginEntry({
id: PROVIDER_ID,
name: "Fireworks Provider",
description: "Bundled Fireworks AI provider plugin",
provider: {
label: "Fireworks",
aliases: ["fireworks-ai"],
docsPath: "/providers/fireworks",
auth: [
{
methodId: "api-key",
label: "Fireworks API key",
hint: "API key",
optionKey: "fireworksApiKey",
flagName: "--fireworks-api-key",
envVar: "FIREWORKS_API_KEY",
promptMessage: "Enter Fireworks API key",
defaultModel: FIREWORKS_DEFAULT_MODEL_REF,
applyConfig: (cfg) => applyFireworksConfig(cfg),
},
],
catalog: {
buildProvider: buildFireworksProvider,
allowExplicitBaseUrl: true,
},
...OPENAI_COMPATIBLE_REPLAY_HOOKS,
resolveDynamicModel: (ctx) => resolveFireworksDynamicModel(ctx),
isModernModelRef: () => true,
},
});

View File

@ -0,0 +1,34 @@
import {
createDefaultModelsPresetAppliers,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import {
buildFireworksCatalogModels,
buildFireworksProvider,
FIREWORKS_DEFAULT_MODEL_ID,
} from "./provider-catalog.js";
export const FIREWORKS_DEFAULT_MODEL_REF = `fireworks/${FIREWORKS_DEFAULT_MODEL_ID}`;
const fireworksPresetAppliers = createDefaultModelsPresetAppliers({
primaryModelRef: FIREWORKS_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => {
const defaultProvider = buildFireworksProvider();
return {
providerId: "fireworks",
api: defaultProvider.api ?? "openai-completions",
baseUrl: defaultProvider.baseUrl,
defaultModels: buildFireworksCatalogModels(),
defaultModelId: FIREWORKS_DEFAULT_MODEL_ID,
aliases: [{ modelRef: FIREWORKS_DEFAULT_MODEL_REF, alias: "Kimi K2.5 Turbo" }],
};
},
});
export function applyFireworksProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return fireworksPresetAppliers.applyProviderConfig(cfg);
}
export function applyFireworksConfig(cfg: OpenClawConfig): OpenClawConfig {
return fireworksPresetAppliers.applyConfig(cfg);
}

View File

@ -0,0 +1,28 @@
{
"id": "fireworks",
"enabledByDefault": true,
"providers": ["fireworks"],
"providerAuthEnvVars": {
"fireworks": ["FIREWORKS_API_KEY"]
},
"providerAuthChoices": [
{
"provider": "fireworks",
"method": "api-key",
"choiceId": "fireworks-api-key",
"choiceLabel": "Fireworks API key",
"groupId": "fireworks",
"groupLabel": "Fireworks",
"groupHint": "API key",
"optionKey": "fireworksApiKey",
"cliFlag": "--fireworks-api-key",
"cliOption": "--fireworks-api-key <key>",
"cliDescription": "Fireworks API key"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@ -0,0 +1,12 @@
{
"name": "@openclaw/fireworks-provider",
"version": "2026.4.1-beta.1",
"private": true,
"description": "OpenClaw Fireworks provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@ -0,0 +1,38 @@
import type {
ModelDefinitionConfig,
ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
export const FIREWORKS_BASE_URL = "https://api.fireworks.ai/inference/v1";
export const FIREWORKS_DEFAULT_MODEL_ID = "accounts/fireworks/routers/kimi-k2p5-turbo";
export const FIREWORKS_DEFAULT_CONTEXT_WINDOW = 256000;
export const FIREWORKS_DEFAULT_MAX_TOKENS = 256000;
const ZERO_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
} as const;
export function buildFireworksCatalogModels(): ModelDefinitionConfig[] {
return [
{
id: FIREWORKS_DEFAULT_MODEL_ID,
name: "Kimi K2.5 Turbo (Fire Pass)",
reasoning: true,
input: ["text", "image"],
cost: ZERO_COST,
contextWindow: FIREWORKS_DEFAULT_CONTEXT_WINDOW,
maxTokens: FIREWORKS_DEFAULT_MAX_TOKENS,
},
];
}
export function buildFireworksProvider(): ModelProviderConfig {
return {
baseUrl: FIREWORKS_BASE_URL,
api: "openai-completions",
models: buildFireworksCatalogModels(),
};
}

View File

@ -1036,7 +1036,6 @@
"check": "pnpm check:no-conflict-markers && pnpm check:host-env-policy:swift && pnpm tsgo && pnpm lint && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",
"check:base-config-schema": "node --import tsx scripts/generate-base-config-schema.ts --check",
"check:bundled-channel-config-metadata": "node --import tsx scripts/generate-bundled-channel-config-metadata.ts --check",
"check:bundled-provider-auth-env-vars": "node scripts/generate-bundled-provider-auth-env-vars.mjs --check",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links",
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
@ -1132,7 +1131,7 @@
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
"release:check": "pnpm check:base-config-schema && pnpm check:bundled-channel-config-metadata && pnpm check:bundled-provider-auth-env-vars && pnpm config:docs:check && pnpm plugin-sdk:check-exports && pnpm plugin-sdk:facades:check && pnpm plugin-sdk:api:check && node --import tsx scripts/release-check.ts",
"release:check": "pnpm check:base-config-schema && pnpm check:bundled-channel-config-metadata && pnpm config:docs:check && pnpm plugin-sdk:check-exports && pnpm plugin-sdk:facades:check && pnpm plugin-sdk:api:check && node --import tsx scripts/release-check.ts",
"release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts",
"release:openclaw:npm:verify-published": "node --import tsx scripts/openclaw-npm-postpublish-verify.ts",
"release:plugins:clawhub:check": "node --import tsx scripts/plugin-clawhub-release-check.ts",

View File

@ -21,7 +21,6 @@ const allowedFiles = new Set([
"src/agents/tools/web-fetch.test-harness.ts",
"src/config/legacy-web-fetch.ts",
"src/config/zod-schema.agent-runtime.ts",
"src/plugins/bundled-provider-auth-env-vars.generated.ts",
"src/secrets/target-registry-data.ts",
]);
const suspiciousPatterns = [

View File

@ -1,83 +0,0 @@
import path from "node:path";
import { collectBundledPluginSources } from "./lib/bundled-plugin-source-utils.mjs";
import { reportGeneratedOutputCli, writeGeneratedOutput } from "./lib/generated-output-utils.mjs";
const GENERATED_BY = "scripts/generate-bundled-provider-auth-env-vars.mjs";
const DEFAULT_OUTPUT_PATH = "src/plugins/bundled-provider-auth-env-vars.generated.ts";
function normalizeProviderAuthEnvVars(providerAuthEnvVars) {
if (
!providerAuthEnvVars ||
typeof providerAuthEnvVars !== "object" ||
Array.isArray(providerAuthEnvVars)
) {
return [];
}
return Object.entries(providerAuthEnvVars)
.map(([providerId, envVars]) => {
const normalizedProviderId = providerId.trim();
const normalizedEnvVars = Array.isArray(envVars)
? envVars.map((value) => String(value).trim()).filter(Boolean)
: [];
if (!normalizedProviderId || normalizedEnvVars.length === 0) {
return null;
}
return [normalizedProviderId, normalizedEnvVars];
})
.filter(Boolean)
.toSorted(([left], [right]) => left.localeCompare(right));
}
export function collectBundledProviderAuthEnvVars(params = {}) {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const entries = new Map();
for (const source of collectBundledPluginSources({ repoRoot })) {
for (const [providerId, envVars] of normalizeProviderAuthEnvVars(
source.manifest.providerAuthEnvVars,
)) {
entries.set(providerId, envVars);
}
}
return Object.fromEntries(
[...entries.entries()].toSorted(([left], [right]) => left.localeCompare(right)),
);
}
export function renderBundledProviderAuthEnvVarModule(entries) {
const renderedEntries = Object.entries(entries)
.map(([providerId, envVars]) => {
const renderedKey = /^[$A-Z_a-z][\w$]*$/u.test(providerId)
? providerId
: JSON.stringify(providerId);
const renderedEnvVars = envVars.map((value) => JSON.stringify(value)).join(", ");
return ` ${renderedKey}: [${renderedEnvVars}],`;
})
.join("\n");
return `// Auto-generated by ${GENERATED_BY}. Do not edit directly.
export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = {
${renderedEntries}
} as const satisfies Record<string, readonly string[]>;
`;
}
export function writeBundledProviderAuthEnvVarModule(params = {}) {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const next = renderBundledProviderAuthEnvVarModule(
collectBundledProviderAuthEnvVars({ repoRoot }),
);
return writeGeneratedOutput({
repoRoot,
outputPath: params.outputPath ?? DEFAULT_OUTPUT_PATH,
next,
check: params.check,
});
}
reportGeneratedOutputCli({
importMetaUrl: import.meta.url,
label: "bundled-provider-auth-env-vars",
run: ({ check }) => writeBundledProviderAuthEnvVarModule({ check }),
});

View File

@ -1,9 +1,13 @@
import {
PROVIDER_AUTH_ENV_VAR_CANDIDATES,
listKnownProviderAuthEnvVarNames,
resolveProviderAuthEnvVarCandidates,
} from "../secrets/provider-env-vars.js";
export const PROVIDER_ENV_API_KEY_CANDIDATES = PROVIDER_AUTH_ENV_VAR_CANDIDATES;
export function resolveProviderEnvApiKeyCandidates(): Record<string, readonly string[]> {
return resolveProviderAuthEnvVarCandidates();
}
export const PROVIDER_ENV_API_KEY_CANDIDATES = resolveProviderEnvApiKeyCandidates();
export function listKnownProviderEnvApiKeyNames(): string[] {
return listKnownProviderAuthEnvVarNames();

View File

@ -2,7 +2,7 @@ import { getEnvApiKey } from "@mariozechner/pi-ai";
import { hasAnthropicVertexAvailableAuth } from "../../extensions/anthropic-vertex/api.js";
import { getShellEnvAppliedKeys } from "../infra/shell-env.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
import { PROVIDER_ENV_API_KEY_CANDIDATES } from "./model-auth-env-vars.js";
import { resolveProviderEnvApiKeyCandidates } from "./model-auth-env-vars.js";
import { GCP_VERTEX_CREDENTIALS_MARKER } from "./model-auth-markers.js";
import { normalizeProviderIdForAuth } from "./provider-id.js";
@ -26,7 +26,7 @@ export function resolveEnvApiKey(
return { apiKey: value, source };
};
const candidates = PROVIDER_ENV_API_KEY_CANDIDATES[normalized];
const candidates = resolveProviderEnvApiKeyCandidates()[normalized];
if (candidates) {
for (const envVar of candidates) {
const resolved = pick(envVar);

View File

@ -14,7 +14,7 @@ import {
} from "../plugins/provider-runtime.js";
import type { ProviderRuntimeModel } from "../plugins/types.js";
import { ensureAuthProfileStore } from "./auth-profiles.js";
import { PROVIDER_ENV_API_KEY_CANDIDATES } from "./model-auth-env-vars.js";
import { resolveProviderEnvApiKeyCandidates } from "./model-auth-env-vars.js";
import { resolveEnvApiKey } from "./model-auth-env.js";
import { resolvePiCredentialMapFromStore, type PiCredentialMap } from "./pi-auth-credentials.js";
@ -232,7 +232,7 @@ function resolvePiCredentials(agentDir: string): PiCredentialMap {
// pi-coding-agent hides providers from its registry when auth storage lacks
// a matching credential entry. Mirror env-backed provider auth here so
// live/model discovery sees the same providers runtime auth can use.
for (const provider of Object.keys(PROVIDER_ENV_API_KEY_CANDIDATES)) {
for (const provider of Object.keys(resolveProviderEnvApiKeyCandidates())) {
if (credentials[provider]) {
continue;
}

View File

@ -899,7 +899,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
attempts: [
{
provider: "fireworks",
model: "fireworks/minimax-m2p5",
model: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
error: "Provider fireworks is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
@ -960,7 +960,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
attempts: [
{
provider: "fireworks",
model: "fireworks/minimax-m2p5",
model: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
error: "Provider fireworks is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
@ -1034,7 +1034,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
attempts: [
{
provider: "fireworks",
model: "fireworks/minimax-m2p5",
model: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
error: "Provider fireworks is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
@ -1097,7 +1097,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
attempts: [
{
provider: "fireworks",
model: "fireworks/minimax-m2p5",
model: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
error: "Provider fireworks is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
@ -1177,7 +1177,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
attempts: [
{
provider: "fireworks",
model: "fireworks/minimax-m2p5",
model: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
error: "Provider fireworks is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
@ -1299,7 +1299,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
sessionId,
updatedAt: Date.now(),
sessionFile: transcriptPath,
fallbackNoticeSelectedModel: "fireworks/minimax-m2p5",
fallbackNoticeSelectedModel: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
fallbackNoticeReason: "rate limit",
};

View File

@ -207,16 +207,18 @@ describe("/model chat UX", () => {
it("shows active runtime model when different from selected model", async () => {
const reply = await resolveModelInfoReply({
provider: "fireworks",
model: "fireworks/minimax-m2p5",
model: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
defaultProvider: "fireworks",
defaultModel: "fireworks/minimax-m2p5",
defaultModel: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
sessionEntry: {
modelProvider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
},
});
expect(reply?.text).toContain("Current: fireworks/minimax-m2p5 (selected)");
expect(reply?.text).toContain(
"Current: fireworks/accounts/fireworks/routers/kimi-k2p5-turbo (selected)",
);
expect(reply?.text).toContain("Active: deepinfra/moonshotai/Kimi-K2.5 (runtime)");
});

View File

@ -746,7 +746,7 @@ describe("buildStatusMessage", () => {
updatedAt: 0,
modelProvider: "anthropic",
model: "claude-haiku-4-5",
fallbackNoticeSelectedModel: "fireworks/minimax-m2p5",
fallbackNoticeSelectedModel: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
fallbackNoticeReason: "rate limit",
},

View File

@ -15,11 +15,66 @@ const resolveManifestProviderAuthChoices = vi.hoisted(() =>
const resolveProviderWizardOptions = vi.hoisted(() =>
vi.fn<() => ProviderWizardOption[]>(() => []),
);
vi.mock("../plugins/provider-auth-choices.js", () => ({
resolveManifestProviderAuthChoices,
}));
vi.mock("../plugins/provider-wizard.js", () => ({
resolveProviderWizardOptions,
function includesOnboardingScope(
scopes: readonly ("text-inference" | "image-generation")[] | undefined,
scope: "text-inference" | "image-generation",
): boolean {
return scopes ? scopes.includes(scope) : scope === "text-inference";
}
vi.mock("../flows/provider-flow.js", () => ({
resolveProviderSetupFlowContributions: vi.fn(
(params?: { scope?: "text-inference" | "image-generation" }) => {
const scope = params?.scope ?? "text-inference";
return [
...resolveManifestProviderAuthChoices()
.filter((choice) => includesOnboardingScope(choice.onboardingScopes, scope))
.map((choice) => ({
option: {
value: choice.choiceId,
label: choice.choiceLabel,
...(choice.choiceHint ? { hint: choice.choiceHint } : {}),
...(choice.groupId && choice.groupLabel
? {
group: {
id: choice.groupId,
label: choice.groupLabel,
...(choice.groupHint ? { hint: choice.groupHint } : {}),
},
}
: {}),
...(choice.assistantPriority !== undefined
? { assistantPriority: choice.assistantPriority }
: {}),
...(choice.assistantVisibility
? { assistantVisibility: choice.assistantVisibility }
: {}),
},
})),
...resolveProviderWizardOptions()
.filter((option) => includesOnboardingScope(option.onboardingScopes, scope))
.map((option) => ({
option: {
value: option.value,
label: option.label,
...(option.hint ? { hint: option.hint } : {}),
group: {
id: option.groupId,
label: option.groupLabel,
...(option.groupHint ? { hint: option.groupHint } : {}),
},
...(option.assistantPriority !== undefined
? { assistantPriority: option.assistantPriority }
: {}),
...(option.assistantVisibility
? { assistantVisibility: option.assistantVisibility }
: {}),
},
})),
];
},
),
}));
const EMPTY_STORE: AuthProfileStore = { version: 1, profiles: {} };
@ -212,7 +267,7 @@ describe("buildAuthChoiceOptions", () => {
}
});
it("builds cli help choices from the same catalog", () => {
it("builds cli help choices from the same runtime catalog", () => {
resolveManifestProviderAuthChoices.mockReturnValue([
{
pluginId: "chutes",
@ -257,7 +312,7 @@ describe("buildAuthChoiceOptions", () => {
expect(cliChoices).toContain("custom-api-key");
expect(cliChoices).toContain("skip");
expect(options.some((option) => option.value === "ollama")).toBe(true);
expect(cliChoices).not.toContain("ollama");
expect(cliChoices).toContain("ollama");
});
it("can include legacy aliases in cli help choices", () => {

View File

@ -1,9 +1,6 @@
import type { AuthProfileStore } from "../agents/auth-profiles.js";
import type { OpenClawConfig } from "../config/config.js";
import {
resolveManifestProviderSetupFlowContributions,
resolveProviderSetupFlowContributions,
} from "../flows/provider-flow.js";
import { resolveProviderSetupFlowContributions } from "../flows/provider-flow.js";
import {
CORE_AUTH_CHOICE_OPTIONS,
type AuthChoiceGroup,
@ -38,6 +35,12 @@ function resolveProviderChoiceOptions(params?: {
value: contribution.option.value as AuthChoice,
label: contribution.option.label,
...(contribution.option.hint ? { hint: contribution.option.hint } : {}),
...(contribution.option.assistantPriority !== undefined
? { assistantPriority: contribution.option.assistantPriority }
: {}),
...(contribution.option.assistantVisibility
? { assistantVisibility: contribution.option.assistantVisibility }
: {}),
...(contribution.option.group
? {
groupId: contribution.option.group.id as AuthChoiceGroupId,
@ -57,7 +60,7 @@ export function formatAuthChoiceChoicesForCli(params?: {
}): string {
const values = [
...formatStaticAuthChoiceChoicesForCli(params).split("|"),
...resolveManifestProviderSetupFlowContributions({
...resolveProviderSetupFlowContributions({
...params,
scope: "text-inference",
}).map((contribution) => contribution.option.value),

View File

@ -48,8 +48,7 @@ function resolveProviderChoiceModelAllowlist(params: {
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
});
return resolveProviderPluginChoice({
providers,

View File

@ -31,8 +31,7 @@ export async function maybeRepairLegacyOAuthProfileIds(
const providers = resolvePluginProviders({
config: cfg,
env: process.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
});
for (const provider of providers) {
for (const repairSpec of provider.oauthProfileIdRepairs ?? []) {
@ -134,8 +133,7 @@ export async function maybeRemoveDeprecatedCliAuthProfiles(
const providers = resolvePluginProviders({
config: cfg,
env: process.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
});
const deprecatedEntries = providers.flatMap((provider) =>
(provider.deprecatedProfileIds ?? [])

View File

@ -189,8 +189,7 @@ describe("promptDefaultModel", () => {
config,
workspaceDir: undefined,
env: undefined,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
});
expect(result.model).toBe("vllm/meta-llama/Meta-Llama-3-8B-Instruct");
expect(result.config?.models?.providers?.vllm).toMatchObject({

View File

@ -110,8 +110,7 @@ async function resolveModelsAuthContext(): Promise<ResolvedModelsAuthContext> {
const providers = resolvePluginProviders({
config,
workspaceDir,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
});
return { config, agentDir, workspaceDir, providers };
}

View File

@ -110,8 +110,7 @@ export async function applyNonInteractivePluginProviderChoice(params: {
config: resolutionConfig,
workspaceDir,
onlyPluginIds: owningPluginIds,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
}),
choice: params.authChoice,
});

View File

@ -6,27 +6,19 @@ const mocks = vi.hoisted(() => ({
resolvePluginWebSearchProviders: vi.fn<
(params?: { config?: OpenClawConfig }) => PluginWebSearchProviderEntry[]
>(() => []),
listBundledWebSearchProviders: vi.fn<() => PluginWebSearchProviderEntry[]>(() => []),
resolveBundledWebSearchPluginId: vi.fn<(providerId?: string) => string | undefined>(
() => undefined,
),
}));
vi.mock("../plugins/web-search-providers.runtime.js", () => ({
resolvePluginWebSearchProviders: mocks.resolvePluginWebSearchProviders,
}));
vi.mock("../plugins/bundled-web-search.js", () => ({
listBundledWebSearchProviders: mocks.listBundledWebSearchProviders,
resolveBundledWebSearchPluginId: mocks.resolveBundledWebSearchPluginId,
}));
function createCustomProviderEntry(): PluginWebSearchProviderEntry {
return {
id: "custom-search" as never,
pluginId: "custom-plugin",
label: "Custom Search",
hint: "Custom provider",
onboardingScopes: ["text-inference"],
envVars: ["CUSTOM_SEARCH_API_KEY"],
placeholder: "custom-...",
signupUrl: "https://example.com/custom",
@ -83,6 +75,7 @@ function createBundledDuckDuckGoEntry(): PluginWebSearchProviderEntry {
pluginId: "duckduckgo",
label: "DuckDuckGo Search (experimental)",
hint: "Free fallback",
onboardingScopes: ["text-inference"],
requiresCredential: false,
envVars: [],
placeholder: "(no key needed)",
@ -196,10 +189,7 @@ describe("onboard-search provider resolution", () => {
});
it("does not treat hard-disabled bundled providers as selectable credentials", async () => {
const firecrawlEntry = createBundledFirecrawlEntry();
mocks.resolvePluginWebSearchProviders.mockReturnValue([]);
mocks.listBundledWebSearchProviders.mockReturnValue([firecrawlEntry]);
mocks.resolveBundledWebSearchPluginId.mockReturnValue("firecrawl");
const cfg: OpenClawConfig = {
tools: {
@ -257,7 +247,7 @@ describe("onboard-search provider resolution", () => {
expect(notes.some((message) => message.includes("works without an API key"))).toBe(true);
});
it("keeps the legacy default onboarding search surface when no config is present", async () => {
it("uses the runtime onboarding search surface when no config is present", async () => {
const firecrawlEntry = createBundledFirecrawlEntry();
const duckduckgoEntry = createBundledDuckDuckGoEntry();
const tavilyEntry: PluginWebSearchProviderEntry = {
@ -272,16 +262,20 @@ describe("onboard-search provider resolution", () => {
};
const customEntry = createCustomProviderEntry();
mocks.listBundledWebSearchProviders.mockReturnValue([
mocks.resolvePluginWebSearchProviders.mockReturnValue([
customEntry,
duckduckgoEntry,
firecrawlEntry,
tavilyEntry,
]);
mocks.resolvePluginWebSearchProviders.mockReturnValue([customEntry]);
const options = mod.resolveSearchProviderOptions();
expect(options.map((entry) => entry.id)).toEqual(["firecrawl", "tavily"]);
expect(options.map((entry) => entry.id)).toEqual([
"custom-search",
"duckduckgo",
"firecrawl",
"tavily",
]);
});
});

View File

@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { SEARCH_PROVIDER_OPTIONS, setupSearch } from "./onboard-search.js";
import { listSearchProviderOptions, setupSearch } from "./onboard-search.js";
const runtime: RuntimeEnv = {
log: vi.fn(),
@ -614,8 +614,9 @@ describe("setupSearch", () => {
});
it("exports all 7 providers in alphabetical order", () => {
const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.id);
expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(7);
const providers = listSearchProviderOptions();
const values = providers.map((e) => e.id);
expect(providers).toHaveLength(7);
expect(values).toEqual([
"brave",
"firecrawl",

View File

@ -1,9 +1,9 @@
export {
SEARCH_PROVIDER_OPTIONS,
applySearchKey,
applySearchProviderSelection,
hasExistingKey,
hasKeyInEnv,
listSearchProviderOptions,
resolveExistingKey,
resolveSearchProviderOptions,
runSearchSetupFlow as setupSearch,

View File

@ -27,8 +27,7 @@ export function resolveProviderAuthLoginCommand(params: {
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
}).find((candidate) => matchesProviderId(candidate, params.provider));
if (!provider || provider.auth.length === 0) {
return undefined;

View File

@ -288,8 +288,7 @@ async function maybeHandleProviderPluginSelection(params: {
config: params.cfg,
workspaceDir: params.workspaceDir,
env: params.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
});
pluginResolution = pluginProviders.some(
(provider) => normalizeProviderId(provider.id) === normalizeProviderId(params.selection),
@ -318,8 +317,7 @@ async function maybeHandleProviderPluginSelection(params: {
config: params.cfg,
workspaceDir: params.workspaceDir,
env: params.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
});
}
const resolved = resolveProviderPluginChoice({

View File

@ -1,18 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
resolveManifestProviderSetupFlowContributions,
resolveProviderSetupFlowContributions,
resolveProviderModelPickerFlowContributions,
} from "./provider-flow.js";
const resolveManifestProviderAuthChoices = vi.hoisted(() => vi.fn(() => []));
const resolveProviderWizardOptions = vi.hoisted(() => vi.fn(() => []));
const resolveProviderModelPickerEntries = vi.hoisted(() => vi.fn(() => []));
const resolvePluginProviders = vi.hoisted(() => vi.fn(() => []));
vi.mock("../plugins/provider-auth-choices.js", () => ({
resolveManifestProviderAuthChoices,
}));
vi.mock("../plugins/provider-wizard.js", () => ({
resolveProviderWizardOptions,
resolveProviderModelPickerEntries,
@ -27,21 +22,20 @@ describe("provider flow", () => {
vi.clearAllMocks();
});
it("uses bundled compat when resolving docs for manifest-backed setup contributions", () => {
resolveManifestProviderAuthChoices.mockReturnValue([
it("uses setup mode when resolving docs for setup contributions", () => {
resolveProviderWizardOptions.mockReturnValue([
{
pluginId: "sglang",
providerId: "sglang",
methodId: "custom",
choiceId: "provider-plugin:sglang:custom",
choiceLabel: "SGLang",
value: "provider-plugin:sglang:custom",
label: "SGLang",
groupId: "sglang",
groupLabel: "SGLang",
},
] as never);
resolvePluginProviders.mockReturnValue([
{ id: "sglang", docsPath: "/providers/sglang" },
] as never);
const contributions = resolveManifestProviderSetupFlowContributions({
const contributions = resolveProviderSetupFlowContributions({
config: {},
workspaceDir: "/tmp/workspace",
env: process.env,
@ -51,13 +45,13 @@ describe("provider flow", () => {
config: {},
workspaceDir: "/tmp/workspace",
env: process.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
});
expect(contributions[0]?.option.docs).toEqual({ path: "/providers/sglang" });
expect(contributions[0]?.source).toBe("runtime");
});
it("uses bundled compat when resolving docs for runtime model-picker contributions", () => {
it("uses setup mode when resolving docs for runtime model-picker contributions", () => {
resolveProviderModelPickerEntries.mockReturnValue([
{
value: "provider-plugin:vllm:custom",
@ -76,8 +70,7 @@ describe("provider flow", () => {
config: {},
workspaceDir: "/tmp/workspace",
env: process.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
});
expect(contributions[0]?.option.docs).toEqual({ path: "/providers/vllm" });
});

View File

@ -1,5 +1,4 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveManifestProviderAuthChoices } from "../plugins/provider-auth-choices.js";
import {
resolveProviderModelPickerEntries,
resolveProviderWizardOptions,
@ -7,7 +6,7 @@ import {
import { resolvePluginProviders } from "../plugins/providers.runtime.js";
import type { ProviderPlugin } from "../plugins/types.js";
import type { FlowContribution, FlowOption } from "./types.js";
import { mergeFlowContributions, sortFlowContributionsByLabel } from "./types.js";
import { sortFlowContributionsByLabel } from "./types.js";
export type ProviderFlowScope = "text-inference" | "image-generation";
@ -26,7 +25,7 @@ export type ProviderSetupFlowContribution = FlowContribution & {
pluginId?: string;
option: ProviderSetupFlowOption;
onboardingScopes?: ProviderFlowScope[];
source: "manifest" | "runtime";
source: "runtime";
};
export type ProviderModelPickerFlowContribution = FlowContribution & {
@ -54,8 +53,7 @@ function resolveProviderDocsById(params?: {
config: params?.config,
workspaceDir: params?.workspaceDir,
env: params?.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
})
.filter((provider): provider is ProviderPlugin & { docsPath: string } =>
Boolean(provider.docsPath?.trim()),
@ -64,103 +62,6 @@ function resolveProviderDocsById(params?: {
);
}
export function resolveManifestProviderSetupFlowOptions(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
scope?: ProviderFlowScope;
}): ProviderSetupFlowOption[] {
return resolveManifestProviderSetupFlowContributions(params).map(
(contribution) => contribution.option,
);
}
export function resolveManifestProviderSetupFlowContributions(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
scope?: ProviderFlowScope;
}): ProviderSetupFlowContribution[] {
const scope = params?.scope ?? DEFAULT_PROVIDER_FLOW_SCOPE;
const docsByProvider = resolveProviderDocsById(params ?? {});
return resolveManifestProviderAuthChoices(params)
.filter((choice) => includesProviderFlowScope(choice.onboardingScopes, scope))
.map((choice) => ({
id: `provider:setup:${choice.choiceId}`,
kind: "provider" as const,
surface: "setup" as const,
providerId: choice.providerId,
pluginId: choice.pluginId,
option: {
value: choice.choiceId,
label: choice.choiceLabel,
...(choice.choiceHint ? { hint: choice.choiceHint } : {}),
...(choice.assistantPriority !== undefined
? { assistantPriority: choice.assistantPriority }
: {}),
...(choice.assistantVisibility ? { assistantVisibility: choice.assistantVisibility } : {}),
...(choice.groupId && choice.groupLabel
? {
group: {
id: choice.groupId,
label: choice.groupLabel,
...(choice.groupHint ? { hint: choice.groupHint } : {}),
},
}
: {}),
...(docsByProvider.get(choice.providerId)
? { docs: { path: docsByProvider.get(choice.providerId)! } }
: {}),
},
...(choice.onboardingScopes ? { onboardingScopes: [...choice.onboardingScopes] } : {}),
source: "manifest" as const,
}));
}
export function resolveRuntimeFallbackProviderSetupFlowOptions(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
scope?: ProviderFlowScope;
}): ProviderSetupFlowOption[] {
return resolveRuntimeFallbackProviderSetupFlowContributions(params).map(
(contribution) => contribution.option,
);
}
export function resolveRuntimeFallbackProviderSetupFlowContributions(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
scope?: ProviderFlowScope;
}): ProviderSetupFlowContribution[] {
const scope = params?.scope ?? DEFAULT_PROVIDER_FLOW_SCOPE;
return resolveProviderWizardOptions(params ?? {})
.filter((option) => includesProviderFlowScope(option.onboardingScopes, scope))
.map((option) => ({
id: `provider:setup:${option.value}`,
kind: "provider" as const,
surface: "setup" as const,
providerId: option.groupId,
option: {
value: option.value,
label: option.label,
...(option.hint ? { hint: option.hint } : {}),
...(option.assistantPriority !== undefined
? { assistantPriority: option.assistantPriority }
: {}),
...(option.assistantVisibility ? { assistantVisibility: option.assistantVisibility } : {}),
group: {
id: option.groupId,
label: option.groupLabel,
...(option.groupHint ? { hint: option.groupHint } : {}),
},
},
...(option.onboardingScopes ? { onboardingScopes: [...option.onboardingScopes] } : {}),
source: "runtime" as const,
}));
}
export function resolveProviderSetupFlowOptions(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
@ -176,11 +77,38 @@ export function resolveProviderSetupFlowContributions(params?: {
env?: NodeJS.ProcessEnv;
scope?: ProviderFlowScope;
}): ProviderSetupFlowContribution[] {
const scope = params?.scope ?? DEFAULT_PROVIDER_FLOW_SCOPE;
const docsByProvider = resolveProviderDocsById(params ?? {});
return sortFlowContributionsByLabel(
mergeFlowContributions({
primary: resolveManifestProviderSetupFlowContributions(params),
fallbacks: resolveRuntimeFallbackProviderSetupFlowContributions(params),
}),
resolveProviderWizardOptions(params ?? {})
.filter((option) => includesProviderFlowScope(option.onboardingScopes, scope))
.map((option) => ({
id: `provider:setup:${option.value}`,
kind: "provider" as const,
surface: "setup" as const,
providerId: option.groupId,
option: {
value: option.value,
label: option.label,
...(option.hint ? { hint: option.hint } : {}),
...(option.assistantPriority !== undefined
? { assistantPriority: option.assistantPriority }
: {}),
...(option.assistantVisibility
? { assistantVisibility: option.assistantVisibility }
: {}),
group: {
id: option.groupId,
label: option.groupLabel,
...(option.groupHint ? { hint: option.groupHint } : {}),
},
...(docsByProvider.get(option.groupId)
? { docs: { path: docsByProvider.get(option.groupId)! } }
: {}),
},
...(option.onboardingScopes ? { onboardingScopes: [...option.onboardingScopes] } : {}),
source: "runtime" as const,
})),
);
}

View File

@ -92,12 +92,6 @@ const mockGrokProvider = vi.hoisted(() => ({
},
}));
vi.mock("../plugins/bundled-web-search.js", () => ({
listBundledWebSearchProviders: () => [mockGrokProvider],
resolveBundledWebSearchPluginId: (providerId: string | undefined) =>
providerId === "grok" ? "xai" : undefined,
}));
vi.mock("../plugins/web-search-providers.runtime.js", () => ({
resolvePluginWebSearchProviders: () => [mockGrokProvider],
}));

View File

@ -7,10 +7,6 @@ import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "../config/types.secrets.js";
import {
listBundledWebSearchProviders,
resolveBundledWebSearchPluginId,
} from "../plugins/bundled-web-search.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js";
@ -35,7 +31,7 @@ export type SearchProviderSetupContribution = FlowContribution & {
surface: "setup";
provider: PluginWebSearchProviderEntry;
option: SearchProviderSetupOption;
source: "bundled" | "runtime";
source: "runtime";
};
function resolveSearchProviderCredentialLabel(
@ -47,8 +43,11 @@ function resolveSearchProviderCredentialLabel(
return entry.credentialLabel?.trim() || `${entry.label} API key`;
}
export const SEARCH_PROVIDER_OPTIONS: readonly PluginWebSearchProviderEntry[] =
resolveSearchProviderSetupContributions().map((contribution) => contribution.provider);
export function listSearchProviderOptions(
config?: OpenClawConfig,
): readonly PluginWebSearchProviderEntry[] {
return resolveSearchProviderOptions(config);
}
function showsSearchProviderInSetup(
entry: Pick<PluginWebSearchProviderEntry, "onboardingScopes">,
@ -56,20 +55,6 @@ function showsSearchProviderInSetup(
return entry.onboardingScopes?.includes("text-inference") ?? false;
}
function canRepairBundledProviderSelection(
config: OpenClawConfig,
provider: Pick<PluginWebSearchProviderEntry, "id" | "pluginId">,
): boolean {
const pluginId = provider.pluginId ?? resolveBundledWebSearchPluginId(provider.id);
if (!pluginId) {
return false;
}
if (config.plugins?.enabled === false) {
return false;
}
return !config.plugins?.deny?.includes(pluginId);
}
export function resolveSearchProviderOptions(
config?: OpenClawConfig,
): readonly PluginWebSearchProviderEntry[] {
@ -80,7 +65,7 @@ export function resolveSearchProviderOptions(
function buildSearchProviderSetupContribution(params: {
provider: PluginWebSearchProviderEntry;
source: "bundled" | "runtime";
source: "runtime";
}): SearchProviderSetupContribution {
return {
id: `search:setup:${params.provider.id}`,
@ -100,33 +85,18 @@ function buildSearchProviderSetupContribution(params: {
export function resolveSearchProviderSetupContributions(
config?: OpenClawConfig,
): SearchProviderSetupContribution[] {
if (!config) {
return sortFlowContributionsByLabel(
sortWebSearchProviders(listBundledWebSearchProviders())
.filter(showsSearchProviderInSetup)
.map((provider) => buildSearchProviderSetupContribution({ provider, source: "bundled" })),
);
}
const merged = new Map<string, SearchProviderSetupContribution>(
const providers = sortWebSearchProviders(
resolvePluginWebSearchProviders({
config,
bundledAllowlistCompat: true,
env: process.env,
}).map((provider) => [
provider.id,
buildSearchProviderSetupContribution({ provider, source: "runtime" }),
]),
mode: "setup",
}),
);
return sortFlowContributionsByLabel(
providers
.filter(showsSearchProviderInSetup)
.map((provider) => buildSearchProviderSetupContribution({ provider, source: "runtime" })),
);
for (const provider of listBundledWebSearchProviders()) {
if (merged.has(provider.id) || !canRepairBundledProviderSelection(config, provider)) {
continue;
}
merged.set(provider.id, buildSearchProviderSetupContribution({ provider, source: "bundled" }));
}
return sortFlowContributionsByLabel([...merged.values()]);
}
function resolveSearchProviderEntry(
@ -182,8 +152,8 @@ export function hasExistingKey(config: OpenClawConfig, provider: SearchProvider)
function buildSearchEnvRef(config: OpenClawConfig, provider: SearchProvider): SecretRef {
const entry =
resolveSearchProviderEntry(config, provider) ??
SEARCH_PROVIDER_OPTIONS.find((candidate) => candidate.id === provider) ??
listBundledWebSearchProviders().find((candidate) => candidate.id === provider);
listSearchProviderOptions(config).find((candidate) => candidate.id === provider) ??
listSearchProviderOptions().find((candidate) => candidate.id === provider);
const envVar = entry?.envVars.find((k) => Boolean(process.env[k]?.trim())) ?? entry?.envVars[0];
if (!envVar) {
throw new Error(

View File

@ -119,7 +119,7 @@ describe("agent event handler", () => {
const FALLBACK_LIFECYCLE_DATA = {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
selectedModel: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
} as const;

View File

@ -47,6 +47,38 @@ export type BundledPluginCompatibleActivationInputs = PluginActivationInputs & {
compatPluginIds: string[];
};
export function withActivatedPluginIds(params: {
config?: OpenClawConfig;
pluginIds: readonly string[];
}): OpenClawConfig | undefined {
if (params.pluginIds.length === 0) {
return params.config;
}
const allow = new Set(params.config?.plugins?.allow ?? []);
const entries = {
...params.config?.plugins?.entries,
};
for (const pluginId of params.pluginIds) {
const normalized = pluginId.trim();
if (!normalized) {
continue;
}
allow.add(normalized);
entries[normalized] = {
...entries[normalized],
enabled: true,
};
}
return {
...params.config,
plugins: {
...params.config?.plugins,
...(allow.size > 0 ? { allow: [...allow] } : {}),
entries,
},
};
}
export function applyPluginCompatibilityOverrides(params: {
config?: OpenClawConfig;
compat?: PluginActivationCompatConfig;

View File

@ -1,49 +0,0 @@
// Auto-generated by scripts/generate-bundled-provider-auth-env-vars.mjs. Do not edit directly.
export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = {
anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
brave: ["BRAVE_API_KEY"],
byteplus: ["BYTEPLUS_API_KEY"],
chutes: ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"],
"cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"],
deepgram: ["DEEPGRAM_API_KEY"],
deepseek: ["DEEPSEEK_API_KEY"],
exa: ["EXA_API_KEY"],
fal: ["FAL_KEY"],
firecrawl: ["FIRECRAWL_API_KEY"],
"github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
google: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
groq: ["GROQ_API_KEY"],
huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"],
kilocode: ["KILOCODE_API_KEY"],
kimi: ["KIMI_API_KEY", "KIMICODE_API_KEY"],
"kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"],
litellm: ["LITELLM_API_KEY"],
"microsoft-foundry": ["AZURE_OPENAI_API_KEY"],
minimax: ["MINIMAX_API_KEY"],
"minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"],
mistral: ["MISTRAL_API_KEY"],
moonshot: ["MOONSHOT_API_KEY"],
nvidia: ["NVIDIA_API_KEY"],
ollama: ["OLLAMA_API_KEY"],
openai: ["OPENAI_API_KEY"],
opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
"opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
openrouter: ["OPENROUTER_API_KEY"],
perplexity: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
qianfan: ["QIANFAN_API_KEY"],
qwen: ["QWEN_API_KEY", "MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"],
sglang: ["SGLANG_API_KEY"],
stepfun: ["STEPFUN_API_KEY"],
"stepfun-plan": ["STEPFUN_API_KEY"],
synthetic: ["SYNTHETIC_API_KEY"],
tavily: ["TAVILY_API_KEY"],
together: ["TOGETHER_API_KEY"],
venice: ["VENICE_API_KEY"],
"vercel-ai-gateway": ["AI_GATEWAY_API_KEY"],
vllm: ["VLLM_API_KEY"],
volcengine: ["VOLCANO_ENGINE_API_KEY"],
xai: ["XAI_API_KEY"],
xiaomi: ["XIAOMI_API_KEY"],
zai: ["ZAI_API_KEY", "Z_AI_API_KEY"],
} as const satisfies Record<string, readonly string[]>;

View File

@ -1,112 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
collectBundledProviderAuthEnvVars,
writeBundledProviderAuthEnvVarModule,
} from "../../scripts/generate-bundled-provider-auth-env-vars.mjs";
import { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "./bundled-provider-auth-env-vars.js";
import {
createGeneratedPluginTempRoot,
installGeneratedPluginTempRootCleanup,
pluginTestRepoRoot as repoRoot,
writeJson,
} from "./generated-plugin-test-helpers.js";
installGeneratedPluginTempRootCleanup();
function expectGeneratedAuthEnvVarModuleState(params: {
tempRoot: string;
expectedChanged: boolean;
expectedWrote: boolean;
}) {
const result = writeBundledProviderAuthEnvVarModule({
repoRoot: params.tempRoot,
outputPath: "src/plugins/bundled-provider-auth-env-vars.generated.ts",
check: true,
});
expect(result.changed).toBe(params.expectedChanged);
expect(result.wrote).toBe(params.expectedWrote);
}
function expectGeneratedAuthEnvVarCheckMode(tempRoot: string) {
expectGeneratedAuthEnvVarModuleState({
tempRoot,
expectedChanged: false,
expectedWrote: false,
});
}
function expectBundledProviderEnvVars(expected: Record<string, readonly string[]>) {
expect(
Object.fromEntries(
Object.keys(expected).map((providerId) => [
providerId,
BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES[
providerId as keyof typeof BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES
],
]),
),
).toEqual(expected);
}
function expectMissingBundledProviderEnvVars(providerIds: readonly string[]) {
providerIds.forEach((providerId) => {
expect(providerId in BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toBe(false);
});
}
describe("bundled provider auth env vars", () => {
it("matches the generated manifest snapshot", () => {
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toEqual(
collectBundledProviderAuthEnvVars({ repoRoot }),
);
});
it("reads bundled provider auth env vars from plugin manifests", () => {
expectBundledProviderEnvVars({
brave: ["BRAVE_API_KEY"],
deepgram: ["DEEPGRAM_API_KEY"],
firecrawl: ["FIRECRAWL_API_KEY"],
"github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
groq: ["GROQ_API_KEY"],
perplexity: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
tavily: ["TAVILY_API_KEY"],
"minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"],
openai: ["OPENAI_API_KEY"],
fal: ["FAL_KEY"],
});
expectMissingBundledProviderEnvVars(["openai-codex"]);
});
it("supports check mode for stale generated artifacts", () => {
const tempRoot = createGeneratedPluginTempRoot("openclaw-provider-auth-env-vars-");
writeJson(path.join(tempRoot, "extensions", "alpha", "openclaw.plugin.json"), {
id: "alpha",
providerAuthEnvVars: {
alpha: ["ALPHA_TOKEN"],
},
});
const initial = writeBundledProviderAuthEnvVarModule({
repoRoot: tempRoot,
outputPath: "src/plugins/bundled-provider-auth-env-vars.generated.ts",
});
expect(initial.wrote).toBe(true);
expectGeneratedAuthEnvVarCheckMode(tempRoot);
fs.writeFileSync(
path.join(tempRoot, "src/plugins/bundled-provider-auth-env-vars.generated.ts"),
"// stale\n",
"utf8",
);
expectGeneratedAuthEnvVarModuleState({
tempRoot,
expectedChanged: true,
expectedWrote: false,
});
});
});

View File

@ -1,3 +0,0 @@
// Generated from extension manifests so core secrets/auth code does not need
// static imports into extension source trees.
export { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "./bundled-provider-auth-env-vars.generated.js";

View File

@ -1,7 +1 @@
import { BUNDLED_WEB_FETCH_PLUGIN_IDS as BUNDLED_WEB_FETCH_PLUGIN_IDS_FROM_METADATA } from "./bundled-capability-metadata.js";
export const BUNDLED_WEB_FETCH_PLUGIN_IDS = BUNDLED_WEB_FETCH_PLUGIN_IDS_FROM_METADATA;
export function listBundledWebFetchPluginIds(): string[] {
return [...BUNDLED_WEB_FETCH_PLUGIN_IDS];
}
export { resolveBundledWebFetchPluginIds as listBundledWebFetchPluginIds } from "./bundled-web-fetch.js";

View File

@ -1,18 +1 @@
import { BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS } from "./bundled-capability-metadata.js";
const bundledWebFetchProviderPluginIds = Object.fromEntries(
BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.flatMap((entry) =>
entry.webFetchProviderIds.map((providerId) => [providerId, entry.pluginId] as const),
).toSorted(([left], [right]) => left.localeCompare(right)),
) as Readonly<Record<string, string>>;
export function resolveBundledWebFetchPluginId(providerId: string | undefined): string | undefined {
if (!providerId) {
return undefined;
}
const normalizedProviderId = providerId.trim().toLowerCase();
if (!(normalizedProviderId in bundledWebFetchProviderPluginIds)) {
return undefined;
}
return bundledWebFetchProviderPluginIds[normalizedProviderId];
}
export { resolveBundledWebFetchPluginId } from "./bundled-web-fetch.js";

View File

@ -1,25 +1,50 @@
import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js";
import { BUNDLED_WEB_FETCH_PLUGIN_IDS } from "./bundled-web-fetch-ids.js";
import { resolveBundledWebFetchPluginId as resolveBundledWebFetchPluginIdFromMap } from "./bundled-web-fetch-provider-ids.js";
import type { PluginLoadOptions } from "./loader.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import type { PluginWebFetchProviderEntry } from "./types.js";
type BundledWebFetchProviderEntry = PluginWebFetchProviderEntry & { pluginId: string };
let bundledWebFetchProvidersCache: BundledWebFetchProviderEntry[] | null = null;
const bundledWebFetchProvidersCache = new Map<string, BundledWebFetchProviderEntry[]>();
function loadBundledWebFetchProviders(): BundledWebFetchProviderEntry[] {
if (!bundledWebFetchProvidersCache) {
bundledWebFetchProvidersCache = loadBundledCapabilityRuntimeRegistry({
pluginIds: BUNDLED_WEB_FETCH_PLUGIN_IDS,
pluginSdkResolution: "dist",
}).webFetchProviders.map((entry) => ({
pluginId: entry.pluginId,
...entry.provider,
}));
function resolveBundledWebFetchManifestPlugins(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}) {
return loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}).plugins.filter(
(plugin) =>
plugin.origin === "bundled" && (plugin.contracts?.webFetchProviders?.length ?? 0) > 0,
);
}
function loadBundledWebFetchProviders(params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): BundledWebFetchProviderEntry[] {
const pluginIds = resolveBundledWebFetchPluginIds(params ?? {});
const cacheKey = pluginIds.join("\u0000");
const cached = bundledWebFetchProvidersCache.get(cacheKey);
if (cached) {
return cached;
}
return bundledWebFetchProvidersCache;
const providers =
pluginIds.length === 0
? []
: loadBundledCapabilityRuntimeRegistry({
pluginIds,
pluginSdkResolution: "dist",
}).webFetchProviders.map((entry) => ({
pluginId: entry.pluginId,
...entry.provider,
}));
bundledWebFetchProvidersCache.set(cacheKey, providers);
return providers;
}
export function resolveBundledWebFetchPluginIds(params: {
@ -27,23 +52,41 @@ export function resolveBundledWebFetchPluginIds(params: {
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): string[] {
const bundledWebFetchPluginIdSet = new Set<string>(BUNDLED_WEB_FETCH_PLUGIN_IDS);
return loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})
.plugins.filter(
(plugin) => plugin.origin === "bundled" && bundledWebFetchPluginIdSet.has(plugin.id),
)
return resolveBundledWebFetchManifestPlugins(params)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
export function listBundledWebFetchProviders(): PluginWebFetchProviderEntry[] {
return loadBundledWebFetchProviders();
export function listBundledWebFetchProviders(params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): PluginWebFetchProviderEntry[] {
return loadBundledWebFetchProviders(params);
}
export function resolveBundledWebFetchPluginId(providerId: string | undefined): string | undefined {
return resolveBundledWebFetchPluginIdFromMap(providerId);
export function resolveBundledWebFetchPluginId(
providerId: string | undefined,
params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
},
): string | undefined {
if (!providerId) {
return undefined;
}
const normalizedProviderId = providerId.trim().toLowerCase();
if (!normalizedProviderId) {
return undefined;
}
return resolveBundledWebFetchManifestPlugins({
config: params?.config,
workspaceDir: params?.workspaceDir,
env: params?.env,
}).find((plugin) =>
plugin.contracts?.webFetchProviders?.some(
(candidate) => candidate.trim().toLowerCase() === normalizedProviderId,
),
)?.id;
}

View File

@ -1,7 +1,7 @@
import { BUNDLED_WEB_SEARCH_PLUGIN_IDS as BUNDLED_WEB_SEARCH_PLUGIN_IDS_FROM_METADATA } from "./bundled-capability-metadata.js";
import { listBundledWebSearchPluginIds as listBundledWebSearchPluginIdsImpl } from "./bundled-web-search.js";
export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = BUNDLED_WEB_SEARCH_PLUGIN_IDS_FROM_METADATA;
export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = listBundledWebSearchPluginIdsImpl();
export function listBundledWebSearchPluginIds(): string[] {
return [...BUNDLED_WEB_SEARCH_PLUGIN_IDS];
return listBundledWebSearchPluginIdsImpl();
}

View File

@ -1,14 +1 @@
import { BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS } from "./bundled-capability-metadata.js";
export function resolveBundledWebSearchPluginId(
providerId: string | undefined,
): string | undefined {
if (!providerId) {
return undefined;
}
const normalizedProviderId = providerId.trim().toLowerCase();
if (!(normalizedProviderId in BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS)) {
return undefined;
}
return BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS[normalizedProviderId];
}
export { resolveBundledWebSearchPluginId } from "./bundled-web-search.js";

View File

@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js";
import { BUNDLED_WEB_SEARCH_PLUGIN_IDS } from "./bundled-web-search-ids.js";
import { hasBundledWebSearchCredential } from "./bundled-web-search-registry.js";
import {
listBundledWebSearchPluginIds,
@ -58,8 +57,9 @@ describe("bundled web search helpers", () => {
vi.clearAllMocks();
vi.mocked(loadPluginManifestRegistry).mockReturnValue({
plugins: [
{ id: "xai", origin: "bundled" },
{ id: "google", origin: "bundled" },
{ id: "xai", origin: "bundled", contracts: { webSearchProviders: ["grok"] } },
{ id: "google", origin: "bundled", contracts: { webSearchProviders: ["gemini"] } },
{ id: "minimax", origin: "bundled", contracts: { webSearchProviders: ["minimax"] } },
{ id: "noise", origin: "bundled" },
{ id: "external-google", origin: "workspace" },
] as never[],
@ -92,7 +92,7 @@ describe("bundled web search helpers", () => {
} as never);
});
it("filters bundled manifest entries down to known bundled web search plugins", () => {
it("returns bundled manifest-derived web search plugins from the registry", () => {
expect(
resolveBundledWebSearchPluginIds({
config: {
@ -103,7 +103,7 @@ describe("bundled web search helpers", () => {
workspaceDir: "/tmp/workspace",
env: { OPENCLAW_HOME: "/tmp/openclaw-home" },
}),
).toEqual(["google", "xai"]);
).toEqual(["google", "minimax", "xai"]);
expect(loadPluginManifestRegistry).toHaveBeenCalledWith({
config: {
plugins: {
@ -117,8 +117,8 @@ describe("bundled web search helpers", () => {
it("returns a copy of the bundled plugin id fast-path list", () => {
const listed = listBundledWebSearchPluginIds();
expect(listed).toEqual([...BUNDLED_WEB_SEARCH_PLUGIN_IDS]);
expect(listed).not.toBe(BUNDLED_WEB_SEARCH_PLUGIN_IDS);
expect(listed).toEqual(["google", "minimax", "xai"]);
expect(listed).not.toBe(listBundledWebSearchPluginIds());
});
it("maps bundled provider ids back to their owning plugins", () => {
@ -140,7 +140,7 @@ describe("bundled web search helpers", () => {
]);
expect(loadBundledCapabilityRuntimeRegistry).toHaveBeenCalledTimes(1);
expect(loadBundledCapabilityRuntimeRegistry).toHaveBeenCalledWith({
pluginIds: BUNDLED_WEB_SEARCH_PLUGIN_IDS,
pluginIds: ["google", "minimax", "xai"],
pluginSdkResolution: "dist",
});
});

View File

@ -1,25 +1,50 @@
import { BUNDLED_WEB_SEARCH_PLUGIN_IDS } from "./bundled-capability-metadata.js";
import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js";
import { resolveBundledWebSearchPluginId as resolveBundledWebSearchPluginIdFromMap } from "./bundled-web-search-provider-ids.js";
import type { PluginLoadOptions } from "./loader.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import type { PluginWebSearchProviderEntry } from "./types.js";
type BundledWebSearchProviderEntry = PluginWebSearchProviderEntry & { pluginId: string };
let bundledWebSearchProvidersCache: BundledWebSearchProviderEntry[] | null = null;
const bundledWebSearchProvidersCache = new Map<string, BundledWebSearchProviderEntry[]>();
function loadBundledWebSearchProviders(): BundledWebSearchProviderEntry[] {
if (!bundledWebSearchProvidersCache) {
bundledWebSearchProvidersCache = loadBundledCapabilityRuntimeRegistry({
pluginIds: BUNDLED_WEB_SEARCH_PLUGIN_IDS,
pluginSdkResolution: "dist",
}).webSearchProviders.map((entry) => ({
pluginId: entry.pluginId,
...entry.provider,
}));
function resolveBundledWebSearchManifestPlugins(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}) {
return loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}).plugins.filter(
(plugin) =>
plugin.origin === "bundled" && (plugin.contracts?.webSearchProviders?.length ?? 0) > 0,
);
}
function loadBundledWebSearchProviders(params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): BundledWebSearchProviderEntry[] {
const pluginIds = resolveBundledWebSearchPluginIds(params ?? {});
const cacheKey = pluginIds.join("\u0000");
const cached = bundledWebSearchProvidersCache.get(cacheKey);
if (cached) {
return cached;
}
return bundledWebSearchProvidersCache;
const providers =
pluginIds.length === 0
? []
: loadBundledCapabilityRuntimeRegistry({
pluginIds,
pluginSdkResolution: "dist",
}).webSearchProviders.map((entry) => ({
pluginId: entry.pluginId,
...entry.provider,
}));
bundledWebSearchProvidersCache.set(cacheKey, providers);
return providers;
}
export function resolveBundledWebSearchPluginIds(params: {
@ -27,29 +52,49 @@ export function resolveBundledWebSearchPluginIds(params: {
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): string[] {
const bundledWebSearchPluginIdSet = new Set<string>(BUNDLED_WEB_SEARCH_PLUGIN_IDS);
return loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})
.plugins.filter(
(plugin) => plugin.origin === "bundled" && bundledWebSearchPluginIdSet.has(plugin.id),
)
return resolveBundledWebSearchManifestPlugins(params)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
export function listBundledWebSearchPluginIds(): string[] {
return [...BUNDLED_WEB_SEARCH_PLUGIN_IDS];
export function listBundledWebSearchPluginIds(params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): string[] {
return resolveBundledWebSearchPluginIds(params ?? {});
}
export function listBundledWebSearchProviders(): PluginWebSearchProviderEntry[] {
return loadBundledWebSearchProviders();
export function listBundledWebSearchProviders(params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): PluginWebSearchProviderEntry[] {
return loadBundledWebSearchProviders(params);
}
export function resolveBundledWebSearchPluginId(
providerId: string | undefined,
params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
},
): string | undefined {
return resolveBundledWebSearchPluginIdFromMap(providerId);
if (!providerId) {
return undefined;
}
const normalizedProviderId = providerId.trim().toLowerCase();
if (!normalizedProviderId) {
return undefined;
}
return resolveBundledWebSearchManifestPlugins({
config: params?.config,
workspaceDir: params?.workspaceDir,
env: params?.env,
}).find((plugin) =>
plugin.contracts?.webSearchProviders?.some(
(candidate) => candidate.trim().toLowerCase() === normalizedProviderId,
),
)?.id;
}

View File

@ -25,8 +25,7 @@ export async function resolvePreferredProviderForAuthChoice(params: {
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
});
const pluginResolved = resolveProviderPluginChoice({
providers,

View File

@ -174,8 +174,7 @@ export async function applyAuthChoiceLoadedPluginProvider(
config: params.config,
workspaceDir,
env: params.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
});
const resolved = resolveProviderPluginChoice({
providers,
@ -256,8 +255,7 @@ export async function applyAuthChoicePluginProvider(
config: nextConfig,
workspaceDir,
env: params.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
});
const provider = resolveProviderMatch(providers, options.providerId);
if (!provider) {

View File

@ -79,64 +79,6 @@ function createWizardRuntimeParams(params?: {
};
}
function expectWizardResolutionCount(params: {
provider: ProviderPlugin;
config?: object;
env?: NodeJS.ProcessEnv;
expectedCount: number;
}) {
setResolvedProviders(params.provider);
resolveProviderWizardOptions(
createWizardRuntimeParams({
config: params.config,
env: params.env,
}),
);
resolveProviderWizardOptions(
createWizardRuntimeParams({
config: params.config,
env: params.env,
}),
);
expectProviderResolutionCall({
config: params.config,
env: params.env,
count: params.expectedCount,
});
}
function expectWizardCacheInvalidationCount(params: {
provider: ProviderPlugin;
config: { [key: string]: unknown };
env: NodeJS.ProcessEnv;
mutate: () => void;
expectedCount?: number;
}) {
setResolvedProviders(params.provider);
resolveProviderWizardOptions(
createWizardRuntimeParams({
config: params.config,
env: params.env,
}),
);
params.mutate();
resolveProviderWizardOptions(
createWizardRuntimeParams({
config: params.config,
env: params.env,
}),
);
expectProviderResolutionCall({
config: params.config,
env: params.env,
count: params.expectedCount ?? 2,
});
}
function expectProviderResolutionCall(params?: {
config?: object;
env?: NodeJS.ProcessEnv;
@ -146,8 +88,7 @@ function expectProviderResolutionCall(params?: {
expect(resolvePluginProviders).toHaveBeenCalledTimes(params?.count ?? 1);
expect(resolvePluginProviders).toHaveBeenCalledWith({
...createWizardRuntimeParams(params),
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
});
}
@ -351,7 +292,7 @@ describe("provider wizard boundaries", () => {
]);
});
it("reuses provider resolution across wizard consumers for the same config and env", () => {
it("resolves providers in setup mode across wizard consumers", () => {
const provider = createSglangWizardProvider({ includeModelPicker: true });
const config = {};
const env = createHomeEnv();
@ -361,82 +302,9 @@ describe("provider wizard boundaries", () => {
expect(resolveProviderWizardOptions(runtimeParams)).toHaveLength(1);
expect(resolveProviderModelPickerEntries(runtimeParams)).toHaveLength(1);
expectProviderResolutionCall({ config, env });
});
it("invalidates the wizard cache when config or env contents change in place", () => {
const config = createSglangConfig();
const env = createHomeEnv("-a");
expectWizardCacheInvalidationCount({
provider: createSglangWizardProvider(),
config,
env,
mutate: () => {
config.plugins.allow = ["vllm"];
env.OPENCLAW_HOME = "/tmp/openclaw-home-b";
},
});
});
it.each([
{
name: "skips provider-wizard memoization when plugin cache opt-outs are set",
env: createHomeEnv("", {
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
}),
},
{
name: "skips provider-wizard memoization when discovery cache ttl is zero",
env: createHomeEnv("", {
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "0",
}),
},
] as const)("$name", ({ env }) => {
expectWizardResolutionCount({
provider: createSglangWizardProvider(),
config: createSglangConfig(),
env,
expectedCount: 2,
});
});
it("expires provider-wizard memoization after the shortest plugin cache ttl", () => {
vi.useFakeTimers();
const provider = createSglangWizardProvider();
const config = {};
const env = createHomeEnv("", {
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5",
OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "20",
});
setResolvedProviders(provider);
const runtimeParams = createWizardRuntimeParams({ config, env });
resolveProviderWizardOptions(runtimeParams);
vi.advanceTimersByTime(4);
resolveProviderWizardOptions(runtimeParams);
vi.advanceTimersByTime(2);
resolveProviderWizardOptions(runtimeParams);
expectProviderResolutionCall({ config, env, count: 2 });
});
it("invalidates provider-wizard snapshots when cache-control env values change in place", () => {
const config = {};
const env = createHomeEnv("", {
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "1000",
});
expectWizardCacheInvalidationCount({
provider: createSglangWizardProvider(),
config,
env,
mutate: () => {
env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS = "5";
},
});
});
it("routes model-selected hooks only to the matching provider", async () => {
const matchingHook = vi.fn(async () => {});
const otherHook = vi.fn(async () => {});

View File

@ -2,11 +2,6 @@ import { DEFAULT_PROVIDER } from "../agents/defaults.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
buildPluginSnapshotCacheEnvKey,
resolvePluginSnapshotCacheTtlMs,
shouldUsePluginSnapshotCache,
} from "./cache-controls.js";
import { resolvePluginProviders } from "./providers.runtime.js";
import type {
ProviderAuthMethod,
@ -16,26 +11,6 @@ import type {
} from "./types.js";
export const PROVIDER_PLUGIN_CHOICE_PREFIX = "provider-plugin:";
type ProviderWizardCacheEntry = {
expiresAt: number;
providers: ProviderPlugin[];
};
const providerWizardCache = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, ProviderWizardCacheEntry>>
>();
function buildProviderWizardCacheKey(params: {
config: OpenClawConfig;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
}): string {
return JSON.stringify({
workspaceDir: params.workspaceDir ?? "",
config: params.config,
env: buildPluginSnapshotCacheEnvKey(params.env),
});
}
export type ProviderWizardOption = {
value: string;
@ -135,53 +110,12 @@ function resolveProviderWizardProviders(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderPlugin[] {
if (!params.config) {
return resolvePluginProviders(params);
}
const env = params.env ?? process.env;
if (!shouldUsePluginSnapshotCache(env)) {
return resolvePluginProviders({
config: params.config,
workspaceDir: params.workspaceDir,
env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
});
}
const cacheKey = buildProviderWizardCacheKey({
return resolvePluginProviders({
config: params.config,
workspaceDir: params.workspaceDir,
env,
env: params.env,
mode: "setup",
});
const configCache = providerWizardCache.get(params.config);
const envCache = configCache?.get(env);
const cached = envCache?.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.providers;
}
const providers = resolvePluginProviders({
config: params.config,
workspaceDir: params.workspaceDir,
env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
});
const ttlMs = resolvePluginSnapshotCacheTtlMs(env);
let nextConfigCache = configCache;
if (!nextConfigCache) {
nextConfigCache = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderWizardCacheEntry>>();
providerWizardCache.set(params.config, nextConfigCache);
}
let nextEnvCache = nextConfigCache.get(env);
if (!nextEnvCache) {
nextEnvCache = new Map<string, ProviderWizardCacheEntry>();
nextConfigCache.set(env, nextEnvCache);
}
nextEnvCache.set(cacheKey, {
expiresAt: Date.now() + ttlMs,
providers,
});
return providers;
}
export function resolveProviderWizardOptions(params: {

View File

@ -1,8 +1,14 @@
import { createSubsystemLogger } from "../logging/subsystem.js";
import { withActivatedPluginIds } from "./activation-context.js";
import { resolveBundledPluginCompatibleActivationInputs } from "./activation-context.js";
import { resolveRuntimePluginRegistry, type PluginLoadOptions } from "./loader.js";
import {
loadOpenClawPlugins,
resolveRuntimePluginRegistry,
type PluginLoadOptions,
} from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
import {
resolveDiscoveredProviderPluginIds,
resolveEnabledProviderPluginIds,
resolveBundledProviderCompatPluginIds,
resolveOwningPluginIdsForModelRefs,
@ -12,38 +18,6 @@ import type { ProviderPlugin } from "./types.js";
const log = createSubsystemLogger("plugins");
function withRuntimeActivatedPluginIds(params: {
config?: PluginLoadOptions["config"];
pluginIds: readonly string[];
}): PluginLoadOptions["config"] {
if (params.pluginIds.length === 0) {
return params.config;
}
const allow = new Set(params.config?.plugins?.allow ?? []);
const entries = {
...params.config?.plugins?.entries,
};
for (const pluginId of params.pluginIds) {
const normalized = pluginId.trim();
if (!normalized) {
continue;
}
allow.add(normalized);
entries[normalized] = {
...entries[normalized],
enabled: true,
};
}
return {
...params.config,
plugins: {
...params.config?.plugins,
...(allow.size > 0 ? { allow: [...allow] } : {}),
entries,
},
};
}
export function resolvePluginProviders(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
@ -56,6 +30,7 @@ export function resolvePluginProviders(params: {
activate?: boolean;
cache?: boolean;
pluginSdkResolution?: PluginLoadOptions["pluginSdkResolution"];
mode?: "runtime" | "setup";
}): ProviderPlugin[] {
const env = params.env ?? process.env;
const modelOwnedPluginIds = params.modelRefs?.length
@ -70,10 +45,40 @@ export function resolvePluginProviders(params: {
params.onlyPluginIds || modelOwnedPluginIds.length > 0
? [...new Set([...(params.onlyPluginIds ?? []), ...modelOwnedPluginIds])]
: undefined;
const runtimeConfig = withRuntimeActivatedPluginIds({
const runtimeConfig = withActivatedPluginIds({
config: params.config,
pluginIds: modelOwnedPluginIds,
});
if (params.mode === "setup") {
const providerPluginIds = resolveDiscoveredProviderPluginIds({
config: runtimeConfig,
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: requestedPluginIds,
});
if (providerPluginIds.length === 0) {
return [];
}
const registry = loadOpenClawPlugins({
config: withActivatedPluginIds({
config: runtimeConfig,
pluginIds: providerPluginIds,
}),
activationSourceConfig: runtimeConfig,
autoEnabledReasons: {},
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: providerPluginIds,
pluginSdkResolution: params.pluginSdkResolution,
cache: params.cache ?? false,
activate: params.activate ?? false,
logger: createPluginLoaderLogger(log),
});
return registry.providers.map((entry) => ({
...entry.provider,
pluginId: entry.pluginId,
}));
}
const activation = resolveBundledPluginCompatibleActivationInputs({
rawConfig: runtimeConfig,
env,

View File

@ -6,11 +6,13 @@ import { createEmptyPluginRegistry } from "./registry-empty.js";
import type { ProviderPlugin } from "./types.js";
type ResolveRuntimePluginRegistry = typeof import("./loader.js").resolveRuntimePluginRegistry;
type LoadOpenClawPlugins = typeof import("./loader.js").loadOpenClawPlugins;
type LoadPluginManifestRegistry =
typeof import("./manifest-registry.js").loadPluginManifestRegistry;
type ApplyPluginAutoEnable = typeof import("../config/plugin-auto-enable.js").applyPluginAutoEnable;
const resolveRuntimePluginRegistryMock = vi.fn<ResolveRuntimePluginRegistry>();
const loadOpenClawPluginsMock = vi.fn<LoadOpenClawPlugins>();
const loadPluginManifestRegistryMock = vi.fn<LoadPluginManifestRegistry>();
const applyPluginAutoEnableMock = vi.fn<ApplyPluginAutoEnable>();
@ -131,6 +133,20 @@ function expectLastRuntimeRegistryLoad(params?: {
);
}
function expectLastSetupRegistryLoad(params?: {
env?: NodeJS.ProcessEnv;
onlyPluginIds?: readonly string[];
}) {
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
cache: false,
activate: false,
...(params?.env ? { env: params.env } : {}),
...(params?.onlyPluginIds ? { onlyPluginIds: params.onlyPluginIds } : {}),
}),
);
}
function getLastResolvedPluginConfig() {
return getLastRuntimeRegistryCall().config as
| {
@ -142,6 +158,19 @@ function getLastResolvedPluginConfig() {
| undefined;
}
function getLastSetupLoadedPluginConfig() {
const call = loadOpenClawPluginsMock.mock.calls.at(-1)?.[0];
expect(call).toBeDefined();
return (call?.config ?? undefined) as
| {
plugins?: {
allow?: string[];
entries?: Record<string, { enabled?: boolean }>;
};
}
| undefined;
}
function createBundledProviderCompatOptions(params?: { onlyPluginIds?: readonly string[] }) {
return {
config: {
@ -222,6 +251,8 @@ describe("resolvePluginProviders", () => {
beforeAll(async () => {
vi.resetModules();
vi.doMock("./loader.js", () => ({
loadOpenClawPlugins: (...args: Parameters<LoadOpenClawPlugins>) =>
loadOpenClawPluginsMock(...args),
resolveRuntimePluginRegistry: (...args: Parameters<ResolveRuntimePluginRegistry>) =>
resolveRuntimePluginRegistryMock(...args),
}));
@ -243,6 +274,7 @@ describe("resolvePluginProviders", () => {
beforeEach(() => {
resolveRuntimePluginRegistryMock.mockReset();
loadOpenClawPluginsMock.mockReset();
const provider: ProviderPlugin = {
id: "demo-provider",
label: "Demo Provider",
@ -251,6 +283,7 @@ describe("resolvePluginProviders", () => {
const registry = createEmptyPluginRegistry();
registry.providers.push({ pluginId: "google", provider, source: "bundled" });
resolveRuntimePluginRegistryMock.mockReturnValue(registry);
loadOpenClawPluginsMock.mockReturnValue(registry);
loadPluginManifestRegistryMock.mockReset();
applyPluginAutoEnableMock.mockReset();
applyPluginAutoEnableMock.mockImplementation(
@ -452,6 +485,43 @@ describe("resolvePluginProviders", () => {
});
});
it("loads all discovered provider plugins in setup mode", () => {
resolvePluginProviders({
config: {
plugins: {
allow: ["openrouter"],
entries: {
google: { enabled: false },
},
},
},
mode: "setup",
});
expectLastSetupRegistryLoad({
onlyPluginIds: ["google", "kilocode", "moonshot", "workspace-provider"],
});
expect(getLastSetupLoadedPluginConfig()).toEqual(
expect.objectContaining({
plugins: expect.objectContaining({
allow: expect.arrayContaining([
"openrouter",
"google",
"kilocode",
"moonshot",
"workspace-provider",
]),
entries: expect.objectContaining({
google: { enabled: true },
kilocode: { enabled: true },
moonshot: { enabled: true },
"workspace-provider": { enabled: true },
}),
}),
}),
);
});
it("loads provider plugins from the auto-enabled config snapshot", () => {
const { rawConfig, autoEnabledConfig } = createAutoEnabledProviderConfig();
applyPluginAutoEnableMock.mockReturnValue({

View File

@ -69,8 +69,29 @@ export function resolveEnabledProviderPluginIds(params: {
.toSorted((left, right) => left.localeCompare(right));
}
export function resolveDiscoveredProviderPluginIds(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
onlyPluginIds?: readonly string[];
}): string[] {
const onlyPluginIdSet = params.onlyPluginIds ? new Set(params.onlyPluginIds) : null;
return loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})
.plugins.filter(
(plugin) =>
plugin.providers.length > 0 && (!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)),
)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
export const __testing = {
resolveEnabledProviderPluginIds,
resolveDiscoveredProviderPluginIds,
resolveBundledProviderCompatPluginIds,
withBundledProviderVitestCompat,
} as const;

View File

@ -345,6 +345,32 @@ describe("resolvePluginWebSearchProviders", () => {
expectLoaderCallCount(1);
});
it("loads manifest-declared web-search providers in setup mode", () => {
const providers = resolvePluginWebSearchProviders({
config: {
plugins: {
allow: ["perplexity"],
},
},
mode: "setup",
});
expect(toRuntimeProviderKeys(providers)).toEqual(["brave:brave"]);
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["brave"],
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: ["perplexity", "brave"],
entries: {
brave: { enabled: true },
},
}),
}),
}),
);
});
it("loads plugin web-search providers from the auto-enabled config snapshot", () => {
const rawConfig = createBraveAllowConfig();
const autoEnabledConfig = {

View File

@ -1,6 +1,7 @@
import type { OpenClawConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { isRecord } from "../utils.js";
import { withActivatedPluginIds } from "./activation-context.js";
import {
buildPluginSnapshotCacheEnvKey,
resolvePluginSnapshotCacheTtlMs,
@ -155,8 +156,36 @@ export function resolvePluginWebSearchProviders(params: {
onlyPluginIds?: readonly string[];
activate?: boolean;
cache?: boolean;
mode?: "runtime" | "setup";
}): PluginWebSearchProviderEntry[] {
const env = params.env ?? process.env;
if (params.mode === "setup") {
const pluginIds =
resolveWebSearchCandidatePluginIds({
config: params.config,
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: params.onlyPluginIds,
}) ?? [];
if (pluginIds.length === 0) {
return [];
}
const registry = loadOpenClawPlugins({
config: withActivatedPluginIds({
config: params.config,
pluginIds,
}),
activationSourceConfig: params.config,
autoEnabledReasons: {},
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: pluginIds,
cache: params.cache ?? false,
activate: params.activate ?? false,
logger: createPluginLoaderLogger(log),
});
return mapRegistryWebSearchProviders({ registry, onlyPluginIds: pluginIds });
}
const cacheOwnerConfig = params.config;
const shouldMemoizeSnapshot =
params.activate !== true && params.cache !== true && shouldUsePluginSnapshotCache(env);

View File

@ -0,0 +1,38 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const loadPluginManifestRegistry = vi.hoisted(() =>
vi.fn(() => ({ plugins: [], diagnostics: [] })),
);
vi.mock("../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry,
}));
describe("provider env vars dynamic manifest metadata", () => {
beforeEach(() => {
vi.resetModules();
loadPluginManifestRegistry.mockReset();
loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] });
});
it("includes later-installed plugin env vars without a bundled generated map", async () => {
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "external-fireworks",
origin: "global",
providerAuthEnvVars: {
fireworks: ["FIREWORKS_ALT_API_KEY"],
},
},
],
diagnostics: [],
});
const mod = await import("./provider-env-vars.js");
expect(mod.getProviderEnvVars("fireworks")).toEqual(["FIREWORKS_ALT_API_KEY"]);
expect(mod.listKnownProviderAuthEnvVarNames()).toContain("FIREWORKS_ALT_API_KEY");
expect(mod.listKnownSecretEnvVarNames()).toContain("FIREWORKS_ALT_API_KEY");
});
});

View File

@ -1,4 +1,5 @@
import { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "../plugins/bundled-provider-auth-env-vars.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
const CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES = {
voyage: ["VOYAGE_API_KEY"],
@ -11,6 +12,73 @@ const CORE_PROVIDER_SETUP_ENV_VAR_OVERRIDES = {
"minimax-cn": ["MINIMAX_API_KEY"],
} as const;
type ProviderEnvVarLookupParams = {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
};
function appendUniqueEnvVarCandidates(
target: Record<string, string[]>,
providerId: string,
keys: readonly string[],
) {
const normalizedProviderId = providerId.trim();
if (!normalizedProviderId || keys.length === 0) {
return;
}
const bucket = (target[normalizedProviderId] ??= []);
const seen = new Set(bucket);
for (const key of keys) {
const normalizedKey = key.trim();
if (!normalizedKey || seen.has(normalizedKey)) {
continue;
}
seen.add(normalizedKey);
bucket.push(normalizedKey);
}
}
function resolveManifestProviderAuthEnvVarCandidates(
params?: ProviderEnvVarLookupParams,
): Record<string, string[]> {
const registry = loadPluginManifestRegistry({
config: params?.config,
workspaceDir: params?.workspaceDir,
env: params?.env,
});
const candidates: Record<string, string[]> = Object.create(null) as Record<string, string[]>;
for (const plugin of registry.plugins) {
if (!plugin.providerAuthEnvVars) {
continue;
}
for (const [providerId, keys] of Object.entries(plugin.providerAuthEnvVars).toSorted(
([left], [right]) => left.localeCompare(right),
)) {
appendUniqueEnvVarCandidates(candidates, providerId, keys);
}
}
return candidates;
}
export function resolveProviderAuthEnvVarCandidates(
params?: ProviderEnvVarLookupParams,
): Record<string, readonly string[]> {
return {
...resolveManifestProviderAuthEnvVarCandidates(params),
...CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES,
};
}
export function resolveProviderEnvVars(
params?: ProviderEnvVarLookupParams,
): Record<string, readonly string[]> {
return {
...resolveProviderAuthEnvVarCandidates(params),
...CORE_PROVIDER_SETUP_ENV_VAR_OVERRIDES,
};
}
/**
* Provider auth env candidates used by generic auth resolution.
*
@ -19,8 +87,7 @@ const CORE_PROVIDER_SETUP_ENV_VAR_OVERRIDES = {
* metadata so auth probes do not need to load plugin runtime.
*/
export const PROVIDER_AUTH_ENV_VAR_CANDIDATES: Record<string, readonly string[]> = {
...BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES,
...CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES,
...resolveProviderAuthEnvVarCandidates(),
};
/**
@ -33,39 +100,36 @@ export const PROVIDER_AUTH_ENV_VAR_CANDIDATES: Record<string, readonly string[]>
* overrides where generic onboarding wants a different preferred env var.
*/
export const PROVIDER_ENV_VARS: Record<string, readonly string[]> = {
...PROVIDER_AUTH_ENV_VAR_CANDIDATES,
...CORE_PROVIDER_SETUP_ENV_VAR_OVERRIDES,
...resolveProviderEnvVars(),
};
export function getProviderEnvVars(providerId: string): string[] {
const envVars = Object.hasOwn(PROVIDER_ENV_VARS, providerId)
? PROVIDER_ENV_VARS[providerId]
export function getProviderEnvVars(
providerId: string,
params?: ProviderEnvVarLookupParams,
): string[] {
const providerEnvVars = resolveProviderEnvVars(params);
const envVars = Object.hasOwn(providerEnvVars, providerId)
? providerEnvVars[providerId]
: undefined;
return Array.isArray(envVars) ? [...envVars] : [];
}
const EXTRA_PROVIDER_AUTH_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY"] as const;
const KNOWN_SECRET_ENV_VARS = [
...new Set(Object.values(PROVIDER_ENV_VARS).flatMap((keys) => keys)),
];
// OPENCLAW_API_KEY authenticates the local OpenClaw bridge itself and must
// remain available to child bridge/runtime processes.
const KNOWN_PROVIDER_AUTH_ENV_VARS = [
...new Set([
...Object.values(PROVIDER_AUTH_ENV_VAR_CANDIDATES).flatMap((keys) => keys),
...KNOWN_SECRET_ENV_VARS,
...EXTRA_PROVIDER_AUTH_ENV_VARS,
]),
];
export function listKnownProviderAuthEnvVarNames(): string[] {
return [...KNOWN_PROVIDER_AUTH_ENV_VARS];
export function listKnownProviderAuthEnvVarNames(params?: ProviderEnvVarLookupParams): string[] {
return [
...new Set([
...Object.values(resolveProviderAuthEnvVarCandidates(params)).flatMap((keys) => keys),
...Object.values(resolveProviderEnvVars(params)).flatMap((keys) => keys),
...EXTRA_PROVIDER_AUTH_ENV_VARS,
]),
];
}
export function listKnownSecretEnvVarNames(): string[] {
return [...KNOWN_SECRET_ENV_VARS];
export function listKnownSecretEnvVarNames(params?: ProviderEnvVarLookupParams): string[] {
return [...new Set(Object.values(resolveProviderEnvVars(params)).flatMap((keys) => keys))];
}
export function omitEnvKeysCaseInsensitive(

View File

@ -89,7 +89,7 @@ vi.mock("../commands/health.js", () => ({
}));
vi.mock("../commands/onboard-search.js", () => ({
SEARCH_PROVIDER_OPTIONS: [],
listSearchProviderOptions: () => [],
resolveSearchProviderOptions: () => [],
hasExistingKey,
hasKeyInEnv,

View File

@ -48,8 +48,7 @@ async function resolveAuthChoiceModelSelectionPolicy(params: {
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
});
const resolvedChoice = resolveProviderPluginChoice({
providers,

View File

@ -51,14 +51,16 @@ describe("app-tool-stream fallback lifecycle handling", () => {
data: {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
selectedModel: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
reasonSummary: "rate limit",
},
});
expect(host.fallbackStatus?.selected).toBe("fireworks/minimax-m2p5");
expect(host.fallbackStatus?.selected).toBe(
"fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
);
expect(host.fallbackStatus?.active).toBe("deepinfra/moonshotai/Kimi-K2.5");
expect(host.fallbackStatus?.reason).toBe("rate limit");
vi.useRealTimers();
@ -77,7 +79,7 @@ describe("app-tool-stream fallback lifecycle handling", () => {
data: {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
selectedModel: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
},
@ -100,7 +102,7 @@ describe("app-tool-stream fallback lifecycle handling", () => {
data: {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
selectedModel: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
},
@ -127,9 +129,9 @@ describe("app-tool-stream fallback lifecycle handling", () => {
data: {
phase: "fallback_cleared",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
selectedModel: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
activeProvider: "fireworks",
activeModel: "fireworks/minimax-m2p5",
activeModel: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
previousActiveProvider: "deepinfra",
previousActiveModel: "moonshotai/Kimi-K2.5",
},

View File

@ -622,9 +622,9 @@ describe("chat view", () => {
renderChat(
createProps({
fallbackStatus: {
selected: "fireworks/minimax-m2p5",
selected: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
active: "deepinfra/moonshotai/Kimi-K2.5",
attempts: ["fireworks/minimax-m2p5: rate limit"],
attempts: ["fireworks/accounts/fireworks/routers/kimi-k2p5-turbo: rate limit"],
occurredAt: 900,
},
}),
@ -645,7 +645,7 @@ describe("chat view", () => {
renderChat(
createProps({
fallbackStatus: {
selected: "fireworks/minimax-m2p5",
selected: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
active: "deepinfra/moonshotai/Kimi-K2.5",
attempts: [],
occurredAt: 0,
@ -667,8 +667,8 @@ describe("chat view", () => {
createProps({
fallbackStatus: {
phase: "cleared",
selected: "fireworks/minimax-m2p5",
active: "fireworks/minimax-m2p5",
selected: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
active: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
previous: "deepinfra/moonshotai/Kimi-K2.5",
attempts: [],
occurredAt: 900,
@ -680,7 +680,9 @@ describe("chat view", () => {
const indicator = container.querySelector(".compaction-indicator--fallback-cleared");
expect(indicator).not.toBeNull();
expect(indicator?.textContent).toContain("Fallback cleared: fireworks/minimax-m2p5");
expect(indicator?.textContent).toContain(
"Fallback cleared: fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
);
nowSpy.mockRestore();
});