diff --git a/extensions/openai/index.test.ts b/extensions/openai/index.test.ts index 91f74867882..495b040ea4c 100644 --- a/extensions/openai/index.test.ts +++ b/extensions/openai/index.test.ts @@ -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" }, diff --git a/extensions/openai/index.ts b/extensions/openai/index.ts index cfaa2ae3d69..822bb0bbedb 100644 --- a/extensions/openai/index.ts +++ b/extensions/openai/index.ts @@ -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 = >( provider: T, ): T => ({ ...provider, + ...openAIToolCompatHooks, resolveSystemPromptContribution: (ctx) => resolveOpenAISystemPromptContribution({ mode: promptOverlayMode, diff --git a/src/plugin-sdk/provider-tools.test.ts b/src/plugin-sdk/provider-tools.test.ts index 557f23f3b65..153713f87ce 100644 --- a/src/plugin-sdk/provider-tools.test.ts +++ b/src/plugin-sdk/provider-tools.test.ts @@ -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(); diff --git a/src/plugin-sdk/provider-tools.ts b/src/plugin-sdk/provider-tools.ts index ae828a059d3..fec86fca99f 100644 --- a/src/plugin-sdk/provider-tools.ts +++ b/src/plugin-sdk/provider-tools.ts @@ -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; + let changed = false; + const normalized: Record = {}; + 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).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; + 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) + : 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"); }