feat: add bundled Chutes extension (#49136)

* refactor: generalize bundled provider discovery seams

* feat: land chutes extension via plugin-owned auth (#41416) (thanks @Veightor)
This commit is contained in:
Peter Steinberger 2026-03-17 09:35:21 -07:00 committed by GitHub
parent ea15819ecf
commit a724bbce1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1856 additions and 171 deletions

View File

@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
- Plugins/bundles: make enabled bundle MCP servers expose runnable tools in embedded Pi, and default relative bundle MCP launches to the bundle root so marketplace bundles like Context7 work through Pi instead of stopping at config import.
- Scope message SecretRef resolution and harden doctor/status paths. (#48728) Thanks @joshavant.
- Plugins/testing: add a public `openclaw/plugin-sdk/testing` seam for plugin-author test helpers, and move bundled-extension-only test bridges out of `extensions/` into private repo test helpers.
- Plugins/Chutes: add a bundled Chutes provider with plugin-owned OAuth/API-key auth, dynamic model discovery, and default-on extension wiring. (#41416) Thanks @Veightor.
### Breaking

184
extensions/chutes/index.ts Normal file
View File

@ -0,0 +1,184 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import {
buildOauthProviderAuthResult,
createProviderApiKeyAuthMethod,
loginChutes,
resolveOAuthApiKeyMarker,
type ProviderAuthContext,
type ProviderAuthResult,
} from "openclaw/plugin-sdk/provider-auth";
import {
CHUTES_DEFAULT_MODEL_REF,
applyChutesApiKeyConfig,
applyChutesProviderConfig,
} from "./onboard.js";
import { buildChutesProvider } from "./provider-catalog.js";
const PROVIDER_ID = "chutes";
async function runChutesOAuth(ctx: ProviderAuthContext): Promise<ProviderAuthResult> {
const isRemote = ctx.isRemote;
const redirectUri =
process.env.CHUTES_OAUTH_REDIRECT_URI?.trim() || "http://127.0.0.1:1456/oauth-callback";
const scopes = process.env.CHUTES_OAUTH_SCOPES?.trim() || "openid profile chutes:invoke";
const clientId =
process.env.CHUTES_CLIENT_ID?.trim() ||
String(
await ctx.prompter.text({
message: "Enter Chutes OAuth client id",
placeholder: "cid_xxx",
validate: (value: string) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined;
await ctx.prompter.note(
isRemote
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, paste the redirect URL back here.",
"",
`Redirect URI: ${redirectUri}`,
].join("\n")
: [
"Browser will open for Chutes authentication.",
"If the callback doesn't auto-complete, paste the redirect URL.",
"",
`Redirect URI: ${redirectUri}`,
].join("\n"),
"Chutes OAuth",
);
const progress = ctx.prompter.progress("Starting Chutes OAuth…");
try {
const { onAuth, onPrompt } = ctx.oauth.createVpsAwareHandlers({
isRemote,
prompter: ctx.prompter,
runtime: ctx.runtime,
spin: progress,
openUrl: ctx.openUrl,
localBrowserMessage: "Complete sign-in in browser…",
});
const creds = await loginChutes({
app: {
clientId,
clientSecret,
redirectUri,
scopes: scopes.split(/\s+/).filter(Boolean),
},
manual: isRemote,
onAuth,
onPrompt,
onProgress: (message) => progress.update(message),
});
progress.stop("Chutes OAuth complete");
return buildOauthProviderAuthResult({
providerId: PROVIDER_ID,
defaultModel: CHUTES_DEFAULT_MODEL_REF,
access: creds.access,
refresh: creds.refresh,
expires: creds.expires,
email: typeof creds.email === "string" ? creds.email : undefined,
credentialExtra: {
clientId,
...("accountId" in creds && typeof creds.accountId === "string"
? { accountId: creds.accountId }
: {}),
},
configPatch: applyChutesProviderConfig({}),
notes: [
"Chutes OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.",
`Redirect URI: ${redirectUri}`,
],
});
} catch (err) {
progress.stop("Chutes OAuth failed");
await ctx.prompter.note(
[
"Trouble with OAuth?",
"Verify CHUTES_CLIENT_ID (and CHUTES_CLIENT_SECRET if required).",
`Verify the OAuth app redirect URI includes: ${redirectUri}`,
"Chutes docs: https://chutes.ai/docs/sign-in-with-chutes/overview",
].join("\n"),
"OAuth help",
);
throw err;
}
}
export default definePluginEntry({
id: PROVIDER_ID,
name: "Chutes Provider",
description: "Bundled Chutes.ai provider plugin",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: "Chutes",
docsPath: "/providers/chutes",
envVars: ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"],
auth: [
{
id: "oauth",
label: "Chutes OAuth",
hint: "Browser sign-in",
kind: "oauth",
wizard: {
choiceId: "chutes",
choiceLabel: "Chutes (OAuth)",
choiceHint: "Browser sign-in",
groupId: "chutes",
groupLabel: "Chutes",
groupHint: "OAuth + API key",
},
run: async (ctx) => await runChutesOAuth(ctx),
},
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "Chutes API key",
hint: "Open-source models including Llama, DeepSeek, and more",
optionKey: "chutesApiKey",
flagName: "--chutes-api-key",
envVar: "CHUTES_API_KEY",
promptMessage: "Enter Chutes API key",
noteTitle: "Chutes",
noteMessage: [
"Chutes provides access to leading open-source models including Llama, DeepSeek, and more.",
"Get your API key at: https://chutes.ai/settings/api-keys",
].join("\n"),
defaultModel: CHUTES_DEFAULT_MODEL_REF,
expectedProviders: ["chutes"],
applyConfig: (cfg) => applyChutesApiKeyConfig(cfg),
wizard: {
choiceId: "chutes-api-key",
choiceLabel: "Chutes API key",
groupId: "chutes",
groupLabel: "Chutes",
groupHint: "OAuth + API key",
},
}),
],
catalog: {
order: "profile",
run: async (ctx) => {
const { apiKey, discoveryApiKey } = ctx.resolveProviderAuth(PROVIDER_ID, {
oauthMarker: resolveOAuthApiKeyMarker(PROVIDER_ID),
});
if (!apiKey) {
return null;
}
return {
provider: {
...(await buildChutesProvider(discoveryApiKey)),
apiKey,
},
};
},
},
});
},
});

View File

@ -0,0 +1,67 @@
import {
CHUTES_BASE_URL,
CHUTES_DEFAULT_MODEL_REF,
CHUTES_MODEL_CATALOG,
buildChutesModelDefinition,
} from "openclaw/plugin-sdk/provider-models";
import {
applyAgentDefaultModelPrimary,
applyProviderConfigWithModelCatalog,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
export { CHUTES_DEFAULT_MODEL_REF };
/**
* Apply Chutes provider configuration without changing the default model.
* Registers all catalog models and sets provider aliases (chutes-fast, etc.).
*/
export function applyChutesProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
for (const m of CHUTES_MODEL_CATALOG) {
models[`chutes/${m.id}`] = {
...models[`chutes/${m.id}`],
};
}
models["chutes-fast"] = { alias: "chutes/zai-org/GLM-4.7-FP8" };
models["chutes-vision"] = { alias: "chutes/chutesai/Mistral-Small-3.2-24B-Instruct-2506" };
models["chutes-pro"] = { alias: "chutes/deepseek-ai/DeepSeek-V3.2-TEE" };
const chutesModels = CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition);
return applyProviderConfigWithModelCatalog(cfg, {
agentModels: models,
providerId: "chutes",
api: "openai-completions",
baseUrl: CHUTES_BASE_URL,
catalogModels: chutesModels,
});
}
/**
* Apply Chutes provider configuration AND set Chutes as the default model.
*/
export function applyChutesConfig(cfg: OpenClawConfig): OpenClawConfig {
const next = applyChutesProviderConfig(cfg);
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
primary: CHUTES_DEFAULT_MODEL_REF,
fallbacks: ["chutes/deepseek-ai/DeepSeek-V3.2-TEE", "chutes/Qwen/Qwen3-32B"],
},
imageModel: {
primary: "chutes/chutesai/Mistral-Small-3.2-24B-Instruct-2506",
fallbacks: ["chutes/chutesai/Mistral-Small-3.1-24B-Instruct-2503"],
},
},
},
};
}
export function applyChutesApiKeyConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(applyChutesProviderConfig(cfg), CHUTES_DEFAULT_MODEL_REF);
}

View File

@ -0,0 +1,39 @@
{
"id": "chutes",
"enabledByDefault": true,
"providers": ["chutes"],
"providerAuthEnvVars": {
"chutes": ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"]
},
"providerAuthChoices": [
{
"provider": "chutes",
"method": "oauth",
"choiceId": "chutes",
"choiceLabel": "Chutes (OAuth)",
"choiceHint": "Browser sign-in",
"groupId": "chutes",
"groupLabel": "Chutes",
"groupHint": "OAuth + API key"
},
{
"provider": "chutes",
"method": "api-key",
"choiceId": "chutes-api-key",
"choiceLabel": "Chutes API key",
"choiceHint": "Open-source models including Llama, DeepSeek, and more",
"groupId": "chutes",
"groupLabel": "Chutes",
"groupHint": "OAuth + API key",
"optionKey": "chutesApiKey",
"cliFlag": "--chutes-api-key",
"cliOption": "--chutes-api-key <key>",
"cliDescription": "Chutes API key"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@ -0,0 +1,12 @@
{
"name": "@openclaw/chutes-provider",
"version": "2026.3.17",
"private": true,
"description": "OpenClaw Chutes.ai provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@ -0,0 +1,21 @@
import {
CHUTES_BASE_URL,
CHUTES_MODEL_CATALOG,
buildChutesModelDefinition,
discoverChutesModels,
type ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-models";
/**
* Build the Chutes provider with dynamic model discovery.
* Falls back to the static catalog on failure.
* Accepts an optional access token (API key or OAuth access token) for authenticated discovery.
*/
export async function buildChutesProvider(accessToken?: string): Promise<ModelProviderConfig> {
const models = await discoverChutesModels(accessToken);
return {
baseUrl: CHUTES_BASE_URL,
api: "openai-completions",
models: models.length > 0 ? models : CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition),
};
}

View File

@ -0,0 +1,320 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import {
buildChutesModelDefinition,
CHUTES_MODEL_CATALOG,
discoverChutesModels,
clearChutesModelCache,
} from "./chutes-models.js";
describe("chutes-models", () => {
beforeEach(() => {
clearChutesModelCache();
});
it("buildChutesModelDefinition returns config with required fields", () => {
const entry = CHUTES_MODEL_CATALOG[0];
const def = buildChutesModelDefinition(entry);
expect(def.id).toBe(entry.id);
expect(def.name).toBe(entry.name);
expect(def.reasoning).toBe(entry.reasoning);
expect(def.input).toEqual(entry.input);
expect(def.cost).toEqual(entry.cost);
expect(def.contextWindow).toBe(entry.contextWindow);
expect(def.maxTokens).toBe(entry.maxTokens);
expect(def.compat?.supportsUsageInStreaming).toBe(false);
});
it("discoverChutesModels returns static catalog when accessToken is empty", async () => {
const models = await discoverChutesModels("");
expect(models).toHaveLength(CHUTES_MODEL_CATALOG.length);
expect(models.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id));
});
it("discoverChutesModels returns static catalog in test env by default", async () => {
const models = await discoverChutesModels("test-token");
expect(models).toHaveLength(CHUTES_MODEL_CATALOG.length);
expect(models[0]?.id).toBe("Qwen/Qwen3-32B");
});
it("discoverChutesModels correctly maps API response when not in test env", async () => {
const oldNodeEnv = process.env.NODE_ENV;
const oldVitest = process.env.VITEST;
delete process.env.NODE_ENV;
delete process.env.VITEST;
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [
{ id: "zai-org/GLM-4.7-TEE" },
{
id: "new-provider/new-model-r1",
supported_features: ["reasoning"],
input_modalities: ["text", "image"],
context_length: 200000,
max_output_length: 16384,
pricing: { prompt: 0.1, completion: 0.2 },
},
{ id: "new-provider/simple-model" },
],
}),
});
vi.stubGlobal("fetch", mockFetch);
try {
const models = await discoverChutesModels("test-token-real-fetch");
expect(models.length).toBeGreaterThan(0);
if (models.length === 3) {
expect(models[0]?.id).toBe("zai-org/GLM-4.7-TEE");
expect(models[1]?.reasoning).toBe(true);
expect(models[1]?.compat?.supportsUsageInStreaming).toBe(false);
}
} finally {
process.env.NODE_ENV = oldNodeEnv;
process.env.VITEST = oldVitest;
vi.unstubAllGlobals();
}
});
it("discoverChutesModels retries without auth on 401", async () => {
const oldNodeEnv = process.env.NODE_ENV;
const oldVitest = process.env.VITEST;
delete process.env.NODE_ENV;
delete process.env.VITEST;
const mockFetch = vi.fn().mockImplementation((url, init) => {
if (init?.headers?.Authorization === "Bearer test-token-error") {
// pragma: allowlist secret
return Promise.resolve({
ok: false,
status: 401,
});
}
return Promise.resolve({
ok: true,
json: async () => ({
data: [
{
id: "Qwen/Qwen3-32B",
name: "Qwen/Qwen3-32B",
supported_features: ["reasoning"],
input_modalities: ["text"],
context_length: 40960,
max_output_length: 40960,
pricing: { prompt: 0.08, completion: 0.24 },
},
{
id: "unsloth/Mistral-Nemo-Instruct-2407",
name: "unsloth/Mistral-Nemo-Instruct-2407",
input_modalities: ["text"],
context_length: 131072,
max_output_length: 131072,
pricing: { prompt: 0.02, completion: 0.04 },
},
{
id: "deepseek-ai/DeepSeek-V3-0324-TEE",
name: "deepseek-ai/DeepSeek-V3-0324-TEE",
supported_features: ["reasoning"],
input_modalities: ["text"],
context_length: 131072,
max_output_length: 65536,
pricing: { prompt: 0.28, completion: 0.42 },
},
],
}),
});
});
vi.stubGlobal("fetch", mockFetch);
try {
const models = await discoverChutesModels("test-token-error");
expect(models.length).toBeGreaterThan(0);
expect(mockFetch).toHaveBeenCalled();
} finally {
process.env.NODE_ENV = oldNodeEnv;
process.env.VITEST = oldVitest;
vi.unstubAllGlobals();
}
});
it("caches fallback static catalog for non-OK responses", async () => {
const oldNodeEnv = process.env.NODE_ENV;
const oldVitest = process.env.VITEST;
delete process.env.NODE_ENV;
delete process.env.VITEST;
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
});
vi.stubGlobal("fetch", mockFetch);
try {
const first = await discoverChutesModels("chutes-fallback-token");
const second = await discoverChutesModels("chutes-fallback-token");
expect(first.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id));
expect(second.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id));
expect(mockFetch).toHaveBeenCalledTimes(1);
} finally {
process.env.NODE_ENV = oldNodeEnv;
process.env.VITEST = oldVitest;
vi.unstubAllGlobals();
}
});
it("scopes discovery cache by access token", async () => {
const oldNodeEnv = process.env.NODE_ENV;
const oldVitest = process.env.VITEST;
delete process.env.NODE_ENV;
delete process.env.VITEST;
const mockFetch = vi
.fn()
.mockImplementation((_url, init?: { headers?: Record<string, string> }) => {
const auth = init?.headers?.Authorization;
if (auth === "Bearer chutes-token-a") {
return Promise.resolve({
ok: true,
json: async () => ({
data: [{ id: "private/model-a" }],
}),
});
}
if (auth === "Bearer chutes-token-b") {
return Promise.resolve({
ok: true,
json: async () => ({
data: [{ id: "private/model-b" }],
}),
});
}
return Promise.resolve({
ok: true,
json: async () => ({
data: [{ id: "public/model" }],
}),
});
});
vi.stubGlobal("fetch", mockFetch);
try {
const modelsA = await discoverChutesModels("chutes-token-a");
const modelsB = await discoverChutesModels("chutes-token-b");
const modelsASecond = await discoverChutesModels("chutes-token-a");
expect(modelsA[0]?.id).toBe("private/model-a");
expect(modelsB[0]?.id).toBe("private/model-b");
expect(modelsASecond[0]?.id).toBe("private/model-a");
// One request per token, then cache hit for the repeated token-a call.
expect(mockFetch).toHaveBeenCalledTimes(2);
} finally {
process.env.NODE_ENV = oldNodeEnv;
process.env.VITEST = oldVitest;
vi.unstubAllGlobals();
}
});
it("evicts oldest token entries when cache reaches max size", async () => {
const oldNodeEnv = process.env.NODE_ENV;
const oldVitest = process.env.VITEST;
delete process.env.NODE_ENV;
delete process.env.VITEST;
const mockFetch = vi
.fn()
.mockImplementation((_url, init?: { headers?: Record<string, string> }) => {
const auth = init?.headers?.Authorization ?? "";
return Promise.resolve({
ok: true,
json: async () => ({
data: [{ id: auth ? `${auth}-model` : "public-model" }],
}),
});
});
vi.stubGlobal("fetch", mockFetch);
try {
for (let i = 0; i < 150; i += 1) {
await discoverChutesModels(`cache-token-${i}`);
}
// The oldest key should have been evicted once we exceed the cap.
await discoverChutesModels("cache-token-0");
expect(mockFetch).toHaveBeenCalledTimes(151);
} finally {
process.env.NODE_ENV = oldNodeEnv;
process.env.VITEST = oldVitest;
vi.unstubAllGlobals();
}
});
it("prunes expired token cache entries during subsequent discovery", async () => {
const oldNodeEnv = process.env.NODE_ENV;
const oldVitest = process.env.VITEST;
delete process.env.NODE_ENV;
delete process.env.VITEST;
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-01T00:00:00.000Z"));
const mockFetch = vi
.fn()
.mockImplementation((_url, init?: { headers?: Record<string, string> }) => {
const auth = init?.headers?.Authorization ?? "";
return Promise.resolve({
ok: true,
json: async () => ({
data: [{ id: auth ? `${auth}-model` : "public-model" }],
}),
});
});
vi.stubGlobal("fetch", mockFetch);
try {
await discoverChutesModels("token-a");
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
await discoverChutesModels("token-b");
await discoverChutesModels("token-a");
expect(mockFetch).toHaveBeenCalledTimes(3);
} finally {
process.env.NODE_ENV = oldNodeEnv;
process.env.VITEST = oldVitest;
vi.unstubAllGlobals();
vi.useRealTimers();
}
});
it("does not cache 401 fallback under the failed token key", async () => {
const oldNodeEnv = process.env.NODE_ENV;
const oldVitest = process.env.VITEST;
delete process.env.NODE_ENV;
delete process.env.VITEST;
const mockFetch = vi
.fn()
.mockImplementation((_url, init?: { headers?: Record<string, string> }) => {
if (init?.headers?.Authorization === "Bearer failed-token") {
return Promise.resolve({
ok: false,
status: 401,
});
}
return Promise.resolve({
ok: true,
json: async () => ({
data: [{ id: "public/model" }],
}),
});
});
vi.stubGlobal("fetch", mockFetch);
try {
await discoverChutesModels("failed-token");
await discoverChutesModels("failed-token");
// Two calls each perform: authenticated attempt (401) + public fallback.
expect(mockFetch).toHaveBeenCalledTimes(4);
} finally {
process.env.NODE_ENV = oldNodeEnv;
process.env.VITEST = oldVitest;
vi.unstubAllGlobals();
}
});
});

639
src/agents/chutes-models.ts Normal file
View File

@ -0,0 +1,639 @@
import type { ModelDefinitionConfig } from "../config/types.models.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
const log = createSubsystemLogger("chutes-models");
/** Chutes.ai OpenAI-compatible API base URL. */
export const CHUTES_BASE_URL = "https://llm.chutes.ai/v1";
export const CHUTES_DEFAULT_MODEL_ID = "zai-org/GLM-4.7-TEE";
export const CHUTES_DEFAULT_MODEL_REF = `chutes/${CHUTES_DEFAULT_MODEL_ID}`;
/** Default cost for Chutes models (actual cost varies by model and compute). */
export const CHUTES_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
/** Default context window and max tokens for discovered models. */
const CHUTES_DEFAULT_CONTEXT_WINDOW = 128000;
const CHUTES_DEFAULT_MAX_TOKENS = 4096;
/**
* Static catalog of popular Chutes models.
* Used as a fallback and for initial onboarding allowlisting.
*/
export const CHUTES_MODEL_CATALOG: ModelDefinitionConfig[] = [
{
id: "Qwen/Qwen3-32B",
name: "Qwen/Qwen3-32B",
reasoning: true,
input: ["text"],
contextWindow: 40960,
maxTokens: 40960,
cost: { input: 0.08, output: 0.24, cacheRead: 0, cacheWrite: 0 },
},
{
id: "unsloth/Mistral-Nemo-Instruct-2407",
name: "unsloth/Mistral-Nemo-Instruct-2407",
reasoning: false,
input: ["text"],
contextWindow: 131072,
maxTokens: 131072,
cost: { input: 0.02, output: 0.04, cacheRead: 0, cacheWrite: 0 },
},
{
id: "deepseek-ai/DeepSeek-V3-0324-TEE",
name: "deepseek-ai/DeepSeek-V3-0324-TEE",
reasoning: true,
input: ["text"],
contextWindow: 163840,
maxTokens: 65536,
cost: { input: 0.25, output: 1, cacheRead: 0, cacheWrite: 0 },
},
{
id: "Qwen/Qwen3-235B-A22B-Instruct-2507-TEE",
name: "Qwen/Qwen3-235B-A22B-Instruct-2507-TEE",
reasoning: true,
input: ["text"],
contextWindow: 262144,
maxTokens: 65536,
cost: { input: 0.08, output: 0.55, cacheRead: 0, cacheWrite: 0 },
},
{
id: "openai/gpt-oss-120b-TEE",
name: "openai/gpt-oss-120b-TEE",
reasoning: true,
input: ["text"],
contextWindow: 131072,
maxTokens: 65536,
cost: { input: 0.05, output: 0.45, cacheRead: 0, cacheWrite: 0 },
},
{
id: "chutesai/Mistral-Small-3.1-24B-Instruct-2503",
name: "chutesai/Mistral-Small-3.1-24B-Instruct-2503",
reasoning: false,
input: ["text", "image"],
contextWindow: 131072,
maxTokens: 131072,
cost: { input: 0.03, output: 0.11, cacheRead: 0, cacheWrite: 0 },
},
{
id: "deepseek-ai/DeepSeek-V3.2-TEE",
name: "deepseek-ai/DeepSeek-V3.2-TEE",
reasoning: true,
input: ["text"],
contextWindow: 131072,
maxTokens: 65536,
cost: { input: 0.28, output: 0.42, cacheRead: 0, cacheWrite: 0 },
},
{
id: "zai-org/GLM-4.7-TEE",
name: "zai-org/GLM-4.7-TEE",
reasoning: true,
input: ["text"],
contextWindow: 202752,
maxTokens: 65535,
cost: { input: 0.4, output: 2, cacheRead: 0, cacheWrite: 0 },
},
{
id: "moonshotai/Kimi-K2.5-TEE",
name: "moonshotai/Kimi-K2.5-TEE",
reasoning: true,
input: ["text", "image"],
contextWindow: 262144,
maxTokens: 65535,
cost: { input: 0.45, output: 2.2, cacheRead: 0, cacheWrite: 0 },
},
{
id: "unsloth/gemma-3-27b-it",
name: "unsloth/gemma-3-27b-it",
reasoning: false,
input: ["text", "image"],
contextWindow: 128000,
maxTokens: 65536,
cost: { input: 0.04, output: 0.15, cacheRead: 0, cacheWrite: 0 },
},
{
id: "XiaomiMiMo/MiMo-V2-Flash-TEE",
name: "XiaomiMiMo/MiMo-V2-Flash-TEE",
reasoning: true,
input: ["text"],
contextWindow: 262144,
maxTokens: 65536,
cost: { input: 0.09, output: 0.29, cacheRead: 0, cacheWrite: 0 },
},
{
id: "chutesai/Mistral-Small-3.2-24B-Instruct-2506",
name: "chutesai/Mistral-Small-3.2-24B-Instruct-2506",
reasoning: false,
input: ["text", "image"],
contextWindow: 131072,
maxTokens: 131072,
cost: { input: 0.06, output: 0.18, cacheRead: 0, cacheWrite: 0 },
},
{
id: "deepseek-ai/DeepSeek-R1-0528-TEE",
name: "deepseek-ai/DeepSeek-R1-0528-TEE",
reasoning: true,
input: ["text"],
contextWindow: 163840,
maxTokens: 65536,
cost: { input: 0.45, output: 2.15, cacheRead: 0, cacheWrite: 0 },
},
{
id: "zai-org/GLM-5-TEE",
name: "zai-org/GLM-5-TEE",
reasoning: true,
input: ["text"],
contextWindow: 202752,
maxTokens: 65535,
cost: { input: 0.95, output: 3.15, cacheRead: 0, cacheWrite: 0 },
},
{
id: "deepseek-ai/DeepSeek-V3.1-TEE",
name: "deepseek-ai/DeepSeek-V3.1-TEE",
reasoning: true,
input: ["text"],
contextWindow: 163840,
maxTokens: 65536,
cost: { input: 0.2, output: 0.8, cacheRead: 0, cacheWrite: 0 },
},
{
id: "deepseek-ai/DeepSeek-V3.1-Terminus-TEE",
name: "deepseek-ai/DeepSeek-V3.1-Terminus-TEE",
reasoning: true,
input: ["text"],
contextWindow: 163840,
maxTokens: 65536,
cost: { input: 0.23, output: 0.9, cacheRead: 0, cacheWrite: 0 },
},
{
id: "unsloth/gemma-3-4b-it",
name: "unsloth/gemma-3-4b-it",
reasoning: false,
input: ["text", "image"],
contextWindow: 96000,
maxTokens: 96000,
cost: { input: 0.01, output: 0.03, cacheRead: 0, cacheWrite: 0 },
},
{
id: "MiniMaxAI/MiniMax-M2.5-TEE",
name: "MiniMaxAI/MiniMax-M2.5-TEE",
reasoning: true,
input: ["text"],
contextWindow: 196608,
maxTokens: 65536,
cost: { input: 0.3, output: 1.1, cacheRead: 0, cacheWrite: 0 },
},
{
id: "tngtech/DeepSeek-TNG-R1T2-Chimera",
name: "tngtech/DeepSeek-TNG-R1T2-Chimera",
reasoning: true,
input: ["text"],
contextWindow: 163840,
maxTokens: 163840,
cost: { input: 0.25, output: 0.85, cacheRead: 0, cacheWrite: 0 },
},
{
id: "Qwen/Qwen3-Coder-Next-TEE",
name: "Qwen/Qwen3-Coder-Next-TEE",
reasoning: true,
input: ["text"],
contextWindow: 262144,
maxTokens: 65536,
cost: { input: 0.12, output: 0.75, cacheRead: 0, cacheWrite: 0 },
},
{
id: "NousResearch/Hermes-4-405B-FP8-TEE",
name: "NousResearch/Hermes-4-405B-FP8-TEE",
reasoning: true,
input: ["text"],
contextWindow: 131072,
maxTokens: 65536,
cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 },
},
{
id: "deepseek-ai/DeepSeek-V3",
name: "deepseek-ai/DeepSeek-V3",
reasoning: false,
input: ["text"],
contextWindow: 163840,
maxTokens: 163840,
cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 },
},
{
id: "openai/gpt-oss-20b",
name: "openai/gpt-oss-20b",
reasoning: true,
input: ["text"],
contextWindow: 131072,
maxTokens: 131072,
cost: { input: 0.04, output: 0.15, cacheRead: 0, cacheWrite: 0 },
},
{
id: "unsloth/Llama-3.2-3B-Instruct",
name: "unsloth/Llama-3.2-3B-Instruct",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 4096,
cost: { input: 0.01, output: 0.01, cacheRead: 0, cacheWrite: 0 },
},
{
id: "unsloth/Mistral-Small-24B-Instruct-2501",
name: "unsloth/Mistral-Small-24B-Instruct-2501",
reasoning: false,
input: ["text", "image"],
contextWindow: 32768,
maxTokens: 32768,
cost: { input: 0.07, output: 0.3, cacheRead: 0, cacheWrite: 0 },
},
{
id: "zai-org/GLM-4.7-FP8",
name: "zai-org/GLM-4.7-FP8",
reasoning: true,
input: ["text"],
contextWindow: 202752,
maxTokens: 65535,
cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 },
},
{
id: "zai-org/GLM-4.6-TEE",
name: "zai-org/GLM-4.6-TEE",
reasoning: true,
input: ["text"],
contextWindow: 202752,
maxTokens: 65536,
cost: { input: 0.4, output: 1.7, cacheRead: 0, cacheWrite: 0 },
},
{
id: "Qwen/Qwen3.5-397B-A17B-TEE",
name: "Qwen/Qwen3.5-397B-A17B-TEE",
reasoning: true,
input: ["text", "image"],
contextWindow: 262144,
maxTokens: 65536,
cost: { input: 0.55, output: 3.5, cacheRead: 0, cacheWrite: 0 },
},
{
id: "Qwen/Qwen2.5-72B-Instruct",
name: "Qwen/Qwen2.5-72B-Instruct",
reasoning: false,
input: ["text"],
contextWindow: 32768,
maxTokens: 32768,
cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 },
},
{
id: "NousResearch/DeepHermes-3-Mistral-24B-Preview",
name: "NousResearch/DeepHermes-3-Mistral-24B-Preview",
reasoning: false,
input: ["text"],
contextWindow: 32768,
maxTokens: 32768,
cost: { input: 0.02, output: 0.1, cacheRead: 0, cacheWrite: 0 },
},
{
id: "Qwen/Qwen3-Next-80B-A3B-Instruct",
name: "Qwen/Qwen3-Next-80B-A3B-Instruct",
reasoning: false,
input: ["text"],
contextWindow: 262144,
maxTokens: 262144,
cost: { input: 0.1, output: 0.8, cacheRead: 0, cacheWrite: 0 },
},
{
id: "zai-org/GLM-4.6-FP8",
name: "zai-org/GLM-4.6-FP8",
reasoning: true,
input: ["text"],
contextWindow: 202752,
maxTokens: 65535,
cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 },
},
{
id: "Qwen/Qwen3-235B-A22B-Thinking-2507",
name: "Qwen/Qwen3-235B-A22B-Thinking-2507",
reasoning: true,
input: ["text"],
contextWindow: 262144,
maxTokens: 262144,
cost: { input: 0.11, output: 0.6, cacheRead: 0, cacheWrite: 0 },
},
{
id: "deepseek-ai/DeepSeek-R1-Distill-Llama-70B",
name: "deepseek-ai/DeepSeek-R1-Distill-Llama-70B",
reasoning: true,
input: ["text"],
contextWindow: 131072,
maxTokens: 131072,
cost: { input: 0.03, output: 0.11, cacheRead: 0, cacheWrite: 0 },
},
{
id: "tngtech/R1T2-Chimera-Speed",
name: "tngtech/R1T2-Chimera-Speed",
reasoning: true,
input: ["text"],
contextWindow: 131072,
maxTokens: 65536,
cost: { input: 0.22, output: 0.6, cacheRead: 0, cacheWrite: 0 },
},
{
id: "zai-org/GLM-4.6V",
name: "zai-org/GLM-4.6V",
reasoning: true,
input: ["text", "image"],
contextWindow: 131072,
maxTokens: 65536,
cost: { input: 0.3, output: 0.9, cacheRead: 0, cacheWrite: 0 },
},
{
id: "Qwen/Qwen2.5-VL-32B-Instruct",
name: "Qwen/Qwen2.5-VL-32B-Instruct",
reasoning: false,
input: ["text", "image"],
contextWindow: 16384,
maxTokens: 16384,
cost: { input: 0.05, output: 0.22, cacheRead: 0, cacheWrite: 0 },
},
{
id: "Qwen/Qwen3-VL-235B-A22B-Instruct",
name: "Qwen/Qwen3-VL-235B-A22B-Instruct",
reasoning: false,
input: ["text", "image"],
contextWindow: 262144,
maxTokens: 262144,
cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 },
},
{
id: "Qwen/Qwen3-14B",
name: "Qwen/Qwen3-14B",
reasoning: true,
input: ["text"],
contextWindow: 40960,
maxTokens: 40960,
cost: { input: 0.05, output: 0.22, cacheRead: 0, cacheWrite: 0 },
},
{
id: "Qwen/Qwen2.5-Coder-32B-Instruct",
name: "Qwen/Qwen2.5-Coder-32B-Instruct",
reasoning: false,
input: ["text"],
contextWindow: 32768,
maxTokens: 32768,
cost: { input: 0.03, output: 0.11, cacheRead: 0, cacheWrite: 0 },
},
{
id: "Qwen/Qwen3-30B-A3B",
name: "Qwen/Qwen3-30B-A3B",
reasoning: true,
input: ["text"],
contextWindow: 40960,
maxTokens: 40960,
cost: { input: 0.06, output: 0.22, cacheRead: 0, cacheWrite: 0 },
},
{
id: "unsloth/gemma-3-12b-it",
name: "unsloth/gemma-3-12b-it",
reasoning: false,
input: ["text", "image"],
contextWindow: 131072,
maxTokens: 131072,
cost: { input: 0.03, output: 0.1, cacheRead: 0, cacheWrite: 0 },
},
{
id: "unsloth/Llama-3.2-1B-Instruct",
name: "unsloth/Llama-3.2-1B-Instruct",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 4096,
cost: { input: 0.01, output: 0.01, cacheRead: 0, cacheWrite: 0 },
},
{
id: "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16-TEE",
name: "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16-TEE",
reasoning: true,
input: ["text"],
contextWindow: 128000,
maxTokens: 4096,
cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 },
},
{
id: "NousResearch/Hermes-4-14B",
name: "NousResearch/Hermes-4-14B",
reasoning: true,
input: ["text"],
contextWindow: 40960,
maxTokens: 40960,
cost: { input: 0.01, output: 0.05, cacheRead: 0, cacheWrite: 0 },
},
{
id: "Qwen/Qwen3Guard-Gen-0.6B",
name: "Qwen/Qwen3Guard-Gen-0.6B",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 4096,
cost: { input: 0.01, output: 0.01, cacheRead: 0, cacheWrite: 0 },
},
{
id: "rednote-hilab/dots.ocr",
name: "rednote-hilab/dots.ocr",
reasoning: false,
input: ["text", "image"],
contextWindow: 131072,
maxTokens: 131072,
cost: { input: 0.01, output: 0.01, cacheRead: 0, cacheWrite: 0 },
},
];
export function buildChutesModelDefinition(
model: (typeof CHUTES_MODEL_CATALOG)[number],
): ModelDefinitionConfig {
return {
...model,
// Avoid usage-only streaming chunks that can break OpenAI-compatible parsers.
compat: {
supportsUsageInStreaming: false,
},
};
}
interface ChutesModelEntry {
id: string;
name?: string;
supported_features?: string[];
input_modalities?: string[];
context_length?: number;
max_output_length?: number;
pricing?: {
prompt?: number;
completion?: number;
};
[key: string]: unknown;
}
interface OpenAIListModelsResponse {
data?: ChutesModelEntry[];
}
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const CACHE_MAX_ENTRIES = 100;
interface CacheEntry {
models: ModelDefinitionConfig[];
time: number;
}
// Keyed by trimmed access token (empty string = unauthenticated).
// Prevents a public unauthenticated result from suppressing authenticated
// discovery for users with token-scoped private models.
const modelCache = new Map<string, CacheEntry>();
/** @internal - For testing only */
export function clearChutesModelCache() {
modelCache.clear();
}
function pruneExpiredCacheEntries(now: number = Date.now()): void {
for (const [key, entry] of modelCache.entries()) {
if (now - entry.time >= CACHE_TTL) {
modelCache.delete(key);
}
}
}
/** Cache the result for the given token key and return it. */
function cacheAndReturn(
tokenKey: string,
models: ModelDefinitionConfig[],
): ModelDefinitionConfig[] {
const now = Date.now();
pruneExpiredCacheEntries(now);
if (!modelCache.has(tokenKey) && modelCache.size >= CACHE_MAX_ENTRIES) {
const oldest = modelCache.keys().next();
if (!oldest.done) {
modelCache.delete(oldest.value);
}
}
modelCache.set(tokenKey, { models, time: now });
return models;
}
/**
* Discover models from Chutes.ai API with fallback to static catalog.
* Mimics the logic in Chutes init script.
*/
export async function discoverChutesModels(accessToken?: string): Promise<ModelDefinitionConfig[]> {
const trimmedKey = accessToken?.trim() ?? "";
// Return cached result for this token if still within TTL
const now = Date.now();
pruneExpiredCacheEntries(now);
const cached = modelCache.get(trimmedKey);
if (cached) {
return cached.models;
}
// Skip API discovery in test environment
if (process.env.NODE_ENV === "test" || process.env.VITEST === "true") {
return CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition);
}
// If auth fails the result comes from the public endpoint — cache it under ""
// so the original token key stays uncached and retries cleanly next TTL window.
let effectiveKey = trimmedKey;
const staticCatalog = () =>
cacheAndReturn(effectiveKey, CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition));
const headers: Record<string, string> = {};
if (trimmedKey) {
headers.Authorization = `Bearer ${trimmedKey}`;
}
try {
let response = await fetch(`${CHUTES_BASE_URL}/models`, {
signal: AbortSignal.timeout(10_000),
headers,
});
if (response.status === 401 && trimmedKey) {
// Auth failed — fall back to the public (unauthenticated) endpoint.
// Cache the result under "" so the bad token stays uncached and can
// be retried with a refreshed credential after the TTL expires.
effectiveKey = "";
response = await fetch(`${CHUTES_BASE_URL}/models`, {
signal: AbortSignal.timeout(10_000),
});
}
if (!response.ok) {
// Only log if it's not a common auth/overload error that we have a fallback for
if (response.status !== 401 && response.status !== 503) {
log.warn(`GET /v1/models failed: HTTP ${response.status}, using static catalog`);
}
return staticCatalog();
}
const body = (await response.json()) as OpenAIListModelsResponse;
const data = body?.data;
if (!Array.isArray(data) || data.length === 0) {
log.warn("No models in response, using static catalog");
return staticCatalog();
}
const seen = new Set<string>();
const models: ModelDefinitionConfig[] = [];
for (const entry of data) {
const id = typeof entry?.id === "string" ? entry.id.trim() : "";
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
const isReasoning =
entry.supported_features?.includes("reasoning") ||
id.toLowerCase().includes("r1") ||
id.toLowerCase().includes("thinking") ||
id.toLowerCase().includes("reason") ||
id.toLowerCase().includes("tee");
const input: Array<"text" | "image"> = (entry.input_modalities || ["text"]).filter(
(i): i is "text" | "image" => i === "text" || i === "image",
);
models.push({
id,
name: id, // Mirror init.sh: uses id for name
reasoning: isReasoning,
input,
cost: {
input: entry.pricing?.prompt || 0,
output: entry.pricing?.completion || 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: entry.context_length || CHUTES_DEFAULT_CONTEXT_WINDOW,
maxTokens: entry.max_output_length || CHUTES_DEFAULT_MAX_TOKENS,
compat: {
supportsUsageInStreaming: false,
},
});
}
return cacheAndReturn(
effectiveKey,
models.length > 0 ? models : CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition),
);
} catch (error) {
log.warn(`Discovery failed: ${String(error)}, using static catalog`);
return staticCatalog();
}
}

View File

@ -4,12 +4,14 @@ import {
isKnownEnvApiKeyMarker,
isNonSecretApiKeyMarker,
NON_ENV_SECRETREF_MARKER,
resolveOAuthApiKeyMarker,
} from "./model-auth-markers.js";
describe("model auth markers", () => {
it("recognizes explicit non-secret markers", () => {
expect(isNonSecretApiKeyMarker(NON_ENV_SECRETREF_MARKER)).toBe(true);
expect(isNonSecretApiKeyMarker("qwen-oauth")).toBe(true);
expect(isNonSecretApiKeyMarker(resolveOAuthApiKeyMarker("chutes"))).toBe(true);
expect(isNonSecretApiKeyMarker("ollama-local")).toBe(true);
});

View File

@ -2,6 +2,7 @@ import type { SecretRefSource } from "../config/types.secrets.js";
import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js";
export const MINIMAX_OAUTH_MARKER = "minimax-oauth";
export const OAUTH_API_KEY_MARKER_PREFIX = "oauth:";
export const QWEN_OAUTH_MARKER = "qwen-oauth";
export const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local";
export const CUSTOM_LOCAL_AUTH_MARKER = "custom-local";
@ -41,6 +42,14 @@ export function isKnownEnvApiKeyMarker(value: string): boolean {
return KNOWN_ENV_API_KEY_MARKERS.has(trimmed) && !isAwsSdkAuthMarker(trimmed);
}
export function resolveOAuthApiKeyMarker(providerId: string): string {
return `${OAUTH_API_KEY_MARKER_PREFIX}${providerId.trim()}`;
}
export function isOAuthApiKeyMarker(value: string): boolean {
return value.trim().startsWith(OAUTH_API_KEY_MARKER_PREFIX);
}
export function resolveNonEnvSecretRefApiKeyMarker(_source: SecretRefSource): string {
return NON_ENV_SECRETREF_MARKER;
}
@ -71,6 +80,7 @@ export function isNonSecretApiKeyMarker(
const isKnownMarker =
trimmed === MINIMAX_OAUTH_MARKER ||
trimmed === QWEN_OAUTH_MARKER ||
isOAuthApiKeyMarker(trimmed) ||
trimmed === OLLAMA_LOCAL_AUTH_MARKER ||
trimmed === CUSTOM_LOCAL_AUTH_MARKER ||
trimmed === NON_ENV_SECRETREF_MARKER ||

View File

@ -0,0 +1,212 @@
import { mkdtempSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { CHUTES_BASE_URL } from "./chutes-models.js";
import { resolveOAuthApiKeyMarker } from "./model-auth-markers.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
import { resolveImplicitProviders } from "./models-config.providers.js";
const CHUTES_OAUTH_MARKER = resolveOAuthApiKeyMarker("chutes");
const ORIGINAL_VITEST_ENV = process.env.VITEST;
const ORIGINAL_NODE_ENV = process.env.NODE_ENV;
describe("chutes implicit provider auth mode", () => {
beforeEach(() => {
process.env.VITEST = "true";
process.env.NODE_ENV = "test";
});
afterAll(() => {
process.env.VITEST = ORIGINAL_VITEST_ENV;
process.env.NODE_ENV = ORIGINAL_NODE_ENV;
});
it("auto-loads bundled chutes discovery for env api keys", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const providers = await resolveImplicitProviders({
agentDir,
env: {
CHUTES_API_KEY: "env-chutes-api-key",
} as NodeJS.ProcessEnv,
});
expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL);
expect(providers?.chutes?.apiKey).toBe("CHUTES_API_KEY");
});
it("keeps api_key-backed chutes profiles on the api-key loader path", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await writeFile(
join(agentDir, "auth-profiles.json"),
JSON.stringify(
{
version: 1,
profiles: {
"chutes:default": {
type: "api_key",
provider: "chutes",
key: "chutes-live-api-key", // pragma: allowlist secret
},
},
},
null,
2,
),
"utf8",
);
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL);
expect(providers?.chutes?.apiKey).toBe("chutes-live-api-key");
expect(providers?.chutes?.apiKey).not.toBe(CHUTES_OAUTH_MARKER);
});
it("keeps api_key precedence when oauth profile is inserted first", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await writeFile(
join(agentDir, "auth-profiles.json"),
JSON.stringify(
{
version: 1,
profiles: {
"chutes:oauth": {
type: "oauth",
provider: "chutes",
access: "oauth-access-token",
refresh: "oauth-refresh-token",
expires: Date.now() + 60_000,
},
"chutes:default": {
type: "api_key",
provider: "chutes",
key: "chutes-live-api-key", // pragma: allowlist secret
},
},
},
null,
2,
),
"utf8",
);
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL);
expect(providers?.chutes?.apiKey).toBe("chutes-live-api-key");
expect(providers?.chutes?.apiKey).not.toBe(CHUTES_OAUTH_MARKER);
});
it("keeps api_key precedence when api_key profile is inserted first", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await writeFile(
join(agentDir, "auth-profiles.json"),
JSON.stringify(
{
version: 1,
profiles: {
"chutes:default": {
type: "api_key",
provider: "chutes",
key: "chutes-live-api-key", // pragma: allowlist secret
},
"chutes:oauth": {
type: "oauth",
provider: "chutes",
access: "oauth-access-token",
refresh: "oauth-refresh-token",
expires: Date.now() + 60_000,
},
},
},
null,
2,
),
"utf8",
);
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL);
expect(providers?.chutes?.apiKey).toBe("chutes-live-api-key");
expect(providers?.chutes?.apiKey).not.toBe(CHUTES_OAUTH_MARKER);
});
it("forwards oauth access token to chutes model discovery", async () => {
// Enable real discovery so fetch is actually called.
const originalVitest = process.env.VITEST;
const originalNodeEnv = process.env.NODE_ENV;
const originalFetch = globalThis.fetch;
delete process.env.VITEST;
delete process.env.NODE_ENV;
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ id: "chutes/private-model" }] }),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
try {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await writeFile(
join(agentDir, "auth-profiles.json"),
JSON.stringify(
{
version: 1,
profiles: {
"chutes:default": {
type: "oauth",
provider: "chutes",
access: "my-chutes-access-token",
refresh: "oauth-refresh-token",
expires: Date.now() + 60_000,
},
},
},
null,
2,
),
"utf8",
);
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
expect(providers?.chutes?.apiKey).toBe(CHUTES_OAUTH_MARKER);
const chutesCalls = fetchMock.mock.calls.filter(([url]) => String(url).includes("chutes.ai"));
expect(chutesCalls.length).toBeGreaterThan(0);
const request = chutesCalls[0]?.[1] as { headers?: Record<string, string> } | undefined;
expect(request?.headers?.Authorization).toBe("Bearer my-chutes-access-token");
} finally {
process.env.VITEST = originalVitest;
process.env.NODE_ENV = originalNodeEnv;
globalThis.fetch = originalFetch;
}
});
it("uses CHUTES_OAUTH_MARKER only for oauth-backed chutes profiles", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await writeFile(
join(agentDir, "auth-profiles.json"),
JSON.stringify(
{
version: 1,
profiles: {
"chutes:default": {
type: "oauth",
provider: "chutes",
access: "oauth-access-token",
refresh: "oauth-refresh-token",
expires: Date.now() + 60_000,
},
},
},
null,
2,
),
"utf8",
);
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL);
expect(providers?.chutes?.apiKey).toBe(CHUTES_OAUTH_MARKER);
});
});

View File

@ -617,10 +617,22 @@ type ProviderApiKeyResolver = (provider: string) => {
discoveryApiKey?: string;
};
type ProviderAuthResolver = (
provider: string,
options?: { oauthMarker?: string },
) => {
apiKey: string | undefined;
discoveryApiKey?: string;
mode: "api_key" | "oauth" | "token" | "none";
source: "env" | "profile" | "none";
profileId?: string;
};
type ImplicitProviderContext = ImplicitProviderParams & {
authStore: ReturnType<typeof ensureAuthProfileStore>;
env: NodeJS.ProcessEnv;
resolveProviderApiKey: ProviderApiKeyResolver;
resolveProviderAuth: ProviderAuthResolver;
};
function mergeImplicitProviderSet(
@ -668,6 +680,8 @@ async function resolvePluginImplicitProviders(
env: ctx.env,
resolveProviderApiKey: (providerId) =>
ctx.resolveProviderApiKey(providerId?.trim() || provider.id),
resolveProviderAuth: (providerId, options) =>
ctx.resolveProviderAuth(providerId?.trim() || provider.id, options),
});
mergeImplicitProviderSet(
discovered,
@ -704,11 +718,74 @@ export async function resolveImplicitProviders(
discoveryApiKey: fromProfiles?.discoveryApiKey,
};
};
const resolveProviderAuth: ProviderAuthResolver = (
provider: string,
options?: { oauthMarker?: string },
) => {
const envVar = resolveEnvApiKeyVarName(provider, env);
if (envVar) {
return {
apiKey: envVar,
discoveryApiKey: toDiscoveryApiKey(env[envVar]),
mode: "api_key",
source: "env",
};
}
const ids = listProfilesForProvider(authStore, provider);
let oauthCandidate:
| {
apiKey: string | undefined;
discoveryApiKey?: string;
mode: "oauth";
source: "profile";
profileId: string;
}
| undefined;
for (const id of ids) {
const cred = authStore.profiles[id];
if (!cred) {
continue;
}
if (cred.type === "oauth") {
oauthCandidate ??= {
apiKey: options?.oauthMarker,
discoveryApiKey: toDiscoveryApiKey(cred.access),
mode: "oauth",
source: "profile",
profileId: id,
};
continue;
}
const resolved = resolveApiKeyFromCredential(cred, env);
if (!resolved) {
continue;
}
return {
apiKey: resolved.apiKey,
discoveryApiKey: resolved.discoveryApiKey,
mode: cred.type,
source: "profile",
profileId: id,
};
}
if (oauthCandidate) {
return oauthCandidate;
}
return {
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
};
};
const context: ImplicitProviderContext = {
...params,
authStore,
env,
resolveProviderApiKey,
resolveProviderAuth,
};
mergeImplicitProviderSet(providers, await resolvePluginImplicitProviders(context, "simple"));

View File

@ -1,94 +1,7 @@
import { applyAuthProfileConfig, writeOAuthCredentials } from "../plugins/provider-auth-helpers.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { loginChutes } from "./chutes-oauth.js";
import { isRemoteEnvironment } from "./oauth-env.js";
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
import { openUrl } from "./onboard-helpers.js";
export async function applyAuthChoiceOAuth(
params: ApplyAuthChoiceParams,
_params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult | null> {
if (params.authChoice === "chutes") {
let nextConfig = params.config;
const isRemote = isRemoteEnvironment();
const redirectUri =
process.env.CHUTES_OAUTH_REDIRECT_URI?.trim() || "http://127.0.0.1:1456/oauth-callback";
const scopes = process.env.CHUTES_OAUTH_SCOPES?.trim() || "openid profile chutes:invoke";
const clientId =
process.env.CHUTES_CLIENT_ID?.trim() ||
String(
await params.prompter.text({
message: "Enter Chutes OAuth client id",
placeholder: "cid_xxx",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined;
await params.prompter.note(
isRemote
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, paste the redirect URL back here.",
"",
`Redirect URI: ${redirectUri}`,
].join("\n")
: [
"Browser will open for Chutes authentication.",
"If the callback doesn't auto-complete, paste the redirect URL.",
"",
`Redirect URI: ${redirectUri}`,
].join("\n"),
"Chutes OAuth",
);
const spin = params.prompter.progress("Starting OAuth flow…");
try {
const { onAuth, onPrompt } = createVpsAwareOAuthHandlers({
isRemote,
prompter: params.prompter,
runtime: params.runtime,
spin,
openUrl,
localBrowserMessage: "Complete sign-in in browser…",
});
const creds = await loginChutes({
app: {
clientId,
clientSecret,
redirectUri,
scopes: scopes.split(/\s+/).filter(Boolean),
},
manual: isRemote,
onAuth,
onPrompt,
onProgress: (msg) => spin.update(msg),
});
spin.stop("Chutes OAuth complete");
const profileId = await writeOAuthCredentials("chutes", creds, params.agentDir);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId,
provider: "chutes",
mode: "oauth",
});
} catch (err) {
spin.stop("Chutes OAuth failed");
params.runtime.error(String(err));
await params.prompter.note(
[
"Trouble with OAuth?",
"Verify CHUTES_CLIENT_ID (and CHUTES_CLIENT_SECRET if required).",
`Verify the OAuth app redirect URI includes: ${redirectUri}`,
"Chutes docs: https://chutes.ai/docs/sign-in-with-chutes/overview",
].join("\n"),
"OAuth help",
);
}
return { config: nextConfig };
}
return null;
}

View File

@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import type { OAuthCredentials } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import anthropicPlugin from "../../extensions/anthropic/index.js";
import chutesPlugin from "../../extensions/chutes/index.js";
import cloudflareAiGatewayPlugin from "../../extensions/cloudflare-ai-gateway/index.js";
import googlePlugin from "../../extensions/google/index.js";
import huggingfacePlugin from "../../extensions/huggingface/index.js";
@ -84,6 +85,7 @@ type StoredAuthProfile = {
function createDefaultProviderPlugins() {
return registerProviderPlugins(
anthropicPlugin,
chutesPlugin,
cloudflareAiGatewayPlugin,
googlePlugin,
huggingfacePlugin,
@ -1345,7 +1347,7 @@ describe("applyAuthChoice", () => {
const runtime = createExitThrowingRuntime();
const text: WizardPrompter["text"] = vi.fn(async (params) => {
if (params.message === "Paste the redirect URL") {
if (params.message.startsWith("Paste the redirect URL")) {
const runtimeLog = runtime.log as ReturnType<typeof vi.fn>;
const lastLog = runtimeLog.mock.calls.at(-1)?.[0];
const urlLine = typeof lastLog === "string" ? lastLog : String(lastLog ?? "");
@ -1370,7 +1372,7 @@ describe("applyAuthChoice", () => {
expect(text).toHaveBeenCalledWith(
expect.objectContaining({
message: "Paste the redirect URL",
message: expect.stringContaining("Paste the redirect URL"),
}),
);
expect(result.config.auth?.profiles?.["chutes:remote-user"]).toMatchObject({

View File

@ -3,6 +3,7 @@
export type { OpenClawConfig } from "../config/config.js";
export type { SecretInput } from "../config/types.secrets.js";
export type { ProviderAuthResult } from "../plugins/types.js";
export type { ProviderAuthContext } from "../plugins/types.js";
export type { AuthProfileStore, OAuthCredential } from "../agents/auth-profiles/types.js";
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
@ -16,6 +17,7 @@ export {
} from "../agents/auth-profiles.js";
export {
MINIMAX_OAUTH_MARKER,
resolveOAuthApiKeyMarker,
resolveNonEnvSecretRefApiKeyMarker,
} from "../agents/model-auth-markers.js";
export {
@ -35,6 +37,7 @@ export {
} from "../plugins/provider-auth-token.js";
export { applyAuthProfileConfig, buildApiKeyCredential } from "../plugins/provider-auth-helpers.js";
export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js";
export { loginChutes } from "../commands/chutes-oauth.js";
export { loginOpenAICodexOAuth } from "../plugins/provider-openai-codex-oauth.js";
export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js";
export { coerceSecretRef } from "../config/types.secrets.js";

View File

@ -81,6 +81,14 @@ export {
buildHuggingfaceModelDefinition,
} from "../agents/huggingface-models.js";
export { discoverKilocodeModels } from "../agents/kilocode-models.js";
export {
buildChutesModelDefinition,
CHUTES_BASE_URL,
CHUTES_DEFAULT_MODEL_ID,
CHUTES_DEFAULT_MODEL_REF,
CHUTES_MODEL_CATALOG,
discoverChutesModels,
} from "../agents/chutes-models.js";
export { resolveOllamaApiBase } from "../agents/ollama-models.js";
export {
buildSyntheticModelDefinition,

View File

@ -270,4 +270,9 @@ describe("resolveEnableState", () => {
const state = resolveEnableState("google", "bundled", normalizePluginsConfig({}));
expect(state).toEqual({ enabled: true });
});
it("allows bundled plugins to opt into default enablement from manifest metadata", () => {
const state = resolveEnableState("profile-aware", "bundled", normalizePluginsConfig({}), true);
expect(state).toEqual({ enabled: true });
});
});

View File

@ -274,6 +274,7 @@ export function resolveEnableState(
id: string,
origin: PluginRecord["origin"],
config: NormalizedPluginsConfig,
enabledByDefault?: boolean,
): { enabled: boolean; reason?: string } {
if (!config.enabled) {
return { enabled: false, reason: "plugins disabled" };
@ -298,7 +299,7 @@ export function resolveEnableState(
if (entry?.enabled === true) {
return { enabled: true };
}
if (origin === "bundled" && BUNDLED_ENABLED_BY_DEFAULT.has(id)) {
if (origin === "bundled" && (enabledByDefault ?? BUNDLED_ENABLED_BY_DEFAULT.has(id))) {
return { enabled: true };
}
if (origin === "bundled") {
@ -331,8 +332,9 @@ export function resolveEffectiveEnableState(params: {
origin: PluginRecord["origin"];
config: NormalizedPluginsConfig;
rootConfig?: OpenClawConfig;
enabledByDefault?: boolean;
}): { enabled: boolean; reason?: string } {
const base = resolveEnableState(params.id, params.origin, params.config);
const base = resolveEnableState(params.id, params.origin, params.config, params.enabledByDefault);
if (
!base.enabled &&
base.reason === "bundled (disabled by default)" &&

View File

@ -131,12 +131,30 @@ function runCatalog(params: {
provider: Awaited<ReturnType<typeof requireProvider>>;
env?: NodeJS.ProcessEnv;
resolveProviderApiKey?: () => { apiKey: string | undefined };
resolveProviderAuth?: (
providerId?: string,
options?: { oauthMarker?: string },
) => {
apiKey: string | undefined;
discoveryApiKey?: string;
mode: "api_key" | "oauth" | "token" | "none";
source: "env" | "profile" | "none";
profileId?: string;
};
}) {
return runProviderCatalog({
provider: params.provider,
config: {},
env: params.env ?? ({} as NodeJS.ProcessEnv),
resolveProviderApiKey: params.resolveProviderApiKey ?? (() => ({ apiKey: undefined })),
resolveProviderAuth:
params.resolveProviderAuth ??
((_, options) => ({
apiKey: options?.oauthMarker,
discoveryApiKey: undefined,
mode: options?.oauthMarker ? "oauth" : "none",
source: options?.oauthMarker ? "profile" : "none",
})),
});
}
@ -249,6 +267,12 @@ describe("provider discovery contract", () => {
},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toMatchObject({
provider: {
@ -274,6 +298,12 @@ describe("provider discovery contract", () => {
config: {},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toBeNull();
expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, { quiet: true });
@ -297,6 +327,12 @@ describe("provider discovery contract", () => {
apiKey: "VLLM_API_KEY",
discoveryApiKey: "env-vllm-key",
}),
resolveProviderAuth: () => ({
apiKey: "VLLM_API_KEY",
discoveryApiKey: "env-vllm-key",
mode: "api_key",
source: "env",
}),
}),
).resolves.toEqual({
provider: {
@ -329,6 +365,12 @@ describe("provider discovery contract", () => {
apiKey: "SGLANG_API_KEY",
discoveryApiKey: "env-sglang-key",
}),
resolveProviderAuth: () => ({
apiKey: "SGLANG_API_KEY",
discoveryApiKey: "env-sglang-key",
mode: "api_key",
source: "env",
}),
}),
).resolves.toEqual({
provider: {
@ -352,6 +394,12 @@ describe("provider discovery contract", () => {
MINIMAX_API_KEY: "minimax-key",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: "minimax-key" }),
resolveProviderAuth: () => ({
apiKey: "minimax-key",
discoveryApiKey: undefined,
mode: "api_key",
source: "env",
}),
}),
).resolves.toMatchObject({
provider: {
@ -391,6 +439,13 @@ describe("provider discovery contract", () => {
config: {},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: "minimax-oauth",
discoveryApiKey: "access-token",
mode: "oauth",
source: "profile",
profileId: "minimax-portal:default",
}),
}),
).resolves.toMatchObject({
provider: {
@ -420,6 +475,12 @@ describe("provider discovery contract", () => {
},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toMatchObject({
provider: {
@ -447,6 +508,12 @@ describe("provider discovery contract", () => {
MODELSTUDIO_API_KEY: "modelstudio-key",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: "modelstudio-key" }),
resolveProviderAuth: () => ({
apiKey: "modelstudio-key",
discoveryApiKey: undefined,
mode: "api_key",
source: "env",
}),
}),
).resolves.toMatchObject({
provider: {
@ -468,6 +535,12 @@ describe("provider discovery contract", () => {
config: {},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toBeNull();
});
@ -504,6 +577,12 @@ describe("provider discovery contract", () => {
CLOUDFLARE_AI_GATEWAY_API_KEY: "secret-value",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toEqual({
provider: {

View File

@ -1,41 +1,18 @@
import amazonBedrockPlugin from "../../../extensions/amazon-bedrock/index.js";
import anthropicPlugin from "../../../extensions/anthropic/index.js";
import bravePlugin from "../../../extensions/brave/index.js";
import byteplusPlugin from "../../../extensions/byteplus/index.js";
import cloudflareAiGatewayPlugin from "../../../extensions/cloudflare-ai-gateway/index.js";
import copilotProxyPlugin from "../../../extensions/copilot-proxy/index.js";
import elevenLabsPlugin from "../../../extensions/elevenlabs/index.js";
import firecrawlPlugin from "../../../extensions/firecrawl/index.js";
import githubCopilotPlugin from "../../../extensions/github-copilot/index.js";
import googlePlugin from "../../../extensions/google/index.js";
import huggingFacePlugin from "../../../extensions/huggingface/index.js";
import kilocodePlugin from "../../../extensions/kilocode/index.js";
import kimiCodingPlugin from "../../../extensions/kimi-coding/index.js";
import microsoftPlugin from "../../../extensions/microsoft/index.js";
import minimaxPlugin from "../../../extensions/minimax/index.js";
import mistralPlugin from "../../../extensions/mistral/index.js";
import modelStudioPlugin from "../../../extensions/modelstudio/index.js";
import moonshotPlugin from "../../../extensions/moonshot/index.js";
import nvidiaPlugin from "../../../extensions/nvidia/index.js";
import ollamaPlugin from "../../../extensions/ollama/index.js";
import openAIPlugin from "../../../extensions/openai/index.js";
import opencodeGoPlugin from "../../../extensions/opencode-go/index.js";
import opencodePlugin from "../../../extensions/opencode/index.js";
import openRouterPlugin from "../../../extensions/openrouter/index.js";
import perplexityPlugin from "../../../extensions/perplexity/index.js";
import qianfanPlugin from "../../../extensions/qianfan/index.js";
import qwenPortalPlugin from "../../../extensions/qwen-portal-auth/index.js";
import sglangPlugin from "../../../extensions/sglang/index.js";
import syntheticPlugin from "../../../extensions/synthetic/index.js";
import togetherPlugin from "../../../extensions/together/index.js";
import venicePlugin from "../../../extensions/venice/index.js";
import vercelAiGatewayPlugin from "../../../extensions/vercel-ai-gateway/index.js";
import vllmPlugin from "../../../extensions/vllm/index.js";
import volcenginePlugin from "../../../extensions/volcengine/index.js";
import xaiPlugin from "../../../extensions/xai/index.js";
import xiaomiPlugin from "../../../extensions/xiaomi/index.js";
import zaiPlugin from "../../../extensions/zai/index.js";
import { createCapturedPluginRegistration } from "../captured-registration.js";
import { resolvePluginProviders } from "../providers.js";
import type {
ImageGenerationProviderPlugin,
MediaUnderstandingProviderPlugin,
@ -75,41 +52,6 @@ type PluginRegistrationContractEntry = {
toolNames: string[];
};
const bundledProviderPlugins: RegistrablePlugin[] = [
amazonBedrockPlugin,
anthropicPlugin,
byteplusPlugin,
cloudflareAiGatewayPlugin,
copilotProxyPlugin,
githubCopilotPlugin,
googlePlugin,
huggingFacePlugin,
kilocodePlugin,
kimiCodingPlugin,
minimaxPlugin,
mistralPlugin,
modelStudioPlugin,
moonshotPlugin,
nvidiaPlugin,
ollamaPlugin,
opencodeGoPlugin,
opencodePlugin,
openAIPlugin,
openRouterPlugin,
qianfanPlugin,
qwenPortalPlugin,
sglangPlugin,
syntheticPlugin,
togetherPlugin,
venicePlugin,
vercelAiGatewayPlugin,
vllmPlugin,
volcenginePlugin,
xaiPlugin,
xiaomiPlugin,
zaiPlugin,
];
const bundledWebSearchPlugins: Array<RegistrablePlugin & { credentialValue: unknown }> = [
{ ...bravePlugin, credentialValue: "BSA-test" },
{ ...firecrawlPlugin, credentialValue: "fc-test" },
@ -153,10 +95,30 @@ function buildCapabilityContractRegistry<T>(params: {
}
export const providerContractRegistry: ProviderContractEntry[] = buildCapabilityContractRegistry({
plugins: bundledProviderPlugins,
select: (captured) => captured.providers,
plugins: [],
select: () => [],
});
const loadedBundledProviderRegistry: ProviderContractEntry[] = resolvePluginProviders({
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
cache: false,
activate: false,
})
.filter((provider): provider is ProviderPlugin & { pluginId: string } =>
Boolean(provider.pluginId),
)
.map((provider) => ({
pluginId: provider.pluginId,
provider,
}));
providerContractRegistry.splice(
0,
providerContractRegistry.length,
...loadedBundledProviderRegistry,
);
export const uniqueProviderContractProviders: ProviderPlugin[] = [
...new Map(providerContractRegistry.map((entry) => [entry.provider.id, entry.provider])).values(),
];
@ -234,7 +196,6 @@ export const imageGenerationProviderContractRegistry: ImageGenerationProviderCon
const bundledPluginRegistrationList = [
...new Map(
[
...bundledProviderPlugins,
...bundledSpeechPlugins,
...bundledMediaUnderstandingPlugins,
...bundledImageGenerationPlugins,
@ -243,18 +204,47 @@ const bundledPluginRegistrationList = [
).values(),
];
export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry[] =
bundledPluginRegistrationList.map((plugin) => {
const captured = captureRegistrations(plugin);
return {
pluginId: plugin.id,
providerIds: captured.providers.map((provider) => provider.id),
speechProviderIds: captured.speechProviders.map((provider) => provider.id),
mediaUnderstandingProviderIds: captured.mediaUnderstandingProviders.map(
(provider) => provider.id,
),
imageGenerationProviderIds: captured.imageGenerationProviders.map((provider) => provider.id),
webSearchProviderIds: captured.webSearchProviders.map((provider) => provider.id),
toolNames: captured.tools.map((tool) => tool.name),
};
});
export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry[] = [
...new Map(
providerContractRegistry.map((entry) => [
entry.pluginId,
{
pluginId: entry.pluginId,
providerIds: providerContractRegistry
.filter((candidate) => candidate.pluginId === entry.pluginId)
.map((candidate) => candidate.provider.id),
speechProviderIds: [] as string[],
mediaUnderstandingProviderIds: [] as string[],
imageGenerationProviderIds: [] as string[],
webSearchProviderIds: [] as string[],
toolNames: [] as string[],
},
]),
).values(),
];
for (const plugin of bundledPluginRegistrationList) {
const captured = captureRegistrations(plugin);
const existing = pluginRegistrationContractRegistry.find((entry) => entry.pluginId === plugin.id);
const next = {
pluginId: plugin.id,
providerIds: captured.providers.map((provider) => provider.id),
speechProviderIds: captured.speechProviders.map((provider) => provider.id),
mediaUnderstandingProviderIds: captured.mediaUnderstandingProviders.map(
(provider) => provider.id,
),
imageGenerationProviderIds: captured.imageGenerationProviders.map((provider) => provider.id),
webSearchProviderIds: captured.webSearchProviders.map((provider) => provider.id),
toolNames: captured.tools.map((tool) => tool.name),
};
if (!existing) {
pluginRegistrationContractRegistry.push(next);
continue;
}
existing.providerIds = next.providerIds.length > 0 ? next.providerIds : existing.providerIds;
existing.speechProviderIds = next.speechProviderIds;
existing.mediaUnderstandingProviderIds = next.mediaUnderstandingProviderIds;
existing.imageGenerationProviderIds = next.imageGenerationProviderIds;
existing.webSearchProviderIds = next.webSearchProviderIds;
existing.toolNames = next.toolNames;
}

View File

@ -2877,6 +2877,44 @@ module.exports = {
}
});
it("loads bundled plugins when manifest metadata opts into default enablement", () => {
const bundledDir = makeTempDir();
const plugin = writePlugin({
id: "profile-aware",
body: `module.exports = { id: "profile-aware", register() {} };`,
dir: bundledDir,
filename: "index.cjs",
});
fs.writeFileSync(
path.join(plugin.dir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "profile-aware",
enabledByDefault: true,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
const registry = loadOpenClawPlugins({
cache: false,
workspaceDir: bundledDir,
config: {
plugins: {
enabled: true,
},
},
});
const bundledPlugin = registry.plugins.find((entry) => entry.id === "profile-aware");
expect(bundledPlugin?.origin).toBe("bundled");
expect(bundledPlugin?.status).toBe("loaded");
});
it("keeps scoped and unscoped plugin ids distinct", () => {
useNoBundledPlugins();
const scoped = writePlugin({

View File

@ -1035,6 +1035,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
origin: candidate.origin,
config: normalized,
rootConfig: cfg,
enabledByDefault: manifestRecord.enabledByDefault,
});
const entry = normalized.entries[pluginId];
const record = createPluginRecord({

View File

@ -203,6 +203,7 @@ describe("loadPluginManifestRegistry", () => {
const dir = makeTempDir();
writeManifest(dir, {
id: "openai",
enabledByDefault: true,
providers: ["openai", "openai-codex"],
providerAuthEnvVars: {
openai: ["OPENAI_API_KEY"],
@ -227,6 +228,7 @@ describe("loadPluginManifestRegistry", () => {
expect(registry.plugins[0]?.providerAuthEnvVars).toEqual({
openai: ["OPENAI_API_KEY"],
});
expect(registry.plugins[0]?.enabledByDefault).toBe(true);
expect(registry.plugins[0]?.providerAuthChoices).toEqual([
{
provider: "openai",

View File

@ -35,6 +35,7 @@ export type PluginManifestRecord = {
name?: string;
description?: string;
version?: string;
enabledByDefault?: boolean;
format?: PluginFormat;
bundleFormat?: PluginBundleFormat;
bundleCapabilities?: string[];
@ -154,6 +155,7 @@ function buildRecord(params: {
description:
normalizeManifestLabel(params.manifest.description) ?? params.candidate.packageDescription,
version: normalizeManifestLabel(params.manifest.version) ?? params.candidate.packageVersion,
enabledByDefault: params.manifest.enabledByDefault === true ? true : undefined,
format: params.candidate.format ?? "openclaw",
bundleFormat: params.candidate.bundleFormat,
kind: params.manifest.kind,

View File

@ -11,6 +11,7 @@ export const PLUGIN_MANIFEST_FILENAMES = [PLUGIN_MANIFEST_FILENAME] as const;
export type PluginManifest = {
id: string;
configSchema: Record<string, unknown>;
enabledByDefault?: boolean;
kind?: PluginKind;
channels?: string[];
providers?: string[];
@ -180,6 +181,7 @@ export function loadPluginManifest(
}
const kind = typeof raw.kind === "string" ? (raw.kind as PluginKind) : undefined;
const enabledByDefault = raw.enabledByDefault === true;
const name = typeof raw.name === "string" ? raw.name.trim() : undefined;
const description = typeof raw.description === "string" ? raw.description.trim() : undefined;
const version = typeof raw.version === "string" ? raw.version.trim() : undefined;
@ -199,6 +201,7 @@ export function loadPluginManifest(
manifest: {
id,
configSchema,
...(enabledByDefault ? { enabledByDefault } : {}),
kind,
channels,
providers,

View File

@ -27,6 +27,11 @@ function createCatalogContext(params: {
resolveProviderApiKey: (providerId) => ({
apiKey: providerId ? params.apiKeys?.[providerId] : undefined,
}),
resolveProviderAuth: (providerId) => ({
apiKey: providerId ? params.apiKeys?.[providerId] : undefined,
mode: providerId && params.apiKeys?.[providerId] ? "api_key" : "none",
source: providerId && params.apiKeys?.[providerId] ? "env" : "none",
}),
};
}

View File

@ -120,6 +120,12 @@ describe("runProviderCatalog", () => {
config: {},
env: {},
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
});
expect(result).toEqual({

View File

@ -81,6 +81,16 @@ export function runProviderCatalog(params: {
apiKey: string | undefined;
discoveryApiKey?: string;
};
resolveProviderAuth: (
providerId?: string,
options?: { oauthMarker?: string },
) => {
apiKey: string | undefined;
discoveryApiKey?: string;
mode: "api_key" | "oauth" | "token" | "none";
source: "env" | "profile" | "none";
profileId?: string;
};
}) {
return resolveProviderCatalogHook(params.provider)?.run({
config: params.config,
@ -88,5 +98,6 @@ export function runProviderCatalog(params: {
workspaceDir: params.workspaceDir,
env: params.env,
resolveProviderApiKey: params.resolveProviderApiKey,
resolveProviderAuth: params.resolveProviderAuth,
});
}

View File

@ -70,6 +70,11 @@ describe("resolvePluginProviders", () => {
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: expect.arrayContaining(["openrouter", "google", "kilocode", "moonshot"]),
entries: expect.objectContaining({
google: { enabled: true },
kilocode: { enabled: true },
moonshot: { enabled: true },
}),
}),
}),
cache: false,
@ -89,6 +94,10 @@ describe("resolvePluginProviders", () => {
plugins: expect.objectContaining({
enabled: true,
allow: expect.arrayContaining(["google", "moonshot"]),
entries: expect.objectContaining({
google: { enabled: true },
moonshot: { enabled: true },
}),
}),
}),
cache: false,

View File

@ -1,6 +1,9 @@
import { normalizeProviderId } from "../agents/provider-id.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { withBundledPluginAllowlistCompat } from "./bundled-compat.js";
import {
withBundledPluginAllowlistCompat,
withBundledPluginEnablementCompat,
} from "./bundled-compat.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
@ -165,13 +168,20 @@ export function resolvePluginProviders(params: {
pluginIds: bundledProviderCompatPluginIds,
})
: params.config;
const config = params.bundledProviderVitestCompat
const maybeVitestCompat = params.bundledProviderVitestCompat
? withBundledProviderVitestCompat({
config: maybeAllowlistCompat,
pluginIds: bundledProviderCompatPluginIds,
env: params.env,
})
: maybeAllowlistCompat;
const config =
params.bundledProviderAllowlistCompat || params.bundledProviderVitestCompat
? withBundledPluginEnablementCompat({
config: maybeVitestCompat,
pluginIds: bundledProviderCompatPluginIds,
})
: maybeVitestCompat;
const registry = loadOpenClawPlugins({
config,
workspaceDir: params.workspaceDir,

View File

@ -246,6 +246,18 @@ export type ProviderCatalogContext = {
apiKey: string | undefined;
discoveryApiKey?: string;
};
resolveProviderAuth: (
providerId?: string,
options?: {
oauthMarker?: string;
},
) => {
apiKey: string | undefined;
discoveryApiKey?: string;
mode: "api_key" | "oauth" | "token" | "none";
source: "env" | "profile" | "none";
profileId?: string;
};
};
export type ProviderCatalogResult =