fix: harden OpenAI tool replay compatibility

This commit is contained in:
Peter Steinberger
2026-04-11 01:21:04 +01:00
parent f9a5e0a64f
commit ab687f4637
15 changed files with 170 additions and 26 deletions

View File

@@ -101,6 +101,39 @@ describe("buildProviderToolCompatFamilyHooks", () => {
});
});
it("preserves nested empty property schemas and object annotations", () => {
const hooks = buildProviderToolCompatFamilyHooks("openai");
const parameters = {
type: "object",
properties: {
payload: {},
mode: {
type: "string",
default: {},
const: {},
},
},
required: ["payload", "mode"],
additionalProperties: false,
};
const tools = [{ name: "demo", description: "", parameters }] as never;
const normalized = hooks.normalizeToolSchemas({
provider: "openai",
modelId: "gpt-5.4",
modelApi: "openai-responses",
model: {
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
id: "gpt-5.4",
} as never,
tools,
});
expect(normalized[0]?.parameters).toEqual(parameters);
});
it("does not tighten permissive object schemas just to satisfy strict mode", () => {
const hooks = buildProviderToolCompatFamilyHooks("openai");
const permissiveParameters = {

View File

@@ -183,7 +183,7 @@ export function normalizeOpenAIToolSchemas(
}
function normalizeOpenAIStrictCompatSchema(schema: unknown): unknown {
return normalizeOpenAIStrictCompatSchemaRecursive(schema);
return normalizeOpenAIStrictCompatSchemaRecursive(schema, { promoteEmptyObject: true });
}
function shouldApplyOpenAIToolCompat(ctx: ProviderNormalizeToolSchemasContext): boolean {
@@ -217,11 +217,62 @@ function isOpenAICodexBaseUrl(baseUrl: string): boolean {
return /^https:\/\/chatgpt\.com\/backend-api(?:\/|$)/i.test(baseUrl);
}
function normalizeOpenAIStrictCompatSchemaRecursive(schema: unknown): unknown {
type NormalizeOpenAIStrictCompatOptions = {
promoteEmptyObject: boolean;
};
const OPENAI_STRICT_COMPAT_SCHEMA_MAP_KEYS = new Set([
"$defs",
"definitions",
"dependentSchemas",
"patternProperties",
"properties",
]);
const OPENAI_STRICT_COMPAT_SCHEMA_NESTED_KEYS = new Set([
"additionalProperties",
"allOf",
"anyOf",
"contains",
"else",
"if",
"items",
"not",
"oneOf",
"prefixItems",
"propertyNames",
"then",
"unevaluatedItems",
"unevaluatedProperties",
]);
function normalizeOpenAIStrictCompatSchemaMap(schema: unknown): unknown {
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
return schema;
}
let changed = false;
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema as Record<string, unknown>)) {
const next = normalizeOpenAIStrictCompatSchemaRecursive(value, {
promoteEmptyObject: false,
});
normalized[key] = next;
changed ||= next !== value;
}
return changed ? normalized : schema;
}
function normalizeOpenAIStrictCompatSchemaRecursive(
schema: unknown,
options: NormalizeOpenAIStrictCompatOptions,
): unknown {
if (Array.isArray(schema)) {
let changed = false;
const normalized = schema.map((entry) => {
const next = normalizeOpenAIStrictCompatSchemaRecursive(entry);
const next = normalizeOpenAIStrictCompatSchemaRecursive(entry, {
promoteEmptyObject: false,
});
changed ||= next !== entry;
return next;
});
@@ -235,22 +286,21 @@ function normalizeOpenAIStrictCompatSchemaRecursive(schema: unknown): unknown {
let changed = false;
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(record)) {
const next =
key === "properties" && value && typeof value === "object" && !Array.isArray(value)
? Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(
([propertyName, propertyValue]) => [
propertyName,
normalizeOpenAIStrictCompatSchemaRecursive(propertyValue),
],
),
)
: normalizeOpenAIStrictCompatSchemaRecursive(value);
const next = OPENAI_STRICT_COMPAT_SCHEMA_MAP_KEYS.has(key)
? normalizeOpenAIStrictCompatSchemaMap(value)
: OPENAI_STRICT_COMPAT_SCHEMA_NESTED_KEYS.has(key)
? normalizeOpenAIStrictCompatSchemaRecursive(value, {
promoteEmptyObject: false,
})
: value;
normalized[key] = next;
changed ||= next !== value;
}
if (Object.keys(normalized).length === 0) {
if (!options.promoteEmptyObject) {
return schema;
}
return {
type: "object",
properties: {},