diff --git a/src/adapters/openai-chat/tool-schema.ts b/src/adapters/openai-chat/tool-schema.ts index 23f77121bd5..4e3d1834a07 100644 --- a/src/adapters/openai-chat/tool-schema.ts +++ b/src/adapters/openai-chat/tool-schema.ts @@ -198,6 +198,37 @@ const MOONSHOT_MAX_REF_EXPANSIONS = 512; */ const MOONSHOT_MAX_SCHEMA_DEPTH = 64; const MOONSHOT_MAX_SCHEMA_NODES = 4_096; +const MOONSHOT_MAX_INLINED_SCHEMA_BYTES = 1024 * 1024; + +/** + * Measure only as far as the caller's remaining allowance. Keeping this iterative avoids + * reintroducing the deep-schema stack exhaustion that the normalizer's depth limit prevents. + */ +function serializedJsonBytesUpTo(value: unknown, limit: number): number { + const encoder = new TextEncoder(); + const pending: unknown[] = [value]; + let bytes = 0; + while (pending.length > 0 && bytes <= limit) { + const item = pending.pop(); + if (Array.isArray(item)) { + bytes += 2 + Math.max(0, item.length - 1); + for (const child of item) pending.push(child); + continue; + } + if (isXaiObjectSchema(item)) { + const entries = Object.entries(item); + bytes += 2 + Math.max(0, entries.length - 1); + for (const [key, child] of entries) { + bytes += encoder.encode(JSON.stringify(key)).byteLength + 1; + pending.push(child); + } + continue; + } + const encoded = JSON.stringify(item); + bytes += encoder.encode(encoded === undefined ? "null" : encoded).byteLength; + } + return bytes; +} /** * Assertion keywords whose meaning under a `$ref` is CONJUNCTION, not replacement. A node @@ -309,8 +340,19 @@ function composeProperties( return combined; } +/** + * The inline-byte allowance for one request. Sharing it across tools matters: a per-tool + * budget would let a large catalog multiply the cap by its tool count, reintroducing the + * request amplification this bound exists to prevent. + */ +interface MoonshotInlineByteBudget { + remaining: number; +} + interface MoonshotNormalizeState { activeRefs: Set; + inlineSizeCache: WeakMap, number>; + inlineByteBudget: MoonshotInlineByteBudget; remainingExpansions: number; remainingNodes: number; } @@ -342,10 +384,28 @@ function normalizeMoonshotSchemaNode( const target = lookupLocalJsonPointer(root, ref); if (isXaiObjectSchema(target)) { + // Charge the referenced value before copying it. Object/node counts do not cover large + // maps of boolean schemas, which otherwise allow a small input to create hundreds of + // full copies before the final request is serialized. + let inlineBytes = state.inlineSizeCache.get(target); + if (inlineBytes === undefined) { + inlineBytes = serializedJsonBytesUpTo(target, MOONSHOT_MAX_INLINED_SCHEMA_BYTES); + state.inlineSizeCache.set(target, inlineBytes); + } + if (inlineBytes > state.inlineByteBudget.remaining) return { $ref: ref }; + state.inlineByteBudget.remaining -= inlineBytes; state.remainingExpansions -= 1; state.activeRefs.add(ref); const resolvedTarget = normalizeMoonshotSchemaNode(target, root, state, depth + 1); state.activeRefs.delete(ref); + // Type inference and nested normalization can enlarge the raw target we reserved. + // Charge that growth before retaining the copy; nested expansions share this allowance. + const normalizedBytes = serializedJsonBytesUpTo( + resolvedTarget, inlineBytes + state.inlineByteBudget.remaining, + ); + const growthBytes = Math.max(0, normalizedBytes - inlineBytes); + if (growthBytes > state.inlineByteBudget.remaining) return { $ref: ref }; + state.inlineByteBudget.remaining -= growthBytes; const merged: Record = Object.create(null) as Record; if (isXaiObjectSchema(resolvedTarget)) { for (const [key, value] of Object.entries(resolvedTarget)) merged[key] = value; @@ -380,6 +440,20 @@ function normalizeMoonshotSchemaNode( } merged[key] = normalized; } + + // Re-normalize only composed properties that retain a $ref alongside sibling keywords + if (isXaiObjectSchema(merged.properties)) { + for (const [propName, propVal] of Object.entries(merged.properties as Record)) { + if (isXaiObjectSchema(propVal) && typeof propVal.$ref === "string" && moonshotRefTargetKeys(propVal).length > 0) { + (merged.properties as Record)[propName] = normalizeMoonshotSchemaNode( + propVal, + root, + state, + depth + 1, + ); + } + } + } return merged; } @@ -397,13 +471,49 @@ function normalizeMoonshotSchemaNode( ? value : normalizeMoonshotSchemaNode(value, root, state, depth + 1); } + + // Moonshot MFJS requirements: + // 1. Stamp "object" if properties are present, or if allOf defines object properties/variants, + // so Moonshot's validator recognizes the schema as a valid termination condition. + // 2. Infer scalar types for bare const and enum keywords. + if (out.type === undefined) { + const isObjectAllOf = Array.isArray(out.allOf) && out.allOf.some( + variant => isXaiObjectSchema(variant) && ( + variant.type === "object" || + variant.properties !== undefined || + variant.additionalProperties !== undefined + ), + ); + if (out.properties !== undefined || out.additionalProperties !== undefined || isObjectAllOf) { + out.type = "object"; + } else if (out.const !== undefined) { + const t = typeof out.const; + if (t === "string" || t === "number" || t === "boolean") { + out.type = t; + } + } else if (Array.isArray(out.enum) && out.enum.length > 0) { + if (out.enum.every(x => typeof x === "string")) { + out.type = "string"; + } else if (out.enum.every(x => typeof x === "number")) { + out.type = "number"; + } else if (out.enum.every(x => typeof x === "boolean")) { + out.type = "boolean"; + } + } + } + return out; } -function normalizeMoonshotToolParameters(parameters: unknown): Record { +function normalizeMoonshotToolParameters( + parameters: unknown, + inlineByteBudget: MoonshotInlineByteBudget, +): Record { const rooted = ensureRootObjectType(parameters); const normalized = normalizeMoonshotSchemaNode(rooted, rooted, { activeRefs: new Set(), + inlineSizeCache: new WeakMap, number>(), + inlineByteBudget, remainingExpansions: MOONSHOT_MAX_REF_EXPANSIONS, remainingNodes: MOONSHOT_MAX_SCHEMA_NODES, }); @@ -420,11 +530,14 @@ export function toolsToChatFormat( if (tools.length === 0) return undefined; const xaiTarget = isXaiSchemaTarget(provider); const moonshotTarget = !xaiTarget && isMoonshotSchemaTarget(provider); + const moonshotInlineByteBudget: MoonshotInlineByteBudget = { + remaining: MOONSHOT_MAX_INLINED_SCHEMA_BYTES, + }; const formatted = tools.flatMap(t => { const normalized = xaiTarget ? normalizeXaiToolParameters(t.parameters) : moonshotTarget - ? normalizeMoonshotToolParameters(t.parameters) + ? normalizeMoonshotToolParameters(t.parameters, moonshotInlineByteBudget) : ensureRootObjectType(t.parameters); const parameters = stripUnicodePropertyPatterns(stripResponsesOnlyEncryptedMarker(normalized)); diff --git a/structure/decisions/ADR-0355-chat-structured-output-compatibility.md b/structure/decisions/ADR-0355-chat-structured-output-compatibility.md new file mode 100644 index 00000000000..661d7457c45 --- /dev/null +++ b/structure/decisions/ADR-0355-chat-structured-output-compatibility.md @@ -0,0 +1,27 @@ +# ADR-0355 — decision recorded under "Chat structured-output compatibility" + +- Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#chat-structured-output-compatibility) + +## Decision record + +- 목적과 의도: Bound the request amplification a Moonshot `$ref` inlining can produce without + weakening the tool schema beyond what the wire forces. +- 기존 구현 및 제약 조건: The normalizer walks depth-, node-, and expansion-bounded, but a small + input can name a large boolean `properties` map from many nodes, so each bound can pass while + the serialized output still repeats the map hundreds of times. The adapter sits on the request + path, so amplification is user-facing latency and payload size. +- 검토한 주요 대안: (1) Keep only the three existing budgets. (2) Measure the final serialized + request and reject it over a size cap. (3) Charge each inlined target its serialized JSON bytes + against a shared byte budget before copying it. +- 선택한 방식: (3). Each expansion measures the referenced schema's serialized size once per + target object, charges it against one 1 MiB allowance shared by every tool in the request, + and a reference that would exceed the remaining allowance stays a bare `$ref`. +- 다른 대안 대신 이 방식을 선택한 이유: (1) leaves the demonstrated amplification reachable — + node and expansion counts stay small while output grows without bound. (2) detects the blow-up + only after the bytes were already produced, and a whole-request rejection discards a schema + Moonshot would have accepted in partially inlined form. +- 장점, 단점 및 영향: Output size is bounded independently of how the reference graph is shaped, + and over-budget nodes degrade to the same bare-`$ref` fallback the other budgets already use. + Measuring is iterative and capped at the remaining allowance, so the guard itself cannot + reintroduce the deep-schema stack exhaustion the depth budget prevents. Moonshot 계열 + `openai-chat` baseUrl에만 적용되고 다른 provider는 손대지 않는다. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 1c7465bde6e..7ec0ef7ba66 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -308,10 +308,15 @@ First-party Kimi and Moonshot Chat destinations normalize a `$ref` with sibling their wire rejects that valid JSON Schema 2020-12 shape. Inlining preserves conjunction semantics: `required` members are unioned, lower numeric bounds take the maximum, upper numeric bounds take the minimum, and overlapping `properties` recurse with the same rules. The walk remains depth-, node-, -and expansion-bounded. Unresolvable or cyclic references keep the existing bare-`$ref` fallback, -and unrelated OpenAI-compatible providers retain the caller's schema unchanged. +expansion-, and inline-byte-bounded: each inlined reference is charged its serialized size against +one 1 MiB allowance shared by every tool in the request. Raw target bytes are reserved before +normalization; inferred types and nested normalization must also fit the remaining allowance +before their copy is retained. An over-budget reference keeps the existing bare-`$ref` fallback. +Unresolvable or cyclic references do the same, and unrelated OpenAI-compatible providers +retain the caller's schema unchanged. > Decision record: [ADR-0064](../decisions/ADR-0064-chat-structured-output-compatibility.md) +> Decision record: [ADR-0355](../decisions/ADR-0355-chat-structured-output-compatibility.md) The `openai-chat` adapter translates Responses `text.format` and Chat Completions `response_format` through one internal format, then emits `response_format` on the upstream chat diff --git a/tests/providers/moonshot-tool-schema.test.ts b/tests/providers/moonshot-tool-schema.test.ts index ed1626927ad..f8b257484da 100644 --- a/tests/providers/moonshot-tool-schema.test.ts +++ b/tests/providers/moonshot-tool-schema.test.ts @@ -7,12 +7,12 @@ const createOpenAIChatAdapter = ( ...args: Parameters ) => withTestTranslatorBudget(createOpenAIChatAdapterProduction(...args)); -function parsedRequest(tool: OcxTool): OcxParsedRequest { +function parsedRequest(tool: OcxTool | OcxTool[]): OcxParsedRequest { return { modelId: "k3", context: { messages: [{ role: "user", content: "run the tool", timestamp: 0 }], - tools: [tool], + tools: [tool].flat(), }, stream: true, options: {}, @@ -185,7 +185,6 @@ describe("Moonshot tool schema normalization (issue #2673)", () => { expect(properties.value).toEqual({ $ref: "https://example.com/schema.json#/Thing" }); }); - test("composes duplicate required, properties, and same-key assertions", async () => { // The reviewer's first blocker. `$ref` under 2020-12 is an in-place applicator: the // node and its target BOTH apply. Overwriting made a tool that required `a` and `b` @@ -305,6 +304,77 @@ describe("Moonshot tool schema normalization (issue #2673)", () => { expect(siblingRefPaths(parameters)).toEqual([]); }); + test("bounds repeated large property-map inlining by serialized bytes", async () => { + const bigProperties = Object.fromEntries( + Array.from({ length: 10_000 }, (_, index) => [`property_${index}`, true]), + ); + const references = Object.fromEntries( + Array.from({ length: 64 }, (_, index) => [ + `value_${index}`, + { $ref: "#/$defs/Big", properties: { sibling: { type: "string" } } }, + ]), + ); + const tool: OcxTool = { + name: "bounded_amplification_tool", + parameters: { + type: "object", + $defs: { Big: { type: "object", properties: bigProperties } }, + properties: references, + }, + }; + + const request = await adapterFor("https://api.moonshot.ai/v1").buildRequest(parsedRequest(tool)); + const inputBytes = new TextEncoder().encode(JSON.stringify(tool.parameters)).byteLength; + const outputBytes = new TextEncoder().encode(request.body).byteLength; + const parameters = JSON.parse(request.body).tools[0].function.parameters as Record; + + // The original definition remains available, but repeated sibling refs stop inlining once + // their cumulative serialized cost reaches the fixed allowance. + expect(outputBytes).toBeLessThan(inputBytes + 2 * 1024 * 1024); + expect(siblingRefPaths(parameters)).toEqual([]); + const emitted = parameters.properties as Record>; + expect(Object.values(emitted).some(value => Object.keys(value).length === 1 && "$ref" in value)).toBe(true); + }); + + test("shares the inline-byte budget across the tools of one request", async () => { + // A per-tool allowance would multiply the cap by the catalog size: the second tool + // must spend what the first already charged. + const bigTool = (name: string): OcxTool => ({ + name, + parameters: { + type: "object", + $defs: { + Big: { + type: "object", + properties: Object.fromEntries( + Array.from({ length: 30_000 }, (_, index) => [`property_${index}`, true]), + ), + }, + }, + properties: { + a: { $ref: "#/$defs/Big", properties: { s: { type: "string" } } }, + b: { $ref: "#/$defs/Big", properties: { s: { type: "string" } } }, + }, + }, + }); + + const request = await adapterFor("https://api.moonshot.ai/v1").buildRequest( + parsedRequest([bigTool("first_tool"), bigTool("second_tool")]), + ); + const tools = (JSON.parse(request.body) as { + tools: { function: { parameters: { properties: Record> } } }[]; + }).tools; + const bareRefCount = (tool: (typeof tools)[number]) => + Object.values(tool.function.parameters.properties).filter( + value => Object.keys(value).length === 1 && "$ref" in value, + ).length; + + // Each inline costs ~0.6 MB of the shared 1 MiB allowance, so only the first of the + // four sibling refs fits; the rest degrade to the bare-$ref fallback. + expect(bareRefCount(tools[0])).toBe(1); + expect(bareRefCount(tools[1])).toBe(2); + }); + test("composes a property that both the target and the node define", async () => { // The same conjunction problem `required` had, one level down. Letting the sibling @@ -325,6 +395,42 @@ describe("Moonshot tool schema normalization (issue #2673)", () => { expect(shared.type).toBe("string"); }); + test("counts type-inference growth before retaining an inlined target", async () => { + const target = { + properties: Object.fromEntries(Array.from({ length: 1_000 }, (_, index) => [`p${index}`, { const: "v" }])), + description: "", + }; + target.description = "x".repeat(1024 * 1024 - JSON.stringify(target).length - 100); + const parameters = await emittedParameters("https://api.kimi.com/coding/v1", { + name: "inferred_byte_growth", + parameters: { type: "object", $defs: { Big: target }, properties: { value: { $ref: "#/$defs/Big", required: ["p0"] } } }, + }); + const value = (parameters!.properties as Record>).value; + // Raw target bytes fit, but the inferred types push the copied target over 1 MiB. + expect(Object.keys(value)).toEqual(["$ref"]); + expect(value.$ref).toBe("#/$defs/Big"); + const definition = (parameters!.$defs as Record).Big; + expect(definition.properties.p0).toMatchObject({ const: "v", type: "string" }); + }); + + test("composed-property re-normalization spends the shared catalog byte allowance", async () => { + const bigProperties = Object.fromEntries(Array.from({ length: 30_000 }, (_, index) => [`property_${index}`, true])); + const tool = (name: string): OcxTool => ({ name, parameters: { + type: "object", + $defs: { Big: { type: "object", properties: bigProperties }, Base: { type: "object", properties: { child: { $ref: "#/$defs/Big" } } } }, + properties: { value: { $ref: "#/$defs/Base", properties: { child: { properties: { sibling: { type: "string" } } } } } }, + } }); + const request = await adapterFor("https://api.kimi.com/coding/v1").buildRequest(parsedRequest([tool("first"), tool("second")])); + const emitted = JSON.parse(request.body).tools; + const first = emitted[0].function.parameters.properties.value.properties.child; + const second = emitted[1].function.parameters.properties.value.properties.child; + expect(first.properties.sibling).toEqual({ type: "string" }); + expect(first.properties.property_0).toBe(true); + expect(Object.keys(second)).toEqual(["$ref"]); + expect(second.$ref).toBe("#/$defs/Big"); + expect(siblingRefPaths(emitted)).toEqual([]); + }); + test("intersects bounds when both sides define the same property", async () => { const parameters = await emittedParameters("https://api.moonshot.ai/v1", { name: "shared_property_bounds_tool", @@ -447,4 +553,70 @@ describe("Moonshot tool schema normalization (issue #2673)", () => { expect(parameters?.$defs).toEqual(CODEX_STYLE_SCHEMA.$defs as Record); expect(siblingRefPaths(parameters).length).toBeGreaterThan(0); }); + + test("infers object type for allOf/properties and scalar types for const/enum", async () => { + const parameters = await emittedParameters("https://api.kimi.com/coding/v1", { + name: "inference_tool", + parameters: { + type: "object", + properties: { + leaf: { + allOf: [{ properties: { id: { type: "integer" } } }], + }, + status: { const: "ACTIVE" }, + count: { const: 42 }, + flag: { const: true }, + color: { enum: ["red", "blue"] }, + toggle: { enum: [true, false] }, + stringAllOf: { allOf: [{ type: "string" }, { minLength: 1 }] }, + }, + }, + }); + + const props = parameters?.properties as Record>; + expect(props.leaf.type).toBe("object"); + expect(props.status.type).toBe("string"); + expect(props.count.type).toBe("number"); + expect(props.flag.type).toBe("boolean"); + expect(props.color.type).toBe("string"); + expect(props.toggle.type).toBe("boolean"); + expect(props.stringAllOf.type).toBeUndefined(); + }); + + test("re-normalizes composed properties when sibling narrows a referenced property", async () => { + // When Base defines `op: { $ref: "#/$defs/Op" }` and a sibling node narrows it with + // `properties: { op: { const: "AND" } }`, `composeProperties` merges them. The merged + // property must re-normalize rather than emitting a `$ref` beside `const`. + const parameters = await emittedParameters("https://api.kimi.com/coding/v1", { + name: "ast_tool", + parameters: { + type: "object", + $defs: { + Op: { type: "string", enum: ["AND", "OR"] }, + Base: { + type: "object", + properties: { + op: { $ref: "#/$defs/Op" }, + left: { type: "string" }, + }, + }, + }, + properties: { + andNode: { + $ref: "#/$defs/Base", + properties: { + op: { const: "AND" }, + }, + }, + }, + }, + }); + + expect(siblingRefPaths(parameters)).toEqual([]); + const andNode = (parameters?.properties as Record>).andNode; + const op = (andNode.properties as Record>).op; + expect(op.$ref).toBeUndefined(); + expect(op.const).toBe("AND"); + expect(op.type).toBe("string"); + }); });