diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 9add6b8a8c..f2a36e0490 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -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, @@ -1127,7 +1128,20 @@ function composeProperties( const member: Record = Object.create(null) as Record; 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, 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; diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 4d91be8944..150f7b781a 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -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 diff --git a/tests/moonshot-tool-schema.test.ts b/tests/moonshot-tool-schema.test.ts index ae37643172..6628984340 100644 --- a/tests/moonshot-tool-schema.test.ts +++ b/tests/moonshot-tool-schema.test.ts @@ -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>).value!; + const properties = value.properties as Record>; + 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>).value!; + const shared = (value.properties as Record>).shared!; + const leaf = (shared.properties as Record>).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