mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 20:46:57 +02:00
fix: use static provider catalogs for model listing
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -52,6 +52,7 @@ export default defineSingleProviderPluginEntry({
|
||||
],
|
||||
catalog: {
|
||||
buildProvider: buildMoonshotProvider,
|
||||
buildStaticProvider: buildMoonshotProvider,
|
||||
allowExplicitBaseUrl: true,
|
||||
},
|
||||
applyNativeStreamingUsageCompat: ({ providerConfig }) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
50
src/commands/models/list.provider-catalog.test.ts
Normal file
50
src/commands/models/list.provider-catalog.test.ts
Normal 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"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user