mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 21:21:10 +00:00
fix: harden OpenAI strict tool fallback
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user