From a724bbce1a63ab361f5f306f747fb4c3ac08bbc4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:35:21 -0700 Subject: [PATCH] feat: add bundled Chutes extension (#49136) * refactor: generalize bundled provider discovery seams * feat: land chutes extension via plugin-owned auth (#41416) (thanks @Veightor) --- CHANGELOG.md | 1 + extensions/chutes/index.ts | 184 +++++ extensions/chutes/onboard.ts | 67 ++ extensions/chutes/openclaw.plugin.json | 39 ++ extensions/chutes/package.json | 12 + extensions/chutes/provider-catalog.ts | 21 + src/agents/chutes-models.test.ts | 320 +++++++++ src/agents/chutes-models.ts | 639 ++++++++++++++++++ src/agents/model-auth-markers.test.ts | 2 + src/agents/model-auth-markers.ts | 10 + .../models-config.providers.chutes.test.ts | 212 ++++++ src/agents/models-config.providers.ts | 77 +++ src/commands/auth-choice.apply.oauth.ts | 89 +-- src/commands/auth-choice.test.ts | 6 +- src/plugin-sdk/provider-auth.ts | 3 + src/plugin-sdk/provider-models.ts | 8 + src/plugins/config-state.test.ts | 5 + src/plugins/config-state.ts | 6 +- .../contracts/discovery.contract.test.ts | 79 +++ src/plugins/contracts/registry.ts | 144 ++-- src/plugins/loader.test.ts | 38 ++ src/plugins/loader.ts | 1 + src/plugins/manifest-registry.test.ts | 2 + src/plugins/manifest-registry.ts | 2 + src/plugins/manifest.ts | 3 + src/plugins/provider-catalog.test.ts | 5 + src/plugins/provider-discovery.test.ts | 6 + src/plugins/provider-discovery.ts | 11 + src/plugins/providers.test.ts | 9 + src/plugins/providers.ts | 14 +- src/plugins/types.ts | 12 + 31 files changed, 1856 insertions(+), 171 deletions(-) create mode 100644 extensions/chutes/index.ts create mode 100644 extensions/chutes/onboard.ts create mode 100644 extensions/chutes/openclaw.plugin.json create mode 100644 extensions/chutes/package.json create mode 100644 extensions/chutes/provider-catalog.ts create mode 100644 src/agents/chutes-models.test.ts create mode 100644 src/agents/chutes-models.ts create mode 100644 src/agents/models-config.providers.chutes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c6bd8a223eb..d3836e10c9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/extensions/chutes/index.ts b/extensions/chutes/index.ts new file mode 100644 index 00000000000..a61cd4ec93f --- /dev/null +++ b/extensions/chutes/index.ts @@ -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 { + 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, + }, + }; + }, + }, + }); + }, +}); diff --git a/extensions/chutes/onboard.ts b/extensions/chutes/onboard.ts new file mode 100644 index 00000000000..f51914c3ca8 --- /dev/null +++ b/extensions/chutes/onboard.ts @@ -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); +} diff --git a/extensions/chutes/openclaw.plugin.json b/extensions/chutes/openclaw.plugin.json new file mode 100644 index 00000000000..26174f31b3a --- /dev/null +++ b/extensions/chutes/openclaw.plugin.json @@ -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 ", + "cliDescription": "Chutes API key" + } + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/chutes/package.json b/extensions/chutes/package.json new file mode 100644 index 00000000000..be860172a27 --- /dev/null +++ b/extensions/chutes/package.json @@ -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" + ] + } +} diff --git a/extensions/chutes/provider-catalog.ts b/extensions/chutes/provider-catalog.ts new file mode 100644 index 00000000000..1467f405dde --- /dev/null +++ b/extensions/chutes/provider-catalog.ts @@ -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 { + const models = await discoverChutesModels(accessToken); + return { + baseUrl: CHUTES_BASE_URL, + api: "openai-completions", + models: models.length > 0 ? models : CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition), + }; +} diff --git a/src/agents/chutes-models.test.ts b/src/agents/chutes-models.test.ts new file mode 100644 index 00000000000..66bafde50ad --- /dev/null +++ b/src/agents/chutes-models.test.ts @@ -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 }) => { + 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 }) => { + 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 }) => { + 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 }) => { + 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(); + } + }); +}); diff --git a/src/agents/chutes-models.ts b/src/agents/chutes-models.ts new file mode 100644 index 00000000000..585723e3adc --- /dev/null +++ b/src/agents/chutes-models.ts @@ -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(); + +/** @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 { + 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 = {}; + 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(); + 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(); + } +} diff --git a/src/agents/model-auth-markers.test.ts b/src/agents/model-auth-markers.test.ts index b90f1fd9ffa..960a648675b 100644 --- a/src/agents/model-auth-markers.test.ts +++ b/src/agents/model-auth-markers.test.ts @@ -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); }); diff --git a/src/agents/model-auth-markers.ts b/src/agents/model-auth-markers.ts index 8a890d3a694..37ec67ba2c0 100644 --- a/src/agents/model-auth-markers.ts +++ b/src/agents/model-auth-markers.ts @@ -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 || diff --git a/src/agents/models-config.providers.chutes.test.ts b/src/agents/models-config.providers.chutes.test.ts new file mode 100644 index 00000000000..a47ee57fcb3 --- /dev/null +++ b/src/agents/models-config.providers.chutes.test.ts @@ -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 } | 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); + }); +}); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 19ce478b2f4..af9c3d6e34a 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -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; 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")); diff --git a/src/commands/auth-choice.apply.oauth.ts b/src/commands/auth-choice.apply.oauth.ts index a2a3104e447..1966d2bd8d8 100644 --- a/src/commands/auth-choice.apply.oauth.ts +++ b/src/commands/auth-choice.apply.oauth.ts @@ -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 { - 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; } diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 8d6316e9acb..dd270a6d3d2 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -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; 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({ diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index d30dd81f7d6..84373befb88 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -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"; diff --git a/src/plugin-sdk/provider-models.ts b/src/plugin-sdk/provider-models.ts index c2a68c7b579..996135c9011 100644 --- a/src/plugin-sdk/provider-models.ts +++ b/src/plugin-sdk/provider-models.ts @@ -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, diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 915f647950e..01f2b14cfd7 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -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 }); + }); }); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 0dde14a8941..26827e50aa3 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -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)" && diff --git a/src/plugins/contracts/discovery.contract.test.ts b/src/plugins/contracts/discovery.contract.test.ts index 9035391ac9e..47e098a2baf 100644 --- a/src/plugins/contracts/discovery.contract.test.ts +++ b/src/plugins/contracts/discovery.contract.test.ts @@ -131,12 +131,30 @@ function runCatalog(params: { provider: Awaited>; 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: { diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index f33571b8008..e4b6cf1059a 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -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 = [ { ...bravePlugin, credentialValue: "BSA-test" }, { ...firecrawlPlugin, credentialValue: "fc-test" }, @@ -153,10 +95,30 @@ function buildCapabilityContractRegistry(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; +} diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 691cd7e7607..60673ffa67f 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 8d064d477c3..251a08beb4e 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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({ diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index c60e5444443..14a571c9250 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -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", diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 7a5c10d67f0..eea801a72ea 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -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, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index dd8615d7350..a75a2a9b6ab 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -11,6 +11,7 @@ export const PLUGIN_MANIFEST_FILENAMES = [PLUGIN_MANIFEST_FILENAME] as const; export type PluginManifest = { id: string; configSchema: Record; + 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, diff --git a/src/plugins/provider-catalog.test.ts b/src/plugins/provider-catalog.test.ts index a49e82a98e6..e7dcf201226 100644 --- a/src/plugins/provider-catalog.test.ts +++ b/src/plugins/provider-catalog.test.ts @@ -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", + }), }; } diff --git a/src/plugins/provider-discovery.test.ts b/src/plugins/provider-discovery.test.ts index 4952961062b..30efba6081b 100644 --- a/src/plugins/provider-discovery.test.ts +++ b/src/plugins/provider-discovery.test.ts @@ -120,6 +120,12 @@ describe("runProviderCatalog", () => { config: {}, env: {}, resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), }); expect(result).toEqual({ diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index e249bf6e45a..b3816e2faf1 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -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, }); } diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index b8da58c1921..ff804babb43 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -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, diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 45c84986e6c..e966e9d4128 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -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, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index a96913360be..ae5b2d116b4 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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 =