From 2b299c7acbbfd18afee4115ab4c87c82ae23ef8b Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 11 Oct 2026 22:27:30 +0900 Subject: [PATCH] fix(kimi): omit explicit empty assistant history on native Responses (carry #6931) Kimi Code's native Responses endpoint rejects a content-empty assistant message ("the message at position N with role 'assistant' must not be empty"). For the exact api.kimi.com/coding/v1/responses destination, the outgoing input now drops assistant messages whose content is "" or only empty text parts, keeping any message referenced by a same-input item_reference and any message with unknown fields. On the translated Chat wire, an empty assistant turn between a tool call and its result no longer splits the pair: it used to close the pending call with a synthetic "no tool result was recorded" message and turn the real result into an orphan. A standalone empty answer keeps its slot so chat templates with strict role alternation still accept the history. Co-authored-by: panyuanyuan Co-authored-by: yuanyuanlove --- .../src/content/docs/guides/providers.md | 5 + .../content/docs/zh-cn/guides/providers.md | 5 + scripts/test-layout/layout.json | 2 +- src/adapters/openai-chat/messages.ts | 6 +- .../openai-responses/empty-assistant.ts | 32 ++++ src/adapters/openai-responses/passthrough.ts | 2 + structure/adapters/registry.md | 18 +++ .../openai-empty-assistant-history.test.ts | 150 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- 9 files changed, 219 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..3c286e20e9e 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -263,6 +263,11 @@ 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. +An empty message referenced by an `item_reference` in the same input is retained rather than leaving a dangling reference. + 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/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index a5edd4a1077..e4d90fa4495 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -136,6 +136,11 @@ Nous refresh 发生终止性失败后,请运行 `ocx login nous` 重新认证 若已 opt-in 的上游拒绝该字段,opencodex 不会删除字段后重试,也不会改动已保存配置;其他 provider 仍保持 deny-by-default。 +Kimi Code 原生 Responses 路由会省略明确正文为空的 assistant 历史消息,避免 +`assistant must not be empty` 拒绝。非空文本、空白字符、拒绝内容、推理与工具调用/结果 +保持原样;不会改写客户端存储的历史。被同一输入中的 `item_reference` 引用的空消息也会保留, +避免留下悬空引用。 + `kimi`、`kimi-code` 和 `kimi-responses` 中 `k3`、`k3[1m]`、`k3-256k` 的费用是 **API 参考价格估算**, 采用已公布的默认 5 分钟缓存写入价格,不代表 Code Plan 的实际账单或额度消耗:K3 的 1M 版本消耗 约为 `k3-256k` 两倍的额度。`kimi-for-coding` 已切换为 K2.8 Preview,因此不再使用旧 K2.7 价格; diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index bebe0dbe1b9..c81c3b5da22 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1318,7 +1318,7 @@ "ollama-native-v4.test.ts": "providers/ollama", "ollama-native.test.ts": "providers/ollama", "ollama-show-enrichment-v7.test.ts": "providers/ollama", "ollama-show-enrichment.test.ts": "providers/ollama", "ollama-show-ignore-abort.test.ts": "providers/ollama", "omo-client.test.ts": "clients", "omp-path-contract.test.ts": "clients", "omp-yaml-source-inline-comments.test.ts": "clients", "opaque-blob-wrapped-rejection.test.ts": "responses", "openai-api-virtual-models.test.ts": "adapters/openai", "openai-chat-bounded-tool-names.test.ts": "adapters/openai", "openai-chat-dangling-toolcalls.test.ts": "adapters/openai", - "openai-chat-developer-position.test.ts": "adapters/openai", "openai-chat-eof.test.ts": "adapters/openai", "openai-chat-hardening.test.ts": "adapters/openai", "openai-chat-image-normalization.test.ts": "adapters/openai", + "openai-chat-developer-position.test.ts": "adapters/openai", "openai-chat-eof.test.ts": "adapters/openai", "openai-chat-hardening.test.ts": "adapters/openai", "openai-empty-assistant-history.test.ts": "adapters/openai", "openai-chat-image-normalization.test.ts": "adapters/openai", "openai-chat-inline-think-tags.test.ts": "adapters/openai", "openai-chat-invalid-tool-call-diagnostics.test.ts": "adapters/openai", "openai-chat-model-suffix.test.ts": "adapters/openai", "openai-chat-native-policy.test.ts": "adapters/openai", "openai-chat-parallel-stream.test.ts": "adapters/openai", "openai-chat-path-override.test.ts": "adapters/openai", "openai-chat-qwen38-leading-system.test.ts": "adapters/openai", "openai-chat-reasoning-wire-policy.test.ts": "adapters/openai", "openai-chat-sanitization-review-regressions.test.ts": "adapters/openai", "openai-chat-serialized-tool-call-content.test.ts": "adapters/openai", "openai-chat-serialized-tool-call-hold-bound.test.ts": "adapters/openai", "openai-chat-serialized-tool-call-think.test.ts": "adapters/openai", diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index a3cb3e0682d..0b09922e6d0 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -292,7 +292,11 @@ 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; + // A turn with no answer text, tools or reasoning carries nothing. Between a call and its + // result it would also split the pair, so drop it there; a standalone empty answer keeps + // its slot so templates that require strict user/assistant alternation still accept it. + const carriesNothing = !chatMsg.content && toolCalls.length === 0 && !hasReplayedReasoning; + if (carriesNothing && (chatMsg.content === undefined || pendingToolCalls.length > 0)) 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..205292812e1 --- /dev/null +++ b/src/adapters/openai-responses/empty-assistant.ts @@ -0,0 +1,32 @@ +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 referencedIds = new Set(); + for (const item of body.input) { + if (isPlainObject(item) && item.type === "item_reference" && typeof item.id === "string") referencedIds.add(item.id); + } + const input = body.input.filter(item => !isEmptyAssistantMessage(item) + || (isPlainObject(item) && typeof item.id === "string" && referencedIds.has(item.id))); + 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..fb58c444e97 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -151,6 +151,24 @@ 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 an assistant turn whose joined text is empty and +that carries neither tool calls nor serialized reasoning while earlier tool calls still await +their results, so the empty turn cannot split a call from its result. Outside that window, a +standalone answer made of explicit blank text parts keeps `content: ""`, because dropping it +would put two user turns next to each other on chat templates that require strict alternation. +A message with no text parts at all (`content: ""` or `[]` on input) is still omitted, as it +was before. 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. A same-input `item_reference` keeps its target message, even +when empty, so filtering cannot create a dangling reference. 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..ffb03751edb --- /dev/null +++ b/tests/adapters/openai/openai-empty-assistant-history.test.ts @@ -0,0 +1,150 @@ +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("retains an empty message targeted by a same-input item reference without keeping unreferenced empties", () => { + const target = assistant("", { id: "msg_referenced" }); + const reference = { type: "item_reference", id: "msg_referenced" }; + expect(build([target, assistant("", { id: "msg_unreferenced" }), reference, user]).body.input) + .toEqual([target, reference, user]); + }); + + test("keeps multimodal and opaque non-text assistant parts", () => { + const kept = [assistant([{ type: "output_image", image_url: "https://example.test/fixture.png" }]), + assistant([{ type: "output_audio", data: "synthetic-audio" }])]; + expect(build([...kept, user]).body.input).toEqual([...kept, 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("a standalone empty assistant keeps its slot so strict role alternation survives", () => { + // Templates such as Mistral's accept an empty assistant string but reject consecutive users. + const first = { type: "message", role: "user", content: "first" }; + const { parsed, body } = build([first, assistant([{ type: "output_text", text: "" }]), user], "chat"); + expect(parsed.context.messages[1]).toMatchObject({ role: "assistant", content: [{ type: "text", text: "" }] }); + expect(body.messages).toEqual([ + { role: "user", content: "first" }, { role: "assistant", content: "" }, { 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 d5fdc2cafb4..bc6bd3dc2c9 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -818,7 +818,7 @@ "openai-api-virtual-models.test.ts": "adapters/openai", "openai-chat-bounded-tool-names.test.ts": "adapters/openai", "openai-chat-dangling-toolcalls.test.ts": "adapters/openai", "openai-chat-developer-position.test.ts": "adapters/openai", "openai-chat-eof.test.ts": "adapters/openai", - "openai-chat-hardening.test.ts": "adapters/openai", "openai-chat-image-normalization.test.ts": "adapters/openai", + "openai-chat-hardening.test.ts": "adapters/openai", "openai-empty-assistant-history.test.ts": "adapters/openai", "openai-chat-image-normalization.test.ts": "adapters/openai", "openai-chat-inline-think-tags.test.ts": "adapters/openai", "openai-chat-invalid-tool-call-diagnostics.test.ts": "adapters/openai", "openai-chat-model-suffix.test.ts": "adapters/openai", "openai-chat-native-policy.test.ts": "adapters/openai",