diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md index 1b1ec7938dd..0d3cfbe939b 100644 --- a/docs/plugins/sdk-provider-plugins.md +++ b/docs/plugins/sdk-provider-plugins.md @@ -229,11 +229,22 @@ API key auth, and dynamic model resolution. baseUrl: "https://api.acme-ai.com/v1", models: [{ id: "acme-large", name: "Acme Large" }], }), + buildStaticProvider: () => ({ + api: "openai-completions", + baseUrl: "https://api.acme-ai.com/v1", + models: [{ id: "acme-large", name: "Acme Large" }], + }), }, }, }); ``` + `buildProvider` is the live catalog path used when OpenClaw can resolve real + provider auth. It may perform provider-specific discovery. Use + `buildStaticProvider` only for bundled/offline rows that are safe to show in + display-only surfaces such as `models list --all` before auth is configured; + it must not require credentials or make network requests. + If your auth flow also needs to patch `models.providers.*`, aliases, and the agent default model during onboarding, use the preset helpers from `openclaw/plugin-sdk/provider-onboard`. The narrowest helpers are diff --git a/extensions/chutes/index.ts b/extensions/chutes/index.ts index a953882ecda..e3a3c01a7f2 100644 --- a/extensions/chutes/index.ts +++ b/extensions/chutes/index.ts @@ -13,7 +13,7 @@ import { applyChutesApiKeyConfig, applyChutesProviderConfig, } from "./onboard.js"; -import { buildChutesProvider } from "./provider-catalog.js"; +import { buildChutesProvider, buildStaticChutesProvider } from "./provider-catalog.js"; const PROVIDER_ID = "chutes"; @@ -180,6 +180,12 @@ export default definePluginEntry({ }; }, }, + staticCatalog: { + order: "profile", + run: async () => ({ + provider: buildStaticChutesProvider(), + }), + }, }); }, }); diff --git a/extensions/chutes/provider-catalog.ts b/extensions/chutes/provider-catalog.ts index 5148037509d..3f28345fa42 100644 --- a/extensions/chutes/provider-catalog.ts +++ b/extensions/chutes/provider-catalog.ts @@ -6,6 +6,14 @@ import { discoverChutesModels, } from "./models.js"; +export function buildStaticChutesProvider(): ModelProviderConfig { + return { + baseUrl: CHUTES_BASE_URL, + api: "openai-completions", + models: CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition), + }; +} + /** * Build the Chutes provider with dynamic model discovery. * Falls back to the static catalog on failure. diff --git a/extensions/kilocode/index.ts b/extensions/kilocode/index.ts index 5d801008526..89c38a7683e 100644 --- a/extensions/kilocode/index.ts +++ b/extensions/kilocode/index.ts @@ -3,7 +3,7 @@ import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-en import { PASSTHROUGH_GEMINI_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared"; import { KILOCODE_THINKING_STREAM_HOOKS } from "openclaw/plugin-sdk/provider-stream-family"; import { applyKilocodeConfig, KILOCODE_DEFAULT_MODEL_REF } from "./onboard.js"; -import { buildKilocodeProviderWithDiscovery } from "./provider-catalog.js"; +import { buildKilocodeProvider, buildKilocodeProviderWithDiscovery } from "./provider-catalog.js"; const PROVIDER_ID = "kilocode"; @@ -29,6 +29,7 @@ export default defineSingleProviderPluginEntry({ ], catalog: { buildProvider: buildKilocodeProviderWithDiscovery, + buildStaticProvider: buildKilocodeProvider, }, augmentModelCatalog: ({ config }) => readConfiguredProviderCatalogEntries({ diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index a51a3fc8066..137fbd7ebae 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -52,6 +52,7 @@ export default defineSingleProviderPluginEntry({ ], catalog: { buildProvider: buildMoonshotProvider, + buildStaticProvider: buildMoonshotProvider, allowExplicitBaseUrl: true, }, applyNativeStreamingUsageCompat: ({ providerConfig }) => diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts index c8e52b513a9..8891701b2ed 100644 --- a/extensions/openrouter/index.test.ts +++ b/extensions/openrouter/index.test.ts @@ -2,8 +2,15 @@ import { describe, expect, it, vi } from "vitest"; import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js"; import { expectPassthroughReplayPolicy } from "../../test/helpers/provider-replay-policy.ts"; import openrouterPlugin from "./index.js"; +import { buildOpenrouterProvider } from "./provider-catalog.js"; describe("openrouter provider hooks", () => { + it("includes Kimi K2.6 in the bundled catalog", () => { + expect(buildOpenrouterProvider().models?.map((model) => model.id)).toContain( + "moonshotai/kimi-k2.6", + ); + }); + it("owns passthrough-gemini replay policy for Gemini-backed models", async () => { await expectPassthroughReplayPolicy({ plugin: openrouterPlugin, diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index c4066501e76..02bdd2b0212 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -110,6 +110,12 @@ export default definePluginEntry({ }; }, }, + staticCatalog: { + order: "simple", + run: async () => ({ + provider: buildOpenrouterProvider(), + }), + }, resolveDynamicModel: (ctx) => buildDynamicOpenRouterModel(ctx), prepareDynamicModel: async (ctx) => { await loadOpenRouterModelCapabilities(ctx.modelId); diff --git a/extensions/vercel-ai-gateway/index.ts b/extensions/vercel-ai-gateway/index.ts index f224698e1d7..fdbba1740be 100644 --- a/extensions/vercel-ai-gateway/index.ts +++ b/extensions/vercel-ai-gateway/index.ts @@ -1,6 +1,9 @@ import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; import { applyVercelAiGatewayConfig, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF } from "./onboard.js"; -import { buildVercelAiGatewayProvider } from "./provider-catalog.js"; +import { + buildStaticVercelAiGatewayProvider, + buildVercelAiGatewayProvider, +} from "./provider-catalog.js"; const PROVIDER_ID = "vercel-ai-gateway"; @@ -30,6 +33,7 @@ export default defineSingleProviderPluginEntry({ ], catalog: { buildProvider: buildVercelAiGatewayProvider, + buildStaticProvider: buildStaticVercelAiGatewayProvider, }, }, }); diff --git a/extensions/vercel-ai-gateway/provider-catalog.test.ts b/extensions/vercel-ai-gateway/provider-catalog.test.ts index fbb8ae0fd51..d51c8c2a58c 100644 --- a/extensions/vercel-ai-gateway/provider-catalog.test.ts +++ b/extensions/vercel-ai-gateway/provider-catalog.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import { getStaticVercelAiGatewayModelCatalog, VERCEL_AI_GATEWAY_BASE_URL } from "./api.js"; -import { buildVercelAiGatewayProvider } from "./provider-catalog.js"; +import { + buildStaticVercelAiGatewayProvider, + buildVercelAiGatewayProvider, +} from "./provider-catalog.js"; describe("vercel ai gateway provider catalog", () => { it("builds the bundled Vercel AI Gateway defaults", async () => { @@ -9,13 +12,29 @@ describe("vercel ai gateway provider catalog", () => { expect(provider.baseUrl).toBe(VERCEL_AI_GATEWAY_BASE_URL); expect(provider.api).toBe("anthropic-messages"); expect(provider.models?.map((model) => model.id)).toEqual( - expect.arrayContaining(["anthropic/claude-opus-4.6", "openai/gpt-5.4", "openai/gpt-5.4-pro"]), + expect.arrayContaining([ + "anthropic/claude-opus-4.6", + "openai/gpt-5.4", + "openai/gpt-5.4-pro", + "moonshotai/kimi-k2.6", + ]), ); }); it("exposes the static fallback model catalog", () => { expect(getStaticVercelAiGatewayModelCatalog().map((model) => model.id)).toEqual( - expect.arrayContaining(["anthropic/claude-opus-4.6", "openai/gpt-5.4", "openai/gpt-5.4-pro"]), + expect.arrayContaining([ + "anthropic/claude-opus-4.6", + "openai/gpt-5.4", + "openai/gpt-5.4-pro", + "moonshotai/kimi-k2.6", + ]), + ); + }); + + it("builds an offline static provider catalog", () => { + expect(buildStaticVercelAiGatewayProvider().models?.map((model) => model.id)).toEqual( + expect.arrayContaining(["moonshotai/kimi-k2.6"]), ); }); }); diff --git a/extensions/vercel-ai-gateway/provider-catalog.ts b/extensions/vercel-ai-gateway/provider-catalog.ts index e2390784f81..9327cc4668b 100644 --- a/extensions/vercel-ai-gateway/provider-catalog.ts +++ b/extensions/vercel-ai-gateway/provider-catalog.ts @@ -1,5 +1,17 @@ import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; -import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL } from "./models.js"; +import { + discoverVercelAiGatewayModels, + getStaticVercelAiGatewayModelCatalog, + VERCEL_AI_GATEWAY_BASE_URL, +} from "./models.js"; + +export function buildStaticVercelAiGatewayProvider(): ModelProviderConfig { + return { + baseUrl: VERCEL_AI_GATEWAY_BASE_URL, + api: "anthropic-messages", + models: getStaticVercelAiGatewayModelCatalog(), + }; +} export async function buildVercelAiGatewayProvider(): Promise { return { diff --git a/src/commands/models/list.provider-catalog.test.ts b/src/commands/models/list.provider-catalog.test.ts new file mode 100644 index 00000000000..8987927d006 --- /dev/null +++ b/src/commands/models/list.provider-catalog.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loadProviderCatalogModelsForList } from "./list.provider-catalog.js"; + +const baseParams = { + cfg: { + plugins: { + entries: { + chutes: { enabled: true }, + moonshot: { enabled: true }, + }, + }, + }, + agentDir: "/tmp/openclaw-provider-catalog-test", + env: { + ...process.env, + CHUTES_API_KEY: "", + MOONSHOT_API_KEY: "", + }, +}; + +describe("loadProviderCatalogModelsForList", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not use live provider discovery for display-only rows", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("blocked fetch")); + + await loadProviderCatalogModelsForList({ + ...baseParams, + providerFilter: "chutes", + }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("includes unauthenticated Moonshot static catalog rows", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("blocked fetch")); + + const rows = await loadProviderCatalogModelsForList({ + ...baseParams, + providerFilter: "moonshot", + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(rows.map((row) => `${row.provider}/${row.id}`)).toEqual( + expect.arrayContaining(["moonshot/kimi-k2.6"]), + ); + }); +}); diff --git a/src/commands/models/list.provider-catalog.ts b/src/commands/models/list.provider-catalog.ts index 6bc05b7bae7..de32365e117 100644 --- a/src/commands/models/list.provider-catalog.ts +++ b/src/commands/models/list.provider-catalog.ts @@ -6,11 +6,10 @@ import { groupPluginDiscoveryProvidersByOrder, normalizePluginDiscoveryResult, resolvePluginDiscoveryProviders, - runProviderCatalog, + runProviderStaticCatalog, } from "../../plugins/provider-discovery.js"; import { resolveOwningPluginIdsForProvider } from "../../plugins/providers.js"; -const CATALOG_DISPLAY_API_KEY = "__openclaw_catalog_display__"; const DISCOVERY_ORDERS = ["simple", "profile", "paired", "late"] as const; const SELF_HOSTED_DISCOVERY_PROVIDER_IDS = new Set(["lmstudio", "ollama", "sglang", "vllm"]); @@ -65,21 +64,13 @@ export async function loadProviderCatalogModelsForList(params: { if (!providerFilter && SELF_HOSTED_DISCOVERY_PROVIDER_IDS.has(provider.id)) { continue; } - let result: Awaited> | null; + let result: Awaited> | null; try { - result = await runProviderCatalog({ + result = await runProviderStaticCatalog({ provider, config: params.cfg, agentDir: params.agentDir, env, - resolveProviderApiKey: () => ({ - apiKey: CATALOG_DISPLAY_API_KEY, - }), - resolveProviderAuth: () => ({ - apiKey: CATALOG_DISPLAY_API_KEY, - mode: "api_key", - source: "env", - }), }); } catch { result = null; diff --git a/src/plugin-sdk/provider-entry.test.ts b/src/plugin-sdk/provider-entry.test.ts index 81aa293c519..be95a143678 100644 --- a/src/plugin-sdk/provider-entry.test.ts +++ b/src/plugin-sdk/provider-entry.test.ts @@ -42,7 +42,8 @@ async function captureProviderEntry(params: { const captured = capturePluginRegistration(params.entry); const provider = captured.providers[0]; const catalog = await provider?.catalog?.run(createCatalogContext(params.config)); - return { captured, provider, catalog }; + const staticCatalog = await provider?.staticCatalog?.run(createCatalogContext(params.config)); + return { captured, provider, catalog, staticCatalog }; } describe("defineSingleProviderPluginEntry", () => { @@ -72,11 +73,16 @@ describe("defineSingleProviderPluginEntry", () => { baseUrl: "https://api.demo.test/v1", models: [createModel("default", "Default")], }), + buildStaticProvider: () => ({ + api: "openai-completions", + baseUrl: "https://api.demo.test/v1", + models: [createModel("default", "Default")], + }), }, }, }); - const { captured, provider, catalog } = await captureProviderEntry({ entry }); + const { captured, provider, catalog, staticCatalog } = await captureProviderEntry({ entry }); expect(captured.providers).toHaveLength(1); expect(provider).toMatchObject({ id: "demo", @@ -107,6 +113,13 @@ describe("defineSingleProviderPluginEntry", () => { models: [createModel("default", "Default")], }, }); + expect(staticCatalog).toEqual({ + provider: { + api: "openai-completions", + baseUrl: "https://api.demo.test/v1", + models: [createModel("default", "Default")], + }, + }); }); it("supports provider overrides, explicit env vars, and extra registration", async () => { diff --git a/src/plugin-sdk/provider-entry.ts b/src/plugin-sdk/provider-entry.ts index eea243feac5..b8339cbc1ab 100644 --- a/src/plugin-sdk/provider-entry.ts +++ b/src/plugin-sdk/provider-entry.ts @@ -27,14 +27,18 @@ export type SingleProviderPluginApiKeyAuthOptions = Omit< export type SingleProviderPluginCatalogOptions = | { buildProvider: Parameters[0]["buildProvider"]; + buildStaticProvider?: Parameters[0]["buildProvider"]; allowExplicitBaseUrl?: boolean; run?: never; order?: never; + staticRun?: never; } | { run: ProviderPluginCatalog["run"]; + staticRun?: ProviderPluginCatalog["run"]; order?: ProviderPluginCatalog["order"]; buildProvider?: never; + buildStaticProvider?: never; allowExplicitBaseUrl?: never; }; @@ -54,7 +58,7 @@ export type SingleProviderPluginOptions = { catalog: SingleProviderPluginCatalogOptions; } & Omit< ProviderPlugin, - "id" | "label" | "docsPath" | "aliases" | "envVars" | "auth" | "catalog" + "id" | "label" | "docsPath" | "aliases" | "envVars" | "auth" | "catalog" | "staticCatalog" >; register?: (api: OpenClawPluginApi) => void; }; @@ -146,6 +150,22 @@ export function defineSingleProviderPluginEntry(options: SingleProviderPluginOpt }), }; } + const staticCatalog: ProviderPluginCatalog | undefined = + "run" in provider.catalog + ? provider.catalog.staticRun + ? { + order: provider.catalog.order ?? "simple", + run: provider.catalog.staticRun, + } + : undefined + : provider.catalog.buildStaticProvider + ? { + order: "simple", + run: async () => ({ + provider: await provider.catalog.buildStaticProvider!(), + }), + } + : undefined; api.registerProvider({ id: providerId, label: provider.label, @@ -154,10 +174,20 @@ export function defineSingleProviderPluginEntry(options: SingleProviderPluginOpt ...(envVars ? { envVars } : {}), auth, catalog, + ...(staticCatalog ? { staticCatalog } : {}), ...Object.fromEntries( Object.entries(provider).filter( ([key]) => - !["id", "label", "docsPath", "aliases", "envVars", "auth", "catalog"].includes(key), + ![ + "id", + "label", + "docsPath", + "aliases", + "envVars", + "auth", + "catalog", + "staticCatalog", + ].includes(key), ), ), }); diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index f5b75e68c83..e471d6ea210 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -15,6 +15,10 @@ function resolveProviderCatalogHook(provider: ProviderPlugin) { return provider.catalog ?? provider.discovery; } +function resolveProviderCatalogOrderHook(provider: ProviderPlugin) { + return resolveProviderCatalogHook(provider) ?? provider.staticCatalog; +} + export async function resolvePluginDiscoveryProviders(params: { config?: OpenClawConfig; workspaceDir?: string; @@ -23,7 +27,7 @@ export async function resolvePluginDiscoveryProviders(params: { }): Promise { return (await loadProviderRuntime()) .resolvePluginDiscoveryProvidersRuntime(params) - .filter((provider) => resolveProviderCatalogHook(provider)); + .filter((provider) => resolveProviderCatalogOrderHook(provider)); } export function groupPluginDiscoveryProvidersByOrder( @@ -37,7 +41,7 @@ export function groupPluginDiscoveryProvidersByOrder( } as Record; for (const provider of providers) { - const order = resolveProviderCatalogHook(provider)?.order ?? "late"; + const order = resolveProviderCatalogOrderHook(provider)?.order ?? "late"; grouped[order].push(provider); } @@ -118,3 +122,26 @@ export function runProviderCatalog(params: { resolveProviderAuth: params.resolveProviderAuth, }); } + +export function runProviderStaticCatalog(params: { + provider: ProviderPlugin; + config: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}) { + return params.provider.staticCatalog?.run({ + config: params.config, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + env: params.env, + resolveProviderApiKey: () => ({ + apiKey: undefined, + }), + resolveProviderAuth: () => ({ + apiKey: undefined, + mode: "none", + source: "none", + }), + }); +} diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 473e585230e..90634ac1c08 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1083,6 +1083,14 @@ export type ProviderPlugin = { * Returns provider config/model definitions that merge into models.providers. */ catalog?: ProviderPluginCatalog; + /** + * Offline provider catalog for display-only surfaces. + * + * Unlike `catalog`, this hook must not perform network I/O or require real + * credentials. Use it for bundled/static rows that can be shown before auth is + * configured. + */ + staticCatalog?: ProviderPluginCatalog; /** * Legacy alias for catalog. * Kept for compatibility with existing provider plugins.