mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-14 18:51:04 +00:00
agents: add openai provider-owned tool compat
This commit is contained in:
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user