diff --git a/src/agents/model-catalog-browse.test.ts b/src/agents/model-catalog-browse.test.ts index c0bc3370996a..d75080c83adf 100644 --- a/src/agents/model-catalog-browse.test.ts +++ b/src/agents/model-catalog-browse.test.ts @@ -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(() => {})); 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(() => {})); + + 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"); diff --git a/src/agents/model-catalog-browse.ts b/src/agents/model-catalog-browse.ts index 8ef894dd399d..b0a5d21f390c 100644 --- a/src/agents/model-catalog-browse.ts +++ b/src/agents/model-catalog-browse.ts @@ -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(params: { cfg: OpenClawConfig; + agentId?: string; view?: ModelCatalogBrowseView; loadCatalog: (params: { readOnly: boolean }) => Promise; empty: T; @@ -64,8 +82,18 @@ async function loadCatalogForBrowse(params: { onTimeout?: (timeoutMs: number) => void; }): Promise { 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(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; timeoutFullDiscovery?: boolean; diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index 20e12a8fde61..9f03011452bb 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -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, }; } diff --git a/src/agents/models-config.plan.test-support.ts b/src/agents/models-config.plan.test-support.ts index 48b95ea2e670..32b94d92d027 100644 --- a/src/agents/models-config.plan.test-support.ts +++ b/src/agents/models-config.plan.test-support.ts @@ -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; @@ -21,6 +22,7 @@ type PlanResult = Awaited< >; type ResolveProvidersParams = { cfg: OpenClawConfig; + discoveryAuthConfig?: OpenClawConfig; agentDir: string; env: NodeJS.ProcessEnv; workspaceDir?: string; diff --git a/src/agents/models-config.plan.ts b/src/agents/models-config.plan.ts index 8fe4bea79203..77f024226e6b 100644 --- a/src/agents/models-config.plan.ts +++ b/src/agents/models-config.plan.ts @@ -32,6 +32,7 @@ type ModelsConfig = NonNullable; type ResolveImplicitProvidersForModelsJson = (params: { agentDir: string; config: OpenClawConfig; + discoveryAuthConfig?: OpenClawConfig; env: NodeJS.ProcessEnv; workspaceDir?: string; explicitProviders: Record; @@ -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 } : {}), diff --git a/src/agents/models-config.providers.auth-provenance.test.ts b/src/agents/models-config.providers.auth-provenance.test.ts index c50f326f60ab..b4787e48f81d 100644 --- a/src/agents/models-config.providers.auth-provenance.test.ts +++ b/src/agents/models-config.providers.auth-provenance.test.ts @@ -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, + }); + }); }); diff --git a/src/agents/models-config.providers.implicit.ts b/src/agents/models-config.providers.implicit.ts index 7cba64aea3a0..58151e78ca87 100644 --- a/src/agents/models-config.providers.implicit.ts +++ b/src/agents/models-config.providers.implicit.ts @@ -58,6 +58,7 @@ type ImplicitProviderParams = { agentDir: string; authStore?: AuthProfileStore; config?: OpenClawConfig; + discoveryAuthConfig?: OpenClawConfig; env?: NodeJS.ProcessEnv; workspaceDir?: string; explicitProviders?: Record | 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 }) => diff --git a/src/agents/models-config.providers.secrets.ts b/src/agents/models-config.providers.secrets.ts index dc4d0e4a9d92..c161dd5915eb 100644 --- a/src/agents/models-config.providers.secrets.ts +++ b/src/agents/models-config.providers.secrets.ts @@ -338,7 +338,6 @@ function resolveConfigBackedProviderAuth(params: { } return { apiKey: resolveNonEnvSecretRefApiKeyMarker(configuredApiKeyRef.source), - discoveryApiKey: undefined, mode: "api_key", source: "config", }; diff --git a/src/agents/models-config.replace-mode-skip-implicit-discovery.test.ts b/src/agents/models-config.replace-mode-skip-implicit-discovery.test.ts index 5284b7318a01..a6febaf49bd9 100644 --- a/src/agents/models-config.replace-mode-skip-implicit-discovery.test.ts +++ b/src/agents/models-config.replace-mode-skip-implicit-discovery.test.ts @@ -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, + }), + ); + }); }); diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 842b7b2b2f14..b0d3c62e2400 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -88,6 +88,7 @@ async function readFileMtimeMs(pathname: string): Promise { 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, diff --git a/src/agents/prepared-model-runtime.facts.ts b/src/agents/prepared-model-runtime.facts.ts index 5aa9b086615c..4c5038f7051a 100644 --- a/src/agents/prepared-model-runtime.facts.ts +++ b/src/agents/prepared-model-runtime.facts.ts @@ -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); diff --git a/src/agents/prepared-model-runtime.startup-static.test.ts b/src/agents/prepared-model-runtime.startup-static.test.ts index d0bf9c031026..5151c6b5d923 100644 --- a/src/agents/prepared-model-runtime.startup-static.test.ts +++ b/src/agents/prepared-model-runtime.startup-static.test.ts @@ -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 }), ); diff --git a/src/agents/prepared-model-runtime.test.ts b/src/agents/prepared-model-runtime.test.ts index f11c03cc6d2f..e41a704b092d 100644 --- a/src/agents/prepared-model-runtime.test.ts +++ b/src/agents/prepared-model-runtime.test.ts @@ -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(); }); diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index cf6edb074c99..57d3715086ed 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -170,6 +170,7 @@ export async function buildModelsProviderData( const snapshot = await loadPreparedModelCatalogSnapshotForBrowse({ cfg, + agentId, view: options.view ?? "default", loadCatalog: ({ readOnly }) => loadPreparedModelCatalogSnapshot({ diff --git a/src/gateway/server-methods/chat-startup-projection-memo.ts b/src/gateway/server-methods/chat-startup-projection-memo.ts index 0465bfc48ddf..d73ca189c8b8 100644 --- a/src/gateway/server-methods/chat-startup-projection-memo.ts +++ b/src/gateway/server-methods/chat-startup-projection-memo.ts @@ -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 { diff --git a/src/gateway/server-methods/models-list-result.openai-routes.test.ts b/src/gateway/server-methods/models-list-result.openai-routes.test.ts index 9f4709b4bd74..a581f3363aa6 100644 --- a/src/gateway/server-methods/models-list-result.openai-routes.test.ts +++ b/src/gateway/server-methods/models-list-result.openai-routes.test.ts @@ -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; 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( diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index ecd75339299b..34073b78273c 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -260,9 +260,38 @@ function resolveGatewayModelCatalogRouteKey(entry: ModelCatalogEntry): string { ); } +/** Configured dynamic-catalog providers that omit explicit model inventory. */ +function listConfiguredRuntimeDiscoveryProviderIds( + cfg: OpenClawConfig, + metadataSnapshot?: Pick, +): Set { + const ids = new Set(); + const providers = cfg.models?.providers; + if (!providers || typeof providers !== "object" || !metadataSnapshot) { + return ids; + } + const dynamicProviders = new Set(); + 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; }): ModelCatalogEntry[] { const canonicalByKey = new Map(); 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, }; diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index 0ef49027de66..c574aacdb9cd 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -420,6 +420,59 @@ describe("models.list", () => { }); }); + it("does not block wildcard provider inventory on slow full discovery", async () => { + const catalog = createDeferred(); + 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(); const loadGatewayModelCatalog = vi.fn(() => catalog.promise);