mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
fix: restore provider-filtered model registry rows
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { addEnvBackedPiCredentials } from "./pi-auth-discovery-core.js";
|
||||
|
||||
export type DiscoverAuthStorageOptions = {
|
||||
readOnly?: boolean;
|
||||
skipCredentials?: boolean;
|
||||
};
|
||||
|
||||
export function resolvePiCredentialsForDiscovery(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -123,5 +123,6 @@ export async function planAllModelListSources(params: {
|
||||
|
||||
return createSourcePlan({
|
||||
kind: "provider-runtime-scoped",
|
||||
fallbackToRegistryWhenEmpty: true,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user