From ab7f684bfcd088c06607d2d551a7ed0f34f75a26 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Fri, 28 Aug 2026 13:10:26 +0900 Subject: [PATCH 1/5] 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 9add6b8a8cf..cbb09244666 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 2c36481f54d..901c8cdd5e1 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 ae376431726..2bf6e0ba987 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 From 060cc3633c452820d546513bf73a4c000d80e3a2 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Mon, 21 Sep 2026 02:30:57 +0000 Subject: [PATCH 2/5] ci: retrigger checks (empty commit; dev merge conflicts) From 7c8e39bee243bcb015f68506dd92b636f9fd959f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:18:09 +0000 Subject: [PATCH 3/5] ci: retrigger macos 1/2 (wedged-spawn silent-stall in client-connect.test.ts) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> From 3b8cb5e29ac060f16158b0e9aa08f07413b5f17b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:15:06 +0000 Subject: [PATCH 4/5] docs(structure): record Moonshot inline-byte budget in a new ADR Decision records are historical; the byte-budget reasoning moves out of ADR-0093 into ADR-0355 and the chat-compat contract now states the inline-byte bound alongside depth, node, and expansion bounds. Co-Authored-By: Epinephrine --- ...oonshot-ref-with-siblings-normalization.md | 9 +++---- ...55-chat-structured-output-compatibility.md | 27 +++++++++++++++++++ structure/providers/chat-compat.md | 7 +++-- 3 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 structure/decisions/ADR-0355-chat-structured-output-compatibility.md diff --git a/structure/decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md b/structure/decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md index def5ecf3359..576eddc4760 100644 --- a/structure/decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md +++ b/structure/decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md @@ -23,12 +23,9 @@ 순수 `$ref`로 닫힌다 — 약해진 스키마를 절반만 내보내는 것보다 낫다. Moonshot 계열 `openai-chat` baseUrl에만 적용되고 다른 provider는 손대지 않는다. -## Why four budgets +## Why three budgets -예산은 네 가지다. 확장 횟수만으로는 참조가 하나도 없는 깊은 스키마를 막지 못해서, 깊이와 -노드 수를 따로 둔다 — `google-tool-schema.ts`가 이미 쓰는 형태다. 인라인된 참조의 직렬화 -바이트도 누적해서 제한한다. 큰 boolean `properties` 맵은 노드 수가 작아도 출력에서 반복 복제될 -수 있기 때문이다. 제한을 넘는 참조는 Moonshot이 허용하는 순수 `$ref`로 남긴다. 두 가드 모두 -제거했을 때 +예산은 세 가지다. 확장 횟수만으로는 참조가 하나도 없는 깊은 스키마를 막지 못해서, 깊이와 +노드 수를 따로 둔다 — `google-tool-schema.ts`가 이미 쓰는 형태다. 두 가드 모두 제거했을 때 실제로 red가 되는지 확인했고, 예산을 풀면 20k 깊이에서 `RangeError: Maximum call stack size exceeded`가 난다. 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..6cfafc2184f --- /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 a 1 MiB allowance shared by the whole walk, 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..98c8526a711 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 +a shared 1 MiB allowance, 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 From 292e7e1c9df982edd369198e97882babb84fd4b7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:20:33 +0000 Subject: [PATCH 5/5] fix(openai-chat): share the Moonshot inline-byte budget across request tools A per-tool 1 MiB allowance let a large catalog multiply the cap by its tool count, reopening the request amplification the budget exists to bound. Hoist the allowance to one MoonshotInlineByteBudget per toolsToChatFormat call so every tool spends from the same pool. Adds a regression test: two tools carrying ~0.6 MB inline targets each now share one allowance, so only the first of four sibling refs inlines and the rest keep the bare-$ref fallback. Co-Authored-By: Epinephrine --- src/adapters/openai-chat/tool-schema.ts | 27 +++++++++--- ...55-chat-structured-output-compatibility.md | 4 +- structure/providers/chat-compat.md | 4 +- tests/providers/moonshot-tool-schema.test.ts | 43 ++++++++++++++++++- 4 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/adapters/openai-chat/tool-schema.ts b/src/adapters/openai-chat/tool-schema.ts index 0e434084ca3..7362e058eb5 100644 --- a/src/adapters/openai-chat/tool-schema.ts +++ b/src/adapters/openai-chat/tool-schema.ts @@ -340,11 +340,20 @@ 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; - remainingInlineBytes: number; remainingNodes: number; } @@ -383,8 +392,8 @@ function normalizeMoonshotSchemaNode( inlineBytes = serializedJsonBytesUpTo(target, MOONSHOT_MAX_INLINED_SCHEMA_BYTES); state.inlineSizeCache.set(target, inlineBytes); } - if (inlineBytes > state.remainingInlineBytes) return { $ref: ref }; - state.remainingInlineBytes -= 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); @@ -443,13 +452,16 @@ 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, - remainingInlineBytes: MOONSHOT_MAX_INLINED_SCHEMA_BYTES, remainingNodes: MOONSHOT_MAX_SCHEMA_NODES, }); return isXaiObjectSchema(normalized) ? normalized : rooted; @@ -465,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 index 6cfafc2184f..661d7457c45 100644 --- a/structure/decisions/ADR-0355-chat-structured-output-compatibility.md +++ b/structure/decisions/ADR-0355-chat-structured-output-compatibility.md @@ -14,8 +14,8 @@ 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 a 1 MiB allowance shared by the whole walk, and a reference - that would exceed the remaining allowance stays a bare `$ref`. + 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 diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 98c8526a711..73d2d488ee2 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -309,8 +309,8 @@ their wire rejects that valid JSON Schema 2020-12 shape. Inlining preserves conj `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-, expansion-, and inline-byte-bounded: each inlined reference is charged its serialized size against -a shared 1 MiB allowance, 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 +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) diff --git a/tests/providers/moonshot-tool-schema.test.ts b/tests/providers/moonshot-tool-schema.test.ts index 7aec479be56..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: {}, @@ -336,6 +336,45 @@ describe("Moonshot tool schema normalization (issue #2673)", () => { 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