From 007ad5aa6098b0f87c00f5c0ee55cf961e38219b Mon Sep 17 00:00:00 2001 From: panyuanyuan Date: Sun, 11 Oct 2026 13:01:39 +0800 Subject: [PATCH] fix(kimi): omit explicit empty assistant history on native Responses --- .../src/content/docs/guides/providers.md | 4 + scripts/test-layout/layout.json | 2 +- src/adapters/openai-chat/messages.ts | 2 +- .../openai-responses/empty-assistant.ts | 27 ++++ src/adapters/openai-responses/passthrough.ts | 2 + structure/adapters/registry.md | 12 ++ .../openai-empty-assistant-history.test.ts | 133 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- 8 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 src/adapters/openai-responses/empty-assistant.ts create mode 100644 tests/adapters/openai/openai-empty-assistant-history.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 9d432c16f13..977e36f7a6a 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -263,6 +263,10 @@ cache hit rates, while requests without a key remain keyless. If an opted-in ups field, opencodex does not strip it and retry or mutate saved configuration. Other providers remain deny-by-default. +Kimi Code's native Responses route omits explicit content-empty assistant history messages +to avoid its `assistant must not be empty` rejection. Nonempty text, whitespace, refusals, +reasoning and tool calls/results are retained; stored client history is not rewritten. + Kimi Coding usage prices for `k3`, `k3[1m]`, and `k3-256k` are **API-reference estimates** across `kimi`, `kimi-code`, and `kimi-responses`, using the published default 5-minute cache-write rate. They do not reproduce Code Plan billing or quota: K3's 1M variant consumes about twice the quota diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 3ff37c9e98b..97c7f575326 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicit": { - "messages-request-id-headers.test.ts": "claude-integration", + "messages-request-id-headers.test.ts": "claude-integration", "openai-empty-assistant-history.test.ts": "adapters/openai", "messages-request-id-endpoint.test.ts": "claude-integration", "anthropic-revoked-token.test.ts": "adapters/anthropic", "messages-revoked-token.test.ts": "claude-integration", diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index a3cb3e0682d..b5dc71ac596 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -292,7 +292,7 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv } } const hasReplayedReasoning = chatMsg.reasoning_content !== undefined || chatMsg.reasoning_details !== undefined; - if (chatMsg.content === undefined && toolCalls.length === 0 && !hasReplayedReasoning) break; + if (!chatMsg.content && toolCalls.length === 0 && !hasReplayedReasoning) break; flushPendingToolCalls(); const wireToolCalls = toolCalls.map(tc => { let id = tc.id; diff --git a/src/adapters/openai-responses/empty-assistant.ts b/src/adapters/openai-responses/empty-assistant.ts new file mode 100644 index 00000000000..cb9d296c767 --- /dev/null +++ b/src/adapters/openai-responses/empty-assistant.ts @@ -0,0 +1,27 @@ +import { isPlainObject } from "./internal"; + +const MESSAGE_FIELDS = new Set(["type", "role", "content", "id", "status", "phase"]); + +function isEmptyTextPart(part: unknown): boolean { + if (!isPlainObject(part) || !["text", "input_text", "output_text"].includes(String(part.type)) || part.text !== "") return false; + // Nonempty annotations and unknown fields may carry information despite the blank text. + return Object.keys(part).every(key => key === "type" || key === "text" + || ((key === "annotations" || key === "logprobs") && Array.isArray(part[key]) && part[key].length === 0)); +} + +function isEmptyAssistantMessage(item: unknown): boolean { + if (!isPlainObject(item) || item.role !== "assistant" || (item.type !== undefined && item.type !== "message")) return false; + // Never discard tools, reasoning, opaque state or unknown extension fields on a message. + if (!Object.keys(item).every(key => MESSAGE_FIELDS.has(key))) return false; + return item.content === "" || (Array.isArray(item.content) && item.content.every(isEmptyTextPart)); +} + +/** Kimi Code rejects content-empty assistant messages even on its native Responses endpoint. */ +export function stripKimiEmptyAssistantMessages(body: unknown, responseUrl: string): unknown { + let url: URL; + try { url = new URL(responseUrl); } catch { return body; } + if (url.hostname !== "api.kimi.com" || !/^\/coding\/v1\/responses\/?$/.test(url.pathname)) return body; + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + const input = body.input.filter(item => !isEmptyAssistantMessage(item)); + return input.length === body.input.length ? body : { ...body, input }; +} diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 2c02780a618..3b913b3caec 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -50,6 +50,7 @@ import { observeOutbound } from "../../usage/cache-diagnostic"; import { normalizeForwardedClientHeaderName } from "../../lib/provider-client-headers"; import { normalizeMuseToolChoice } from "./muse-tool-choice"; import { renderCompactionSearchHistoryItems } from "./compaction-search-history"; +import { stripKimiEmptyAssistantMessages } from "./empty-assistant"; /** * Identifies DeepSeek's strict Responses replay contract: tool-bearing continuations need @@ -477,6 +478,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Run after routed compaction so nested input_image parts are replaced before a malformed // tool output is flattened to text and can no longer be inspected structurally. outBody = repairUnidentifiedToolOutputItems(outBody); + outBody = stripKimiEmptyAssistantMessages(outBody, url); if (parsed._plaintextV2AgentMessages === true && isCanonicalOpenAiForwardProvider(provider)) { const prepared = preparePlaintextV2AgentMessages(outBody); outBody = prepared.body; diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index f5e0545ab74..a78de6f6b33 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -151,6 +151,18 @@ owned by the selected adapter's final compiler and cannot affect adapter selecti The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. +## Empty assistant history + +`src/adapters/openai-chat/messages.ts` omits assistant turns whose joined text is empty and +that carry neither tool calls nor serialized reasoning. Whitespace and nonempty answer text +stay byte-exact; tool-call and reasoning-only continuations keep their existing content shape. + +On native Responses, `src/adapters/openai-responses/empty-assistant.ts` removes only explicit +content-empty assistant message items sent to `api.kimi.com/coding/v1/responses`. Refusals, +nonempty annotations, unknown parts/fields, reasoning and tools are retained. Independent +reasoning/call/result items, caller-owned raw history and other destinations are unchanged. +`tests/adapters/openai/openai-empty-assistant-history.test.ts` covers both final wires. + ## Moonshot `$ref`-with-siblings normalization Moonshot/Kimi enforce the draft-07 reading where `$ref` must stand alone and 400 the whole diff --git a/tests/adapters/openai/openai-empty-assistant-history.test.ts b/tests/adapters/openai/openai-empty-assistant-history.test.ts new file mode 100644 index 00000000000..a22eb7f2267 --- /dev/null +++ b/tests/adapters/openai/openai-empty-assistant-history.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import { createResponsesPassthroughAdapter } from "../../../src/adapters/openai-responses"; +import { parseRequest } from "../../../src/responses/parser"; +import { createTranslatorBudget } from "../../../src/lib/translator-budget"; +import type { OcxProviderConfig } from "../../../src/types"; + +const provider: OcxProviderConfig = { + adapter: "openai-responses", baseUrl: "https://api.kimi.com/coding/v1", + authMode: "oauth", apiKey: "test-credential", modelSuffixBracketStrip: true, + preserveReasoningContentModels: ["k3", "k3[1m]"], + requiresAdjacentResponsesToolResults: true, +}; +const user = { type: "message", role: "user", content: "continue" }; +const assistant = (content: unknown, extra: Record = {}) => + ({ type: "message", role: "assistant", content, ...extra }); + +function build(input: unknown[], wire: "responses" | "chat" = "responses", target = provider) { + const raw = { model: "k3[1m]", input, store: true }; + const snapshot = JSON.stringify(raw); + const parsed = parseRequest(raw); + const adapter = wire === "responses" + ? createResponsesPassthroughAdapter(target) + : createOpenAIChatAdapter({ ...target, adapter: "openai-chat" }); + const budget = createTranslatorBudget(); + const request = adapter.buildRequest(parsed, { headers: new Headers(), translatorBudget: budget }); + request.releaseBodyObservation?.(); + budget.dispose(); + expect(JSON.stringify(raw)).toBe(snapshot); + expect(JSON.stringify(parsed._rawBody)).toBe(snapshot); + return { parsed, request, body: JSON.parse(request.body) as Record }; +} + +describe("Kimi empty assistant history on the final Responses wire", () => { + test.each(["", [], [{ type: "output_text", text: "" }], [ + { type: "output_text", text: "", annotations: [] }, { type: "output_text", text: "" }, + ]].map(content => ({ content })))("removes explicit content-empty messages without inventing text: $content", ({ content }) => { + const { body } = build([assistant(content), user]); + expect(body.input).toEqual([user]); + }); + + test("synthetic 585th empty assistant passes an offline strict upstream after filtering", async () => { + const input: unknown[] = Array.from({ length: 584 }, (_, i) => ({ ...user, content: `turn ${i}` })); + input.push(assistant([{ type: "output_text", text: "" }]), user); + const { request, body } = build(input); + // This models the observed error, not a capture of the user's 585th source item. + const fakeFetch = async (_url: string, init: RequestInit) => { + const wire = JSON.parse(String(init.body)); + const bad = wire.input.findIndex((item: any) => item.role === "assistant" + && Array.isArray(item.content) && item.content.every((part: any) => part.text === "")); + return bad >= 0 + ? new Response(`Invalid request: the message at position ${bad + 1} with role 'assistant' must not be empty`, { status: 400 }) + : new Response("{}", { status: 200 }); + }; + const original = await fakeFetch(request.url, { body: JSON.stringify({ input }) }); + expect(original.status).toBe(400); + expect(await original.text()).toBe("Invalid request: the message at position 585 with role 'assistant' must not be empty"); + expect(body.input).toHaveLength(585); + expect((await fakeFetch(request.url, { body: request.body })).status).toBe(200); + }); + + test("preserves text, whitespace, refusals, unknown parts and independent tools/reasoning", () => { + const kept = [ + assistant("valid answer"), assistant(" \n"), + assistant([{ type: "output_text", text: "", annotations: [{ type: "citation", value: "evidence" }] }]), + assistant([{ type: "refusal", refusal: "refused" }]), + assistant([{ type: "future_part", payload: "keep" }]), + assistant("", { reasoning_content: "keep reasoning" }), + assistant([], { reasoning_details: [{ type: "reasoning.text", text: "keep details" }] }), + assistant([], { encrypted_content: "opaque-test-state" }), + assistant("", { tool_calls: [{ id: "legacy", type: "function", function: { name: "read_file", arguments: "{}" } }] }), + { type: "reasoning", summary: [{ type: "summary_text", text: "reasoning" }] }, + { type: "reasoning", encrypted_content: "opaque-test-state", summary: [] }, + { type: "function_call", call_id: "call_1", name: "read_file", arguments: "{}" }, + { type: "function_call_output", call_id: "call_1", output: "result" }, user, + ]; + expect(build(kept).body.input).toEqual(kept); + }); + + test("drops blank metadata-bearing messages between paired calls without changing the tool history", () => { + const call = { type: "function_call", call_id: "call_1", name: "read_file", arguments: "{}" }; + const result = { type: "function_call_output", call_id: "call_1", output: "result" }; + const implicit = { role: "assistant", content: [{ type: "output_text", text: "" }] }; + expect(build([ + call, assistant("", { id: "msg_blank", status: "completed", phase: "commentary" }), + result, implicit, user, + ]).body.input).toEqual([call, result, user]); + }); + + test.each([ + "https://api.openai.com/v1", "https://api.deepseek.com/v1", + "https://api.minimax.io/v1", "https://api.moonshot.ai/v1", + "https://api.kimi.com/v1", "https://api.kimi.com.evil.test/coding/v1", + ])("keeps other destination contracts unchanged: %s", baseUrl => { + const input = [assistant(""), user]; + expect(build(input, "responses", { ...provider, baseUrl }).body.input).toEqual(input); + }); +}); + +describe("translated Chat empty assistant projection", () => { + test.each(["", [], [{ type: "output_text", text: "" }]].map(content => ({ content })))( + "retains serialized reasoning despite empty answer content: $content", ({ content }) => { + const reasoning = { type: "reasoning", summary: [{ type: "summary_text", text: "retained reasoning" }] }; + const { body } = build([reasoning, assistant(content), user], "chat"); + expect(body.messages[0]).toEqual({ role: "assistant", content: "", reasoning_content: "retained reasoning" }); + const details = build([reasoning, assistant(content), user], "chat", { + ...provider, baseUrl: "https://api.minimax.io/v1", reasoningDetailsModels: ["k3[1m]"], + }).body.messages[0]; + expect(details.reasoning_details[0].text).toBe("retained reasoning"); + expect(details.content).toBe(""); + }, + ); + + test("parser preserves empty output text but the Chat wire omits content-empty assistant", () => { + const { parsed, body } = build([assistant([{ type: "output_text", text: "" }]), user], "chat"); + expect(parsed.context.messages[0]).toMatchObject({ role: "assistant", content: [{ type: "text", text: "" }] }); + expect(body.messages).toEqual([{ role: "user", content: "continue" }]); + }); + + test("an empty assistant does not flush pending calls or duplicate matching results", () => { + const { body } = build([ + { type: "function_call", call_id: "call_1", name: "read_file", arguments: "{}" }, + assistant([{ type: "output_text", text: "" }]), + { type: "function_call_output", call_id: "call_1", output: "result" }, + assistant("valid answer"), assistant(" \n"), user, + ], "chat"); + expect(body.messages.map((item: any) => item.role)).toEqual(["assistant", "tool", "assistant", "assistant", "user"]); + expect(body.messages[0].tool_calls[0].id).toBe("call_1"); + expect(body.messages[1]).toEqual({ role: "tool", tool_call_id: "call_1", content: "result" }); + expect(body.messages[2].content).toBe("valid answer"); + expect(body.messages[3].content).toBe(" \n"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 9071bebffff..674ecfb4851 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,5 @@ { - "messages-request-id-headers.test.ts": "claude-integration", + "messages-request-id-headers.test.ts": "claude-integration", "openai-empty-assistant-history.test.ts": "adapters/openai", "messages-request-id-endpoint.test.ts": "claude-integration", "anthropic-revoked-token.test.ts": "adapters/anthropic", "messages-revoked-token.test.ts": "claude-integration",