import { cleanSchemaForGemini, GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, } from "../agents/schema/clean-for-gemini.js"; import type { ModelCompatConfig } from "../config/types.models.js"; import { applyModelCompatPatch } from "../plugins/provider-model-compat.js"; import type { AnyAgentTool, ProviderNormalizeToolSchemasContext, ProviderToolSchemaDiagnostic, } from "./plugin-entry.js"; // Shared provider-tool helpers for plugin-owned schema compatibility rewrites. export { cleanSchemaForGemini, GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS }; export const XAI_TOOL_SCHEMA_PROFILE = "xai"; export const HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING = "html-entities"; export const XAI_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ "minLength", "maxLength", "minItems", "maxItems", "minContains", "maxContains", ]); export function stripUnsupportedSchemaKeywords( schema: unknown, unsupportedKeywords: ReadonlySet, ): unknown { if (!schema || typeof schema !== "object") { return schema; } if (Array.isArray(schema)) { return schema.map((entry) => stripUnsupportedSchemaKeywords(entry, unsupportedKeywords)); } const obj = schema as Record; const cleaned: Record = {}; for (const [key, value] of Object.entries(obj)) { if (unsupportedKeywords.has(key)) { continue; } if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) { cleaned[key] = Object.fromEntries( Object.entries(value as Record).map(([childKey, childValue]) => [ childKey, stripUnsupportedSchemaKeywords(childValue, unsupportedKeywords), ]), ); continue; } if (key === "items" && value && typeof value === "object") { cleaned[key] = Array.isArray(value) ? value.map((entry) => stripUnsupportedSchemaKeywords(entry, unsupportedKeywords)) : stripUnsupportedSchemaKeywords(value, unsupportedKeywords); continue; } if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) { cleaned[key] = value.map((entry) => stripUnsupportedSchemaKeywords(entry, unsupportedKeywords), ); continue; } cleaned[key] = value; } return cleaned; } export function stripXaiUnsupportedKeywords(schema: unknown): unknown { return stripUnsupportedSchemaKeywords(schema, XAI_UNSUPPORTED_SCHEMA_KEYWORDS); } export function resolveXaiModelCompatPatch(): ModelCompatConfig { return { toolSchemaProfile: XAI_TOOL_SCHEMA_PROFILE, unsupportedToolSchemaKeywords: Array.from(XAI_UNSUPPORTED_SCHEMA_KEYWORDS), nativeWebSearchTool: true, toolCallArgumentsEncoding: HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING, }; } export function applyXaiModelCompat(model: T): T { return applyModelCompatPatch( model as T & { compat?: ModelCompatConfig }, resolveXaiModelCompatPatch(), ) as T; } export function findUnsupportedSchemaKeywords( schema: unknown, path: string, unsupportedKeywords: ReadonlySet, ): string[] { if (!schema || typeof schema !== "object") { return []; } if (Array.isArray(schema)) { return schema.flatMap((item, index) => findUnsupportedSchemaKeywords(item, `${path}[${index}]`, unsupportedKeywords), ); } const record = schema as Record; const violations: string[] = []; const properties = record.properties && typeof record.properties === "object" && !Array.isArray(record.properties) ? (record.properties as Record) : undefined; if (properties) { for (const [key, value] of Object.entries(properties)) { violations.push( ...findUnsupportedSchemaKeywords(value, `${path}.properties.${key}`, unsupportedKeywords), ); } } for (const [key, value] of Object.entries(record)) { if (key === "properties") { continue; } if (unsupportedKeywords.has(key)) { violations.push(`${path}.${key}`); } if (value && typeof value === "object") { violations.push( ...findUnsupportedSchemaKeywords(value, `${path}.${key}`, unsupportedKeywords), ); } } return violations; } export function normalizeGeminiToolSchemas( ctx: ProviderNormalizeToolSchemasContext, ): AnyAgentTool[] { return ctx.tools.map((tool) => { if (!tool.parameters || typeof tool.parameters !== "object") { return tool; } return { ...tool, parameters: cleanSchemaForGemini(tool.parameters as Record), }; }); } export function inspectGeminiToolSchemas( ctx: ProviderNormalizeToolSchemasContext, ): ProviderToolSchemaDiagnostic[] { return ctx.tools.flatMap((tool, toolIndex) => { const violations = findUnsupportedSchemaKeywords( tool.parameters, `${tool.name}.parameters`, GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, ); if (violations.length === 0) { return []; } return [{ toolName: tool.name, toolIndex, violations }]; }); } export function normalizeOpenAIToolSchemas( ctx: ProviderNormalizeToolSchemasContext, ): AnyAgentTool[] { if (!shouldApplyOpenAIToolCompat(ctx)) { return ctx.tools; } return ctx.tools.map((tool) => { if (tool.parameters == null) { return { ...tool, parameters: normalizeOpenAIStrictCompatSchema({}), }; } if (typeof tool.parameters !== "object") { return tool; } return { ...tool, parameters: normalizeOpenAIStrictCompatSchema(tool.parameters), }; }); } function normalizeOpenAIStrictCompatSchema(schema: unknown): unknown { return normalizeOpenAIStrictCompatSchemaRecursive(schema, { promoteEmptyObject: true }); } function shouldApplyOpenAIToolCompat(ctx: ProviderNormalizeToolSchemasContext): boolean { const provider = (ctx.model?.provider ?? ctx.provider ?? "").trim().toLowerCase(); const api = (ctx.model?.api ?? ctx.modelApi ?? "").trim().toLowerCase(); const baseUrl = (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); } 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 = {}; for (const [key, value] of Object.entries(schema as Record)) { 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, { promoteEmptyObject: false, }); 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 = 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: {}, 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; changed = true; } return changed ? normalized : schema; } export function findOpenAIStrictSchemaViolations( schema: unknown, path: string, options?: { requireObjectRoot?: boolean }, ): string[] { if (Array.isArray(schema)) { if (options?.requireObjectRoot) { return [`${path}.type`]; } return schema.flatMap((item, index) => findOpenAIStrictSchemaViolations(item, `${path}[${index}]`), ); } if (!schema || typeof schema !== "object") { if (options?.requireObjectRoot) { return [`${path}.type`]; } 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 []; } // Native OpenAI transports fall back to `strict: false` when any tool schema is not // strict-compatible, so these findings are expected for optional-heavy tool schemas. return []; } export type ProviderToolCompatFamily = "gemini" | "openai"; export function buildProviderToolCompatFamilyHooks(family: ProviderToolCompatFamily): { normalizeToolSchemas: (ctx: ProviderNormalizeToolSchemasContext) => AnyAgentTool[]; inspectToolSchemas: (ctx: ProviderNormalizeToolSchemasContext) => ProviderToolSchemaDiagnostic[]; } { switch (family) { case "gemini": return { normalizeToolSchemas: normalizeGeminiToolSchemas, inspectToolSchemas: inspectGeminiToolSchemas, }; case "openai": return { normalizeToolSchemas: normalizeOpenAIToolSchemas, inspectToolSchemas: inspectOpenAIToolSchemas, }; } throw new Error("Unsupported provider tool compatibility family"); }