Skip to content

Commit f7bb38f

Browse files
fix(models): honor replace mode in list output
Replace the stale fork PR with the reviewed fix on current main. Refs: #94735 Co-authored-by: 毛宇豪0668001457 <[email protected]>
1 parent ecad886 commit f7bb38f

4 files changed

Lines changed: 141 additions & 32 deletions

File tree

src/commands/models/list.configured.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,22 @@ describe("resolveConfiguredEntries", () => {
120120
expect(entries[0]?.tags).toEqual(new Set(["default", "configured"]));
121121
});
122122

123+
it("preserves manifest aliases that select a distinct transport", () => {
124+
vi.stubEnv("OPENCLAW_BUNDLED_PLUGINS_DIR", path.resolve("extensions"));
125+
126+
const { entries } = resolveConfiguredEntries({
127+
agents: {
128+
defaults: {
129+
model: { primary: "azure-openai-responses/gpt-5.4" },
130+
},
131+
},
132+
models: { providers: {} },
133+
});
134+
135+
expect(entries.map((entry) => entry.key)).toEqual(["azure-openai-responses/gpt-5.4"]);
136+
expect(entries[0]?.tags).toEqual(new Set(["default"]));
137+
});
138+
123139
it("recovers bundled source aliases when stale dist metadata omits them", () => {
124140
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-alias-source-"));
125141
try {

src/commands/models/list.list-command.forward-compat.test.ts

Lines changed: 91 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -620,13 +620,15 @@ describe("modelsListCommand forward-compat", () => {
620620
});
621621
});
622622

623-
it("includes configured provider and auth-backed catalog rows in configured-mode lists", async () => {
623+
function configureAuthenticatedCatalogScenario(mode: "merge" | "replace") {
624624
const config = {
625625
agents: { defaults: { model: { primary: "xiaomi/mimo-v2.5-pro" } } },
626626
models: {
627+
mode,
627628
providers: {
628-
xiaomi: {
629-
api: "openai-completions",
629+
XIAOMI: {
630+
// Replace-mode configured models can rely on runtime transport inference.
631+
...(mode === "merge" ? { api: "openai-completions" as const } : {}),
630632
apiKey: "tp-fixture",
631633
baseUrl: "https://api.xiaomi.example/v1",
632634
models: [
@@ -637,12 +639,12 @@ describe("modelsListCommand forward-compat", () => {
637639
},
638640
},
639641
};
640-
mocks.loadModelsConfigWithSource.mockResolvedValueOnce({
642+
mocks.loadModelsConfigWithSource.mockResolvedValue({
641643
sourceConfig: config,
642644
resolvedConfig: config,
643645
diagnostics: [],
644646
});
645-
mocks.ensureAuthProfileStore.mockReturnValueOnce({
647+
mocks.ensureAuthProfileStore.mockReturnValue({
646648
version: 1,
647649
profiles: {
648650
"google:default": {
@@ -653,7 +655,7 @@ describe("modelsListCommand forward-compat", () => {
653655
},
654656
order: {},
655657
});
656-
mocks.resolveConfiguredEntries.mockReturnValueOnce({
658+
mocks.resolveConfiguredEntries.mockReturnValue({
657659
entries: [
658660
{
659661
key: "xiaomi/mimo-v2.5-pro",
@@ -663,7 +665,7 @@ describe("modelsListCommand forward-compat", () => {
663665
},
664666
],
665667
});
666-
mocks.loadModelCatalog.mockResolvedValueOnce([
668+
mocks.loadModelCatalog.mockResolvedValue([
667669
{
668670
provider: "google",
669671
id: "gemini-3.1-flash-lite",
@@ -672,23 +674,92 @@ describe("modelsListCommand forward-compat", () => {
672674
contextWindow: 1_000_000,
673675
},
674676
]);
675-
const runtime = createRuntime();
677+
}
676678

677-
await modelsListCommand({ json: true }, runtime as never);
679+
it.each([
680+
{
681+
mode: "merge",
682+
expectedKeys: [
683+
"xiaomi/mimo-v2.5-pro",
684+
"xiaomi/mimo-v2.5",
685+
"google/gemini-3.1-flash-lite",
686+
],
687+
},
688+
{
689+
mode: "replace",
690+
expectedKeys: ["xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5"],
691+
},
692+
] as const)(
693+
"uses $mode catalog semantics for the default list",
694+
async ({ mode, expectedKeys }) => {
695+
configureAuthenticatedCatalogScenario(mode);
696+
const runtime = createRuntime();
678697

679-
expect(mocks.loadModelRegistry).not.toHaveBeenCalled();
680-
const rows = lastPrintedRows<{ key: string; name: string; available: boolean }>();
681-
expectRowKeys(rows, [
682-
"xiaomi/mimo-v2.5-pro",
683-
"xiaomi/mimo-v2.5",
684-
"google/gemini-3.1-flash-lite",
685-
]);
686-
expectRowFields(rows, "xiaomi/mimo-v2.5-pro", { name: "MiMo V2.5 Pro" });
687-
expectRowFields(rows, "xiaomi/mimo-v2.5", { name: "MiMo V2.5" });
688-
expectRowFields(rows, "google/gemini-3.1-flash-lite", {
698+
await modelsListCommand({ json: true }, runtime as never);
699+
700+
expect(mocks.loadModelRegistry).not.toHaveBeenCalled();
701+
const rows = lastPrintedRows<{ key: string; name: string; available: boolean }>();
702+
expectRowKeys(rows, [...expectedKeys]);
703+
expectRowFields(rows, "xiaomi/mimo-v2.5-pro", { name: "MiMo V2.5 Pro" });
704+
expectRowFields(rows, "xiaomi/mimo-v2.5", { name: "MiMo V2.5" });
705+
if (mode === "merge") {
706+
expectRowFields(rows, "google/gemini-3.1-flash-lite", {
707+
name: "Gemini 3.1 Flash Lite",
708+
available: true,
709+
});
710+
expect(mocks.loadModelCatalog).toHaveBeenCalledOnce();
711+
} else {
712+
expect(mocks.loadModelCatalog).not.toHaveBeenCalled();
713+
}
714+
},
715+
);
716+
717+
it("preserves explicit all and provider browsing in replace mode", async () => {
718+
configureAuthenticatedCatalogScenario("replace");
719+
const googleModelKey = "google/gemini-3.1-flash-lite";
720+
const googleModel = {
721+
provider: "google",
722+
id: "gemini-3.1-flash-lite",
689723
name: "Gemini 3.1 Flash Lite",
690-
available: true,
724+
api: "google-generative-ai",
725+
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
726+
input: ["text"],
727+
contextWindow: 1_000_000,
728+
maxTokens: 65_536,
729+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
730+
};
731+
mocks.loadModelRegistry.mockResolvedValueOnce({
732+
models: [googleModel],
733+
availableKeys: new Set([googleModelKey]),
734+
registry: { getAll: () => [googleModel] },
691735
});
736+
const runtime = createRuntime();
737+
738+
await modelsListCommand({ all: true, json: true }, runtime as never);
739+
740+
expectRowFields(
741+
lastPrintedRows<{ key: string; available: boolean }>(),
742+
googleModelKey,
743+
{ available: true },
744+
);
745+
expect(mocks.loadModelRegistry).toHaveBeenCalledOnce();
746+
747+
mocks.loadStaticManifestCatalogRowsForList.mockReturnValueOnce([
748+
{
749+
...googleModel,
750+
ref: googleModelKey,
751+
mergeKey: "google::gemini-3.1-flash-lite",
752+
source: "manifest",
753+
reasoning: false,
754+
status: "available",
755+
},
756+
]);
757+
758+
await modelsListCommand({ provider: "google", json: true }, runtime as never);
759+
760+
expectRowKeys(lastPrintedRows<{ key: string }>(), [googleModelKey]);
761+
expect(mocks.loadModelRegistry).toHaveBeenCalledOnce();
762+
expect(mocks.loadModelCatalog).not.toHaveBeenCalled();
692763
});
693764

694765
it("does not mark configured codex model as missing when forward-compat can build a fallback", async () => {

src/commands/models/list.rows.ts

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,19 @@ function loadProviderCatalogModule(): Promise<ProviderCatalogModule> {
6868
return providerCatalogModuleLoader.load();
6969
}
7070

71+
function canonicalizeListProvider(context: RowBuilderContext, provider: string): string {
72+
return canonicalizeModelCatalogProviderAlias(provider, {
73+
cfg: context.cfg,
74+
metadataSnapshot: context.metadataSnapshot,
75+
});
76+
}
77+
7178
function matchesProviderFilter(context: RowBuilderContext, provider: string): boolean {
7279
const providerFilter = context.filter.provider;
7380
if (!providerFilter) {
7481
return true;
7582
}
76-
const canonicalProvider = canonicalizeModelCatalogProviderAlias(provider, {
77-
cfg: context.cfg,
78-
metadataSnapshot: context.metadataSnapshot,
79-
});
83+
const canonicalProvider = canonicalizeListProvider(context, provider);
8084
return normalizeProviderId(canonicalProvider) === providerFilter;
8185
}
8286

@@ -267,11 +271,13 @@ function shouldListConfiguredProviderModel(params: {
267271
}
268272

269273
function findConfiguredProviderModel(params: {
270-
cfg: OpenClawConfig;
274+
context: RowBuilderContext;
271275
provider: string;
272276
modelId: string;
273277
}): ListRowModel | undefined {
274-
const providerConfig = params.cfg.models?.providers?.[params.provider];
278+
const providerConfig = Object.entries(params.context.cfg.models?.providers ?? {}).find(
279+
([provider]) => canonicalizeListProvider(params.context, provider) === params.provider,
280+
)?.[1];
275281
const configuredModel = providerConfig?.models?.find((model) => model.id === params.modelId);
276282
if (!providerConfig || !configuredModel) {
277283
return undefined;
@@ -283,10 +289,13 @@ function findConfiguredProviderModel(params: {
283289
});
284290
}
285291

286-
function toFallbackConfiguredListModel(entry: ConfiguredEntry, cfg: OpenClawConfig): ListRowModel {
292+
function toFallbackConfiguredListModel(
293+
entry: ConfiguredEntry,
294+
context: RowBuilderContext,
295+
): ListRowModel {
287296
return (
288297
findConfiguredProviderModel({
289-
cfg,
298+
context,
290299
provider: entry.ref.provider,
291300
modelId: entry.ref.model,
292301
}) ?? {
@@ -359,13 +368,18 @@ export async function appendConfiguredProviderRows(params: {
359368
for (const [provider, providerConfig] of Object.entries(
360369
params.context.cfg.models?.providers ?? {},
361370
)) {
371+
const canonicalProvider = canonicalizeListProvider(params.context, provider);
362372
for (const configuredModel of providerConfig.models ?? []) {
363-
if (!shouldListConfiguredProviderModel({ providerConfig, model: configuredModel })) {
373+
// Replace-mode rows are canonical even when runtime infers their transport.
374+
if (
375+
params.context.cfg.models?.mode !== "replace" &&
376+
!shouldListConfiguredProviderModel({ providerConfig, model: configuredModel })
377+
) {
364378
continue;
365379
}
366-
const key = modelKey(provider, configuredModel.id);
380+
const key = modelKey(canonicalProvider, configuredModel.id);
367381
const model = toConfiguredProviderListModel({
368-
provider,
382+
provider: canonicalProvider,
369383
providerConfig,
370384
model: configuredModel,
371385
});
@@ -388,6 +402,10 @@ export async function appendAuthenticatedCatalogRows(params: {
388402
context: RowBuilderContext;
389403
seenKeys: Set<string>;
390404
}): Promise<void> {
405+
// Replace narrows default configured lists; explicit browse paths skip this helper.
406+
if (params.context.cfg.models?.mode === "replace") {
407+
return;
408+
}
391409
const { loadModelCatalog } = await loadModelCatalogModule();
392410
const catalog = await loadModelCatalog({
393411
config: params.context.cfg,
@@ -563,7 +581,7 @@ export async function appendConfiguredRows(params: {
563581
modelRegistry: params.modelRegistry,
564582
cfg: params.context.cfg,
565583
})
566-
: toFallbackConfiguredListModel(entry, params.context.cfg);
584+
: toFallbackConfiguredListModel(entry, params.context);
567585
const model = resolvedModel
568586
? normalizeListRowWithProviderPlugin({ model: resolvedModel, context: params.context })
569587
: resolvedModel;

src/commands/models/provider-aliases.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ function collectModelCatalogAliases(
8282
modelCatalog: PluginManifestModelCatalog | undefined,
8383
): void {
8484
for (const [aliasProvider, target] of Object.entries(modelCatalog?.aliases ?? {})) {
85+
// Transport-bearing aliases are concrete routes, not spelling aliases.
86+
if (target.api || target.baseUrl) {
87+
continue;
88+
}
8589
const alias = normalizeProviderId(aliasProvider);
8690
const provider = normalizeProviderId(target.provider);
8791
if (alias && provider) {

0 commit comments

Comments
 (0)