mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
fix(claudeHelper): preserve latest assistant thinking blocks verbatim
Derived from squash commit 161cfcf7 (PR #9). The original squash was fat (316 files) because the source branch was rebased on an old base; this commit applies only the claudeHelper-relevant files surgically onto deploy. Computes latestAssistantIndex once before the message loop and skips the rewrite-to-redacted-thinking transform on the latest assistant message. Symmetric guard for non-Anthropic Claude-shape providers preserves plain thinking.thinking text on the latest message. Co-authored-by: OmniRoute Ops <ops@nomenak.dev>
This commit is contained in:
@@ -1,5 +1,15 @@
|
||||
// Claude helper functions for translator
|
||||
import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts";
|
||||
import { lookupReasoning, recordReplay } from "../../services/reasoningCache.ts";
|
||||
|
||||
// Placeholder thinking text used as last-resort fallback when:
|
||||
// - Target upstream is a non-Anthropic Claude-shape provider
|
||||
// (kimi-coding, glmt, zai, …) that rejects redacted_thinking blobs
|
||||
// - Client (e.g. Capy) sent only redacted_thinking on replay
|
||||
// - reasoningCache has no entry for the corresponding tool_use.id
|
||||
// Must be non-empty: kimi-coding treats empty `thinking.thinking` as
|
||||
// `reasoning_content missing` and 400s.
|
||||
const NON_ANTHROPIC_THINKING_PLACEHOLDER = "(prior reasoning summary unavailable)";
|
||||
|
||||
type ClaudeContentBlock = {
|
||||
type?: string;
|
||||
@@ -151,6 +161,17 @@ export function prepareClaudeRequest(
|
||||
const supportsPromptCaching =
|
||||
provider === "claude" || provider?.startsWith?.("anthropic-compatible-");
|
||||
|
||||
// Non-Anthropic Claude-shape providers (kimi-coding, glmt, zai, …) cannot
|
||||
// validate the synthetic redacted_thinking.data blob — they're not Anthropic
|
||||
// and don't speak its signature scheme. They expect plain `thinking { text }`
|
||||
// blocks with the original reasoning text, or fail with:
|
||||
// "thinking is enabled but reasoning_content is missing in assistant
|
||||
// tool call message at index N"
|
||||
// We use the same allowlist as prompt-caching: only Anthropic-native
|
||||
// upstreams get redacted_thinking. Everything else gets plain thinking blocks
|
||||
// backed by reasoningCache (real text) or a placeholder (cache miss).
|
||||
const supportsRedactedThinking = supportsPromptCaching;
|
||||
|
||||
const systemBlocks = body.system;
|
||||
if (systemBlocks && Array.isArray(systemBlocks) && !preserveCacheControl) {
|
||||
body.system = systemBlocks.map((block, i) => {
|
||||
@@ -233,6 +254,19 @@ export function prepareClaudeRequest(
|
||||
}
|
||||
|
||||
// Pass 2 (reverse): add cache_control to last assistant + handle thinking for Anthropic
|
||||
|
||||
// Index of the LAST assistant message in the filtered array. Anthropic
|
||||
// enforces the latest assistant message's thinking blocks cannot be
|
||||
// modified — preserve them verbatim. Older assistant messages can be
|
||||
// rewritten to redacted_thinking { data } as before.
|
||||
let latestAssistantIndex = -1;
|
||||
for (let k = filtered.length - 1; k >= 0; k--) {
|
||||
if (filtered[k]?.role === "assistant") {
|
||||
latestAssistantIndex = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let lastAssistantProcessed = false;
|
||||
for (let i = filtered.length - 1; i >= 0; i--) {
|
||||
const msg = filtered[i];
|
||||
@@ -264,46 +298,132 @@ export function prepareClaudeRequest(
|
||||
// assistant tool call message at index N" (kimi-coding)
|
||||
// "Invalid signature in thinking block" (claude native, on
|
||||
// cross-provider replay)
|
||||
// Guard: never modify EXISTING thinking blocks in the latest assistant
|
||||
// message when sending to an Anthropic-native upstream. Anthropic returns
|
||||
// 400 "blocks in the latest assistant message cannot be modified" if any
|
||||
// field changes. Injecting a NEW thinking block (when none exists) is fine.
|
||||
// Older assistant messages can still be rewritten.
|
||||
// For non-Anthropic providers: only the text replacement is skipped
|
||||
// for the latest assistant (if it already has non-empty thinking text);
|
||||
// field cleanup (signature strip, type normalization) still runs.
|
||||
const isLatestAssistant = i === latestAssistantIndex;
|
||||
const latestHasExistingThinking =
|
||||
isLatestAssistant &&
|
||||
content.some((b: any) => b.type === "thinking" || b.type === "redacted_thinking");
|
||||
if (latestHasExistingThinking && supportsRedactedThinking) {
|
||||
// Anthropic: skip all thinking-block rewrites entirely — the
|
||||
// blocks must remain verbatim (type, thinking, signature, data).
|
||||
continue;
|
||||
}
|
||||
|
||||
let hasToolUse = false;
|
||||
let hasThinking = false;
|
||||
|
||||
// Convert thinking blocks to redacted_thinking with synthetic `data`.
|
||||
// When requests cross provider boundaries (e.g., combo fallback) or
|
||||
// when client-stored signatures (Capy) replay back to Anthropic, the
|
||||
// original `thinking.signature` no longer validates: "Invalid signature
|
||||
// in thinking block" 400. redacted_thinking accepts without signature
|
||||
// validation — but Anthropic REQUIRES a `data` field on it. Previous
|
||||
// versions emitted `signature` on redacted_thinking (wrong field,
|
||||
// belongs on regular `thinking`) and omitted `data`, causing:
|
||||
// messages.N.content.0.redacted_thinking.data: Field required (400)
|
||||
// Pre-collect tool_use ids in this content[] for reasoningCache
|
||||
// lookups when the upstream is a non-Anthropic Claude-shape provider.
|
||||
// The cache is keyed by tool_call_id which equals tool_use.id for
|
||||
// Anthropic-shape (the same value is reused across formats — see
|
||||
// claude-to-openai.ts:63 where openai tool_call.id = claude tool_use.id).
|
||||
const toolUseIds: string[] = [];
|
||||
if (!supportsRedactedThinking) {
|
||||
for (const block of content) {
|
||||
if (block.type === "tool_use" && typeof block.id === "string") {
|
||||
toolUseIds.push(block.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert thinking blocks per provider type:
|
||||
//
|
||||
// Fix: emit only the correct fields per type.
|
||||
// - redacted_thinking: { type, data }
|
||||
// - thinking: { type, thinking, signature }
|
||||
// We use DEFAULT_THINKING_CLAUDE_SIGNATURE as the `data` placeholder
|
||||
// — it's a proven Anthropic-format base64 blob accepted as a valid
|
||||
// redacted_thinking payload (replay context).
|
||||
// Anthropic-native (claude, anthropic-compatible-*):
|
||||
// Emit redacted_thinking { data } with synthetic blob. Anthropic
|
||||
// accepts this as a valid placeholder for replay context without
|
||||
// re-validating the original signature. Previous behavior — keep.
|
||||
//
|
||||
// When requests cross provider boundaries (e.g., combo fallback) or
|
||||
// when client-stored signatures (Capy) replay back to Anthropic, the
|
||||
// original `thinking.signature` no longer validates: "Invalid
|
||||
// signature in thinking block" 400. redacted_thinking accepts without
|
||||
// signature validation — but Anthropic REQUIRES a `data` field.
|
||||
// Field rules: redacted_thinking={type,data} ; thinking={type,thinking,signature}.
|
||||
//
|
||||
// Non-Anthropic Claude-shape (kimi-coding, glmt, zai, …):
|
||||
// Emit plain thinking { thinking: <text> } using the real reasoning
|
||||
// text from reasoningCache (captured on the prior assistant
|
||||
// response). Falls back to NON_ANTHROPIC_THINKING_PLACEHOLDER if the
|
||||
// cache misses (rare but possible after a process restart or TTL
|
||||
// eviction). Empty text is treated as "missing" by kimi-coding so
|
||||
// never emit an empty thinking field.
|
||||
let thinkingBlockIdx = 0;
|
||||
for (const block of content) {
|
||||
if (block.type === "thinking" || block.type === "redacted_thinking") {
|
||||
block.type = "redacted_thinking";
|
||||
block.data = DEFAULT_THINKING_CLAUDE_SIGNATURE;
|
||||
delete block.thinking;
|
||||
delete block.signature;
|
||||
if (supportsRedactedThinking) {
|
||||
block.type = "redacted_thinking";
|
||||
block.data = DEFAULT_THINKING_CLAUDE_SIGNATURE;
|
||||
delete block.thinking;
|
||||
delete block.signature;
|
||||
} else {
|
||||
const existing =
|
||||
typeof block.thinking === "string" && block.thinking.length > 0
|
||||
? block.thinking
|
||||
: "";
|
||||
let text = existing;
|
||||
// For the latest assistant message on non-Anthropic upstreams,
|
||||
// preserve the thinking text verbatim when it is already present.
|
||||
// Cache lookups and the placeholder fallback only apply to older
|
||||
// messages (or to the latest if the client sent empty text).
|
||||
if (!text || !latestHasExistingThinking) {
|
||||
if (!text) {
|
||||
const pairedToolUseId = toolUseIds[thinkingBlockIdx];
|
||||
if (pairedToolUseId) {
|
||||
const cached = lookupReasoning(pairedToolUseId);
|
||||
if (cached) {
|
||||
text = cached;
|
||||
recordReplay();
|
||||
}
|
||||
}
|
||||
}
|
||||
block.type = "thinking";
|
||||
block.thinking = text || NON_ANTHROPIC_THINKING_PLACEHOLDER;
|
||||
} else {
|
||||
// latestHasExistingThinking + non-empty text: preserve text, still clean up fields
|
||||
block.type = "thinking";
|
||||
}
|
||||
delete block.data;
|
||||
delete block.signature;
|
||||
}
|
||||
hasThinking = true;
|
||||
thinkingBlockIdx++;
|
||||
}
|
||||
if (block.type === "tool_use") hasToolUse = true;
|
||||
}
|
||||
|
||||
// Add thinking block if thinking enabled + has tool_use but no thinking.
|
||||
// Required for Anthropic-shape thinking-mode upstreams (claude, kimi,
|
||||
// glm) when the assistant turn's content[] needs a precursor thinking
|
||||
// block in front of any tool_use. Use redacted_thinking shape (with
|
||||
// `data`) to match what we emit when converting real thinking blocks.
|
||||
// Add precursor thinking block if thinking enabled + has tool_use but
|
||||
// no existing thinking-ish block. Required for Anthropic-shape
|
||||
// thinking-mode upstreams (claude, kimi-coding, glm, …) when the
|
||||
// assistant turn's content[] needs a thinking block in front of any
|
||||
// tool_use. Use the same provider-aware shape selection as above.
|
||||
if (thinkingEnabled && !hasThinking && hasToolUse) {
|
||||
content.unshift({
|
||||
type: "redacted_thinking",
|
||||
data: DEFAULT_THINKING_CLAUDE_SIGNATURE,
|
||||
});
|
||||
if (supportsRedactedThinking) {
|
||||
content.unshift({
|
||||
type: "redacted_thinking",
|
||||
data: DEFAULT_THINKING_CLAUDE_SIGNATURE,
|
||||
});
|
||||
} else {
|
||||
let text = "";
|
||||
const firstToolUseId = toolUseIds[0];
|
||||
if (firstToolUseId) {
|
||||
const cached = lookupReasoning(firstToolUseId);
|
||||
if (cached) {
|
||||
text = cached;
|
||||
recordReplay();
|
||||
}
|
||||
}
|
||||
content.unshift({
|
||||
type: "thinking",
|
||||
thinking: text || NON_ANTHROPIC_THINKING_PLACEHOLDER,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import assert from "node:assert/strict";
|
||||
const { prepareClaudeRequest } = await import("../../open-sse/translator/helpers/claudeHelper.ts");
|
||||
const { DEFAULT_THINKING_CLAUDE_SIGNATURE } =
|
||||
await import("../../open-sse/config/defaultThinkingSignature.ts");
|
||||
const reasoningCache = await import("../../open-sse/services/reasoningCache.ts");
|
||||
|
||||
// Placeholder string from claudeHelper.ts — kept in sync via direct constant.
|
||||
const PLACEHOLDER = "(prior reasoning summary unavailable)";
|
||||
|
||||
function multiTurnBodyWithoutThinkingBlock() {
|
||||
return {
|
||||
@@ -16,46 +20,149 @@ function multiTurnBodyWithoutThinkingBlock() {
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call_x",
|
||||
content: "README.md\npackage.json",
|
||||
},
|
||||
],
|
||||
content: [{ type: "tool_result", tool_use_id: "call_x", content: "README.md" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test("prepareClaudeRequest: claude provider — injects thinking before tool_use (regression)", () => {
|
||||
function multiTurnBodyWithThinkingBlock(thinkingText: string, toolUseId = "call_y") {
|
||||
return {
|
||||
thinking: { type: "enabled", budget_tokens: 4096 },
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "hi" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: thinkingText, signature: "client-stored-sig" },
|
||||
{ type: "tool_use", id: toolUseId, name: "ls", input: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: toolUseId, content: "ok" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────── Anthropic-native (claude, anthropic-compatible-*) ────────────────
|
||||
|
||||
test("claude provider — empty content, injects redacted_thinking{data} before tool_use", () => {
|
||||
const body = multiTurnBodyWithoutThinkingBlock();
|
||||
const result = prepareClaudeRequest(body as any, "claude");
|
||||
const assistantContent = (result as any).messages[1].content;
|
||||
assert.equal(assistantContent.length, 2, "thinking + tool_use");
|
||||
assert.equal(assistantContent[0].type, "redacted_thinking");
|
||||
assert.equal(assistantContent[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
|
||||
assert.equal(assistantContent[0].thinking, undefined);
|
||||
assert.equal(assistantContent[0].signature, undefined);
|
||||
assert.equal(assistantContent[1].type, "tool_use");
|
||||
const content = (result as any).messages[1].content;
|
||||
assert.equal(content.length, 2);
|
||||
assert.equal(content[0].type, "redacted_thinking");
|
||||
assert.equal(content[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
|
||||
assert.equal(content[0].thinking, undefined);
|
||||
assert.equal(content[0].signature, undefined);
|
||||
assert.equal(content[1].type, "tool_use");
|
||||
});
|
||||
|
||||
test("prepareClaudeRequest: kimi-coding provider — injects thinking before tool_use (new behavior)", () => {
|
||||
// Previously: kimi-coding was excluded by the provider gate and the assistant
|
||||
// turn shipped to api.kimi.com/coding/v1/messages without a thinking
|
||||
// precursor, triggering 400 "thinking is enabled but reasoning_content is
|
||||
// missing in assistant tool call message at index N".
|
||||
test("claude provider — existing thinking block converted to redacted_thinking{data} on older messages", () => {
|
||||
// Uses a two-assistant-turn body: the first assistant (with thinking) is an
|
||||
// older turn; the second (latest) assistant's thinking must stay verbatim.
|
||||
// This verifies that older assistant thinking blocks ARE rewritten.
|
||||
const body: any = {
|
||||
thinking: { type: "enabled", budget_tokens: 4096 },
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "hi" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "real thinking text", signature: "client-stored-sig" },
|
||||
{ type: "tool_use", id: "call_y", name: "ls", input: {} },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_y", content: "ok" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "latest thinking", signature: "latest-sig" },
|
||||
{ type: "tool_use", id: "call_z", name: "ls", input: {} },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_z", content: "ok" }] },
|
||||
],
|
||||
};
|
||||
prepareClaudeRequest(body, "claude");
|
||||
// Older assistant: thinking rewritten to redacted_thinking
|
||||
const olderContent = body.messages[1].content;
|
||||
assert.equal(olderContent.length, 2, "no double-inject");
|
||||
assert.equal(olderContent[0].type, "redacted_thinking");
|
||||
assert.equal(olderContent[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
|
||||
assert.equal(
|
||||
olderContent[0].thinking,
|
||||
undefined,
|
||||
"plain text stripped (Anthropic does not trust replay text)"
|
||||
);
|
||||
assert.equal(olderContent[0].signature, undefined);
|
||||
assert.equal(olderContent[1].type, "tool_use");
|
||||
// Latest assistant: thinking preserved verbatim
|
||||
const latestContent = body.messages[3].content;
|
||||
assert.equal(latestContent[0].type, "thinking");
|
||||
assert.equal(latestContent[0].thinking, "latest thinking");
|
||||
assert.equal(latestContent[0].signature, "latest-sig");
|
||||
});
|
||||
|
||||
test("anthropic-compatible-* provider — same as claude (redacted_thinking)", () => {
|
||||
const body = multiTurnBodyWithoutThinkingBlock();
|
||||
const result = prepareClaudeRequest(body as any, "anthropic-compatible-abc123");
|
||||
const content = (result as any).messages[1].content;
|
||||
assert.equal(content[0].type, "redacted_thinking");
|
||||
assert.equal(content[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
|
||||
});
|
||||
|
||||
// ──────────────── Non-Anthropic Claude-shape (kimi-coding, glmt, zai, …) ────────────────
|
||||
|
||||
test("kimi-coding provider — empty content, injects plain thinking{text} with placeholder (cache miss)", () => {
|
||||
reasoningCache.clearReasoningCacheAll();
|
||||
const body = multiTurnBodyWithoutThinkingBlock();
|
||||
const result = prepareClaudeRequest(body as any, "kimi-coding");
|
||||
const assistantContent = (result as any).messages[1].content;
|
||||
assert.equal(assistantContent.length, 2, "thinking + tool_use");
|
||||
assert.equal(assistantContent[0].type, "redacted_thinking");
|
||||
assert.equal(assistantContent[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
|
||||
assert.equal(assistantContent[0].thinking, undefined);
|
||||
assert.equal(assistantContent[0].signature, undefined);
|
||||
const content = (result as any).messages[1].content;
|
||||
assert.equal(content.length, 2);
|
||||
assert.equal(content[0].type, "thinking");
|
||||
assert.equal(content[0].thinking, PLACEHOLDER);
|
||||
assert.equal(content[0].data, undefined, "no data field on plain thinking");
|
||||
assert.equal(content[0].signature, undefined, "no signature field on cross-provider replay");
|
||||
assert.equal(content[1].type, "tool_use");
|
||||
});
|
||||
|
||||
test("prepareClaudeRequest: existing thinking block — redacted, signature replaced, no double-inject", () => {
|
||||
test("kimi-coding provider — empty content + cache hit on tool_use.id, injects real reasoning text", () => {
|
||||
reasoningCache.clearReasoningCacheAll();
|
||||
reasoningCache.cacheReasoning(
|
||||
"call_x",
|
||||
"kimi-coding",
|
||||
"kimi-k2.6",
|
||||
"the model actually thought this"
|
||||
);
|
||||
const body = multiTurnBodyWithoutThinkingBlock();
|
||||
const result = prepareClaudeRequest(body as any, "kimi-coding");
|
||||
const content = (result as any).messages[1].content;
|
||||
assert.equal(content[0].type, "thinking");
|
||||
assert.equal(content[0].thinking, "the model actually thought this");
|
||||
});
|
||||
|
||||
test("kimi-coding provider — existing thinking block: client text preserved, signature stripped, data NOT added", () => {
|
||||
reasoningCache.clearReasoningCacheAll();
|
||||
const body = multiTurnBodyWithThinkingBlock("client preserved reasoning", "call_y");
|
||||
const result = prepareClaudeRequest(body as any, "kimi-coding");
|
||||
const content = (result as any).messages[1].content;
|
||||
assert.equal(content.length, 2);
|
||||
assert.equal(content[0].type, "thinking");
|
||||
assert.equal(content[0].thinking, "client preserved reasoning", "client text preserved");
|
||||
assert.equal(content[0].data, undefined);
|
||||
assert.equal(
|
||||
content[0].signature,
|
||||
undefined,
|
||||
"client-stored signature stripped (no value for kimi)"
|
||||
);
|
||||
});
|
||||
|
||||
test("kimi-coding provider — existing redacted_thinking block (no text), cache hit injects real text", () => {
|
||||
reasoningCache.clearReasoningCacheAll();
|
||||
reasoningCache.cacheReasoning("call_z", "kimi-coding", "kimi-k2.6", "cached reasoning v2");
|
||||
const body = {
|
||||
thinking: { type: "enabled", budget_tokens: 4096 },
|
||||
messages: [
|
||||
@@ -63,35 +170,10 @@ test("prepareClaudeRequest: existing thinking block — redacted, signature repl
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "reasoning here", signature: "old-sig" },
|
||||
{ type: "tool_use", id: "call_y", name: "ls", input: {} },
|
||||
{ type: "redacted_thinking", data: "opaque-blob-from-prior-anthropic-turn" },
|
||||
{ type: "tool_use", id: "call_z", name: "ls", input: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "call_y", content: "ok" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = prepareClaudeRequest(body as any, "kimi-coding");
|
||||
const assistantContent = (result as any).messages[1].content;
|
||||
assert.equal(assistantContent.length, 2, "no double-inject — exactly 2 blocks");
|
||||
assert.equal(assistantContent[0].type, "redacted_thinking", "thinking → redacted_thinking");
|
||||
assert.equal(assistantContent[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
|
||||
assert.equal(assistantContent[0].thinking, undefined, "thinking field stripped");
|
||||
assert.equal(assistantContent[0].signature, undefined, "signature field stripped");
|
||||
assert.equal(assistantContent[1].type, "tool_use");
|
||||
});
|
||||
|
||||
test("prepareClaudeRequest: thinking disabled — no inject regardless of tool_use presence", () => {
|
||||
const body = {
|
||||
thinking: { type: "disabled" },
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "hi" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "call_z", name: "ls", input: {} }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "call_z", content: "ok" }],
|
||||
@@ -99,20 +181,151 @@ test("prepareClaudeRequest: thinking disabled — no inject regardless of tool_u
|
||||
],
|
||||
};
|
||||
const result = prepareClaudeRequest(body as any, "kimi-coding");
|
||||
const assistantContent = (result as any).messages[1].content;
|
||||
assert.equal(assistantContent.length, 1, "no thinking injected when thinking is disabled");
|
||||
assert.equal(assistantContent[0].type, "tool_use");
|
||||
const content = (result as any).messages[1].content;
|
||||
assert.equal(content[0].type, "thinking");
|
||||
assert.equal(content[0].thinking, "cached reasoning v2", "cache substitutes redacted data");
|
||||
assert.equal(content[0].data, undefined);
|
||||
});
|
||||
|
||||
test("prepareClaudeRequest: thinking enabled + no tool_use — no inject (single-turn text)", () => {
|
||||
test("kimi-coding provider — existing redacted_thinking block (no text), cache miss → placeholder", () => {
|
||||
reasoningCache.clearReasoningCacheAll();
|
||||
const body = {
|
||||
thinking: { type: "enabled", budget_tokens: 4096 },
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "hi" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "redacted_thinking", data: "opaque-blob" },
|
||||
{ type: "tool_use", id: "call_z_miss", name: "ls", input: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "call_z_miss", content: "ok" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = prepareClaudeRequest(body as any, "kimi-coding");
|
||||
const userContent = (result as any).messages[0].content;
|
||||
assert.ok(Array.isArray(userContent));
|
||||
assert.equal(userContent[0].type, "text");
|
||||
// No new thinking block prepended on user messages
|
||||
assert.equal(userContent.length, 1);
|
||||
const content = (result as any).messages[1].content;
|
||||
assert.equal(content[0].type, "thinking");
|
||||
assert.equal(content[0].thinking, PLACEHOLDER);
|
||||
});
|
||||
|
||||
// ──────────────── Disabled / no-op paths ────────────────
|
||||
|
||||
test("thinking disabled — no inject regardless of provider or tool_use", () => {
|
||||
for (const provider of ["claude", "kimi-coding", "anthropic-compatible-x"]) {
|
||||
const body = {
|
||||
thinking: { type: "disabled" },
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "hi" }] },
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "x", name: "ls", input: {} }] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "x", content: "ok" }] },
|
||||
],
|
||||
};
|
||||
const result = prepareClaudeRequest(body as any, provider);
|
||||
const content = (result as any).messages[1].content;
|
||||
assert.equal(content.length, 1, `${provider}: no inject when thinking disabled`);
|
||||
assert.equal(content[0].type, "tool_use");
|
||||
}
|
||||
});
|
||||
|
||||
test("thinking enabled + no tool_use — no precursor inject (single-turn text)", () => {
|
||||
for (const provider of ["claude", "kimi-coding"]) {
|
||||
const body = {
|
||||
thinking: { type: "enabled", budget_tokens: 4096 },
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
|
||||
};
|
||||
const result = prepareClaudeRequest(body as any, provider);
|
||||
const content = (result as any).messages[0].content;
|
||||
assert.ok(Array.isArray(content));
|
||||
assert.equal(content.length, 1);
|
||||
assert.equal(content[0].type, "text");
|
||||
}
|
||||
});
|
||||
|
||||
// ──────────────── Latest-assistant preservation (Anthropic & non-Anthropic) ────────────────
|
||||
|
||||
test("preserves verbatim thinking on the LATEST assistant message; rewrites only older ones", () => {
|
||||
const body: any = {
|
||||
thinking: { type: "enabled", budget_tokens: 4096 },
|
||||
model: "claude-opus-4-7",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "older thought", signature: "sig-OLD" },
|
||||
{ type: "tool_use", id: "tool_1", name: "do_x", input: {} },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "tool_1", content: "ok" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "latest thought", signature: "sig-LATEST" },
|
||||
{ type: "tool_use", id: "tool_2", name: "do_y", input: {} },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "tool_2", content: "ok" }] },
|
||||
],
|
||||
};
|
||||
|
||||
prepareClaudeRequest(body, "claude");
|
||||
|
||||
const olderAssistant = body.messages[0];
|
||||
const latestAssistant = body.messages[2];
|
||||
|
||||
// Older assistant thinking: rewritten to redacted_thinking { data }
|
||||
assert.equal(olderAssistant.content[0].type, "redacted_thinking");
|
||||
assert.ok(
|
||||
typeof olderAssistant.content[0].data === "string" && olderAssistant.content[0].data.length > 0
|
||||
);
|
||||
assert.equal(olderAssistant.content[0].thinking, undefined);
|
||||
assert.equal(olderAssistant.content[0].signature, undefined);
|
||||
|
||||
// Latest assistant thinking: untouched (type, text, signature all preserved)
|
||||
assert.equal(latestAssistant.content[0].type, "thinking");
|
||||
assert.equal(latestAssistant.content[0].thinking, "latest thought");
|
||||
assert.equal(latestAssistant.content[0].signature, "sig-LATEST");
|
||||
assert.equal(latestAssistant.content[0].data, undefined);
|
||||
});
|
||||
|
||||
test("non-Anthropic upstream: preserves latest assistant thinking text verbatim, only fills older from cache/placeholder", () => {
|
||||
reasoningCache.clearReasoningCacheAll();
|
||||
const body: any = {
|
||||
thinking: { type: "enabled", budget_tokens: 4096 },
|
||||
model: "kimi-k2.6-thinking",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "" /* stripped on wire */ },
|
||||
{ type: "tool_use", id: "tool_1", name: "do_x", input: {} },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "tool_1", content: "ok" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "latest reasoning text" },
|
||||
{ type: "tool_use", id: "tool_2", name: "do_y", input: {} },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "tool_2", content: "ok" }] },
|
||||
],
|
||||
};
|
||||
|
||||
prepareClaudeRequest(body, "kimi-coding");
|
||||
|
||||
// Latest assistant: text preserved verbatim
|
||||
assert.equal(body.messages[2].content[0].type, "thinking");
|
||||
assert.equal(body.messages[2].content[0].thinking, "latest reasoning text");
|
||||
|
||||
// Older assistant: empty text -> placeholder (cache miss path)
|
||||
assert.equal(body.messages[0].content[0].type, "thinking");
|
||||
assert.ok(
|
||||
typeof body.messages[0].content[0].thinking === "string" &&
|
||||
body.messages[0].content[0].thinking.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,8 +6,6 @@ const openaiHelper = await import("../../open-sse/translator/helpers/openaiHelpe
|
||||
const claudeHelper = await import("../../open-sse/translator/helpers/claudeHelper.ts");
|
||||
const geminiHelper = await import("../../open-sse/translator/helpers/geminiHelper.ts");
|
||||
const toolCallHelper = await import("../../open-sse/translator/helpers/toolCallHelper.ts");
|
||||
const { DEFAULT_THINKING_CLAUDE_SIGNATURE } =
|
||||
await import("../../open-sse/config/defaultThinkingSignature.ts");
|
||||
|
||||
const originalMathRandom = Math.random;
|
||||
|
||||
@@ -306,13 +304,24 @@ test("claudeHelper validates content, ordering and request preparation branches"
|
||||
assert.equal(prepared.messages.length, 6);
|
||||
assert.equal(prepared.messages[2].content.at(-1).cache_control.type, "ephemeral");
|
||||
assert.equal(prepared.messages[4].content[0].type, "tool_result");
|
||||
// messages[5] is the latest (and last) assistant message; Anthropic enforces
|
||||
// that its thinking blocks must remain verbatim — not rewritten to
|
||||
// redacted_thinking. The guard in prepareClaudeRequest preserves them.
|
||||
assert.deepEqual(
|
||||
prepared.messages[5].content.map((block) => block.type),
|
||||
["redacted_thinking", "text"]
|
||||
["thinking", "text"]
|
||||
);
|
||||
assert.equal(prepared.messages[5].content[0].thinking, "old", "thinking text preserved verbatim");
|
||||
assert.equal(
|
||||
prepared.messages[5].content[0].signature,
|
||||
"replace",
|
||||
"signature preserved verbatim"
|
||||
);
|
||||
assert.equal(
|
||||
prepared.messages[5].content[0].data,
|
||||
undefined,
|
||||
"no data field on verbatim thinking"
|
||||
);
|
||||
assert.equal(prepared.messages[5].content[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
|
||||
assert.equal(prepared.messages[5].content[0].signature, undefined);
|
||||
assert.equal(prepared.messages[5].content[0].thinking, undefined);
|
||||
assert.equal(prepared.tools.length, 2);
|
||||
assert.equal(prepared.tools[0].cache_control, undefined);
|
||||
assert.deepEqual(prepared.tools[1].cache_control, { type: "ephemeral", ttl: "1h" });
|
||||
|
||||
Reference in New Issue
Block a user