Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 115 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;

/**
* 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,10 +384,28 @@ 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);
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<string, unknown> = Object.create(null) as Record<string, unknown>;
if (isXaiObjectSchema(resolvedTarget)) {
for (const [key, value] of Object.entries(resolvedTarget)) merged[key] = value;
Expand Down Expand Up @@ -380,6 +440,20 @@ function normalizeMoonshotSchemaNode(
}
merged[key] = normalized;
}

// 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<string, unknown>)) {
if (isXaiObjectSchema(propVal) && typeof propVal.$ref === "string" && moonshotRefTargetKeys(propVal).length > 0) {
(merged.properties as Record<string, unknown>)[propName] = normalizeMoonshotSchemaNode(
propVal,
root,
state,
depth + 1,
);
}
}
}
return merged;
}

Expand All @@ -397,13 +471,49 @@ function normalizeMoonshotSchemaNode(
? value
: normalizeMoonshotSchemaNode(value, root, state, depth + 1);
}

// Moonshot MFJS requirements:
// 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) {
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;
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";
} else if (out.enum.every(x => typeof x === "boolean")) {
out.type = "boolean";
}
}
}

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 +530,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는 손대지 않는다.
9 changes: 7 additions & 2 deletions structure/providers/chat-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,10 +308,15 @@ 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. 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)
> 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
Loading
Loading