Files
OmniRoute/open-sse/services/toolSchemaSanitizer.ts
NMI 27b822e412 fix(tools): keep opaque object schemas open (#3097)
* fix(tools): keep opaque object schemas open

* test: align opaque-object-schema expectations with additionalProperties:true

The opaque-schema fix intentionally injects additionalProperties:true on empty
object schemas (incl. the web_search passthrough shim and null/missing parameter
fallbacks). Update the pre-fix snapshot assertions to match the new behavior.

---------

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-06-03 18:29:31 -03:00

143 lines
5.5 KiB
TypeScript

/**
* Sanitize OpenAI-format tool definitions for strict upstream JSON Schema
* validators (e.g. Moonshot AI behind opencode-go/kimi-k2.6).
*
* The concrete bug this was written for: ForgeCode emits enum schemas like
* { type: "string", enum: ["a", "b", "c", null], nullable: true }
* for nullable optional fields. Lenient providers (Z.AI / GLM) accept the null
* entry; Moonshot rejects with
* "At path 'properties.X.enum': enum value (<nil>) does not match any type
* in [string]"
* before the request reaches the model.
*
* The fix is to strip null/undefined from `enum` arrays. Everything else here
* is defensive hygiene: ensures `parameters` is always a valid object schema,
* filters `required[]` to keys that exist in `properties`, and normalizes a
* few other shapes that strict validators tend to reject.
*/
const MAX_RECURSION_DEPTH = 32;
function isPlainObject(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === "object" && !Array.isArray(v);
}
function hasOwn(obj: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(obj, key);
}
function keepOpaqueObjectSchemasOpen(schema: Record<string, unknown>): void {
const explicitAdditionalProperties = hasOwn(schema, "additionalProperties");
if (explicitAdditionalProperties) return;
const properties = schema.properties;
const isObjectSchema = schema.type === "object" || isPlainObject(properties);
if (!isObjectSchema) return;
if (properties === undefined) {
schema.properties = {};
schema.additionalProperties = true;
} else if (isPlainObject(properties) && Object.keys(properties).length === 0) {
schema.additionalProperties = true;
}
}
function sanitizeSchema(value: unknown, depth = 0): Record<string, unknown> {
if (depth > MAX_RECURSION_DEPTH) return {};
if (!isPlainObject(value)) return {};
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
if (v === null || v === undefined) continue;
if (k === "properties" && isPlainObject(v)) {
const cleaned: Record<string, unknown> = {};
for (const [pk, pv] of Object.entries(v)) {
if (isPlainObject(pv)) {
cleaned[pk] = sanitizeSchema(pv, depth + 1);
} else if (typeof pv === "boolean") {
// JSON Schema 2019 boolean form: `true` (anything) / `false` (nothing).
// Preserve as-is; Moonshot accepts these.
cleaned[pk] = pv;
} else {
cleaned[pk] = {};
}
}
result[k] = cleaned;
} else if (k === "items") {
// Recurse into items if it's a single schema. Tuple-form (array) is
// valid JSON Schema but rejected by Moonshot; coerce to single schema.
if (Array.isArray(v)) {
const firstObject = v.find(isPlainObject);
result[k] = firstObject ? sanitizeSchema(firstObject, depth + 1) : {};
} else if (isPlainObject(v)) {
result[k] = sanitizeSchema(v, depth + 1);
}
} else if (k === "anyOf" || k === "oneOf" || k === "allOf") {
// Moonshot recursively validates inside `anyOf` (confirmed empirically),
// so we must descend to strip null-in-enum etc. `oneOf`/`allOf` aren't
// currently validated by Moonshot but are recursed for symmetry/defense.
if (Array.isArray(v)) {
result[k] = v.map((s) => (isPlainObject(s) ? sanitizeSchema(s, depth + 1) : {}));
}
} else if (k === "additionalProperties") {
// Moonshot recursively validates the schema form of additionalProperties
// (confirmed empirically). The boolean form is also valid JSON Schema.
if (isPlainObject(v)) {
result[k] = sanitizeSchema(v, depth + 1);
} else if (typeof v === "boolean") {
result[k] = v;
}
} else if (k === "enum" && Array.isArray(v)) {
// The actual fix: strip null/undefined entries that ForgeCode adds for
// nullable optional fields.
result[k] = v.filter((e) => e !== null && e !== undefined);
} else if (k === "required" && Array.isArray(v)) {
result[k] = v.filter((r) => typeof r === "string");
} else {
result[k] = v;
}
}
if (Array.isArray(result.required) && isPlainObject(result.properties)) {
const validKeys = new Set(Object.keys(result.properties));
result.required = (result.required as string[]).filter((r) => validKeys.has(r));
}
keepOpaqueObjectSchemasOpen(result);
return result;
}
function normalizeParameters(parameters: unknown): unknown {
if (isPlainObject(parameters)) return sanitizeSchema(parameters);
if (parameters === null || parameters === undefined) {
return { type: "object", properties: {}, additionalProperties: true };
}
return { type: "object", properties: {}, additionalProperties: true };
}
export function sanitizeOpenAITool(tool: unknown): unknown {
if (!isPlainObject(tool)) return tool;
const t = { ...tool };
if (isPlainObject(t.function)) {
// Chat Completions format: { type: "function", function: { name, parameters } }
const f = { ...t.function };
f.parameters = normalizeParameters(f.parameters);
t.function = f;
} else if (t.type === "function") {
// Responses API format: { type: "function", name, parameters } — no `function`
// wrapper. /v1/responses requests reach chatCore in this shape and are only
// unwrapped later by the request translator, so we have to sanitize here too.
t.parameters = normalizeParameters(t.parameters);
}
return t;
}
export function sanitizeOpenAITools(tools: unknown[]): unknown[] {
return tools.map(sanitizeOpenAITool);
}