fix: harden OpenAI strict tool fallback

This commit is contained in:
Peter Steinberger
2026-04-05 19:53:21 +01:00
parent 2ff29a33d0
commit 8cd9007ec1
3 changed files with 88 additions and 10 deletions

View File

@@ -122,6 +122,7 @@ Docs: https://docs.openclaw.ai
- Providers/OpenAI Codex: split native `contextWindow` from runtime `contextTokens`, keep the default effective cap at `272000`, and expose a per-model `contextTokens` override on `models.providers.*.models[]`.
- Providers/OpenAI-compatible WS: compute fallback token totals from normalized usage when providers omit or zero `total_tokens`, so DashScope-compatible sessions stop storing zero totals after alias normalization. (#54940) Thanks @lyfuci.
- Agents/OpenAI: mark Claude-compatible file tool schemas as `additionalProperties: false` so direct OpenAI GPT-5 routes stop rejecting the `read` tool with invalid strict-schema errors.
- Agents/OpenAI: fall back to `strict: false` for native OpenAI tool calls when a tool schema is not strict-compatible, and normalize empty-object tool schemas to include `required: []`, so direct GPT-5 routes stop failing with invalid strict-schema errors like missing `path` in `required`.
- Agents/GPT: add explicit work-item lifecycle events for embedded runs, use them to surface real progress more reliably, and stop counting tool-started turns as planning-only retries.
- Plugins/OpenAI: enable `gpt-image-1` reference-image edits through `/images/edits` multipart uploads, and stop inferring unsupported resolution overrides when no explicit `size` or `resolution` is provided.
- Agents/replay: remove the malformed assistant-content canonicalization repair from replay history sanitization instead of extending that legacy repair path into replay validation.

View File

@@ -690,9 +690,17 @@ describe("openai transport stream", () => {
) as { tools?: Array<{ strict?: boolean }> };
expect(params.tools?.[0]?.strict).toBe(true);
expect(params.tools?.[0]).toMatchObject({
parameters: {
type: "object",
properties: {},
additionalProperties: false,
required: [],
},
});
});
it("omits responses strict tool shaping when a native OpenAI tool schema is not strict-compatible", () => {
it("falls back to strict:false when a native OpenAI tool schema is not strict-compatible", () => {
const params = buildOpenAIResponsesParams(
{
id: "gpt-5.4",
@@ -713,14 +721,19 @@ describe("openai transport stream", () => {
{
name: "read",
description: "Read file",
parameters: { type: "object", properties: {} },
parameters: {
type: "object",
additionalProperties: false,
properties: { path: { type: "string" } },
required: [],
},
},
],
} as never,
undefined,
) as { tools?: Array<{ strict?: boolean }> };
expect(params.tools?.[0]).not.toHaveProperty("strict");
expect(params.tools?.[0]?.strict).toBe(false);
});
it("omits responses strict tool shaping for proxy-like OpenAI routes", () => {
@@ -1155,7 +1168,7 @@ describe("openai transport stream", () => {
expect(params.tools?.[0]?.function?.strict).toBe(true);
});
it("omits completions strict tool shaping when a native OpenAI tool schema is not strict-compatible", () => {
it("falls back to completions strict:false when a native OpenAI tool schema is not strict-compatible", () => {
const params = buildOpenAICompletionsParams(
{
id: "gpt-5",
@@ -1183,7 +1196,7 @@ describe("openai transport stream", () => {
undefined,
) as { tools?: Array<{ function?: { strict?: boolean } }> };
expect(params.tools?.[0]?.function).not.toHaveProperty("strict");
expect(params.tools?.[0]?.function?.strict).toBe(false);
});
it("uses Mistral compat defaults for direct Mistral completions providers", () => {

View File

@@ -345,11 +345,57 @@ function convertResponsesTools(
type: "function",
name: tool.name,
description: tool.description,
parameters: tool.parameters,
parameters: normalizeOpenAIStrictToolParameters(tool.parameters, strict),
strict,
}));
}
function normalizeOpenAIStrictToolParameters<T>(schema: T, strict: boolean): T {
if (!strict) {
return schema;
}
return normalizeStrictOpenAIJsonSchema(schema) as T;
}
function normalizeStrictOpenAIJsonSchema(schema: unknown): unknown {
if (Array.isArray(schema)) {
let changed = false;
const normalized = schema.map((entry) => {
const next = normalizeStrictOpenAIJsonSchema(entry);
changed ||= next !== entry;
return next;
});
return changed ? normalized : schema;
}
if (!schema || typeof schema !== "object") {
return schema;
}
const record = schema as Record<string, unknown>;
let changed = false;
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(record)) {
const next = normalizeStrictOpenAIJsonSchema(value);
normalized[key] = next;
changed ||= next !== value;
}
if (normalized.type === "object") {
const properties =
normalized.properties &&
typeof normalized.properties === "object" &&
!Array.isArray(normalized.properties)
? (normalized.properties as Record<string, unknown>)
: undefined;
if (properties && Object.keys(properties).length === 0 && !Array.isArray(normalized.required)) {
normalized.required = [];
changed = true;
}
}
return changed ? normalized : schema;
}
function isStrictOpenAIJsonSchemaCompatible(schema: unknown): boolean {
if (Array.isArray(schema)) {
return schema.every((entry) => isStrictOpenAIJsonSchemaCompatible(entry));
@@ -368,6 +414,24 @@ function isStrictOpenAIJsonSchemaCompatible(schema: unknown): boolean {
if (record.type === "object" && record.additionalProperties !== false) {
return false;
}
if (record.type === "object") {
const properties =
record.properties &&
typeof record.properties === "object" &&
!Array.isArray(record.properties)
? (record.properties as Record<string, unknown>)
: {};
const required = Array.isArray(record.required)
? record.required.filter((entry): entry is string => typeof entry === "string")
: undefined;
if (!required) {
return false;
}
const requiredSet = new Set(required);
if (Object.keys(properties).some((key) => !requiredSet.has(key))) {
return false;
}
}
return Object.values(record).every((entry) => isStrictOpenAIJsonSchemaCompatible(entry));
}
@@ -379,9 +443,9 @@ function resolveStrictToolFlagForInventory(
if (strict !== true) {
return strict === false ? false : undefined;
}
return tools.every((tool) => isStrictOpenAIJsonSchemaCompatible(tool.parameters))
? true
: undefined;
return tools.every((tool) =>
isStrictOpenAIJsonSchemaCompatible(normalizeStrictOpenAIJsonSchema(tool.parameters)),
);
}
async function processResponsesStream(
@@ -1305,7 +1369,7 @@ function convertTools(
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters,
parameters: normalizeOpenAIStrictToolParameters(tool.parameters, strict === true),
...(strict === undefined ? {} : { strict }),
},
}));