From 7c05f085d81eb9ae85fb7dedd9b1f6add72b575f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 03:24:27 +0000 Subject: [PATCH 1/6] Merge branch 'dev' into codex/propose-fix-for-moonshot-request-amplification Resolve conflicts from the openai-chat module split: port the serialized-byte inline budget into src/adapters/openai-chat/tool-schema.ts, move the adapter-registry note to structure/decisions/ADR-0093, and keep the moved Moonshot regression tests at tests/providers/moonshot-tool-schema.test.ts. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> (cherry picked from commit 9e2ca52b68c2130058c228b7777c87cc424b9057) --- src/adapters/openai-chat/tool-schema.ts | 45 +++++++++++++++++++ ...oonshot-ref-with-siblings-normalization.md | 9 ++-- tests/providers/moonshot-tool-schema.test.ts | 33 +++++++++++++- 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/adapters/openai-chat/tool-schema.ts b/src/adapters/openai-chat/tool-schema.ts index 23f77121bd5..0e434084ca3 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 @@ -311,7 +342,9 @@ function composeProperties( interface MoonshotNormalizeState { activeRefs: Set; + inlineSizeCache: WeakMap, number>; remainingExpansions: number; + remainingInlineBytes: number; remainingNodes: number; } @@ -342,6 +375,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); @@ -404,7 +447,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/decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md b/structure/decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md index 576eddc4760..def5ecf3359 100644 --- a/structure/decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md +++ b/structure/decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md @@ -23,9 +23,12 @@ 순수 `$ref`로 닫힌다 — 약해진 스키마를 절반만 내보내는 것보다 낫다. Moonshot 계열 `openai-chat` baseUrl에만 적용되고 다른 provider는 손대지 않는다. -## Why three budgets +## Why four budgets -예산은 세 가지다. 확장 횟수만으로는 참조가 하나도 없는 깊은 스키마를 막지 못해서, 깊이와 -노드 수를 따로 둔다 — `google-tool-schema.ts`가 이미 쓰는 형태다. 두 가드 모두 제거했을 때 +예산은 네 가지다. 확장 횟수만으로는 참조가 하나도 없는 깊은 스키마를 막지 못해서, 깊이와 +노드 수를 따로 둔다 — `google-tool-schema.ts`가 이미 쓰는 형태다. 인라인된 참조의 직렬화 +바이트도 누적해서 제한한다. 큰 boolean `properties` 맵은 노드 수가 작아도 출력에서 반복 복제될 +수 있기 때문이다. 제한을 넘는 참조는 Moonshot이 허용하는 순수 `$ref`로 남긴다. 두 가드 모두 +제거했을 때 실제로 red가 되는지 확인했고, 예산을 풀면 20k 깊이에서 `RangeError: Maximum call stack size exceeded`가 난다. diff --git a/tests/providers/moonshot-tool-schema.test.ts b/tests/providers/moonshot-tool-schema.test.ts index ed1626927ad..7aec479be56 100644 --- a/tests/providers/moonshot-tool-schema.test.ts +++ b/tests/providers/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 d70c38f006f00468278100e37c72e7c24f7fe345 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 2/6] 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 (cherry picked from commit 3b8cb5e29ac060f16158b0e9aa08f07413b5f17b) --- ...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 3f161b8c3b21b3ee671d79168e2df90598314757 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 3/6] 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 (cherry picked from commit 292e7e1c9df982edd369198e97882babb84fd4b7) --- 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 From dba03d3dbe584c7dec054a6d8caa59ec603dc11e Mon Sep 17 00:00:00 2001 From: yeongjunyoo <47925973+yeongjunyoo@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:32:22 +0900 Subject: [PATCH 4/6] fix(moonshot): re-normalize composed properties and infer MFJS object/scalar types When composeProperties merges a referenced target and a sibling that narrows one of its properties (for example, supplying a const beside a $ref), the composed property retained the $ref alongside the sibling keyword without being re-normalized. This produced an un-normalized sibling-$ref node that violated Moonshot's strict Draft-07 schema validator. Additionally, Moonshot's schema validator (walle) requires object definitions (especially those composed with allOf or properties and used as base cases in recursive anyOf unions) to explicitly declare type: "object". Without an explicit type, walle fails to recognize the schema as a valid termination condition, rejecting valid recursive tools with: `detected infinite recursion without termination condition`. 1. Recursively re-normalize the merged node in normalizeMoonshotSchemaNode so composed sibling refs are resolved. 2. Infer type: "object" for schemas containing properties or allOf when type is omitted. 3. Infer scalar types for un-typed const and enum keywords. 4. Add regression coverage in tests/providers/moonshot-tool-schema.test.ts. (cherry picked from commit a986a887dc0329262462edbf685fff1d371d0cad) --- src/adapters/openai-chat/tool-schema.ts | 24 +++++++- tests/providers/moonshot-tool-schema.test.ts | 63 ++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/adapters/openai-chat/tool-schema.ts b/src/adapters/openai-chat/tool-schema.ts index 7362e058eb5..eb8db3134b0 100644 --- a/src/adapters/openai-chat/tool-schema.ts +++ b/src/adapters/openai-chat/tool-schema.ts @@ -432,7 +432,7 @@ function normalizeMoonshotSchemaNode( } merged[key] = normalized; } - return merged; + return normalizeMoonshotSchemaNode(merged, root, state, depth + 1); } // Unresolvable pointer: a remote ref, a malformed path, or a non-object target. Dropping @@ -449,6 +449,28 @@ function normalizeMoonshotSchemaNode( ? value : normalizeMoonshotSchemaNode(value, root, state, depth + 1); } + + // Moonshot MFJS requirements: + // 1. Stamp "object" if properties or allOf are present without type, so Moonshot's validator + // recognizes the schema as a valid termination condition for recursive refs. + // 2. Infer scalar types for bare const and enum keywords. + if (out.type === undefined) { + if (out.properties !== undefined || Array.isArray(out.allOf)) { + 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"; + } + } + } + return out; } diff --git a/tests/providers/moonshot-tool-schema.test.ts b/tests/providers/moonshot-tool-schema.test.ts index 3c1372ffabf..05a25784c68 100644 --- a/tests/providers/moonshot-tool-schema.test.ts +++ b/tests/providers/moonshot-tool-schema.test.ts @@ -517,4 +517,67 @@ 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"] }, + }, + }, + }); + + 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"); + }); + + 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"); + }); }); + From 656eee98f5d5f0804de8f62d7a8a5eed7353b33b Mon Sep 17 00:00:00 2001 From: yeongjunyoo <47925973+yeongjunyoo@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:41:32 +0900 Subject: [PATCH 5/6] fix(moonshot): address review findings on allOf object guard, boolean enums, and composed property re-normalization (cherry picked from commit 0fad7640ea6b812ea29e4d3c3478516bfccfa991) --- src/adapters/openai-chat/tool-schema.ts | 31 +++++++++++++++++--- tests/providers/moonshot-tool-schema.test.ts | 4 +++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/adapters/openai-chat/tool-schema.ts b/src/adapters/openai-chat/tool-schema.ts index eb8db3134b0..c155c5b7c04 100644 --- a/src/adapters/openai-chat/tool-schema.ts +++ b/src/adapters/openai-chat/tool-schema.ts @@ -432,7 +432,21 @@ function normalizeMoonshotSchemaNode( } merged[key] = normalized; } - return normalizeMoonshotSchemaNode(merged, root, state, depth + 1); + + // 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; } // Unresolvable pointer: a remote ref, a malformed path, or a non-object target. Dropping @@ -451,11 +465,18 @@ function normalizeMoonshotSchemaNode( } // Moonshot MFJS requirements: - // 1. Stamp "object" if properties or allOf are present without type, so Moonshot's validator - // recognizes the schema as a valid termination condition for recursive refs. + // 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) { - if (out.properties !== undefined || Array.isArray(out.allOf)) { + 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; @@ -467,6 +488,8 @@ function normalizeMoonshotSchemaNode( 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"; } } } diff --git a/tests/providers/moonshot-tool-schema.test.ts b/tests/providers/moonshot-tool-schema.test.ts index 05a25784c68..e083ae0c594 100644 --- a/tests/providers/moonshot-tool-schema.test.ts +++ b/tests/providers/moonshot-tool-schema.test.ts @@ -531,6 +531,8 @@ describe("Moonshot tool schema normalization (issue #2673)", () => { count: { const: 42 }, flag: { const: true }, color: { enum: ["red", "blue"] }, + toggle: { enum: [true, false] }, + stringAllOf: { allOf: [{ type: "string" }, { minLength: 1 }] }, }, }, }); @@ -541,6 +543,8 @@ describe("Moonshot tool schema normalization (issue #2673)", () => { 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 () => { From 93831de7520df0ceaff43b9dd6bb4d251683bd90 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:27:01 +0900 Subject: [PATCH 6/6] fix(moonshot): charge normalized schema growth to the shared inline budget Reserve raw target bytes before normalization and charge inferred-type or nested-normalization growth before retaining the expanded target. Over-budget work keeps the existing bare-ref fallback; depth, node, expansion and shared 1 MiB limits stay unchanged. Add combined regressions for type-inference growth and composed-property re-normalization across multiple tools. The growth case fails before this follow-up and passes after it. The original byte-budget implementation ab7f684bfcd088c06607d2d551a7ed0f34f75a26 is carried through module-split resolution 9e2ca52b68c2130058c228b7777c87cc424b9057 (second-parent delta), preserving the later request-shared allowance. Co-authored-by: yeongjunyoo <47925973+yeongjunyoo@users.noreply.github.com> Co-authored-by: Epinephrine Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/adapters/openai-chat/tool-schema.ts | 8 +++++ structure/providers/chat-compat.md | 6 ++-- tests/providers/moonshot-tool-schema.test.ts | 37 +++++++++++++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/adapters/openai-chat/tool-schema.ts b/src/adapters/openai-chat/tool-schema.ts index c155c5b7c04..4e3d1834a07 100644 --- a/src/adapters/openai-chat/tool-schema.ts +++ b/src/adapters/openai-chat/tool-schema.ts @@ -398,6 +398,14 @@ function normalizeMoonshotSchemaNode( 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; diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 73d2d488ee2..7ec0ef7ba66 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -309,8 +309,10 @@ 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 -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 +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) diff --git a/tests/providers/moonshot-tool-schema.test.ts b/tests/providers/moonshot-tool-schema.test.ts index e083ae0c594..f8b257484da 100644 --- a/tests/providers/moonshot-tool-schema.test.ts +++ b/tests/providers/moonshot-tool-schema.test.ts @@ -395,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", @@ -584,4 +620,3 @@ describe("Moonshot tool schema normalization (issue #2673)", () => { expect(op.type).toBe("string"); }); }); -