From ab7f684bfcd088c06607d2d551a7ed0f34f75a26 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Fri, 28 Aug 2026 13:10:26 +0900 Subject: [PATCH] fix(openai-chat): bound Moonshot schema inlining bytes --- src/adapters/openai-chat.ts | 45 ++++++++++++++++++++++++++++++ structure/10_adapter-registry.md | 7 +++-- tests/moonshot-tool-schema.test.ts | 33 +++++++++++++++++++++- 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 9add6b8a8c..cbb0924466 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1040,6 +1040,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 @@ -1139,7 +1170,9 @@ function composeProperties( interface MoonshotNormalizeState { activeRefs: Set; + inlineSizeCache: WeakMap, number>; remainingExpansions: number; + remainingInlineBytes: number; remainingNodes: number; } @@ -1170,6 +1203,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.remainingInlineBytes) return { $ref: ref }; + state.remainingInlineBytes -= inlineBytes; state.remainingExpansions -= 1; state.activeRefs.add(ref); const resolvedTarget = normalizeMoonshotSchemaNode(target, root, state, depth + 1); @@ -1232,7 +1275,9 @@ function normalizeMoonshotToolParameters(parameters: unknown): Record(), + inlineSizeCache: new WeakMap, number>(), remainingExpansions: MOONSHOT_MAX_REF_EXPANSIONS, + remainingInlineBytes: MOONSHOT_MAX_INLINED_SCHEMA_BYTES, remainingNodes: MOONSHOT_MAX_SCHEMA_NODES, }); return isXaiObjectSchema(normalized) ? normalized : rooted; diff --git a/structure/10_adapter-registry.md b/structure/10_adapter-registry.md index 2c36481f54..901c8cdd5e 100644 --- a/structure/10_adapter-registry.md +++ b/structure/10_adapter-registry.md @@ -61,7 +61,10 @@ so the schema is not something a user can fix from configuration (issue #2673). 순수 `$ref`로 닫힌다 — 약해진 스키마를 절반만 내보내는 것보다 낫다. Moonshot 계열 `openai-chat` baseUrl에만 적용되고 다른 provider는 손대지 않는다. -예산은 세 가지다. 확장 횟수만으로는 참조가 하나도 없는 깊은 스키마를 막지 못해서, 깊이와 -노드 수를 따로 둔다 — `google-tool-schema.ts`가 이미 쓰는 형태다. 두 가드 모두 제거했을 때 +예산은 네 가지다. 확장 횟수만으로는 참조가 하나도 없는 깊은 스키마를 막지 못해서, 깊이와 +노드 수를 따로 둔다 — `google-tool-schema.ts`가 이미 쓰는 형태다. 인라인된 참조의 직렬화 +바이트도 누적해서 제한한다. 큰 boolean `properties` 맵은 노드 수가 작아도 출력에서 반복 복제될 +수 있기 때문이다. 제한을 넘는 참조는 Moonshot이 허용하는 순수 `$ref`로 남긴다. 두 가드 모두 +제거했을 때 실제로 red가 되는지 확인했고, 예산을 풀면 20k 깊이에서 `RangeError: Maximum call stack size exceeded`가 난다. diff --git a/tests/moonshot-tool-schema.test.ts b/tests/moonshot-tool-schema.test.ts index ae37643172..2bf6e0ba98 100644 --- a/tests/moonshot-tool-schema.test.ts +++ b/tests/moonshot-tool-schema.test.ts @@ -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,38 @@ 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("composes a property that both the target and the node define", async () => { // The same conjunction problem `required` had, one level down. Letting the sibling