fix(models): expose runtime provider catalogs in browse views (#116857)

Use the resolved runtime config for discovery auth while preserving source SecretRef markers, keep full-catalog discovery unscoped, honor per-agent wildcards, and restrict discovered inventory to manifest-declared dynamic providers.

Fixes #115953
This commit is contained in:
SunnyShu
2026-08-01 08:37:43 +08:00
committed by GitHub
parent afa8fffab8
commit cbdbf6802b
18 changed files with 445 additions and 38 deletions
+95 -12
View File
@@ -26,8 +26,8 @@ function config(params: { providerWildcard?: boolean } = {}): OpenClawConfig {
agents: params.providerWildcard
? {
defaults: {
models: {
"openai/*": {},
modelPolicy: {
allow: ["openai/*"],
},
},
}
@@ -70,7 +70,7 @@ describe("loadPreparedModelCatalogSnapshotForBrowse", () => {
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false });
});
it("uses the read-only catalog when configured visibility has provider wildcards", async () => {
it("uses the full catalog for default views with provider wildcards", async () => {
const loadCatalog = vi.fn(async ({ readOnly }: { readOnly: boolean }) =>
readOnly ? readOnlyCatalog : fullCatalog,
);
@@ -80,9 +80,9 @@ describe("loadPreparedModelCatalogSnapshotForBrowse", () => {
cfg: config({ providerWildcard: true }),
loadCatalog,
}),
).resolves.toBe(readOnlyCatalog);
).resolves.toBe(fullCatalog);
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: true });
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false });
});
it("uses the full catalog for configured views with provider wildcards", async () => {
@@ -101,21 +101,82 @@ describe("loadPreparedModelCatalogSnapshotForBrowse", () => {
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false });
});
it.each([
["without picker allowlists", config()],
["with provider wildcards", config({ providerWildcard: true })],
])("uses the read-only catalog for provider-config views %s", async (_label, cfg) => {
it("uses the read-only catalog for provider-config views without picker allowlists", async () => {
const loadCatalog = vi.fn(async ({ readOnly }: { readOnly: boolean }) =>
readOnly ? readOnlyCatalog : fullCatalog,
);
await expect(
loadPreparedModelCatalogSnapshotForBrowse({ cfg, view: "provider-config", loadCatalog }),
loadPreparedModelCatalogSnapshotForBrowse({
cfg: config(),
view: "provider-config",
loadCatalog,
}),
).resolves.toBe(readOnlyCatalog);
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: true });
});
it("uses the full catalog for provider-config views with provider wildcards", async () => {
const loadCatalog = vi.fn(async ({ readOnly }: { readOnly: boolean }) =>
readOnly ? readOnlyCatalog : fullCatalog,
);
await expect(
loadPreparedModelCatalogSnapshotForBrowse({
cfg: config({ providerWildcard: true }),
view: "provider-config",
loadCatalog,
}),
).resolves.toBe(fullCatalog);
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false });
});
it("uses the selected agent's provider wildcard", async () => {
const loadCatalog = vi.fn(async ({ readOnly }: { readOnly: boolean }) =>
readOnly ? readOnlyCatalog : fullCatalog,
);
const cfg = {
agents: {
defaults: { modelPolicy: { allow: ["openai/gpt-5.6"] } },
list: [{ id: "research", modelPolicy: { allow: ["litellm/*"] } }],
},
} as OpenClawConfig;
await expect(
loadPreparedModelCatalogSnapshotForBrowse({
cfg,
agentId: "research",
view: "provider-config",
loadCatalog,
}),
).resolves.toBe(fullCatalog);
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false });
});
it("keeps the read-only catalog for default views with legacy models wildcards", async () => {
const loadCatalog = vi.fn(async ({ readOnly }: { readOnly: boolean }) =>
readOnly ? readOnlyCatalog : fullCatalog,
);
const cfg = {
agents: {
defaults: {
models: {
"openai/*": {},
},
},
},
} as OpenClawConfig;
await expect(loadPreparedModelCatalogSnapshotForBrowse({ cfg, loadCatalog })).resolves.toBe(
readOnlyCatalog,
);
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: true });
});
it("builds provider-config inventory independently of picker allowlists", () => {
const cfg = {
agents: {
@@ -143,13 +204,13 @@ describe("loadPreparedModelCatalogSnapshotForBrowse", () => {
]);
});
it("returns an empty catalog when read-only catalog loading times out with provider wildcards", async () => {
it("returns an empty catalog when read-only catalog loading times out", async () => {
vi.useFakeTimers();
const onTimeout = vi.fn();
const loadCatalog = vi.fn(() => new Promise<ModelCatalogSnapshot>(() => {}));
const resultPromise = loadPreparedModelCatalogSnapshotForBrowse({
cfg: config({ providerWildcard: true }),
cfg: config(),
loadCatalog,
timeoutMs: 5,
onTimeout,
@@ -180,6 +241,28 @@ describe("loadPreparedModelCatalogSnapshotForBrowse", () => {
expect(onTimeout).toHaveBeenCalledExactlyOnceWith(5);
});
it.each(["default", "provider-config"] as const)(
"bounds implicit full discovery for %s wildcard views",
async (view) => {
vi.useFakeTimers();
const onTimeout = vi.fn();
const loadCatalog = vi.fn(() => new Promise<ModelCatalogSnapshot>(() => {}));
const resultPromise = loadPreparedModelCatalogSnapshotForBrowse({
cfg: config({ providerWildcard: true }),
view,
loadCatalog,
timeoutMs: 5,
onTimeout,
});
await vi.advanceTimersByTimeAsync(5);
await expect(resultPromise).resolves.toEqual({ entries: [], routeVariants: [] });
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false });
expect(onTimeout).toHaveBeenCalledExactlyOnceWith(5);
},
);
it("uses the default timeout when timeoutMs is non-finite", async () => {
const onTimeout = vi.fn();
const setTimeout = vi.spyOn(globalThis, "setTimeout");
+36 -7
View File
@@ -9,6 +9,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ModelCatalogEntry, ModelCatalogSnapshot } from "./model-catalog.types.js";
import {
buildConfiguredModelCatalog,
LEGACY_MODEL_POLICY_ALLOW_CONFIG_PATH,
parseConfiguredModelVisibilityEntries,
} from "./model-selection-shared.js";
@@ -37,14 +38,30 @@ export function buildProviderConfigModelCatalogForBrowse(params: {
/** True when a browse view requires the full published catalog generation. */
export function modelCatalogBrowseRequiresFullDiscovery(params: {
cfg: OpenClawConfig;
agentId?: string;
view?: ModelCatalogBrowseView;
}): boolean {
const view = params.view ?? "default";
return (
view === "all" ||
(view === "configured" &&
parseConfiguredModelVisibilityEntries({ cfg: params.cfg }).providerWildcards.size > 0)
);
if (view === "all") {
return true;
}
const visibility = parseConfiguredModelVisibilityEntries({
cfg: params.cfg,
agentId: params.agentId,
});
if (visibility.providerWildcards.size === 0) {
return false;
}
// An explicit modelPolicy.allow provider wildcard makes model pickers,
// configured views, and provider-config inventory resolve against the
// discovered catalog so key-scoped runtime rows appear without an explicit
// allowlist entry (see openclaw#115953). Legacy agents.defaults.models
// wildcard entries keep the historical read-only default path and only
// escalate the configured view, as before.
if (visibility.configPath === LEGACY_MODEL_POLICY_ALLOW_CONFIG_PATH) {
return view === "configured";
}
return true;
}
function resolveModelCatalogBrowseTimeoutMs(value: number | undefined): number {
@@ -56,6 +73,7 @@ function resolveModelCatalogBrowseTimeoutMs(value: number | undefined): number {
async function loadCatalogForBrowse<T>(params: {
cfg: OpenClawConfig;
agentId?: string;
view?: ModelCatalogBrowseView;
loadCatalog: (params: { readOnly: boolean }) => Promise<T>;
empty: T;
@@ -64,8 +82,18 @@ async function loadCatalogForBrowse<T>(params: {
onTimeout?: (timeoutMs: number) => void;
}): Promise<T> {
const view = params.view ?? "default";
const requiresFullDiscovery = modelCatalogBrowseRequiresFullDiscovery({ cfg: params.cfg, view });
if (requiresFullDiscovery && !params.timeoutFullDiscovery) {
const requiresFullDiscovery = modelCatalogBrowseRequiresFullDiscovery({
cfg: params.cfg,
agentId: params.agentId,
view,
});
// Provider-policy wildcards newly escalate ordinary inventory views to live discovery.
// Keep those implicit loads within the browse deadline; explicit all/configured loads retain
// their existing completion semantics unless the caller requests a timeout.
const shouldTimeoutFullDiscovery =
params.timeoutFullDiscovery ||
(requiresFullDiscovery && (view === "default" || view === "provider-config"));
if (requiresFullDiscovery && !shouldTimeoutFullDiscovery) {
return await params.loadCatalog({ readOnly: false });
}
@@ -97,6 +125,7 @@ async function loadCatalogForBrowse<T>(params: {
/** Loads an explicit logical/physical catalog snapshot for route-aware browse surfaces. */
export function loadPreparedModelCatalogSnapshotForBrowse(params: {
cfg: OpenClawConfig;
agentId?: string;
view?: ModelCatalogBrowseView;
loadCatalog: (params: { readOnly: boolean }) => Promise<ModelCatalogSnapshot>;
timeoutFullDiscovery?: boolean;
+2 -1
View File
@@ -1513,6 +1513,7 @@ export function normalizeModelSelection(value: unknown): string | undefined {
const DEFAULT_MODEL_POLICY_ALLOW_CONFIG_PATH = "agents.defaults.modelPolicy.allow";
const AGENT_MODEL_POLICY_ALLOW_CONFIG_PATH = "agents.entries.*.modelPolicy.allow";
export const LEGACY_MODEL_POLICY_ALLOW_CONFIG_PATH = "agents.defaults.models";
function resolvePolicyAliasAgentId(
configPath: string | null,
@@ -1552,7 +1553,7 @@ export function resolveConfiguredModelPolicyAllow(params: {
if (legacyDefaultRefs) {
return {
refs: legacyDefaultRefs,
configPath: "agents.defaults.models",
configPath: LEGACY_MODEL_POLICY_ALLOW_CONFIG_PATH,
repairConfigPath: DEFAULT_MODEL_POLICY_ALLOW_CONFIG_PATH,
};
}
@@ -6,6 +6,7 @@ import type { ProviderConfig } from "./models-config.providers.secrets.js";
type ResolveImplicitProvidersForModelsJson = (params: {
agentDir: string;
config: OpenClawConfig;
discoveryAuthConfig?: OpenClawConfig;
env: NodeJS.ProcessEnv;
workspaceDir?: string;
explicitProviders: Record<string, ProviderConfig>;
@@ -21,6 +22,7 @@ type PlanResult = Awaited<
>;
type ResolveProvidersParams = {
cfg: OpenClawConfig;
discoveryAuthConfig?: OpenClawConfig;
agentDir: string;
env: NodeJS.ProcessEnv;
workspaceDir?: string;
+5
View File
@@ -32,6 +32,7 @@ type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
type ResolveImplicitProvidersForModelsJson = (params: {
agentDir: string;
config: OpenClawConfig;
discoveryAuthConfig?: OpenClawConfig;
env: NodeJS.ProcessEnv;
workspaceDir?: string;
explicitProviders: Record<string, ProviderConfig>;
@@ -100,6 +101,7 @@ function buildPluginCatalogWrites(
async function resolveProvidersForModelsJsonWithDeps(
params: {
cfg: OpenClawConfig;
discoveryAuthConfig?: OpenClawConfig;
agentDir: string;
env: NodeJS.ProcessEnv;
workspaceDir?: string;
@@ -128,6 +130,7 @@ async function resolveProvidersForModelsJsonWithDeps(
const implicitProviders = await resolveImplicitProvidersImpl({
agentDir,
config: cfg,
...(params.discoveryAuthConfig ? { discoveryAuthConfig: params.discoveryAuthConfig } : {}),
env,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
explicitProviders,
@@ -212,6 +215,7 @@ function filterWritableProviders(
async function planOpenClawModelsJsonWithDeps(
params: {
cfg: OpenClawConfig;
discoveryAuthConfig?: OpenClawConfig;
sourceConfigForSecrets?: OpenClawConfig;
agentDir: string;
env: NodeJS.ProcessEnv;
@@ -232,6 +236,7 @@ async function planOpenClawModelsJsonWithDeps(
const providers = await resolveProvidersForModelsJsonWithDeps(
{
cfg,
...(params.discoveryAuthConfig ? { discoveryAuthConfig: params.discoveryAuthConfig } : {}),
agentDir,
env,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
@@ -352,4 +352,31 @@ describe("models-config provider auth provenance", () => {
source: "none",
});
});
it("keeps non-env SecretRef markers discovery-key-free when unresolved", () => {
const auth = createProviderApiKeyResolver(
{} as NodeJS.ProcessEnv,
{
version: 1,
profiles: {},
},
{
models: {
providers: {
vllm: {
baseUrl: "http://127.0.0.1:8000/v1",
apiKey: { source: "file", provider: "mounted-json", id: "/providers/vllm/apiKey" },
api: "openai-completions",
models: [],
},
},
},
},
);
expect(auth("vllm")).toEqual({
apiKey: NON_ENV_SECRETREF_MARKER,
discoveryApiKey: undefined,
});
});
});
+13 -9
View File
@@ -58,6 +58,7 @@ type ImplicitProviderParams = {
agentDir: string;
authStore?: AuthProfileStore;
config?: OpenClawConfig;
discoveryAuthConfig?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
workspaceDir?: string;
explicitProviders?: Record<string, ProviderConfig> | null;
@@ -600,15 +601,6 @@ export async function resolveImplicitProviders(
allowKeychainPrompt: false,
externalCliProviderIds: params.providerDiscoveryProviderIds,
}));
const context: ImplicitProviderContext = {
...params,
get authStore() {
return getAuthStore();
},
env,
resolveProviderApiKey: createProviderApiKeyResolver(env, getAuthStore, params.config),
resolveProviderAuth: createProviderAuthResolver(env, getAuthStore, params.config),
};
const discoveryPluginIds = resolveProviderDiscoveryFilter({
config: params.config,
workspaceDir: params.workspaceDir,
@@ -618,6 +610,18 @@ export async function resolveImplicitProviders(
: undefined,
providerIds: params.providerDiscoveryProviderIds,
});
// The runtime config has already resolved SecretRefs at its owning boundary.
// Re-resolving source refs here would execute unrelated file/exec providers on catalog reads.
const discoveryAuthConfig = params.discoveryAuthConfig ?? params.config;
const context: ImplicitProviderContext = {
...params,
get authStore() {
return getAuthStore();
},
env,
resolveProviderApiKey: createProviderApiKeyResolver(env, getAuthStore, discoveryAuthConfig),
resolveProviderAuth: createProviderAuthResolver(env, getAuthStore, discoveryAuthConfig),
};
const preparedStaticEntries = params.preparedStaticProviderCatalog
? params.preparedStaticProviderCatalog.entries.filter(
({ provider }) =>
@@ -338,7 +338,6 @@ function resolveConfigBackedProviderAuth(params: {
}
return {
apiKey: resolveNonEnvSecretRefApiKeyMarker(configuredApiKeyRef.source),
discoveryApiKey: undefined,
mode: "api_key",
source: "config",
};
@@ -118,4 +118,53 @@ describe("models-config plan: replace mode skips implicit discovery", () => {
expect(resolveImplicitSpy).toHaveBeenCalledTimes(1);
});
it("forwards resolved runtime config separately from source config", async () => {
const cfg: OpenClawConfig = {
models: {
providers: {
explicit: {
...createExplicitProvider(),
apiKey: { source: "exec", provider: "must-not-run", id: "explicit" },
},
},
},
};
const discoveryAuthConfig: OpenClawConfig = {
models: {
providers: {
explicit: {
...createExplicitProvider(),
apiKey: "resolved-runtime-key",
},
},
},
};
const resolveImplicitSpy = vi.fn(async () => ({}));
await resolveProvidersForModelsJsonWithDeps(
{
cfg,
discoveryAuthConfig,
agentDir: "/tmp/openclaw-models-config-auth-test",
env: {},
},
{ resolveImplicitProviders: resolveImplicitSpy },
);
expect(resolveImplicitSpy).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
models: expect.objectContaining({
providers: expect.objectContaining({
explicit: expect.objectContaining({
apiKey: { source: "exec", provider: "must-not-run", id: "explicit" },
}),
}),
}),
}),
discoveryAuthConfig,
}),
);
});
});
+10
View File
@@ -88,6 +88,7 @@ async function readFileMtimeMs(pathname: string): Promise<number | null> {
async function buildModelsJsonFingerprint(params: {
config: OpenClawConfig;
discoveryAuthConfig: OpenClawConfig;
sourceConfigForSecrets: OpenClawConfig;
agentDir: string;
workspaceDir?: string;
@@ -110,6 +111,7 @@ async function buildModelsJsonFingerprint(params: {
: undefined;
return stableStringify({
config: params.config,
discoveryAuthConfigHash: hashRuntimeConfigValue(params.discoveryAuthConfig),
sourceConfigForSecrets: params.sourceConfigForSecrets,
envShape: params.env ? hashRuntimeConfigValue(envShape) : envShape,
authProfilesMtimeMs,
@@ -250,6 +252,7 @@ function writePluginCatalogsForModelsJson(params: {
function resolveModelsConfigInput(config?: OpenClawConfig): {
config: OpenClawConfig;
discoveryAuthConfig: OpenClawConfig;
sourceConfigForSecrets: OpenClawConfig;
} {
const runtimeSource = getRuntimeConfigSourceSnapshot();
@@ -257,18 +260,21 @@ function resolveModelsConfigInput(config?: OpenClawConfig): {
const loaded = getRuntimeConfig();
return {
config: runtimeSource ?? loaded,
discoveryAuthConfig: loaded,
sourceConfigForSecrets: runtimeSource ?? loaded,
};
}
if (!runtimeSource) {
return {
config,
discoveryAuthConfig: config,
sourceConfigForSecrets: config,
};
}
const projected = projectConfigOntoRuntimeSourceSnapshot(config);
return {
config: projected,
discoveryAuthConfig: config,
// If projection is skipped (for example incompatible top-level shape),
// keep managed secret persistence anchored to the active source snapshot.
sourceConfigForSecrets: projected === config ? runtimeSource : projected,
@@ -307,6 +313,7 @@ async function buildModelsJsonSourceFingerprint(
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveDefaultAgentDir(cfg);
const fingerprint = await buildModelsJsonFingerprint({
config: cfg,
discoveryAuthConfig: resolved.discoveryAuthConfig,
sourceConfigForSecrets: resolved.sourceConfigForSecrets,
agentDir,
...(workspaceDir ? { workspaceDir } : {}),
@@ -382,6 +389,7 @@ async function prepareOpenClawModelsJsonSource(
});
const plan = await planOpenClawModelsJson({
cfg,
discoveryAuthConfig: resolved.discoveryAuthConfig,
sourceConfigForSecrets: resolved.sourceConfigForSecrets,
agentDir,
env,
@@ -438,6 +446,7 @@ async function prepareOpenClawModelsJsonSource(
const settled = await pending;
const refreshedFingerprint = await buildModelsJsonFingerprint({
config: cfg,
discoveryAuthConfig: resolved.discoveryAuthConfig,
sourceConfigForSecrets: resolved.sourceConfigForSecrets,
agentDir,
...(workspaceDir ? { workspaceDir } : {}),
@@ -511,6 +520,7 @@ export async function planOpenClawModelsJsonSource(
const env = createConfigRuntimeEnv(cfg, options.env);
const plan = await planOpenClawModelsJson({
cfg,
discoveryAuthConfig: resolved.discoveryAuthConfig,
sourceConfigForSecrets: resolved.sourceConfigForSecrets,
agentDir,
env,
+3 -1
View File
@@ -673,7 +673,9 @@ export async function prepareAgentCatalogSource(
providerDiscoveryEntriesOnly: true as const,
providerDiscoveryProviderIds: providerIds,
}
: { providerDiscoveryTimeoutMs: MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS }),
: {
providerDiscoveryTimeoutMs: MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS,
}),
};
if (!persist) {
const source = await planOpenClawModelsJsonSource(input.config, input.agentDir, options);
@@ -49,11 +49,13 @@ const mocks = vi.hoisted(() => {
wrote: false,
}),
),
planOpenClawModelsJsonSource: vi.fn(async (_config: unknown, agentDir: unknown) => ({
agentDir: String(agentDir),
modelsJsonContents: null,
pluginCatalogs: [],
})),
planOpenClawModelsJsonSource: vi.fn(
async (_config: unknown, agentDir: unknown, _options?: unknown) => ({
agentDir: String(agentDir),
modelsJsonContents: null,
pluginCatalogs: [],
}),
),
buildPreparedModelCatalogSnapshot: vi.fn(async () => ({ entries: [], routeVariants: [] })),
ensureRuntimePluginsLoaded: vi.fn(),
loadStaticCatalog: vi.fn(async () => []),
@@ -298,6 +300,8 @@ describe("prepared model runtime Gateway catalog mode", () => {
providerDiscoveryTimeoutMs: 5_000,
}),
);
const fullCatalogOptions = mocks.planOpenClawModelsJsonSource.mock.calls[0]?.[2];
expect(fullCatalogOptions).not.toHaveProperty("providerDiscoveryProviderIds");
expect(mocks.buildPreparedModelCatalogSnapshot).toHaveBeenCalledWith(
expect.objectContaining({ includeProviderPluginAugmentation: true }),
);
@@ -626,6 +626,7 @@ describe("prepared model runtime snapshots", () => {
);
expect(mocks.discoverModels).toHaveBeenCalledOnce();
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
expect(mocks.planOpenClawModelsJsonSource).not.toHaveBeenCalled();
expect(mocks.ensureRuntimePluginsLoaded).not.toHaveBeenCalled();
});
+1
View File
@@ -170,6 +170,7 @@ export async function buildModelsProviderData(
const snapshot = await loadPreparedModelCatalogSnapshotForBrowse({
cfg,
agentId,
view: options.view ?? "default",
loadCatalog: ({ readOnly }) =>
loadPreparedModelCatalogSnapshot({
@@ -112,7 +112,13 @@ async function buildChatStartupMetadataResult(params: {
if (!params.modelCatalog) {
return undefined;
}
if (modelCatalogBrowseRequiresFullDiscovery({ cfg: params.cfg, view: "configured" })) {
if (
modelCatalogBrowseRequiresFullDiscovery({
cfg: params.cfg,
agentId: params.agentId,
view: "configured",
})
) {
return undefined;
}
try {
@@ -22,9 +22,14 @@ function catalogEntry(id: string, api: ModelCatalogEntry["api"]): ModelCatalogEn
return { id, name: id, provider: "openai", api };
}
function providerCatalogEntry(provider: string, id: string): ModelCatalogEntry {
return { ...catalogEntry(id, "openai-completions"), provider };
}
async function listModels(params: {
catalog: ModelCatalogEntry[];
cfg?: OpenClawConfig;
discoveryModes?: Record<string, "refreshable" | "runtime" | "static">;
routeResolverFactory?: typeof createOpenAIModelRoutesResolver;
view?: "all" | "configured" | "provider-config" | "default";
}) {
@@ -46,6 +51,22 @@ async function listModels(params: {
return await buildModelsListResult({
context,
params: { view: params.view ?? "all" },
...(params.discoveryModes
? {
preloadedCatalog: {
agentId: "main",
config,
snapshot: { entries: params.catalog, routeVariants: params.catalog },
},
catalogProjector: {
metadataSnapshot: {
plugins: [
{ id: "test-provider", modelCatalog: { discovery: params.discoveryModes } },
],
},
} as never,
}
: {}),
...(params.routeResolverFactory ? { routeResolverFactory: params.routeResolverFactory } : {}),
});
}
@@ -849,6 +870,62 @@ describe("models.list OpenAI routes", () => {
});
});
it("includes runtime-discovered rows for configured providers without explicit models", async () => {
await withEnvAsync(WITHOUT_OPENAI_ENV_AUTH, async () => {
const cfg = {
models: {
providers: {
litellm: {
api: "openai-completions",
baseUrl: "http://127.0.0.1:14004",
},
},
},
} as unknown as OpenClawConfig;
await expect(
listModels({
cfg,
discoveryModes: { litellm: "runtime" },
view: "provider-config",
catalog: [
providerCatalogEntry("litellm", "model-a"),
providerCatalogEntry("litellm", "model-b"),
],
}),
).resolves.toEqual({
models: [
expect.objectContaining({ id: "model-a", provider: "litellm" }),
expect.objectContaining({ id: "model-b", provider: "litellm" }),
],
});
});
});
it("does not infer runtime inventory for static providers without explicit models", async () => {
await withEnvAsync(WITHOUT_OPENAI_ENV_AUTH, async () => {
const cfg = {
models: {
providers: {
kimi: {
api: "openai-completions",
baseUrl: "https://api.kimi.com/coding/v1",
},
},
},
} as unknown as OpenClawConfig;
await expect(
listModels({
cfg,
discoveryModes: { kimi: "static" },
view: "provider-config",
catalog: [providerCatalogEntry("kimi", "kimi-for-coding")],
}),
).resolves.toEqual({ models: [] });
});
});
it("keeps configured fallback rows visible when their route is unavailable", async () => {
await withEnvAsync(WITHOUT_OPENAI_ENV_AUTH, async () => {
await withOpenClawTestState(
@@ -260,9 +260,38 @@ function resolveGatewayModelCatalogRouteKey(entry: ModelCatalogEntry): string {
);
}
/** Configured dynamic-catalog providers that omit explicit model inventory. */
function listConfiguredRuntimeDiscoveryProviderIds(
cfg: OpenClawConfig,
metadataSnapshot?: Pick<PluginMetadataSnapshot, "plugins">,
): Set<string> {
const ids = new Set<string>();
const providers = cfg.models?.providers;
if (!providers || typeof providers !== "object" || !metadataSnapshot) {
return ids;
}
const dynamicProviders = new Set<string>();
for (const plugin of metadataSnapshot.plugins) {
for (const [providerRaw, mode] of Object.entries(plugin.modelCatalog?.discovery ?? {})) {
const providerId = normalizeProviderId(providerRaw);
if (providerId && (mode === "runtime" || mode === "refreshable")) {
dynamicProviders.add(providerId);
}
}
}
for (const [providerRaw, provider] of Object.entries(providers)) {
const providerId = normalizeProviderId(providerRaw);
if (providerId && dynamicProviders.has(providerId) && !Array.isArray(provider?.models)) {
ids.add(providerId);
}
}
return ids;
}
function resolveProviderConfigInventoryEntries(params: {
authoredEntries: readonly ModelCatalogEntry[];
canonicalEntries: readonly ModelCatalogEntry[];
discoveryOnlyProviderIds?: ReadonlySet<string>;
}): ModelCatalogEntry[] {
const canonicalByKey = new Map<string, ModelCatalogEntry>();
for (const entry of params.canonicalEntries) {
@@ -283,6 +312,21 @@ function resolveProviderConfigInventoryEntries(params: {
// route metadata; configured logical overrides are applied by the projector.
inventory.push(canonicalByKey.get(key) ?? authoredEntry);
}
if (params.discoveryOnlyProviderIds) {
// Providers configured without explicit model lists (for example litellm)
// surface their key-scoped discovered rows as the configured inventory.
for (const canonicalEntry of params.canonicalEntries) {
const key = resolveGatewayModelCatalogRouteKey(canonicalEntry);
if (seen.has(key)) {
continue;
}
if (!params.discoveryOnlyProviderIds.has(normalizeProviderId(canonicalEntry.provider))) {
continue;
}
seen.add(key);
inventory.push(canonicalEntry);
}
}
return inventory;
}
@@ -510,6 +554,7 @@ export async function buildModelsListResult(
};
let snapshot = await loadPreparedModelCatalogSnapshotForBrowse({
cfg: initialConfig,
agentId: initialAgentId,
view,
loadCatalog: async (loadParams) => {
loadedReadOnly = loadParams.readOnly ?? true;
@@ -531,13 +576,18 @@ export async function buildModelsListResult(
if (
loadedSnapshot &&
loadedReadOnly &&
modelCatalogBrowseRequiresFullDiscovery({ cfg: loadedSnapshot.config, view })
modelCatalogBrowseRequiresFullDiscovery({
cfg: loadedSnapshot.config,
agentId: loadedSnapshot.agentId,
view,
})
) {
const escalationAgentId = loadedSnapshot.agentId;
let escalationTimedOut = false;
let fullSnapshot: typeof loadedSnapshot | undefined;
const escalatedCatalog = await loadPreparedModelCatalogSnapshotForBrowse({
cfg: loadedSnapshot.config,
agentId: escalationAgentId,
view,
loadCatalog: async ({ readOnly }) => {
fullSnapshot = await params.context.loadGatewayModelCatalogSnapshot({
@@ -595,6 +645,10 @@ export async function buildModelsListResult(
entries: resolveProviderConfigInventoryEntries({
authoredEntries,
canonicalEntries: catalog,
discoveryOnlyProviderIds: listConfiguredRuntimeDiscoveryProviderIds(
sourceConfig,
metadataSnapshot,
),
}),
routeVariants,
};
+53
View File
@@ -420,6 +420,59 @@ describe("models.list", () => {
});
});
it("does not block wildcard provider inventory on slow full discovery", async () => {
const catalog = createDeferred<never>();
const loadGatewayModelCatalog = vi.fn(() => catalog.promise);
const runtimeConfig = {
agents: {
defaults: {
modelPolicy: { allow: ["vllm/*"] },
},
},
models: {
providers: {
vllm: {
baseUrl: "https://vllm.example/v1",
models: [{ id: "llama-local", name: "Llama Local" }],
},
},
},
} as unknown as OpenClawConfig;
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
const { request, respond } = requestModelsList({
view: "provider-config",
runtimeConfig,
loadGatewayModelCatalog,
reqId: "req-models-list-wildcard-provider-timeout",
});
await vi.advanceTimersByTimeAsync(800);
await vi.runOnlyPendingTimersAsync();
await request;
expect(respond).toHaveBeenCalledWith(
true,
{
models: [
{
id: "llama-local",
name: "Llama Local",
provider: "vllm",
},
],
},
undefined,
);
expect(loadGatewayModelCatalog).toHaveBeenCalledWith(
expect.objectContaining({ readOnly: false }),
);
} finally {
vi.useRealTimers();
}
});
it("keeps SecretRef configured fallback rows unknown when catalog discovery times out", async () => {
const catalog = createDeferred<never>();
const loadGatewayModelCatalog = vi.fn(() => catalog.promise);