Skip to content

Commit 8cd9007

Browse files
committed
fix: harden OpenAI strict tool fallback
1 parent 2ff29a3 commit 8cd9007

3 files changed

Lines changed: 88 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ Docs: https://docs.openclaw.ai
122122
- 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[]`.
123123
- 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.
124124
- 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.
125+
- 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`.
125126
- 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.
126127
- 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.
127128
- Agents/replay: remove the malformed assistant-content canonicalization repair from replay history sanitization instead of extending that legacy repair path into replay validation.

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -690,9 +690,17 @@ describe("openai transport stream", () => {
690690
) as { tools?: Array<{ strict?: boolean }> };
691691

692692
expect(params.tools?.[0]?.strict).toBe(true);
693+
expect(params.tools?.[0]).toMatchObject({
694+
parameters: {
695+
type: "object",
696+
properties: {},
697+
additionalProperties: false,
698+
required: [],
699+
},
700+
});
693701
});
694702

695-
it("omits responses strict tool shaping when a native OpenAI tool schema is not strict-compatible", () => {
703+
it("falls back to strict:false when a native OpenAI tool schema is not strict-compatible", () => {
696704
const params = buildOpenAIResponsesParams(
697705
{
698706
id: "gpt-5.4",
@@ -713,14 +721,19 @@ describe("openai transport stream", () => {
713721
{
714722
name: "read",
715723
description: "Read file",
716-
parameters: { type: "object", properties: {} },
724+
parameters: {
725+
type: "object",
726+
additionalProperties: false,
727+
properties: { path: { type: "string" } },
728+
required: [],
729+
},
717730
},
718731
],
719732
} as never,
720733
undefined,
721734
) as { tools?: Array<{ strict?: boolean }> };
722735

723-
expect(params.tools?.[0]).not.toHaveProperty("strict");
736+
expect(params.tools?.[0]?.strict).toBe(false);
724737
});
725738

726739
it("omits responses strict tool shaping for proxy-like OpenAI routes", () => {
@@ -1155,7 +1168,7 @@ describe("openai transport stream", () => {
11551168
expect(params.tools?.[0]?.function?.strict).toBe(true);
11561169
});
11571170

1158-
it("omits completions strict tool shaping when a native OpenAI tool schema is not strict-compatible", () => {
1171+
it("falls back to completions strict:false when a native OpenAI tool schema is not strict-compatible", () => {
11591172
const params = buildOpenAICompletionsParams(
11601173
{
11611174
id: "gpt-5",
@@ -1183,7 +1196,7 @@ describe("openai transport stream", () => {
11831196
undefined,
11841197
) as { tools?: Array<{ function?: { strict?: boolean } }> };
11851198

1186-
expect(params.tools?.[0]?.function).not.toHaveProperty("strict");
1199+
expect(params.tools?.[0]?.function?.strict).toBe(false);
11871200
});
11881201

11891202
it("uses Mistral compat defaults for direct Mistral completions providers", () => {

src/agents/openai-transport-stream.ts

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -345,11 +345,57 @@ function convertResponsesTools(
345345
type: "function",
346346
name: tool.name,
347347
description: tool.description,
348-
parameters: tool.parameters,
348+
parameters: normalizeOpenAIStrictToolParameters(tool.parameters, strict),
349349
strict,
350350
}));
351351
}
352352

353+
function normalizeOpenAIStrictToolParameters<T>(schema: T, strict: boolean): T {
354+
if (!strict) {
355+
return schema;
356+
}
357+
return normalizeStrictOpenAIJsonSchema(schema) as T;
358+
}
359+
360+
function normalizeStrictOpenAIJsonSchema(schema: unknown): unknown {
361+
if (Array.isArray(schema)) {
362+
let changed = false;
363+
const normalized = schema.map((entry) => {
364+
const next = normalizeStrictOpenAIJsonSchema(entry);
365+
changed ||= next !== entry;
366+
return next;
367+
});
368+
return changed ? normalized : schema;
369+
}
370+
if (!schema || typeof schema !== "object") {
371+
return schema;
372+
}
373+
374+
const record = schema as Record<string, unknown>;
375+
let changed = false;
376+
const normalized: Record<string, unknown> = {};
377+
for (const [key, value] of Object.entries(record)) {
378+
const next = normalizeStrictOpenAIJsonSchema(value);
379+
normalized[key] = next;
380+
changed ||= next !== value;
381+
}
382+
383+
if (normalized.type === "object") {
384+
const properties =
385+
normalized.properties &&
386+
typeof normalized.properties === "object" &&
387+
!Array.isArray(normalized.properties)
388+
? (normalized.properties as Record<string, unknown>)
389+
: undefined;
390+
if (properties && Object.keys(properties).length === 0 && !Array.isArray(normalized.required)) {
391+
normalized.required = [];
392+
changed = true;
393+
}
394+
}
395+
396+
return changed ? normalized : schema;
397+
}
398+
353399
function isStrictOpenAIJsonSchemaCompatible(schema: unknown): boolean {
354400
if (Array.isArray(schema)) {
355401
return schema.every((entry) => isStrictOpenAIJsonSchemaCompatible(entry));
@@ -368,6 +414,24 @@ function isStrictOpenAIJsonSchemaCompatible(schema: unknown): boolean {
368414
if (record.type === "object" && record.additionalProperties !== false) {
369415
return false;
370416
}
417+
if (record.type === "object") {
418+
const properties =
419+
record.properties &&
420+
typeof record.properties === "object" &&
421+
!Array.isArray(record.properties)
422+
? (record.properties as Record<string, unknown>)
423+
: {};
424+
const required = Array.isArray(record.required)
425+
? record.required.filter((entry): entry is string => typeof entry === "string")
426+
: undefined;
427+
if (!required) {
428+
return false;
429+
}
430+
const requiredSet = new Set(required);
431+
if (Object.keys(properties).some((key) => !requiredSet.has(key))) {
432+
return false;
433+
}
434+
}
371435

372436
return Object.values(record).every((entry) => isStrictOpenAIJsonSchemaCompatible(entry));
373437
}
@@ -379,9 +443,9 @@ function resolveStrictToolFlagForInventory(
379443
if (strict !== true) {
380444
return strict === false ? false : undefined;
381445
}
382-
return tools.every((tool) => isStrictOpenAIJsonSchemaCompatible(tool.parameters))
383-
? true
384-
: undefined;
446+
return tools.every((tool) =>
447+
isStrictOpenAIJsonSchemaCompatible(normalizeStrictOpenAIJsonSchema(tool.parameters)),
448+
);
385449
}
386450

387451
async function processResponsesStream(
@@ -1305,7 +1369,7 @@ function convertTools(
13051369
function: {
13061370
name: tool.name,
13071371
description: tool.description,
1308-
parameters: tool.parameters,
1372+
parameters: normalizeOpenAIStrictToolParameters(tool.parameters, strict === true),
13091373
...(strict === undefined ? {} : { strict }),
13101374
},
13111375
}));

0 commit comments

Comments
 (0)