Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 2d154289a9 fix(ai): keep tool schemas within Meta's limits
Meta rejects tool schemas nested more than 10 levels deep and any recursive
$ref. Add a meta sanitizer that sends schemas within those limits unchanged
and otherwise inlines references, cuts recursive ones, and leaves structure
past the limit unconstrained. Meta routes apply it to every model, and
Muse Spark models get it by name on other hosts.
2026-09-24 00:44:34 -05:00
6 changed files with 265 additions and 4 deletions
@@ -0,0 +1,97 @@
import type { JsonSchema } from "../../schema/index.js"
import { isRecord } from "../../utils/record.js"
// Meta rejects tool schemas nested more than 10 levels deep and any recursive `$ref`. It adds a
// level for every `properties` or `items` key holding an object anywhere in the schema, including
// property names and values such as `default`, and follows `$ref` but ignores unreferenced `$defs`.
// Schemas within those limits are sent unchanged. Otherwise references are inlined, a recursive
// reference becomes an unconstrained schema, and structure past the limit is left unconstrained.
const MAX_DEPTH = 10
const DEFS = new Set(["$defs", "definitions"])
const VALUES = new Set(["const", "default", "enum", "examples"])
// Once `properties` is removed, these would constrain the former named properties too.
const PROPERTY_RULES = new Set(["additionalProperties", "patternProperties"])
// These also see members evaluated by subschemas, which may lose their schemas at the limit.
const UNEVALUATED = new Set(["unevaluatedProperties", "unevaluatedItems"])
const ANNOTATIONS = new Set(["title", "description"])
const cost = (key: string, value: unknown) => ((key === "properties" || key === "items") && isRecord(value) ? 1 : 0)
const resolve = (root: unknown, ref: string) => {
if (ref !== "#" && !ref.startsWith("#/")) return undefined
return ref
.split("/")
.slice(1)
.reduce<unknown>((node, token) => {
const key = token.replaceAll("~1", "/").replaceAll("~0", "~")
if (isRecord(node)) return node[key]
if (Array.isArray(node)) return node[Number(key)]
return undefined
}, root)
}
// Nesting depth as Meta counts it. A recursive `$ref` is infinitely deep.
const depth = (root: unknown, value: unknown, refs: ReadonlyArray<string>): number => {
if (Array.isArray(value)) return value.reduce<number>((max, item) => Math.max(max, depth(root, item, refs)), 0)
if (!isRecord(value)) return 0
const own = Object.entries(value).reduce<number>(
(max, [key, child]) => (DEFS.has(key) ? max : Math.max(max, cost(key, child) + depth(root, child, refs))),
0,
)
if (typeof value.$ref !== "string") return own
if (refs.includes(value.$ref)) return Infinity
return Math.max(own, depth(root, resolve(root, value.$ref), [...refs, value.$ref]))
}
const rewrite = (root: unknown, schema: unknown, level: number, refs: ReadonlyArray<string>): unknown => {
if (Array.isArray(schema)) return schema.map((item) => rewrite(root, item, level, refs))
if (!isRecord(schema)) return schema
if (typeof schema.$ref === "string") {
const inlined = inline(root, schema, schema.$ref, refs)
if (inlined) return rewrite(root, inlined, level, [...refs, schema.$ref])
}
// At the limit a schema cannot hold `properties` or `items`, so it accepts any object or array.
const atLimit = level >= MAX_DEPTH
const properties = isRecord(schema.properties) ? schema.properties : undefined
return Object.fromEntries(
Object.entries(schema).flatMap(([key, value]) => {
if (DEFS.has(key) || (atLimit && cost(key, value) > 0)) return []
if (atLimit && (UNEVALUATED.has(key) || (properties && PROPERTY_RULES.has(key)))) return []
if (VALUES.has(key)) return level + depth(root, value, []) > MAX_DEPTH ? [] : [[key, value]]
if (key === "properties" && properties)
return [
[
key,
Object.fromEntries(
Object.entries(properties).map(([name, child]) => {
// A property named `properties` or `items` is itself a level.
const next = level + 1 + cost(name, child)
return [name, next > MAX_DEPTH ? true : rewrite(root, child, next, refs)]
}),
),
],
]
return [[key, rewrite(root, value, level + cost(key, value), refs)]]
}),
)
}
// A recursive reference becomes an unconstrained schema. Sibling keywords apply alongside the target,
// so they merge into it unless they would replace one of its constraints.
const inline = (root: unknown, schema: Record<string, unknown>, ref: string, refs: ReadonlyArray<string>) => {
const target = refs.includes(ref) ? {} : resolve(root, ref)
if (target === undefined) return undefined
const base = isRecord(target) ? target : {}
const siblings = Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "$ref"))
if (Object.keys(siblings).some((key) => Object.hasOwn(base, key) && !ANNOTATIONS.has(key)))
return { allOf: [base, siblings] }
return { ...base, ...siblings }
}
export const normalize = (schema: JsonSchema): JsonSchema => {
if (depth(schema, schema, []) <= MAX_DEPTH) return schema
const result = rewrite(schema, schema, 0, [])
return isRecord(result) ? result : {}
}
export * as MetaJsonSchema from "./meta-json-schema.js"
@@ -1,6 +1,7 @@
import type { JsonSchema, LanguageModel, LanguageModelSanitizerCompatibility } from "../../schema/index.js"
import { isRecord } from "../../utils/record.js"
import { GeminiJsonSchema } from "./gemini-json-schema.js"
import { MetaJsonSchema } from "./meta-json-schema.js"
const tupleItemsSchema = (items: ReadonlyArray<unknown>) => {
const projected = items.map(moonshotNode)
@@ -47,10 +48,12 @@ const openAI = (schema: JsonSchema): JsonSchema => schema
const responses = openAI
const gemini = GeminiJsonSchema.normalize
const meta = MetaJsonSchema.normalize
const MODEL_NAMES = [
[/gemini/i, "gemini"],
[/kimi/i, "moonshot"],
[/muse-spark/i, "meta"],
] as const
// An explicit `sanitizer` wins, and `none` opts out. Otherwise the protocol's own default
@@ -64,6 +67,8 @@ const modelCompatibility = (
switch (model.compatibility?.sanitizer ?? protocolDefault ?? MODEL_NAMES.find(([name]) => name.test(model.id))?.[1]) {
case "gemini":
return gemini(schema)
case "meta":
return meta(schema)
case "moonshot":
return moonshot(schema)
case "none":
@@ -74,6 +79,7 @@ const modelCompatibility = (
export const ToolSchemaProjection = {
gemini,
meta,
modelCompatibility,
moonshot,
openAI,
+8 -3
View File
@@ -128,16 +128,21 @@ export const configure = (input: LanguageModelOptions = {}) => {
const configuredChat = chatRoute.with(options)
const configuredMessages = messagesRoute.with(options)
const responses = (modelID: string | ModelID) =>
configuredResponses.model<OpenResponsesProviderOptionsInput>({ id: modelID })
configuredResponses.model<OpenResponsesProviderOptionsInput>({ id: modelID, compatibility: { sanitizer: "meta" } })
const chat = (modelID: string | ModelID) =>
configuredChat.model<OpenResponsesProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_completion_tokens", supportsStore: false, supportsPromptCacheKey: true },
compatibility: {
maxTokensField: "max_completion_tokens",
supportsStore: false,
supportsPromptCacheKey: true,
sanitizer: "meta",
},
})
const messages = (modelID: string | ModelID) =>
configuredMessages.model<MessagesOptionsInput>({
id: modelID,
compatibility: { requireSignature: false },
compatibility: { requireSignature: false, sanitizer: "meta" },
})
const image = (modelID: string | ModelID) =>
MetaImages.model({
+1 -1
View File
@@ -153,7 +153,7 @@ export const ReasoningEffort = Schema.declare<ReasoningEffort>(
)
/** Tool schema sanitizer for a model family. `none` opts out of the protocol and model-name defaults. */
export const LanguageModelSanitizerCompatibility = Schema.Literals(["gemini", "moonshot", "none"])
export const LanguageModelSanitizerCompatibility = Schema.Literals(["gemini", "meta", "moonshot", "none"])
export type LanguageModelSanitizerCompatibility = Schema.Schema.Type<typeof LanguageModelSanitizerCompatibility>
export const LanguageModelMaxTokensFieldCompatibility = Schema.Literals(["max_completion_tokens", "max_tokens"])
+20
View File
@@ -117,6 +117,26 @@ it.effect("Meta package selectors preserve overrides and Chat token policy on cu
}),
)
it.effect("Meta routes apply Meta tool schema limits to every model", () =>
Effect.gen(function* () {
const meta = Meta.configure({ apiKey: "fixture" })
const tools = [
{
name: "lookup",
description: "Lookup data.",
inputSchema: { type: "object", properties: { child: { $ref: "#" } } },
},
]
const expected = { type: "object", properties: { child: { type: "object", properties: { child: {} } } } }
const responses = yield* compileRequest(LLM.request({ model: meta.responses("sam-3.1"), prompt: "Hello", tools }))
const chat = yield* compileRequest(LLM.request({ model: meta.chat("sam-3.1"), prompt: "Hello", tools }))
const messages = yield* compileRequest(LLM.request({ model: meta.messages("sam-3.1"), prompt: "Hello", tools }))
expect(responses.body.tools?.[0]?.parameters).toEqual(expected)
expect(chat.body.tools?.[0]?.function.parameters).toEqual(expected)
expect(messages.body.tools?.[0]?.input_schema).toEqual(expected)
}),
)
it.effect("Meta resolves environment credentials and accepts explicit auth overrides", () =>
Effect.gen(function* () {
for (const api of ["responses", "chat"] as const) {
@@ -5,8 +5,12 @@ import { OpenAIChat } from "../src/protocols.js"
import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema.js"
import { Auth } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import type { JsonSchema } from "../src/schema/index.js"
import { it } from "./lib/effect.js"
const nested = (levels: number, leaf: JsonSchema) =>
Array.from({ length: levels }).reduce<JsonSchema>((schema) => ({ type: "object", properties: { a: schema } }), leaf)
describe("tool schema projections", () => {
test("moonshot strips $ref siblings and converts tuple arrays to a schema object", () => {
expect(
@@ -60,6 +64,108 @@ describe("tool schema projections", () => {
})
})
test("meta counts levels through references and sends schemas within its limits unchanged", () => {
const within = {
type: "object",
properties: { a: { $ref: "#/$defs/A" } },
$defs: { A: nested(9, { type: "string" }) },
}
expect(ToolSchemaProjection.meta(within)).toBe(within)
expect(ToolSchemaProjection.meta({ ...within, $defs: { A: nested(10, { type: "string" }) } })).toEqual({
type: "object",
properties: { a: nested(9, { type: "object" }) },
})
})
test("meta leaves structure past 10 levels unconstrained", () => {
expect(
ToolSchemaProjection.meta(
nested(9, {
type: "object",
properties: {
deep: {
type: "object",
description: "Deepest object",
required: ["x"],
properties: { x: { type: "string" } },
additionalProperties: false,
},
list: { type: "array", items: { type: "string" }, unevaluatedItems: false },
items: { type: "string" },
config: { type: "object", default: { items: {} }, examples: [{ a: 1 }] },
mode: { type: "string", enum: ["fast"] },
},
}),
),
).toEqual(
nested(9, {
type: "object",
properties: {
deep: { type: "object", description: "Deepest object", required: ["x"] },
list: { type: "array" },
items: true,
config: { type: "object", examples: [{ a: 1 }] },
mode: { type: "string", enum: ["fast"] },
},
}),
)
})
test("meta inlines references and cuts recursion", () => {
const tree = { type: "object", properties: { children: { type: "array", items: {} } } }
expect(
ToolSchemaProjection.meta({
type: "object",
properties: {
value: { type: "string" },
child: { $ref: "#", description: "Nested node" },
tree: { $ref: "#/$defs/Tree" },
},
$defs: {
Tree: { type: "object", properties: { children: { type: "array", items: { $ref: "#/$defs/Tree" } } } },
},
}),
).toEqual({
type: "object",
properties: {
value: { type: "string" },
child: {
type: "object",
description: "Nested node",
properties: { value: { type: "string" }, child: { description: "Nested node" }, tree },
},
tree,
},
})
})
test("meta keeps $ref siblings alongside their target", () => {
const base = {
type: "object",
description: "Base",
properties: { id: { type: "string" } },
additionalProperties: false,
}
expect(
ToolSchemaProjection.meta({
type: "object",
properties: {
deep: nested(10, { type: "string" }),
described: { $ref: "#/$defs/Base", description: "Field" },
extended: { $ref: "#/$defs/Base", properties: { extra: { type: "string" } } },
},
$defs: { Base: base },
}),
).toEqual({
type: "object",
properties: {
deep: nested(9, { type: "object" }),
described: { ...base, description: "Field" },
extended: { allOf: [base, { properties: { extra: { type: "string" } } }] },
},
})
})
it.effect("selects tool schema handling from the model name unless compatibility is explicit", () =>
Effect.gen(function* () {
const route = OpenAIChat.route.with({
@@ -100,6 +206,33 @@ describe("tool schema projections", () => {
}),
)
it.effect("selects Meta tool schema handling for Muse Spark models", () =>
Effect.gen(function* () {
const route = OpenAIChat.route.with({
endpoint: { baseURL: "https://api.openai.test/v1/" },
auth: Auth.bearer("test"),
})
const original = { type: "object", properties: { child: { $ref: "#" } } }
const parameters = (model: ReturnType<typeof route.model>) =>
compileRequest(
LLM.request({
model,
prompt: "Use the tool.",
tools: [{ name: "lookup", description: "Lookup data.", inputSchema: original }],
}),
).pipe(Effect.map((prepared) => prepared.body.tools?.[0]?.function.parameters))
expect(yield* parameters(route.model({ id: "meta/Muse-Spark-1.3" }))).toEqual({
type: "object",
properties: { child: { type: "object", properties: { child: {} } } },
})
expect(yield* parameters(route.model({ id: "meta/muse-glimmer-30b" }))).toEqual(original)
expect(
yield* parameters(route.model({ id: "meta/muse-spark-1.3", compatibility: { sanitizer: "none" } })),
).toEqual(original)
}),
)
it.effect("applies model compatibility without changing schema semantics", () =>
Effect.gen(function* () {
const model = OpenAIChat.route