fix: use static provider catalogs for model listing

This commit is contained in:
Shakker
2026-04-22 02:36:18 +01:00
committed by Shakker
parent 651d5e0022
commit 04ecf284fc
16 changed files with 219 additions and 25 deletions

View File

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

View File

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

View File

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

View File

@@ -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({

View File

@@ -52,6 +52,7 @@ export default defineSingleProviderPluginEntry({
],
catalog: {
buildProvider: buildMoonshotProvider,
buildStaticProvider: buildMoonshotProvider,
allowExplicitBaseUrl: true,
},
applyNativeStreamingUsageCompat: ({ providerConfig }) =>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ModelProviderConfig> {
return {

View File

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

View File

@@ -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<ReturnType<typeof runProviderCatalog>> | null;
let result: Awaited<ReturnType<typeof runProviderStaticCatalog>> | 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;

View File

@@ -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 () => {

View File

@@ -27,14 +27,18 @@ export type SingleProviderPluginApiKeyAuthOptions = Omit<
export type SingleProviderPluginCatalogOptions =
| {
buildProvider: Parameters<typeof buildSingleProviderApiKeyCatalog>[0]["buildProvider"];
buildStaticProvider?: Parameters<typeof buildSingleProviderApiKeyCatalog>[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),
),
),
});

View File

@@ -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<ProviderPlugin[]> {
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<ProviderDiscoveryOrder, ProviderPlugin[]>;
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",
}),
});
}

View File

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