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
126 changes: 126 additions & 0 deletions src/adapters/moonshot-tool-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
type Schema = Record<string, unknown>;

const MAX_SCHEMA_DEPTH = 64;
const MAX_SCHEMA_NODES = 10_000;
const MAX_REF_EXPANSIONS = 64;

interface NormalizeState {
nodes: number;
refExpansions: number;
activeRefs: Set<string>;
}

function isRecord(value: unknown): value is Schema {
return !!value && typeof value === "object" && !Array.isArray(value);
}

function decodePointerToken(token: string): string {
return token.replace(/~1/g, "/").replace(/~0/g, "~");
}

function lookupLocalRef(root: Schema, ref: string): unknown {
if (!ref.startsWith("#/")) return undefined;
let current: unknown = root;
for (const token of ref.slice(2).split("/").map(decodePointerToken)) {
if (!isRecord(current) || !Object.hasOwn(current, token)) return undefined;
current = current[token];
}
return current;
}

function schemaValuesEqual(left: unknown, right: unknown): boolean {
try {
return JSON.stringify(left) === JSON.stringify(right);
} catch {
return false;
}
}

/**
* A 2020-12 ref and its siblings are conjunctive. Moonshot does not expose a documented
* composition subset that we can rely on, so never approximate conjunction by overwriting a
* referenced assertion. Equal duplicate assertions and non-conflicting keys are safe to inline;
* any conflicting duplicate makes the caller retain the pure ref instead.
*/
function mergeCompatibleRefSiblings(target: Schema, siblings: Schema): Schema | undefined {
const merged: Schema = { ...target };
for (const [key, value] of Object.entries(siblings)) {
if (Object.hasOwn(target, key) && !schemaValuesEqual(target[key], value)) return undefined;
merged[key] = value;
}
return merged;
}

function normalizeNode(
value: unknown,
root: Schema,
state: NormalizeState,
depth: number,
): unknown | undefined {
state.nodes += 1;
if (depth > MAX_SCHEMA_DEPTH || state.nodes > MAX_SCHEMA_NODES) return undefined;

if (Array.isArray(value)) {
const out: unknown[] = [];
for (const item of value) {
const normalized = normalizeNode(item, root, state, depth + 1);
if (normalized === undefined) return undefined;
out.push(normalized);
}
return out;
}
if (!isRecord(value)) return value;

const ref = typeof value.$ref === "string" ? value.$ref : undefined;
const siblingEntries = ref === undefined
? []
: Object.entries(value).filter(([key]) => key !== "$ref");
if (ref !== undefined && siblingEntries.length > 0) {
const target = lookupLocalRef(root, ref);
if (
isRecord(target)
&& !state.activeRefs.has(ref)
&& state.refExpansions < MAX_REF_EXPANSIONS
) {
state.activeRefs.add(ref);
state.refExpansions += 1;
const normalizedTarget = normalizeNode(target, root, state, depth + 1);
state.activeRefs.delete(ref);
const normalizedOverlay = normalizeNode(Object.fromEntries(siblingEntries), root, state, depth + 1);
if (isRecord(normalizedTarget) && isRecord(normalizedOverlay)) {
const merged = mergeCompatibleRefSiblings(normalizedTarget, normalizedOverlay);
if (merged !== undefined) return merged;
return { $ref: ref };
}
if (normalizedTarget === undefined || normalizedOverlay === undefined) return undefined;
}

// Moonshot accepts a standalone ref but rejects validation siblings beside it. If the
// target cannot be expanded safely, preserve identity and drop the ambiguous overlay.
return { $ref: ref };
}

const out: Schema = {};
for (const [key, item] of Object.entries(value)) {
const normalized = normalizeNode(item, root, state, depth + 1);
if (normalized === undefined) return undefined;
out[key] = normalized;
}
return out;
}

/**
* Normalize first-party Moonshot tool schemas without changing other OpenAI-chat gateways.
* Over-limit schemas fail closed by omitting the tool instead of publishing a partial rewrite.
*/
export function normalizeMoonshotToolParameters(parameters: unknown): Schema | undefined {
const root: Schema = isRecord(parameters)
? (parameters.type === "object" ? parameters : { ...parameters, type: "object" })
: { type: "object", properties: {} };
const normalized = normalizeNode(root, root, {
nodes: 0,
refExpansions: 0,
activeRefs: new Set(),
}, 0);
return isRecord(normalized) ? normalized : undefined;
}
20 changes: 19 additions & 1 deletion src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from "../providers/fastwire";
import { openaiChatCompletionsUrl } from "./openai-chat-url";
import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema";
import { normalizeMoonshotToolParameters } from "./moonshot-tool-schema";
import {
isTranslatorBudgetExceededError,
retainTranslatedEventBatch,
Expand Down Expand Up @@ -932,6 +933,20 @@ function isXaiSchemaTarget(provider: OcxProviderConfig): boolean {
}
}

const MOONSHOT_SCHEMA_HOSTS = new Set([
"api.kimi.com",
"api.moonshot.ai",
"api.moonshot.cn",
]);

function isMoonshotSchemaTarget(provider: OcxProviderConfig): boolean {
try {
return MOONSHOT_SCHEMA_HOSTS.has(new URL(provider.baseUrl).hostname);
} catch {
return false;
}
}

const VOLCENGINE_ARK_HOSTNAMES = new Set([
"ark.cn-beijing.volces.com",
"ark.ap-southeast.volces.com",
Expand Down Expand Up @@ -1228,10 +1243,13 @@ function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig
const tools = parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools));
if (tools.length === 0) return undefined;
const xaiTarget = isXaiSchemaTarget(provider);
const moonshotTarget = isMoonshotSchemaTarget(provider);
const formatted = tools.flatMap(t => {
const parameters = stripResponsesOnlyEncryptedMarker(xaiTarget
? normalizeXaiToolParameters(t.parameters)
: ensureRootObjectType(t.parameters));
: moonshotTarget
? normalizeMoonshotToolParameters(t.parameters)
: ensureRootObjectType(t.parameters));

if (parameters === undefined) return [];
return [{
Expand Down
19 changes: 19 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,25 @@ executor contract. Main-request migration must not treat that branch as fixed-tr
provider, lets the selected adapter speak the upstream protocol, then bridges adapter events back to
Responses-compatible streaming output.

### Moonshot chat tool-schema compatibility

First-party Kimi and Moonshot chat endpoints implement a JSON-Schema subset in which `$ref` must
stand alone. Their `openai-chat` boundary therefore expands resolvable local `$defs`/`definitions`
references only when the node also carries sibling keywords. Pure refs remain refs, the node's own
non-conflicting keywords combine with the referenced schema, and equal duplicate assertions remain
single assertions. Conflicting duplicate assertions degrade to a pure ref instead of approximating
JSON Schema conjunction by overwriting the referenced constraint. Third-party chat gateways keep
their original schema shape. Depth, node, and reference-expansion limits bound the rewrite; an
over-limit tool is omitted rather than partially weakened.

[Decision Log]
- 목적과 의도: Keep Codex-generated 2020-12 tool schemas usable on first-party Moonshot validators without changing every OpenAI-compatible chat destination.
- 기존 구현 및 제약 조건: The generic chat path fixed only the root object type, while Moonshot rejected nested `$ref` nodes carrying `type`, `minLength`, or `format`; xAI and Google already own different provider-specific schema subsets.
- 검토한 주요 대안: Strip all sibling constraints; normalize every chat provider; reuse Google's lossy allowlist; or add a bounded Moonshot-only local-ref expansion module.
- 선택한 방식: Expand local sibling refs for the three first-party hosts only when duplicate assertions are equal, preserve pure refs, degrade unresolved, cyclic, or conflicting sibling refs to a pure ref, and omit a tool when traversal bounds are exceeded.
- 다른 대안 대신 이 방식을 선택한 이유: Global rewriting would change providers that already accept 2020-12 semantics, while stripping valid constraints loses model guidance and Google's allowlist would discard unrelated Moonshot-supported keywords.
- 장점, 단점 및 영향: Reported tool catalogs reach Moonshot without ref siblings and third-party gateways stay unchanged; ambiguous or conflicting refs retain referenced identity without weakening its assertions but may lose sibling constraints, and pathological schemas lose the one tool instead of failing the whole request with a partial rewrite.

### Fetch-helper import boundary

`src/server/responses/fetch-helpers.ts` is a transport leaf shared by Responses, compact, and native
Expand Down
133 changes: 133 additions & 0 deletions tests/moonshot-tool-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, expect, test } from "bun:test";
import { createOpenAIChatAdapter as createProductionAdapter } from "../src/adapters/openai-chat";
import { normalizeMoonshotToolParameters } from "../src/adapters/moonshot-tool-schema";
import type { OcxParsedRequest, OcxProviderConfig, OcxTool } from "../src/types";
import { withTestTranslatorBudget } from "./helpers/translator-budget";

const createAdapter = (provider: OcxProviderConfig) =>
withTestTranslatorBudget(createProductionAdapter(provider));

const siblingRefSchema = {
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: { threadId: { $ref: "#/$defs/__schema20" } },
required: ["threadId"],
additionalProperties: false,
$defs: {
__schema2: { type: "string", minLength: 1 },
__schema20: {
type: "string",
minLength: 1,
format: "uuid",
description: "Target thread UUID.",
$ref: "#/$defs/__schema2",
},
},
};

function parsed(tool: OcxTool): OcxParsedRequest {
return {
modelId: "k3",
context: {
messages: [{ role: "user", content: "Reply OK", timestamp: 0 }],
tools: [tool],
},
stream: false,
options: {},
};
}

function emittedParameters(baseUrl: string): Record<string, unknown> {
const request = createAdapter({
adapter: "openai-chat",
baseUrl,
apiKey: "test-key",
authMode: "key",
}).buildRequest(parsed({ name: "schema_probe", parameters: structuredClone(siblingRefSchema) }));
const body = JSON.parse(request.body) as {
tools: Array<{ function: { parameters: Record<string, unknown> } }>;
};
return body.tools[0]!.function.parameters;
}

describe("Moonshot tool schema compatibility", () => {
test("inlines local refs that carry sibling validation keywords", () => {
const normalized = normalizeMoonshotToolParameters(structuredClone(siblingRefSchema));
expect(normalized?.$defs).toEqual({
__schema2: { type: "string", minLength: 1 },
__schema20: {
type: "string",
minLength: 1,
format: "uuid",
description: "Target thread UUID.",
},
});
expect((normalized?.properties as Record<string, unknown>).threadId).toEqual({
$ref: "#/$defs/__schema20",
});
});

test("degrades an unresolvable sibling ref to a pure ref", () => {
expect(normalizeMoonshotToolParameters({
type: "object",
properties: { value: { $ref: "#/$defs/missing", type: "string", minLength: 1 } },
})?.properties).toEqual({ value: { $ref: "#/$defs/missing" } });
});

test("does not overwrite conflicting referenced assertions", () => {
const normalized = normalizeMoonshotToolParameters({
type: "object",
properties: {
value: {
$ref: "#/$defs/Base",
required: ["b"],
properties: { b: { type: "number" } },
},
},
$defs: {
Base: {
type: "object",
required: ["a"],
properties: { a: { type: "string" } },
},
},
});

expect((normalized?.properties as Record<string, unknown>).value).toEqual({
$ref: "#/$defs/Base",
});
});

test("does not replace a referenced enum or minimum with a sibling assertion", () => {
const normalized = normalizeMoonshotToolParameters({
type: "object",
properties: {
mode: { $ref: "#/$defs/Mode", enum: ["b"] },
count: { $ref: "#/$defs/Count", minimum: 1 },
},
$defs: {
Mode: { type: "string", enum: ["a"] },
Count: { type: "integer", minimum: 5 },
},
});

expect(normalized?.properties).toEqual({
mode: { $ref: "#/$defs/Mode" },
count: { $ref: "#/$defs/Count" },
});
});

test.each([
"https://api.kimi.com/coding/v1",
"https://api.moonshot.ai/v1",
"https://api.moonshot.cn/v1",
])("normalizes the first-party destination %s", baseUrl => {
const parameters = emittedParameters(baseUrl);
expect(JSON.stringify(parameters)).not.toContain('"$ref":"#/$defs/__schema2"');
expect(JSON.stringify(parameters)).toContain('"format":"uuid"');
});

test("preserves a third-party OpenAI-chat gateway byte-shape", () => {
expect(emittedParameters("https://gateway.example/v1")).toEqual(siblingRefSchema);
});
});
Loading