diff --git a/src/adapters/openai-chat/tool-schema.ts b/src/adapters/openai-chat/tool-schema.ts index 23f77121bd5..7362e058eb5 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,6 +384,16 @@ 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); @@ -400,10 +452,15 @@ function normalizeMoonshotSchemaNode( 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 +477,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..73d2d488ee2 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -308,10 +308,13 @@ 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, and a reference that would exceed it +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..3c1372ffabf 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