Skip to content
Draft
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
45 changes: 45 additions & 0 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1139,7 +1170,9 @@ function composeProperties(

interface MoonshotNormalizeState {
activeRefs: Set<string>;
inlineSizeCache: WeakMap<Record<string, unknown>, number>;
remainingExpansions: number;
remainingInlineBytes: number;
remainingNodes: number;
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1232,7 +1275,9 @@ function normalizeMoonshotToolParameters(parameters: unknown): Record<string, un
const rooted = ensureRootObjectType(parameters);
const normalized = normalizeMoonshotSchemaNode(rooted, rooted, {
activeRefs: new Set<string>(),
inlineSizeCache: new WeakMap<Record<string, unknown>, number>(),
remainingExpansions: MOONSHOT_MAX_REF_EXPANSIONS,
remainingInlineBytes: MOONSHOT_MAX_INLINED_SCHEMA_BYTES,
Comment on lines 1276 to +1280

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Share the byte allowance across the whole tool catalog

The allowance is initialized inside normalizeMoonshotToolParameters, so toolsToChatFormat grants a fresh 1 MiB amplification budget to every tool. Because the inbound tool arrays have no aggregate count or output-size limit, a catalog containing many individually small schemas can still exhaust memory while constructing and serializing the final request; for example, I reproduced a 128-tool, 3.36 MB catalog expanding to about 82 MB. Allocate the budget once per request/catalog and pass it through each normalization, or enforce an aggregate serialized-output cap.

Useful? React with 👍 / 👎.

remainingNodes: MOONSHOT_MAX_SCHEMA_NODES,
});
return isXaiObjectSchema(normalized) ? normalized : rooted;
Expand Down
7 changes: 5 additions & 2 deletions structure/10_adapter-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`가 난다.
33 changes: 32 additions & 1 deletion tests/moonshot-tool-schema.test.ts
Original file line number Diff line number Diff line change
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,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<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("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