Skip to content

Commit 862be9f

Browse files
authored
fix: normalize Xiaomi array tool schemas (#82575)
1 parent 68a4c77 commit 862be9f

8 files changed

Lines changed: 236 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
2828
- CLI: hide decorative startup and status emoji on terminals that are unlikely to render them correctly, keeping semantic message and identity emoji intact.
2929
- CLI/gateway: recover the Linux user systemd bus environment when `openclaw dashboard` starts the Gateway from stripped desktop shells such as VNC terminals.
3030
- Gateway/WebSocket: log expected startup `1013 gateway starting` retry closes at debug instead of warn while preserving WARN for unexpected pre-connect failures. Fixes #76361. (#82457) Thanks @IWhatsskill.
31+
- Providers/Xiaomi: strip synthetic empty array `items` from MiMo tool schemas while preserving typed array items, avoiding strict OpenAI-compatible schema rejection.
3132
- CLI/context engines: bootstrap and finalize non-legacy context engines for CLI turns while preserving transcript snapshots and deferred maintenance ownership. (#81869) Thanks @sahilsatralkar.
3233
- Telegram: persist polling updates through restart replay so queued same-topic messages resume in order instead of losing context after a gateway restart. (#82256) Thanks @VACInc.
3334
- Gateway/Gmail: abort in-flight Gmail watcher startup and hot-reload restarts before shutdown so reloads cannot spawn `gog serve` after the Gateway is closing. Thanks @frankekn.

extensions/xiaomi/index.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,22 @@ describe("xiaomi provider plugin", () => {
242242
expect(replayPolicy?.validateAnthropicTurns).toBe(true);
243243
});
244244

245+
it("marks resolved MiMo models for empty array items omission", async () => {
246+
const provider = await registerSingleProviderPlugin(xiaomiPlugin);
247+
const model = mimoReasoningModel("mimo-v2.5");
248+
249+
const normalized = provider.normalizeResolvedModel?.({
250+
provider: "xiaomi",
251+
modelId: model.id,
252+
modelApi: model.api,
253+
model,
254+
} as never);
255+
256+
expect(
257+
(normalized?.compat as { omitEmptyArrayItems?: unknown } | undefined)?.omitEmptyArrayItems,
258+
).toBe(true);
259+
});
260+
245261
it("advertises thinking profiles for MiMo reasoning models only", async () => {
246262
const provider = await registerSingleProviderPlugin(xiaomiPlugin);
247263
const resolveThinkingProfile = requireThinkingProfileResolver(provider);

extensions/xiaomi/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
2-
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
2+
import {
3+
applyModelCompatPatch,
4+
buildProviderReplayFamilyHooks,
5+
} from "openclaw/plugin-sdk/provider-model-shared";
36
import { PROVIDER_LABELS } from "openclaw/plugin-sdk/provider-usage";
47
import { applyXiaomiConfig, XIAOMI_DEFAULT_MODEL_REF } from "./onboard.js";
58
import { buildXiaomiProvider } from "./provider-catalog.js";
@@ -36,6 +39,8 @@ export default defineSingleProviderPluginEntry({
3639
family: "openai-compatible",
3740
dropReasoningFromHistory: false,
3841
}),
42+
normalizeResolvedModel: ({ model }) =>
43+
applyModelCompatPatch(model, { omitEmptyArrayItems: true }),
3944
wrapStreamFn: (ctx) => createMiMoThinkingWrapper(ctx.streamFn, ctx.thinkingLevel),
4045
resolveThinkingProfile: ({ modelId }) => resolveMiMoThinkingProfile(modelId),
4146
isModernModelRef: ({ modelId }) => Boolean(resolveMiMoThinkingProfile(modelId)),

src/agents/openai-tool-schema.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export { resolveOpenAIStrictToolSetting } from "./openai-strict-tool-setting.js"
44

55
type ToolSchemaCompatInput = {
66
unsupportedToolSchemaKeywords?: unknown;
7+
omitEmptyArrayItems?: unknown;
78
};
89

910
type ToolWithParameters = {
@@ -14,13 +15,20 @@ type ToolWithParameters = {
1415
function resolveToolSchemaModelCompat(
1516
compat: ToolSchemaCompatInput | null | undefined,
1617
): ModelCompatConfig | undefined {
17-
if (!compat || !Array.isArray(compat.unsupportedToolSchemaKeywords)) {
18+
if (!compat) {
19+
return undefined;
20+
}
21+
const unsupportedToolSchemaKeywords = Array.isArray(compat.unsupportedToolSchemaKeywords)
22+
? compat.unsupportedToolSchemaKeywords.filter(
23+
(keyword): keyword is string => typeof keyword === "string",
24+
)
25+
: [];
26+
if (unsupportedToolSchemaKeywords.length === 0 && compat.omitEmptyArrayItems !== true) {
1827
return undefined;
1928
}
2029
return {
21-
unsupportedToolSchemaKeywords: compat.unsupportedToolSchemaKeywords.filter(
22-
(keyword): keyword is string => typeof keyword === "string",
23-
),
30+
...(unsupportedToolSchemaKeywords.length > 0 ? { unsupportedToolSchemaKeywords } : {}),
31+
...(compat.omitEmptyArrayItems === true ? { omitEmptyArrayItems: true } : {}),
2432
};
2533
}
2634

src/agents/openai-transport-stream.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3807,6 +3807,54 @@ describe("openai transport stream", () => {
38073807
expect(params.tools?.[0]?.function?.parameters?.properties?.forbidden).toStrictEqual({});
38083808
});
38093809

3810+
it("applies model compat empty array items omission after completions normalization", () => {
3811+
const params = buildOpenAICompletionsParams(
3812+
{
3813+
id: "mimo-v2.5",
3814+
name: "MiMo V2.5",
3815+
api: "openai-completions",
3816+
provider: "xiaomi",
3817+
baseUrl: "https://api.xiaomimimo.com/v1",
3818+
reasoning: true,
3819+
input: ["text"],
3820+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3821+
contextWindow: 256000,
3822+
maxTokens: 256000,
3823+
compat: {
3824+
omitEmptyArrayItems: true,
3825+
} as never,
3826+
} satisfies Model<"openai-completions">,
3827+
{
3828+
systemPrompt: "system",
3829+
messages: [],
3830+
tools: [
3831+
{
3832+
name: "collect",
3833+
description: "Collect hints",
3834+
parameters: {
3835+
type: "object",
3836+
properties: {
3837+
hints: { type: "array" },
3838+
typedHints: { type: "array", items: { type: "string" } },
3839+
},
3840+
},
3841+
},
3842+
],
3843+
} as never,
3844+
undefined,
3845+
) as {
3846+
tools?: Array<{ function?: { parameters?: { properties?: Record<string, unknown> } } }>;
3847+
};
3848+
3849+
expect(params.tools?.[0]?.function?.parameters?.properties?.hints).toStrictEqual({
3850+
type: "array",
3851+
});
3852+
expect(params.tools?.[0]?.function?.parameters?.properties?.typedHints).toStrictEqual({
3853+
type: "array",
3854+
items: { type: "string" },
3855+
});
3856+
});
3857+
38103858
describe("Gemini thought_signature round-trip on OpenAI-compatible completions", () => {
38113859
const geminiModel = {
38123860
id: "gemini-3-flash-preview",

src/agents/pi-tools-parameter-schema.ts

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import type { TSchema } from "typebox";
22
import type { ModelCompatConfig } from "../config/types.models.js";
33
import { stripUnsupportedSchemaKeywords } from "../plugin-sdk/provider-tools.js";
4-
import { resolveUnsupportedToolSchemaKeywords } from "../plugins/provider-model-compat.js";
4+
import {
5+
resolveUnsupportedToolSchemaKeywords,
6+
shouldOmitEmptyArrayItems,
7+
} from "../plugins/provider-model-compat.js";
58
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
69
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js";
710

@@ -224,6 +227,88 @@ function normalizeArraySchemasMissingItems(schema: unknown): unknown {
224227
return changed ? nextSchema : schema;
225228
}
226229

230+
function schemaAllowsArrayType(schema: Record<string, unknown>): boolean {
231+
const type = schema.type;
232+
return type === "array" || (Array.isArray(type) && type.includes("array"));
233+
}
234+
235+
const ARRAY_ITEMS_SCHEMA_OBJECT_KEYS = new Set([
236+
"additionalProperties",
237+
"contains",
238+
"else",
239+
"if",
240+
"items",
241+
"not",
242+
"propertyNames",
243+
"then",
244+
]);
245+
246+
const ARRAY_ITEMS_SCHEMA_ARRAY_KEYS = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
247+
248+
const ARRAY_ITEMS_SCHEMA_MAP_KEYS = new Set([
249+
"$defs",
250+
"definitions",
251+
"dependentSchemas",
252+
"patternProperties",
253+
"properties",
254+
]);
255+
256+
function stripEmptyArrayItemsFromArraySchemas(schema: unknown): unknown {
257+
if (Array.isArray(schema)) {
258+
let changed = false;
259+
const entries = schema.map((entry) => {
260+
const next = stripEmptyArrayItemsFromArraySchemas(entry);
261+
changed ||= next !== entry;
262+
return next;
263+
});
264+
return changed ? entries : schema;
265+
}
266+
if (!isSchemaRecord(schema)) {
267+
return schema;
268+
}
269+
270+
let changed = false;
271+
const entries = Object.entries(schema).flatMap(([key, value]) => {
272+
if (
273+
key === "items" &&
274+
schemaAllowsArrayType(schema) &&
275+
isSchemaRecord(value) &&
276+
isTrulyEmptySchema(value)
277+
) {
278+
changed = true;
279+
return [];
280+
}
281+
282+
if (ARRAY_ITEMS_SCHEMA_OBJECT_KEYS.has(key)) {
283+
const next = stripEmptyArrayItemsFromArraySchemas(value);
284+
changed ||= next !== value;
285+
return [[key, next] as const];
286+
}
287+
288+
if (ARRAY_ITEMS_SCHEMA_ARRAY_KEYS.has(key) && Array.isArray(value)) {
289+
const next = stripEmptyArrayItemsFromArraySchemas(value);
290+
changed ||= next !== value;
291+
return [[key, next] as const];
292+
}
293+
294+
if (ARRAY_ITEMS_SCHEMA_MAP_KEYS.has(key) && isSchemaRecord(value)) {
295+
let mapChanged = false;
296+
const next = Object.fromEntries(
297+
Object.entries(value).map(([entryKey, entryValue]) => {
298+
const entryNext = stripEmptyArrayItemsFromArraySchemas(entryValue);
299+
mapChanged ||= entryNext !== entryValue;
300+
return [entryKey, entryNext] as const;
301+
}),
302+
);
303+
changed ||= mapChanged;
304+
return [[key, mapChanged ? next : value] as const];
305+
}
306+
307+
return [[key, value] as const];
308+
});
309+
return changed ? Object.fromEntries(entries) : schema;
310+
}
311+
227312
export function normalizeToolParameterSchema(
228313
schema: unknown,
229314
options?: { modelProvider?: string; modelId?: string; modelCompat?: ModelCompatConfig },
@@ -247,16 +332,23 @@ export function normalizeToolParameterSchema(
247332
normalizedProvider.includes("google") || normalizedProvider.includes("gemini");
248333
const isAnthropicProvider = normalizedProvider.includes("anthropic");
249334
const unsupportedToolSchemaKeywords = resolveUnsupportedToolSchemaKeywords(options?.modelCompat);
335+
const omitEmptyArrayItems = shouldOmitEmptyArrayItems(options?.modelCompat);
250336

251337
function applyProviderCleaning(s: unknown): TSchema {
252338
const normalizedSchema = normalizeArraySchemasMissingItems(s);
339+
const arrayItemsCompatibleSchema = omitEmptyArrayItems
340+
? stripEmptyArrayItemsFromArraySchemas(normalizedSchema)
341+
: normalizedSchema;
253342
if (isGeminiProvider && !isAnthropicProvider) {
254-
return cleanSchemaForGemini(normalizedSchema);
343+
return cleanSchemaForGemini(arrayItemsCompatibleSchema);
255344
}
256345
if (unsupportedToolSchemaKeywords.size > 0) {
257-
return stripUnsupportedSchemaKeywords(normalizedSchema, unsupportedToolSchemaKeywords) as TSchema;
346+
return stripUnsupportedSchemaKeywords(
347+
arrayItemsCompatibleSchema,
348+
unsupportedToolSchemaKeywords,
349+
) as TSchema;
258350
}
259-
return normalizedSchema as TSchema;
351+
return arrayItemsCompatibleSchema as TSchema;
260352
}
261353

262354
const conditionalKey = getTopLevelConditionalKey(schemaRecord);

src/agents/pi-tools.schema.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,50 @@ describe("normalizeToolParameters", () => {
525525
expect(parameters.properties?.query.type).toBe("string");
526526
});
527527

528+
it("omits empty array items when model compat requires it", () => {
529+
const tool: AnyAgentTool = {
530+
name: "demo",
531+
label: "demo",
532+
description: "demo",
533+
parameters: {
534+
type: "object",
535+
properties: Object.fromEntries([
536+
["__proto__", { type: "array", items: {} }],
537+
["emptyItems", { type: "array" }],
538+
["typedItems", { type: "array", items: { type: "string" } }],
539+
["falseItems", { type: "array", items: false }],
540+
["nullItems", { type: "array", items: null }],
541+
["literalDefault", { type: "string", default: { type: "array", items: {} } }],
542+
["literalEnum", { type: "string", enum: [{ type: "array", items: {} }] }],
543+
]),
544+
},
545+
execute: vi.fn(),
546+
};
547+
548+
const normalized = normalizeToolParameters(tool, {
549+
modelCompat: { omitEmptyArrayItems: true } as never,
550+
});
551+
552+
expect(normalized.parameters).toEqual({
553+
type: "object",
554+
properties: Object.fromEntries([
555+
["__proto__", { type: "array" }],
556+
["emptyItems", { type: "array" }],
557+
["typedItems", { type: "array", items: { type: "string" } }],
558+
["falseItems", { type: "array", items: false }],
559+
["nullItems", { type: "array", items: null }],
560+
["literalDefault", { type: "string", default: { type: "array", items: {} } }],
561+
["literalEnum", { type: "string", enum: [{ type: "array", items: {} }] }],
562+
]),
563+
});
564+
expect(
565+
Object.prototype.hasOwnProperty.call(
566+
(normalized.parameters as { properties?: Record<string, unknown> }).properties,
567+
"__proto__",
568+
),
569+
).toBe(true);
570+
});
571+
528572
it("filters required to match properties when flattening anyOf for Gemini", () => {
529573
const tool = makeTool({
530574
type: "object",

src/plugins/provider-model-compat.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,13 @@ export function extractModelCompat(
1818
/** @deprecated Provider-owned model compat helper; do not use from third-party plugins. */
1919
export function applyModelCompatPatch<T extends { compat?: ModelCompatConfig }>(
2020
model: T,
21-
patch: ModelCompatConfig,
21+
patch: Partial<ModelCompatConfig> & Record<string, unknown>,
2222
): T {
23-
const nextCompat = { ...model.compat, ...patch };
23+
const nextCompat = { ...model.compat, ...patch } as ModelCompatConfig;
24+
const currentCompat = model.compat as (Record<string, unknown> & ModelCompatConfig) | undefined;
2425
if (
2526
model.compat &&
26-
Object.entries(patch).every(
27-
([key, value]) => model.compat?.[key as keyof ModelCompatConfig] === value,
28-
)
27+
Object.entries(patch).every(([key, value]) => currentCompat?.[key] === value)
2928
) {
3029
return model;
3130
}
@@ -66,6 +65,15 @@ export function resolveUnsupportedToolSchemaKeywords(
6665
);
6766
}
6867

68+
export function shouldOmitEmptyArrayItems(
69+
modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined,
70+
): boolean {
71+
const compat = extractModelCompat(modelOrCompat) as
72+
| (ModelCompatConfig & { omitEmptyArrayItems?: unknown })
73+
| undefined;
74+
return compat?.omitEmptyArrayItems === true;
75+
}
76+
6977
function isOpenAiCompletionsModel(model: Model<Api>): model is Model<"openai-completions"> {
7078
return model.api === "openai-completions";
7179
}

0 commit comments

Comments
 (0)