fix: restore provider-filtered model registry rows

This commit is contained in:
Shakker
2026-04-29 11:21:10 +01:00
parent 1b56c7723b
commit 4e4f9204d7
10 changed files with 96 additions and 28 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI/models: restore provider-filtered `models list --all --provider <id>` rows for providers without manifest/static catalog coverage, including Anthropic and Amazon Bedrock, while keeping the compatibility fallback off expensive availability and resolver paths. Thanks @shakkernerd.
- Plugins/runtime-deps: memoize packaged bundled runtime dist-mirror preparation after the first successful pass while keeping source-checkout mirrors refreshable, so constrained Docker/VPS installs avoid repeated root scans before chat turns. Refs #73428, #73421, #73532, and #73477. Thanks @Dimaoggg, @oromeis, @oadiazp, @jmfraga, @bstanbury, @antoniusfelix, and @jkobject.
- Channels/Discord: treat bare numeric outbound targets that match the effective Discord DM allowlist as user DMs while preserving account-specific legacy `dm.allowFrom` precedence over inherited root `allowFrom`. (#74303) Thanks @Squirbie.
- Control UI: make the chat sidebar split divider focusable, keyboard-resizable, ARIA-described, and pointer-event based so sidebar resizing works without a mouse. Thanks @BunsDev.

View File

@@ -9,6 +9,7 @@ import { addEnvBackedPiCredentials } from "./pi-auth-discovery-core.js";
export type DiscoverAuthStorageOptions = {
readOnly?: boolean;
skipCredentials?: boolean;
};
export function resolvePiCredentialsForDiscovery(

View File

@@ -222,7 +222,8 @@ export function discoverAuthStorage(
agentDir: string,
options?: DiscoverAuthStorageOptions,
): PiAuthStorage {
const credentials = resolvePiCredentialsForDiscovery(agentDir, options);
const credentials =
options?.skipCredentials === true ? {} : resolvePiCredentialsForDiscovery(agentDir, options);
const authPath = path.join(agentDir, "auth.json");
if (options?.readOnly !== true) {
scrubLegacyStaticAuthJsonEntriesForDiscovery(authPath);

View File

@@ -125,6 +125,9 @@ function installModelsListCommandForwardCompatMocks() {
vi.doMock("../../agents/model-suppression.js", () => ({
shouldSuppressBuiltInModel: suppressOpenAiSpark,
shouldSuppressBuiltInModelFromManifest: suppressOpenAiSpark,
createManifestBuiltInModelSuppressor: vi.fn(
() => (model: { provider?: string | null; id?: string | null }) => suppressOpenAiSpark(model),
),
}));
vi.doMock("./load-config.js", () => ({
@@ -156,7 +159,7 @@ function installModelsListCommandForwardCompatMocks() {
vi.doMock("./list.registry-load.js", () => ({
loadListModelRegistry: async (
cfg: unknown,
opts?: { providerFilter?: string; normalizeModels?: boolean },
opts?: { providerFilter?: string; normalizeModels?: boolean; loadAvailability?: boolean },
): Promise<{
models: Array<{ provider: string; id: string }>;
availableKeys?: Set<string>;
@@ -747,15 +750,55 @@ describe("modelsListCommand forward-compat", () => {
]);
});
it("does not fall back to the registry for provider filters without catalog coverage", async () => {
it("falls back to registry rows for provider filters without catalog coverage", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(false);
mocks.loadModelRegistry.mockResolvedValueOnce({
models: [
{
provider: "anthropic",
id: "claude-opus-4-7",
name: "Claude Opus 4.7",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com/v1",
input: ["text", "image"],
contextWindow: 1_000_000,
maxTokens: 64_000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
],
availableKeys: undefined,
registry: {
getAll: () => [
{
provider: "anthropic",
id: "claude-opus-4-7",
name: "Claude Opus 4.7",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com/v1",
input: ["text", "image"],
contextWindow: 1_000_000,
maxTokens: 64_000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
],
},
});
const runtime = createRuntime();
await modelsListCommand({ all: true, provider: "openrouter", json: true }, runtime as never);
await modelsListCommand({ all: true, provider: "anthropic", json: true }, runtime as never);
expect(mocks.loadModelRegistry).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith("No models found.");
expect(mocks.loadModelRegistry).toHaveBeenCalledWith(mocks.resolvedConfig, {
providerFilter: "anthropic",
normalizeModels: false,
loadAvailability: false,
});
expect(lastPrintedRows<{ key: string; available: boolean }>()).toEqual([
expect.objectContaining({
key: "anthropic/claude-opus-4-7",
available: false,
}),
]);
});
it("includes provider-owned supplemental catalog rows with provider filters", async () => {

View File

@@ -92,11 +92,15 @@ export async function modelsListCommand(
})
: undefined;
const shouldLoadRegistry = sourcePlan?.requiresInitialRegistry ?? false;
const loadRegistryState = async () => {
const loadRegistryState = async (opts?: {
normalizeModels?: boolean;
loadAvailability?: boolean;
}) => {
const { loadListModelRegistry } = await loadRegistryLoadModule();
const loaded = await loadListModelRegistry(cfg, {
providerFilter,
normalizeModels: Boolean(providerFilter),
normalizeModels: opts?.normalizeModels ?? Boolean(providerFilter),
loadAvailability: opts?.loadAvailability,
});
modelRegistry = loaded.registry;
registryModels = loaded.models;
@@ -148,21 +152,31 @@ export async function modelsListCommand(
sourcePlan,
});
if (initialAppend.requiresRegistryFallback) {
const useScopedRegistryFallback = sourcePlan.kind === "provider-runtime-scoped";
try {
await loadRegistryState();
await loadRegistryState(
useScopedRegistryFallback
? {
normalizeModels: false,
loadAvailability: false,
}
: undefined,
);
} catch (err) {
runtime.error(`Model registry unavailable:\n${formatErrorWithStack(err)}`);
process.exitCode = 1;
return;
}
rows.length = 0;
rowContext = buildRowContext(false);
rowContext = buildRowContext(useScopedRegistryFallback);
await appendAllModelRowSources({
rows,
context: rowContext,
modelRegistry,
registryModels,
sourcePlan: sourcePlanModule.createRegistryModelListSourcePlan(),
sourcePlan: useScopedRegistryFallback
? sourcePlan
: sourcePlanModule.createRegistryModelListSourcePlan(),
});
}
} else {

View File

@@ -10,7 +10,7 @@ import { modelKey } from "./shared.js";
export async function loadListModelRegistry(
cfg: OpenClawConfig,
opts?: { providerFilter?: string; normalizeModels?: boolean },
opts?: { providerFilter?: string; normalizeModels?: boolean; loadAvailability?: boolean },
) {
const loaded = await loadModelRegistry(cfg, opts);
return {

View File

@@ -121,11 +121,14 @@ function loadAvailableModels(
export async function loadModelRegistry(
cfg: OpenClawConfig,
opts?: { providerFilter?: string; normalizeModels?: boolean },
opts?: { providerFilter?: string; normalizeModels?: boolean; loadAvailability?: boolean },
) {
const runtimeSuppression = opts?.normalizeModels !== false;
const agentDir = resolveOpenClawAgentDir();
const authStorage = discoverAuthStorage(agentDir, { readOnly: true });
const authStorage = discoverAuthStorage(agentDir, {
readOnly: true,
skipCredentials: opts?.loadAvailability === false,
});
const registry = discoverModels(authStorage, agentDir, {
providerFilter: opts?.providerFilter,
normalizeModels: opts?.normalizeModels,
@@ -147,19 +150,21 @@ export async function loadModelRegistry(
let availableKeys: Set<string> | undefined;
let availabilityErrorMessage: string | undefined;
try {
const availableModels = loadAvailableModels(registry, cfg, { runtimeSuppression });
availableKeys = new Set(availableModels.map((model) => modelKey(model.provider, model.id)));
} catch (err) {
if (!shouldFallbackToAuthHeuristics(err)) {
throw err;
}
if (opts?.loadAvailability !== false) {
try {
const availableModels = loadAvailableModels(registry, cfg, { runtimeSuppression });
availableKeys = new Set(availableModels.map((model) => modelKey(model.provider, model.id)));
} catch (err) {
if (!shouldFallbackToAuthHeuristics(err)) {
throw err;
}
// Some providers can report model-level availability as unavailable.
// Fall back to provider-level auth heuristics when availability is undefined.
availableKeys = undefined;
if (!availabilityErrorMessage) {
availabilityErrorMessage = formatErrorWithStack(err);
// Some providers can report model-level availability as unavailable.
// Fall back to provider-level auth heuristics when availability is undefined.
availableKeys = undefined;
if (!availabilityErrorMessage) {
availabilityErrorMessage = formatErrorWithStack(err);
}
}
}
return { registry, models, availableKeys, availabilityErrorMessage };

View File

@@ -72,6 +72,8 @@ export async function appendAllModelRowSources(
models: params.modelRegistry.getAll(),
modelRegistry: params.modelRegistry,
context: params.context,
resolveWithRegistry: false,
skipSuppression: true,
});
}
return { requiresRegistryFallback: false };

View File

@@ -113,7 +113,7 @@ describe("planAllModelListSources", () => {
expect(mocks.loadProviderIndexCatalogRowsForList).not.toHaveBeenCalled();
});
it("keeps scoped runtime catalog fallback separate from broad registry loading", async () => {
it("allows scoped runtime catalog plans to fall back to registry rows", async () => {
const { planAllModelListSources } = await import("./list.source-plan.js");
await expect(
@@ -126,7 +126,7 @@ describe("planAllModelListSources", () => {
kind: "provider-runtime-scoped",
requiresInitialRegistry: false,
skipRuntimeModelSuppression: false,
fallbackToRegistryWhenEmpty: false,
fallbackToRegistryWhenEmpty: true,
});
});

View File

@@ -123,5 +123,6 @@ export async function planAllModelListSources(params: {
return createSourcePlan({
kind: "provider-runtime-scoped",
fallbackToRegistryWhenEmpty: true,
});
}