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
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/zh-cn/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 价格;
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion src/adapters/openai-chat/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions src/adapters/openai-responses/empty-assistant.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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 };
}
2 changes: 2 additions & 0 deletions src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions structure/adapters/registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
150 changes: 150 additions & 0 deletions tests/adapters/openai/openai-empty-assistant-history.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) =>
({ 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<string, any> };
}

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");
});
});
2 changes: 1 addition & 1 deletion tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading