fix(anthropic): preserve configured Claude image capability

This commit is contained in:
Andy Ye
2026-05-19 07:52:31 -07:00
parent d0f7c8fa28
commit 3bc1284add
5 changed files with 106 additions and 4 deletions
+1
View File
@@ -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.
@@ -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"]);
});
});
+45
View File
@@ -32,6 +32,19 @@ const loadProviderIndexCatalogRowsForList = vi.fn<() => Array<Record<string, unk
const hasProviderStaticCatalogForFilter = vi.fn().mockResolvedValue(false);
const shouldSuppressBuiltInModel = vi.fn().mockReturnValue(false);
const shouldSuppressBuiltInModelFromManifest = vi.fn().mockReturnValue(false);
const normalizeProviderResolvedModelWithPlugin = vi.hoisted(() =>
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<Record<string, unknown>>,
available: [] as Array<Record<string, unknown>>,
@@ -124,6 +137,14 @@ vi.mock("../agents/pi-model-discovery.js", () => {
};
});
vi.mock("../plugins/provider-runtime.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/provider-runtime.js")>();
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) => {
+1
View File
@@ -166,6 +166,7 @@ export async function modelsListCommand(
},
skipRuntimeModelSuppression,
metadataSnapshot,
workspaceDir,
});
const rows: ModelRow[] = [];
+45 -3
View File
@@ -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<ModelCatalogModule>(
@@ -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;
}