Skip to content
64 changes: 62 additions & 2 deletions src/adapters/openai-chat/tool-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

/**
* 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
Expand Down Expand Up @@ -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<string>;
inlineSizeCache: WeakMap<Record<string, unknown>, number>;
inlineByteBudget: MoonshotInlineByteBudget;
remainingExpansions: number;
remainingNodes: number;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -400,10 +452,15 @@ function normalizeMoonshotSchemaNode(
return out;
}

function normalizeMoonshotToolParameters(parameters: unknown): Record<string, unknown> {
function normalizeMoonshotToolParameters(
parameters: unknown,
inlineByteBudget: MoonshotInlineByteBudget,
): Record<string, unknown> {
const rooted = ensureRootObjectType(parameters);
const normalized = normalizeMoonshotSchemaNode(rooted, rooted, {
activeRefs: new Set<string>(),
inlineSizeCache: new WeakMap<Record<string, unknown>, number>(),
inlineByteBudget,
remainingExpansions: MOONSHOT_MAX_REF_EXPANSIONS,
remainingNodes: MOONSHOT_MAX_SCHEMA_NODES,
});
Expand All @@ -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));

Expand Down
Original file line number Diff line number Diff line change
@@ -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는 손대지 않는다.
7 changes: 5 additions & 2 deletions structure/providers/chat-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 73 additions & 3 deletions tests/providers/moonshot-tool-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ const createOpenAIChatAdapter = (
...args: Parameters<typeof createOpenAIChatAdapterProduction>
) => 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: {},
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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<string, unknown>;

// 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<string, Record<string, unknown>>;
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<string, Record<string, unknown>> } } }[];
}).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
Expand Down
Loading