agents: add openai provider-owned tool compat

This commit is contained in:
Eva
2026-04-10 19:31:27 +07:00
committed by Peter Steinberger
parent 13337d7048
commit 6aa63b4fdd
4 changed files with 473 additions and 1 deletions

View File

@@ -263,6 +263,84 @@ describe("openai plugin", () => {
).toBeLessThan(runtimeMocks.refreshOpenAICodexToken.mock.invocationCallOrder[0]);
});
it("registers provider-owned OpenAI tool compat hooks for openai and codex", async () => {
const { providers } = await registerOpenAIPluginWithHook();
const openaiProvider = requireRegisteredProvider(providers, "openai");
const codexProvider = requireRegisteredProvider(providers, "openai-codex");
const noParamsTool = {
name: "ping",
description: "",
parameters: {},
execute: vi.fn(),
} as never;
const normalizedOpenAI = openaiProvider.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: [noParamsTool],
} as never);
const normalizedCodex = codexProvider.normalizeToolSchemas?.({
provider: "openai-codex",
modelId: "gpt-5.4",
modelApi: "openai-codex-responses",
model: {
provider: "openai-codex",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
id: "gpt-5.4",
} as never,
tools: [noParamsTool],
} as never);
expect(normalizedOpenAI?.[0]?.parameters).toEqual({
type: "object",
properties: {},
required: [],
additionalProperties: false,
});
expect(normalizedCodex?.[0]?.parameters).toEqual({
type: "object",
properties: {},
required: [],
additionalProperties: false,
});
expect(
openaiProvider.inspectToolSchemas?.({
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: [noParamsTool],
} as never),
).toEqual([]);
expect(
codexProvider.inspectToolSchemas?.({
provider: "openai-codex",
modelId: "gpt-5.4",
modelApi: "openai-codex-responses",
model: {
provider: "openai-codex",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
id: "gpt-5.4",
} as never,
tools: [noParamsTool],
} as never),
).toEqual([]);
});
it("registers GPT-5 system prompt contributions when the friendly overlay is enabled", async () => {
const { on, providers } = await registerOpenAIPluginWithHook({
pluginConfig: { personality: "friendly" },

View File

@@ -1,4 +1,5 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools";
import { buildOpenAICodexCliBackend } from "./cli-backend.js";
import { buildOpenAIImageGenerationProvider } from "./image-generation-provider.js";
import {
@@ -22,10 +23,12 @@ export default definePluginEntry({
description: "Bundled OpenAI provider plugins",
register(api) {
const promptOverlayMode = resolveOpenAIPromptOverlayMode(api.pluginConfig);
const openAIToolCompatHooks = buildProviderToolCompatFamilyHooks("openai");
const buildProviderWithPromptContribution = <T extends ReturnType<typeof buildOpenAIProvider>>(
provider: T,
): T => ({
...provider,
...openAIToolCompatHooks,
resolveSystemPromptContribution: (ctx) =>
resolveOpenAISystemPromptContribution({
mode: promptOverlayMode,

View File

@@ -3,7 +3,9 @@ import {
applyXaiModelCompat,
buildProviderToolCompatFamilyHooks,
inspectGeminiToolSchemas,
inspectOpenAIToolSchemas,
normalizeGeminiToolSchemas,
normalizeOpenAIToolSchemas,
resolveXaiModelCompatPatch,
} from "./provider-tools.js";
@@ -15,6 +17,11 @@ describe("buildProviderToolCompatFamilyHooks", () => {
normalizeToolSchemas: normalizeGeminiToolSchemas,
inspectToolSchemas: inspectGeminiToolSchemas,
},
{
family: "openai" as const,
normalizeToolSchemas: normalizeOpenAIToolSchemas,
inspectToolSchemas: inspectOpenAIToolSchemas,
},
];
for (const testCase of cases) {
@@ -25,6 +32,181 @@ describe("buildProviderToolCompatFamilyHooks", () => {
}
});
it("normalizes parameter-free and typed-object schemas for the openai family", () => {
const hooks = buildProviderToolCompatFamilyHooks("openai");
const tools = [
{ name: "ping", description: "", parameters: {} },
{ name: "exec", description: "", parameters: { type: "object" } },
] 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.map((tool) => tool.parameters)).toEqual([
{ type: "object", properties: {}, required: [], additionalProperties: false },
{ type: "object", properties: {}, required: [], additionalProperties: false },
]);
expect(
hooks.inspectToolSchemas({
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,
}),
).toEqual([]);
});
it("does not tighten permissive object schemas just to satisfy strict mode", () => {
const hooks = buildProviderToolCompatFamilyHooks("openai");
const permissiveParameters = {
type: "object",
properties: {
action: { type: "string" },
schedule: { type: "string" },
},
required: ["action"],
additionalProperties: true,
};
const permissiveTool = {
name: "cron",
description: "",
parameters: permissiveParameters,
} 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: [permissiveTool],
});
expect(normalized[0]?.parameters).toEqual(permissiveParameters);
expect(
hooks.inspectToolSchemas({
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: [permissiveTool],
}),
).toEqual([
{
toolName: "cron",
toolIndex: 0,
violations: expect.arrayContaining([
"cron.parameters.required.schedule",
"cron.parameters.additionalProperties",
]),
},
]);
});
it("skips openai strict-tool normalization on non-native routes", () => {
const hooks = buildProviderToolCompatFamilyHooks("openai");
const tools = [{ name: "ping", description: "", parameters: {} }] as never;
expect(
hooks.normalizeToolSchemas({
provider: "openai",
modelId: "gpt-5.4",
modelApi: "openai-completions",
model: {
provider: "openai",
api: "openai-completions",
baseUrl: "https://example.com/v1",
id: "gpt-5.4",
} as never,
tools,
}),
).toBe(tools);
expect(
hooks.inspectToolSchemas({
provider: "openai",
modelId: "gpt-5.4",
modelApi: "openai-completions",
model: {
provider: "openai",
api: "openai-completions",
baseUrl: "https://example.com/v1",
id: "gpt-5.4",
} as never,
tools,
}),
).toEqual([]);
});
it("reports remaining strict-schema violations for the openai family", () => {
const hooks = buildProviderToolCompatFamilyHooks("openai");
const diagnostics = hooks.inspectToolSchemas({
provider: "openai-codex",
modelId: "gpt-5.4",
modelApi: "openai-codex-responses",
model: {
provider: "openai-codex",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
id: "gpt-5.4",
} as never,
tools: [
{
name: "exec",
description: "",
parameters: {
type: "object",
properties: {
mode: {
anyOf: [{ type: "string" }, { type: "number" }],
},
cwd: { type: "string" },
},
required: ["mode"],
additionalProperties: true,
},
} as never,
],
});
expect(diagnostics).toEqual([
{
toolName: "exec",
toolIndex: 0,
violations: expect.arrayContaining([
"exec.parameters.additionalProperties",
"exec.parameters.required.cwd",
"exec.parameters.properties.mode.anyOf",
]),
},
]);
});
it("covers the shared xAI tool compat patch", () => {
const patch = resolveXaiModelCompatPatch();

View File

@@ -159,7 +159,211 @@ export function inspectGeminiToolSchemas(
});
}
export type ProviderToolCompatFamily = "gemini";
export function normalizeOpenAIToolSchemas(
ctx: ProviderNormalizeToolSchemasContext,
): AnyAgentTool[] {
if (!shouldApplyOpenAIToolCompat(ctx)) {
return ctx.tools;
}
return ctx.tools.map((tool) => {
if (!tool.parameters || typeof tool.parameters !== "object") {
return tool;
}
return {
...tool,
parameters: normalizeOpenAIStrictCompatSchema(tool.parameters),
};
});
}
function normalizeOpenAIStrictCompatSchema(schema: unknown): unknown {
return normalizeOpenAIStrictCompatSchemaRecursive(schema);
}
function shouldApplyOpenAIToolCompat(ctx: ProviderNormalizeToolSchemasContext): boolean {
const provider = String(ctx.model?.provider ?? ctx.provider ?? "")
.trim()
.toLowerCase();
const api = String(ctx.model?.api ?? ctx.modelApi ?? "")
.trim()
.toLowerCase();
const baseUrl = String(ctx.model?.baseUrl ?? "")
.trim()
.toLowerCase();
if (provider === "openai") {
return api === "openai-responses" && (!baseUrl || isOpenAIResponsesBaseUrl(baseUrl));
}
if (provider === "openai-codex") {
return (
api === "openai-codex-responses" &&
(!baseUrl || isOpenAIResponsesBaseUrl(baseUrl) || isOpenAICodexBaseUrl(baseUrl))
);
}
return false;
}
function isOpenAIResponsesBaseUrl(baseUrl: string): boolean {
return /^https:\/\/api\.openai\.com(?:\/v1)?(?:\/|$)/i.test(baseUrl);
}
function isOpenAICodexBaseUrl(baseUrl: string): boolean {
return /^https:\/\/chatgpt\.com\/backend-api(?:\/|$)/i.test(baseUrl);
}
function normalizeOpenAIStrictCompatSchemaRecursive(schema: unknown): unknown {
if (Array.isArray(schema)) {
let changed = false;
const normalized = schema.map((entry) => {
const next = normalizeOpenAIStrictCompatSchemaRecursive(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 = normalizeOpenAIStrictCompatSchemaRecursive(value);
normalized[key] = next;
changed ||= next !== value;
}
if (Object.keys(normalized).length === 0) {
return {
type: "object",
properties: {},
required: [],
additionalProperties: false,
};
}
const hasObjectShapeHints =
!("type" in normalized) &&
((normalized.properties &&
typeof normalized.properties === "object" &&
!Array.isArray(normalized.properties)) ||
Array.isArray(normalized.required));
if (hasObjectShapeHints) {
normalized.type = "object";
changed = true;
}
if (normalized.type === "object" && !("properties" in normalized)) {
normalized.properties = {};
changed = true;
}
const hasEmptyProperties =
normalized.properties &&
typeof normalized.properties === "object" &&
!Array.isArray(normalized.properties) &&
Object.keys(normalized.properties as Record<string, unknown>).length === 0;
if (normalized.type === "object" && !Array.isArray(normalized.required) && hasEmptyProperties) {
normalized.required = [];
changed = true;
}
if (
normalized.type === "object" &&
hasEmptyProperties &&
!("additionalProperties" in normalized) &&
normalized.additionalProperties !== false
) {
normalized.additionalProperties = false;
changed = true;
}
return changed ? normalized : schema;
}
export function findOpenAIStrictSchemaViolations(schema: unknown, path: string): string[] {
if (Array.isArray(schema)) {
return schema.flatMap((item, index) =>
findOpenAIStrictSchemaViolations(item, `${path}[${index}]`),
);
}
if (!schema || typeof schema !== "object") {
return [];
}
const record = schema as Record<string, unknown>;
const violations: string[] = [];
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
if (Array.isArray(record[key])) {
violations.push(`${path}.${key}`);
}
}
if (Array.isArray(record.type)) {
violations.push(`${path}.type`);
}
const properties =
record.properties && typeof record.properties === "object" && !Array.isArray(record.properties)
? (record.properties as Record<string, unknown>)
: undefined;
if (record.type === "object") {
if (record.additionalProperties !== false) {
violations.push(`${path}.additionalProperties`);
}
const required = Array.isArray(record.required)
? record.required.filter((entry): entry is string => typeof entry === "string")
: undefined;
if (!required) {
violations.push(`${path}.required`);
} else if (properties) {
const requiredSet = new Set(required);
for (const key of Object.keys(properties)) {
if (!requiredSet.has(key)) {
violations.push(`${path}.required.${key}`);
}
}
}
}
if (properties) {
for (const [key, value] of Object.entries(properties)) {
violations.push(...findOpenAIStrictSchemaViolations(value, `${path}.properties.${key}`));
}
}
for (const [key, value] of Object.entries(record)) {
if (key === "properties") {
continue;
}
if (value && typeof value === "object") {
violations.push(...findOpenAIStrictSchemaViolations(value, `${path}.${key}`));
}
}
return violations;
}
export function inspectOpenAIToolSchemas(
ctx: ProviderNormalizeToolSchemasContext,
): ProviderToolSchemaDiagnostic[] {
if (!shouldApplyOpenAIToolCompat(ctx)) {
return [];
}
return ctx.tools.flatMap((tool, toolIndex) => {
const violations = findOpenAIStrictSchemaViolations(
normalizeOpenAIStrictCompatSchema(tool.parameters),
`${tool.name}.parameters`,
);
if (violations.length === 0) {
return [];
}
return [{ toolName: tool.name, toolIndex, violations }];
});
}
export type ProviderToolCompatFamily = "gemini" | "openai";
export function buildProviderToolCompatFamilyHooks(family: ProviderToolCompatFamily): {
normalizeToolSchemas: (ctx: ProviderNormalizeToolSchemasContext) => AnyAgentTool[];
@@ -171,6 +375,11 @@ export function buildProviderToolCompatFamilyHooks(family: ProviderToolCompatFam
normalizeToolSchemas: normalizeGeminiToolSchemas,
inspectToolSchemas: inspectGeminiToolSchemas,
};
case "openai":
return {
normalizeToolSchemas: normalizeOpenAIToolSchemas,
inspectToolSchemas: inspectOpenAIToolSchemas,
};
}
throw new Error("Unsupported provider tool compatibility family");
}