fix(models): honor replace mode in list output (#103103)

* fix(models): honor replace mode in list output

* fix(models): make replace rows provider-authoritative
This commit is contained in:
Peter Steinberger
2026-08-09 15:26:37 -07:00
committed by GitHub
parent ba35d2a09a
commit aa0bad1ebe
5 changed files with 296 additions and 21 deletions
+133
View File
@@ -44,6 +44,7 @@ import { appendConfiguredModelRowSources } from "./list.row-sources.js";
import type { ModelRow } from "./list.types.js";
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
});
@@ -203,6 +204,138 @@ describe("resolveConfiguredEntries", () => {
});
describe("configured model list rows", () => {
it("keeps raw alias auth for self-prefixed implicit models in replace mode", async () => {
vi.stubEnv("OPENCLAW_BUNDLED_PLUGINS_DIR", path.resolve("extensions"));
const catalogEntry = {
id: "glm-4.7",
name: "GLM 4.7",
provider: "zai",
input: ["text"] as const,
contextWindow: 128_000,
};
mocks.loadPreparedModelCatalogSnapshot.mockResolvedValue({
entries: [catalogEntry],
routeVariants: [catalogEntry],
});
const cfg = {
agents: {
defaults: {
model: { primary: "z.ai/glm-4.7", fallbacks: ["google/gemini-stale"] },
models: { "openai/gpt-stale": {} },
},
},
models: {
mode: "replace" as const,
providers: {
"z.ai": {
baseUrl: "https://api.z.ai/v1",
models: [
{
id: "z.ai/glm-4.7",
name: "GLM 4.7",
reasoning: false,
input: ["text" as const],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 8_192,
},
{
id: "z.ai/glm-4.8",
name: "GLM 4.8",
reasoning: false,
input: ["text" as const],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 8_192,
},
],
},
},
},
};
const { entries } = resolveConfiguredEntries(cfg);
const evaluateModelAuth = vi.fn((provider: string) => ({
availability: provider === "z.ai",
routeResolution: null,
}));
const rows: ModelRow[] = [];
await appendConfiguredModelRowSources({
rows,
entries,
context: {
cfg,
agentDir: "/tmp/openclaw-agent",
authIndex: { evaluateModelAuth },
configuredByKey: new Map(entries.map((entry) => [entry.key, entry])),
discoveredKeys: new Set(),
filter: {},
skipRuntimeModelSuppression: true,
},
});
expect(rows.map((row) => row.key)).toEqual(["zai/glm-4.7", "zai/glm-4.8"]);
expect(rows[0]).toMatchObject({ name: "GLM 4.7", available: true });
expect(rows[1]).toMatchObject({ name: "GLM 4.8", available: true });
expect(mocks.loadPreparedModelCatalogSnapshot).not.toHaveBeenCalled();
expect(evaluateModelAuth).toHaveBeenCalledWith(
"z.ai",
expect.objectContaining({ modelId: "glm-4.7" }),
);
expect(evaluateModelAuth).toHaveBeenCalledWith(
"z.ai",
expect.objectContaining({ modelId: "glm-4.8" }),
);
});
it("drops a stale default outside models.providers in replace mode", async () => {
const cfg = {
agents: { defaults: { model: { primary: "google/gemini-stale" } } },
models: {
mode: "replace" as const,
providers: {
xiaomi: {
baseUrl: "https://api.xiaomi.example/v1",
models: [
{
id: "mimo-v2.5",
name: "MiMo V2.5",
reasoning: false,
input: ["text" as const],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 8_192,
},
],
},
},
},
};
const { entries } = resolveConfiguredEntries(cfg);
const rows: ModelRow[] = [];
await appendConfiguredModelRowSources({
rows,
entries,
context: {
cfg,
agentDir: "/tmp/openclaw-agent",
authIndex: {
evaluateModelAuth: () => ({ availability: true, routeResolution: null }),
},
configuredByKey: new Map(entries.map((entry) => [entry.key, entry])),
discoveredKeys: new Set(),
filter: {},
skipRuntimeModelSuppression: true,
},
});
expect(rows).toMatchObject([
{ key: "xiaomi/mimo-v2.5", name: "MiMo V2.5", tags: [], available: true },
]);
expect(mocks.loadPreparedModelCatalogSnapshot).not.toHaveBeenCalled();
});
it("renders plugin-catalog metadata for a fallback ref instead of default placeholders", async () => {
const catalogEntry = {
id: "k3",
@@ -769,13 +769,23 @@ describe("modelsListCommand forward-compat", () => {
});
});
it("includes configured provider and auth-backed catalog rows in configured-mode lists", async () => {
it.each([
{
mode: "merge" as const,
expectedKeys: ["xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5", "google/gemini-3.1-flash-lite"],
},
{
mode: "replace" as const,
expectedKeys: ["xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5"],
},
])("honors $mode mode in configured lists", async ({ mode, expectedKeys }) => {
const config = {
agents: { defaults: { model: { primary: "xiaomi/mimo-v2.5-pro" } } },
models: {
mode,
providers: {
xiaomi: {
api: "openai-completions",
...(mode === "merge" ? { api: "openai-completions" as const } : {}),
apiKey: "tp-fixture",
baseUrl: "https://api.xiaomi.example/v1",
models: [
@@ -815,6 +825,22 @@ describe("modelsListCommand forward-compat", () => {
tags: new Set(["default"]),
aliases: [],
},
...(mode === "replace"
? [
{
key: "google/gemini-stale",
ref: { provider: "google", model: "gemini-stale" },
tags: new Set(["fallback#1"]),
aliases: [],
},
{
key: "openai/gpt-stale",
ref: { provider: "openai", model: "gpt-stale" },
tags: new Set(["configured"]),
aliases: [],
},
]
: []),
],
});
mocks.loadModelCatalog.mockResolvedValueOnce([
@@ -831,25 +857,76 @@ describe("modelsListCommand forward-compat", () => {
await modelsListCommand({ json: true }, runtime as never);
expect(mocks.loadModelRegistry).not.toHaveBeenCalled();
expect(mocks.loadModelCatalog).toHaveBeenCalledWith(
expect.objectContaining({
providerDiscoveryProviderIds: ["google", "openai", "xiaomi"],
providerRuntimeDiscoveryProviderIds: [],
providerManifestFallbackProviderIds: ["google", "openai"],
}),
);
if (mode === "merge") {
expect(mocks.loadModelCatalog).toHaveBeenCalledWith(
expect.objectContaining({
providerDiscoveryProviderIds: ["google", "openai", "xiaomi"],
providerRuntimeDiscoveryProviderIds: [],
providerManifestFallbackProviderIds: ["google", "openai"],
}),
);
} else {
expect(mocks.loadModelCatalog).not.toHaveBeenCalled();
}
const rows = lastPrintedRows<{ key: string; name: string; available: boolean }>();
expectRowKeys(rows, [
"xiaomi/mimo-v2.5-pro",
"xiaomi/mimo-v2.5",
"google/gemini-3.1-flash-lite",
]);
expectRowKeys(rows, expectedKeys);
expectRowFields(rows, "xiaomi/mimo-v2.5-pro", { name: "MiMo V2.5 Pro" });
expectRowFields(rows, "xiaomi/mimo-v2.5", { name: "MiMo V2.5" });
expectRowFields(rows, "google/gemini-3.1-flash-lite", {
name: "Gemini 3.1 Flash Lite",
available: true,
if (mode === "merge") {
expectRowFields(rows, "google/gemini-3.1-flash-lite", {
name: "Gemini 3.1 Flash Lite",
available: true,
});
}
});
it.each([
{ name: "--all", options: { all: true } },
{ name: "--provider", options: { provider: "google" } },
])("keeps explicit $name browsing in replace mode", async ({ options }) => {
const config = {
agents: { defaults: { model: { primary: "xiaomi/mimo-v2.5-pro" } } },
models: {
mode: "replace" as const,
providers: {
xiaomi: {
baseUrl: "https://api.xiaomi.example/v1",
models: [{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", input: ["text"] }],
},
},
},
};
mocks.loadModelsConfigWithSource.mockResolvedValueOnce({
sourceConfig: config,
resolvedConfig: config,
diagnostics: [],
});
mocks.resolveConfiguredEntries.mockReturnValueOnce({
entries: [
{
key: "xiaomi/mimo-v2.5-pro",
ref: { provider: "xiaomi", model: "mimo-v2.5-pro" },
tags: new Set(["default"]),
aliases: [],
},
],
});
mocks.loadModelCatalog.mockResolvedValueOnce([
{
provider: "google",
id: "gemini-3.1-flash-lite",
name: "Gemini 3.1 Flash Lite",
input: ["text"],
contextWindow: 1_000_000,
},
]);
const runtime = createRuntime();
await modelsListCommand({ ...options, json: true }, runtime as never);
expect(lastPrintedRows<{ key: string }>().map((row) => row.key)).toContain(
"google/gemini-3.1-flash-lite",
);
});
it("does not mark configured codex model as missing when forward-compat can build a fallback", async () => {
+10
View File
@@ -70,6 +70,16 @@ export async function appendConfiguredModelRowSources(params: {
modelRegistry?: ModelRegistry;
context: RowBuilderContext;
}): Promise<void> {
if (params.context.cfg.models?.mode === "replace") {
// In replace mode models.providers is the complete catalog. Starting from
// default/fallback refs would reintroduce rows absent from that catalog.
await appendConfiguredProviderRows({
rows: params.rows,
context: params.context,
seenKeys: new Set(),
});
return;
}
// Configured rows are emitted first for tag ordering, so they must read the
// same committed generation the catalog rows below use; otherwise a ref that
// only exists in a plugin catalog renders default placeholder metadata.
+23
View File
@@ -948,6 +948,29 @@ describe("appendConfiguredProviderRows", () => {
});
describe("appendAuthenticatedCatalogRows", () => {
it("does not append authenticated catalog rows in replace mode", async () => {
const rows: ModelRow[] = [];
await appendAuthenticatedCatalogRows({
rows,
seenKeys: new Set(),
context: {
cfg: { models: { mode: "replace" } },
agentDir: "/tmp/openclaw-agent",
authIndex: {
evaluateModelAuth: () => ({ availability: true, routeResolution: null }),
},
configuredByKey: new Map(),
discoveredKeys: new Set(),
filter: {},
},
});
expect(rows).toEqual([]);
expect(mocks.loadModelCatalogSnapshot).not.toHaveBeenCalled();
expect(mocks.loadScopedModelCatalogSnapshot).not.toHaveBeenCalled();
});
it("keeps runnable synthetic local catalog rows", async () => {
const entries = [
{
+36 -4
View File
@@ -3,13 +3,17 @@ import {
normalizeProviderId,
normalizeProviderIdForAuth,
} from "@openclaw/model-catalog-core/provider-id";
import { stripSelfProviderModelPrefix } from "@openclaw/model-catalog-core/provider-model-id-normalization";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import {
projectModelCatalogEntryForRoute,
resolveConfiguredModelCatalogOverrides,
} from "../../agents/model-catalog-route.js";
import type { ModelCatalogEntry, ModelCatalogSnapshot } from "../../agents/model-catalog.types.js";
import { modelKey } from "../../agents/model-ref-shared.js";
import {
modelKey,
normalizeConfiguredProviderCatalogModelId,
} from "../../agents/model-ref-shared.js";
import { modelCatalogLogicalKey } from "../../agents/model-selection-shared.js";
import {
shouldSuppressBuiltInModel,
@@ -512,25 +516,50 @@ export async function appendConfiguredProviderRows(params: {
context: RowBuilderContext;
seenKeys: Set<string>;
}): Promise<void> {
const replaceMode = params.context.cfg.models?.mode === "replace";
for (const [provider, providerConfig] of Object.entries(
params.context.cfg.models?.providers ?? {},
)) {
for (const configuredModel of providerConfig.models ?? []) {
if (!shouldListConfiguredProviderModel({ providerConfig, model: configuredModel })) {
if (
!replaceMode &&
!shouldListConfiguredProviderModel({ providerConfig, model: configuredModel })
) {
continue;
}
const key = modelKey(provider, configuredModel.id);
// Strip a self-prefix against the source provider before display aliasing.
// Auth stays on the source provider so alias-backed profiles remain valid.
const modelId = replaceMode
? normalizeConfiguredProviderCatalogModelId(
provider,
stripSelfProviderModelPrefix(provider, configuredModel.id),
{
manifestPlugins: params.context.metadataSnapshot?.manifestRegistry.plugins,
},
)
: configuredModel.id;
const displayProvider = replaceMode
? canonicalizeModelCatalogProviderAlias(provider, {
cfg: params.context.cfg,
metadataSnapshot: params.context.metadataSnapshot,
})
: provider;
const key = modelKey(displayProvider, modelId);
const model = toConfiguredProviderListModel({
provider,
providerConfig,
model: configuredModel,
model: { ...configuredModel, id: modelId },
});
const authEvaluation = replaceMode
? params.context.authIndex.evaluateModelAuth(provider, toModelAuthRef(model))
: undefined;
await appendVisibleRow({
rows: params.rows,
model,
key,
context: params.context,
seenKeys: params.seenKeys,
...(authEvaluation ? { authEvaluation } : {}),
allowAuthAvailabilityOverride: true,
normalizeWithProviderPlugin: true,
});
@@ -545,6 +574,9 @@ export async function appendAuthenticatedCatalogRows(params: {
seenKeys: Set<string>;
catalogSnapshot?: ModelCatalogSnapshot;
}): Promise<void> {
if (params.context.cfg.models?.mode === "replace") {
return;
}
const { entries: catalog, routeVariants } =
params.catalogSnapshot ?? (await loadListModelCatalogSnapshot(params.context));
const routeIndex = createModelCatalogLogicalRouteIndex(routeVariants);