From 3bc1284addce948b057469a44df1a7649eaf968d Mon Sep 17 00:00:00 2001 From: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com> Date: Tue, 19 May 2026 07:52:31 -0700 Subject: [PATCH] fix(anthropic): preserve configured Claude image capability --- CHANGELOG.md | 1 + .../model.inline-provider.test.ts | 15 +++++- src/commands/models.list.e2e.test.ts | 45 +++++++++++++++++ src/commands/models/list.list-command.ts | 1 + src/commands/models/list.rows.ts | 48 +++++++++++++++++-- 5 files changed, 106 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96058bf18e6e..0fe43fa055f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,7 @@ Docs: https://docs.openclaw.ai - Agents/subagents: keep collect-mode announce queues batching unresolved-origin items with compatible same-route messages and resume collection after a true cross-channel drain when a later compatible batch remains. Fixes #83577. - Skills: refresh existing session skill snapshots when watched skill roots change, so changed extra skill directories take effect without starting a new session. Fixes #83782. (#83800) Thanks @hclsys. - Providers/Anthropic: preserve native image input for current Claude model rows when stale local catalog data marks them text-only. (#83756) Thanks @TurboTheTurtle. +- Providers/Anthropic: preserve Claude 4 image capability when configured model refs resolve through a stale local catalog row. (#83756) Thanks @TurboTheTurtle. - Providers/DeepSeek: normalize MCP tool schemas with `anyOf`/`oneOf` unions before normal and compaction requests reach DeepSeek, preventing union-shaped parameters from being rejected. (#83766) Thanks @TurboTheTurtle. - Control UI: render live tool progress from session-scoped `session.tool` Gateway events so externally started runs show their tool cards in the active session. (#83734) Thanks @TurboTheTurtle. - Outbound: resolve send-capable channel plugins from the active runtime registry when the pinned startup registry only has setup metadata. (#83733) Thanks @TurboTheTurtle. diff --git a/src/agents/pi-embedded-runner/model.inline-provider.test.ts b/src/agents/pi-embedded-runner/model.inline-provider.test.ts index c3110a6d0fdc..282c565b991e 100644 --- a/src/agents/pi-embedded-runner/model.inline-provider.test.ts +++ b/src/agents/pi-embedded-runner/model.inline-provider.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildInlineProviderModels } from "./model.inline-provider.js"; +import { buildInlineProviderModels, resolveProviderModelInput } from "./model.inline-provider.js"; import { makeModel } from "./model.test-harness.js"; describe("buildInlineProviderModels", () => { @@ -261,3 +261,16 @@ describe("buildInlineProviderModels", () => { }); }); }); + +describe("resolveProviderModelInput", () => { + it("keeps configured Anthropic model input unchanged before provider-owned normalization", () => { + expect( + resolveProviderModelInput({ + provider: "anthropic", + modelId: "claude-sonnet-4-5", + modelName: "Claude Sonnet 4.5", + input: ["text"], + }), + ).toEqual(["text"]); + }); +}); diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index 520bf4e8b962..d4b81fda8b26 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -32,6 +32,19 @@ const loadProviderIndexCatalogRowsForList = vi.fn<() => Array + vi.fn(({ context }) => { + if ( + context?.provider === "anthropic" && + context?.modelId === "claude-sonnet-4-5" && + Array.isArray(context?.model?.input) && + !context.model.input.includes("image") + ) { + return { ...context.model, input: ["text", "image"] }; + } + return undefined; + }), +); const modelRegistryState = { models: [] as Array>, available: [] as Array>, @@ -124,6 +137,14 @@ vi.mock("../agents/pi-model-discovery.js", () => { }; }); +vi.mock("../plugins/provider-runtime.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + normalizeProviderResolvedModelWithPlugin, + }; +}); + vi.mock("../plugins/synthetic-auth.runtime.js", () => ({ resolveRuntimeSyntheticAuthProviderRefs: () => [], })); @@ -232,6 +253,7 @@ beforeEach(() => { hasProviderStaticCatalogForFilter.mockResolvedValue(false); shouldSuppressBuiltInModel.mockReset(); shouldSuppressBuiltInModel.mockReturnValue(false); + normalizeProviderResolvedModelWithPlugin.mockClear(); readConfigFileSnapshotForWrite.mockClear(); readConfigFileSnapshotForWrite.mockResolvedValue({ snapshot: { valid: false, resolved: {} }, @@ -448,6 +470,29 @@ describe("models list/status", () => { expect(runtimeLogText(runtime)).toBe("openrouter/hunter-alpha"); }); + it("models list configured fallback marks stale Anthropic Claude 4 refs image-capable", async () => { + getRuntimeConfig.mockReturnValue({ + agents: { defaults: { model: "anthropic/claude-sonnet-4-5" } }, + }); + const runtime = makeRuntime(); + + await modelsListCommand({ json: true }, runtime); + + const payload = parseJsonLog(runtime); + expect(payload.models[0]).toMatchObject({ + key: "anthropic/claude-sonnet-4-5", + input: "text+image", + }); + expect(normalizeProviderResolvedModelWithPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "anthropic", + context: expect.objectContaining({ + modelId: "claude-sonnet-4-5", + }), + }), + ); + }); + it.each(["z.ai", "Z.AI", "z-ai"] as const)( "models list provider filter normalizes %s alias", async (provider) => { diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index 07ad0ca9412b..c889555a08cc 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -166,6 +166,7 @@ export async function modelsListCommand( }, skipRuntimeModelSuppression, metadataSnapshot, + workspaceDir, }); const rows: ModelRow[] = []; diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index 0dd8bd36bcea..e76578435467 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -10,6 +10,8 @@ import type { ModelDefinitionConfig, ModelProviderConfig } from "../../config/ty import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { NormalizedModelCatalogRow } from "../../model-catalog/index.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; +import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; +import { normalizeProviderResolvedModelWithPlugin } from "../../plugins/provider-runtime.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import type { ModelListAuthIndex } from "./list.auth-index.js"; import type { ListRowModel } from "./list.model-row.js"; @@ -37,6 +39,7 @@ export type RowBuilderContext = { filter: RowFilter; skipRuntimeModelSuppression?: boolean; metadataSnapshot?: PluginMetadataSnapshot; + workspaceDir?: string; }; const modelCatalogModuleLoader = createLazyImportLoader( @@ -116,6 +119,38 @@ function shouldSuppressListModel(params: { }); } +function normalizeListRowWithProviderPlugin(params: { + model: ListRowModel; + context: RowBuilderContext; +}): ListRowModel { + const normalized = normalizeProviderResolvedModelWithPlugin({ + provider: params.model.provider, + config: params.context.cfg, + workspaceDir: params.context.workspaceDir, + context: { + config: params.context.cfg, + agentDir: params.context.agentDir, + workspaceDir: params.context.workspaceDir, + provider: params.model.provider, + modelId: params.model.id, + model: params.model as ProviderRuntimeModel, + }, + }); + if (!normalized) { + return params.model; + } + return { + ...params.model, + id: normalized.id, + name: normalized.name, + provider: normalized.provider, + baseUrl: normalized.baseUrl, + input: toListRowInput(normalized.input), + contextWindow: normalized.contextWindow, + contextTokens: normalized.contextTokens, + }; +} + async function appendVisibleRow(params: { rows: ModelRow[]; model: ListRowModel; @@ -131,15 +166,19 @@ async function appendVisibleRow(params: { if (!matchesRowFilter(params.context.filter, params.model)) { return false; } + const normalizedModel = normalizeListRowWithProviderPlugin({ + model: params.model, + context: params.context, + }); if ( !params.skipSuppression && - shouldSuppressListModel({ model: params.model, context: params.context }) + shouldSuppressListModel({ model: normalizedModel, context: params.context }) ) { return false; } params.rows.push( await buildRow({ - model: params.model, + model: normalizedModel, key: params.key, context: params.context, allowProviderAvailabilityFallback: params.allowProviderAvailabilityFallback, @@ -487,7 +526,7 @@ export async function appendConfiguredRows(params: { ) { continue; } - const model = + const resolvedModel = params.modelRegistry && resolveModelWithRegistry ? resolveModelWithRegistry({ provider: entry.ref.provider, @@ -496,6 +535,9 @@ export async function appendConfiguredRows(params: { cfg: params.context.cfg, }) : toFallbackConfiguredListModel(entry, params.context.cfg); + const model = resolvedModel + ? normalizeListRowWithProviderPlugin({ model: resolvedModel, context: params.context }) + : resolvedModel; if (params.context.filter.local && model && !isLocalBaseUrl(model.baseUrl ?? "")) { continue; }