mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
committed by
GitHub
parent
ba35d2a09a
commit
aa0bad1ebe
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user