mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user