Skip to content
Merged
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
18 changes: 16 additions & 2 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1113,7 +1113,8 @@ function intersectBound(target: unknown, sibling: unknown, direction: "max" | "m
* Compose two `properties` maps. A property named in BOTH the referenced target and the
* node is the same conjunction problem `required` had: letting the sibling win discards
* the target's constraints for that member. Merge the two member schemas so neither side
* loses its keywords, and let the node narrow on a genuine conflict.
* loses its keywords. Shared member bounds are the same conjunction one level down,
* and nested object members recurse through this helper instead of replacing the target.
*/
function composeProperties(
target: Record<string, unknown>,
Expand All @@ -1127,7 +1128,20 @@ function composeProperties(
const member: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
for (const [k, v] of Object.entries(existing)) member[k] = v;
for (const [k, v] of Object.entries(sub)) {
member[k] = k === "required" ? unionRequired(member[k], v) : v;
if (k === "required") {
member[k] = unionRequired(member[k], v);
continue;
}
if (k === "properties" && isXaiObjectSchema(member[k]) && isXaiObjectSchema(v)) {
member[k] = composeProperties(member[k] as Record<string, unknown>, v);
continue;
}
const boundDirection = MOONSHOT_BOUND_KEYWORDS[k];
if (boundDirection && k in member) {
member[k] = intersectBound(member[k], v, boundDirection);
continue;
}
member[k] = v;
}
combined[name] = member;
continue;
Expand Down
15 changes: 15 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,21 @@ the shared Ark hostname is too broad because the two endpoint families reject op

## Chat structured-output compatibility

First-party Kimi and Moonshot Chat destinations normalize a `$ref` with sibling keywords because
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.

[Decision Log]
- 목적과 의도: Make Moonshot's compatibility rewrite remove rejected sibling `$ref` shapes without silently weakening a tool schema.
- 기존 구현 및 제약 조건: The target and sibling both apply under JSON Schema 2020-12, but a shallow shared-property merge let sibling bounds replace stricter target bounds; Moonshot still requires the local bounded rewrite.
- 검토한 주요 대안: Keep shallow sibling precedence; emit `allOf`; intersect only top-level bounds; recursively compose the supported set-valued and ordered assertions.
- 선택한 방식: Reuse the existing bound and required intersection rules recursively for overlapping object properties inside the first-party destination gate.
- 다른 대안 대신 이 방식을 선택한 이유: Shallow precedence weakens constraints, while a new `allOf` wire shape needs separate provider evidence; recursive composition fixes the demonstrated loss without broadening normalization to custom providers.
- 장점, 단점 및 영향: Looser siblings cannot relax nested constraints and tighter siblings still narrow them; non-ordered conflicting keywords retain the existing sibling precedence and are not treated as a complete JSON Schema algebra.

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
wire. That remains the default because silently returning prose breaks clients that requested a
Expand Down
68 changes: 68 additions & 0 deletions tests/moonshot-tool-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,74 @@ describe("Moonshot tool schema normalization (issue #2673)", () => {
expect(shared.type).toBe("string");
});

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",
parameters: {
type: "object",
$defs: {
Base: {
type: "object",
properties: {
looserSibling: { type: "string", minLength: 5, maxLength: 10 },
tighterSibling: { type: "string", minLength: 1, maxLength: 100 },
},
},
},
properties: {
value: {
$ref: "#/$defs/Base",
properties: {
looserSibling: { type: "string", minLength: 1, maxLength: 99 },
tighterSibling: { type: "string", minLength: 5, maxLength: 10 },
},
},
},
},
});

const value = (parameters?.properties as Record<string, Record<string, unknown>>).value!;
const properties = value.properties as Record<string, Record<string, unknown>>;
expect(properties.looserSibling).toMatchObject({ minLength: 5, maxLength: 10 });
expect(properties.tighterSibling).toMatchObject({ minLength: 5, maxLength: 10 });
});

test("intersects bounds recursively inside shared object properties", async () => {
const parameters = await emittedParameters("https://api.moonshot.ai/v1", {
name: "nested_shared_property_bounds_tool",
parameters: {
type: "object",
$defs: {
Base: {
type: "object",
properties: {
shared: {
type: "object",
properties: { leaf: { type: "string", minLength: 5, maxLength: 10 } },
},
},
},
},
properties: {
value: {
$ref: "#/$defs/Base",
properties: {
shared: {
type: "object",
properties: { leaf: { type: "string", minLength: 1, maxLength: 99 } },
},
},
},
},
},
});

const value = (parameters?.properties as Record<string, Record<string, unknown>>).value!;
const shared = (value.properties as Record<string, Record<string, unknown>>).shared!;
const leaf = (shared.properties as Record<string, Record<string, unknown>>).leaf!;
expect(leaf).toMatchObject({ minLength: 5, maxLength: 10 });
});

test("leaves data-valued keywords alone, even when they look like schemas", async () => {
// `enum` lists VALUES. Recursing into it treated a literal object carrying a "$ref"
// string as a reference node and stripped the key, silently changing a value the tool
Expand Down
Loading