Merge #3437 + reconcile with #3439: unified cross-API reasoning preservation

#3437 (preserve cross-api reasoning replay) and #3439 (preserve 3p thinking blocks across cross-API switches) both rewrite the thinking-block path of transform-messages.ts and the encoder in openai-completions.ts to fix issues #3433/#3434, and overlap structurally. Reconciled into one resolved-compat design:

- transform-messages.ts threads the request-time resolved compat (from #3437) into the predicate, replacing #3439's base+whenThinking union traversal. resolveOpenAICompatPolicy already swaps in whenThinking for thinking-on requests, so the predicate and encoder stay in lockstep (closes both Codex 'use resolved compat' reviews and keeps the #1071/#1484 OpenCode paths correct).

- The predicate keeps #3439's broad coverage: preserve a native, signature-stripped thinking block when the resolved openai-completions target is reasoning-capable, not requiresThinkingAsText, and either requiresReasoningContentForToolCalls or thinkingFormat==='zai'. Other cross-API targets text-demote.

- openai-completions.ts keeps #3439's Z.AI-format encoder branch and #3437's compat threading, but drops #3439's text-fold fallback and isSameModelHistory: under threading they are unreachable (any preserved cross-model block is consumed by the requiresReasoningContentForToolCalls or zai branch; thinking-off targets text-demote in transform). Markup-healed same-model thinking still falls through unemitted, as before.

Regression tests from both PRs retained. Fixes #3433, #3434.
This commit is contained in:
can1357
2026-06-25 06:31:22 +02:00
6 changed files with 142 additions and 77 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
### Fixed
- Fixed cross-API 3p ↔ 3p mid-session switches (Z.AI Anthropic → Z.AI OpenAI, Kimi Anthropic → Kimi OpenAI, custom `models.yaml` provider switches that cross API types) demoting every prior `thinking` block to plain text on the cross-API path of `transformMessages`, so the next request shipped the reasoning chain as conversation text instead of structured `reasoning_content`. `transformMessages` now preserves the prior reasoning as a native, signature-stripped `thinking` block whenever the `openai-completions` target accepts `reasoning_content` as a continuation hint (`requiresReasoningContentForToolCalls` or `thinkingFormat: "zai"`), and the `openai-completions` encoder now surfaces those preserved blocks via `reasoningContentField` for Z.AI-format hosts that don't strictly require the field. Other cross-API targets (encrypted reasoning blobs, signed thought parts) still demote to text so reasoning survives at minimum as visible conversation context. ([#3434](https://github.com/can1357/oh-my-pi/issues/3434))
- Fixed prior-turn reasoning being lost on cross-API provider switches: when a session moved from an Anthropic-compatible 3p endpoint to an OpenAI-compatible one (Z.AI Anthropic → Z.AI OpenAI, Kimi Anthropic → Kimi OpenAI, DeepSeek, OpenCode-hosted reasoning models, or any custom `models.yaml` switch that crosses API types), the cross-API path of `transformMessages` text-demoted every prior `thinking` block, so the next request shipped the reasoning chain as plain conversation `content` instead of structured `reasoning_content` — losing it as reasoning context and re-billing it. `convertMessages` now threads the request-time resolved compat into `transformMessages`, which preserves the prior reasoning as a native, signature-stripped `thinking` block whenever that resolved target accepts `reasoning_content` as a continuation hint (`requiresReasoningContentForToolCalls` — including the `whenThinking` policy OpenCode reactivates for thinking-on requests, #1071/#1484 — or `thinkingFormat: "zai"`); the `openai-completions` encoder surfaces those blocks via `reasoningContentField`, with a new branch for Z.AI-format hosts (Z.AI, Zhipu, Moonshot Kimi, Xiaomi MiMo) that accept but don't require the field. Targets that can't replay unsigned reasoning (encrypted reasoning blobs, signed thought parts, non-reasoning models, thinking-disabled OpenCode) still text-demote so the reasoning survives as conversation context. ([#3437](https://github.com/can1357/oh-my-pi/pull/3437), [#3439](https://github.com/can1357/oh-my-pi/pull/3439) by [@roboomp](https://github.com/roboomp); [#3433](https://github.com/can1357/oh-my-pi/issues/3433), [#3434](https://github.com/can1357/oh-my-pi/issues/3434))
## [16.1.18] - 2026-06-25
@@ -1629,6 +1629,7 @@ export function convertMessages(
id => normalizeToolCallId(id),
maxNormalizedToolCallIdLength,
duplicateToolCallIdSuffixPrefix,
compat,
);
const remappedToolCallIds = new Map<string, string[]>();
@@ -1766,13 +1767,6 @@ export function convertMessages(
const thinkingBlocks = msg.content.filter(b => b.type === "thinking") as ThinkingContent[];
// Filter out empty thinking blocks to avoid API validation errors
const nonEmptyThinkingBlocks = thinkingBlocks.filter(b => b.thinking && b.thinking.trim().length > 0);
// Source identity: same-model history is replay we MUST NOT mutate
// beyond what the existing per-compat branches do. In particular,
// markup-healed thinking (MiniMax `<think>…</think>` and similar)
// arrives with `thinkingSignature: undefined` for same-model turns;
// it is private reasoning the model emitted, not cross-API preserved
// reasoning, and must not be promoted into visible `content`.
const isSameModelHistory = msg.api === model.api && msg.provider === model.provider && msg.model === model.id;
if (nonEmptyThinkingBlocks.length > 0) {
if (compat.requiresThinkingAsText) {
// Convert thinking blocks to plain text (no tags to avoid model mimicking them)
@@ -1811,31 +1805,6 @@ export function convertMessages(
// signature on cross-API replays before the block reaches us.
const reasoningField = compat.reasoningContentField ?? "reasoning_content";
assistantMsg[reasoningField] = nonEmptyThinkingBlocks.map(b => b.thinking).join("\n");
} else if (!isSameModelHistory && nonEmptyThinkingBlocks.every(b => !b.thinkingSignature)) {
// Cross-API preserved thinking (signature stripped by
// `transform-messages` on a non-same-model replay) that reached an
// encoder branch which can't surface `reasoning_content` for THIS
// request — most often an OpenCode-hosted reasoning model in
// thinking-disabled mode, where the resolved base compat must
// keep the field off to dodge the `Extra inputs are not permitted`
// 400 (#1071). Fold the reasoning into the visible content (same
// shape as `requiresThinkingAsText`) so the next turn at least sees
// the prior plan as conversation context, matching the pre-#3434
// cross-API text-demotion behavior.
//
// Gated on `!isSameModelHistory` because some same-model streams
// also produce unsigned thinking blocks — notably markup-healed
// `<think>…</think>` runs (MiniMax M3 on opencode-zen and the
// other `getStreamMarkupHealingPattern === "thinking"` hosts).
// That reasoning is PRIVATE to the source turn and must never be
// promoted into the next request's visible content; same-model
// history therefore keeps the legacy silently-dropped behavior
// when no per-compat branch above consumes it.
const thinkingText = nonEmptyThinkingBlocks.map(b => b.thinking).join("\n\n");
assistantMsg.content =
typeof assistantMsg.content === "string" && assistantMsg.content.length > 0
? `${thinkingText}\n\n${assistantMsg.content}`
: thinkingText;
}
}
+32 -37
View File
@@ -143,47 +143,41 @@ function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messa
return model.api === "anthropic-messages";
}
function isOpenAICompletionsModel(model: Model): model is Model<"openai-completions"> {
return model.api === "openai-completions";
}
/**
* Cross-API targets that accept a prior turn's reasoning as a native,
* signature-stripped `thinking` block. Anthropic's same-API path
* (`replayUnsignedThinking`) covers `anthropic-messages` targets directly;
* this predicate is the analogue for the `openai-completions` branch of the
* cross-API path (#3434). 3p ↔ 3p replays between an Anthropic-compatible
* source (Z.AI Anthropic, Kimi Anthropic, …) and an OpenAI-compat reasoning
* target on the same vendor must keep reasoning as structured
* `reasoning_content` instead of degrading it to conversation text.
* Cross-API `openai-completions` targets that can replay a prior turn's
* reasoning as a native, signature-stripped `thinking` block on the wire.
* Anthropic's same-API path (`replayUnsignedThinking`) covers
* `anthropic-messages` targets directly; this is the analogue for the
* `openai-completions` branch of the cross-API path (#3433/#3434). 3p ↔ 3p
* replays between an Anthropic-compatible source (Z.AI Anthropic, Kimi
* Anthropic, …) and an OpenAI-compat reasoning target on the same vendor must
* keep reasoning as structured `reasoning_content` instead of degrading it to
* conversation text.
*
* Considers both the base compat view AND `compat.whenThinking` because the
* latter is the resolved compat the request actually runs against when
* thinking is engaged on hosts like OpenCode (`opencode-go`/`opencode-zen`),
* where the base compat intentionally suppresses `requiresReasoningContentForToolCalls`
* to dodge the thinking-off `Extra inputs are not permitted` 400 (#1071) and
* the `whenThinking` policy reactivates it for thinking-on requests to dodge
* the `thinking is enabled but reasoning_content is missing` 400 (#1484).
* Ignoring `whenThinking` would re-open #1484 for every cross-API switch into
* an OpenCode-hosted reasoning model.
* `compat` MUST be the request-time RESOLVED compat that `convertMessages`
* threads into `transformMessages`, not `model.compat`. OpenCode-hosted
* reasoning models (`opencode-go`/`opencode-zen`) keep
* `requiresReasoningContentForToolCalls` off on the base compat to dodge the
* thinking-off `Extra inputs are not permitted` 400 (#1071) and reactivate it
* on `compat.whenThinking` for thinking-engaged requests to dodge the
* `thinking is enabled but reasoning_content is missing` 400 (#1484).
* `resolveOpenAICompatPolicy` already swaps in `whenThinking` for thinking-on
* requests, so basing this decision on the resolved compat keeps the predicate
* and the encoder in lockstep; reading `model.compat` would re-open #1484 for
* every cross-API switch into an OpenCode reasoning model.
*
* The downstream encoder MUST then surface the preserved block on the wire
* via `reasoningContentField` — see `openai-completions.ts` for the matching
* The downstream encoder MUST then surface the preserved block on the wire via
* `reasoningContentField` — see `openai-completions.ts` for the matching
* branch.
*/
function openAICompletionsReplaysUnsignedThinking(model: Model<"openai-completions">): boolean {
if (!model.reasoning) return false;
const base = model.compat;
if (acceptsReasoningContentReplay(base)) return true;
const whenThinking = base.whenThinking;
return whenThinking !== undefined && acceptsReasoningContentReplay(whenThinking);
}
function acceptsReasoningContentReplay(compat: Model<"openai-completions">["compat"]): boolean {
function openAICompletionsReplaysUnsignedThinking(model: Model, compat: Model["compat"]): boolean {
if (model.api !== "openai-completions" || !model.reasoning) return false;
if (compat === undefined || !("requiresReasoningContentForToolCalls" in compat)) return false;
if (compat.requiresThinkingAsText) return false;
// Hosts that REQUIRE `reasoning_content` on tool-call turns already accept
// it elsewhere; same for Z.AI-format hosts (Z.AI, Zhipu, Moonshot Kimi,
// Xiaomi MiMo), which advertise `reasoning_content` as a continuation hint.
// Hosts that REQUIRE `reasoning_content` on tool-call turns (DeepSeek
// reasoning, Kimi, OpenRouter reasoning, OpenCode thinking-on) already
// accept the replay; Z.AI-format hosts (Z.AI, Zhipu, Moonshot Kimi native,
// Xiaomi MiMo) advertise `reasoning_content` as a continuation hint.
return compat.requiresReasoningContentForToolCalls || compat.thinkingFormat === "zai";
}
@@ -225,6 +219,7 @@ export function transformMessages<TApi extends Api>(
normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string,
maxNormalizedToolCallIdLength = MAX_TOOL_CALL_ID_LENGTH,
duplicateToolCallIdSuffixPrefix = "_dup",
targetCompat: Model<TApi>["compat"] = model.compat,
): Message[] {
// Build a map of original tool call IDs to normalized IDs
const toolCallIdMap = new Map<string, string>();
@@ -379,8 +374,8 @@ export function transformMessages<TApi extends Api>(
// every cross-API 3p ↔ 3p switch (Z.AI Anthropic → Z.AI OpenAI,
// Kimi Anthropic → Kimi OpenAI, etc.) demoted prior reasoning to
// conversation text and lost it as structured reasoning context
// (#3434).
if (isOpenAICompletionsModel(model) && openAICompletionsReplaysUnsignedThinking(model)) {
// (#3433/#3434).
if (openAICompletionsReplaysUnsignedThinking(model, targetCompat)) {
return sanitized.thinkingSignature ? { ...sanitized, thinkingSignature: undefined } : sanitized;
}
// Other cross-API targets (openai-responses encrypted blobs, google
@@ -327,6 +327,36 @@ describe("DeepSeek reasoning_content tool-call replay", () => {
// Should have set reasoning_content from the thinking text via the openai path.
expect(assistant?.reasoning_content).toBe("some reasoning");
});
it("replays cross-api thinking with stripped signature through reasoning_content", () => {
const model = deepseekModel({
provider: "opencode-go",
baseUrl: "https://opencode.ai/zen/go/v1",
id: "deepseek-v4-flash",
});
const compat = model.compat;
const msg = assistantToolCall(model, [
{
type: "thinking",
thinking: "Need to preserve cross-api reasoning.",
thinkingSignature: "sig_from_anthropic",
},
{
type: "toolCall",
id: "toolu_cross_api",
name: "read",
arguments: { path: "README.md" },
},
]);
msg.api = "anthropic-messages";
msg.provider = "zai";
msg.model = "claude-compatible";
const messages = convertMessages(model, { messages: [msg] }, compat);
const assistant = findOpenAICompletionAssistantWireMessage(messages);
expect(assistant).toBeDefined();
expect(assistant?.reasoning_content).toBe("Need to preserve cross-api reasoning.");
expect(assistant?.content).toBe("");
});
it("falls through to empty-string when thinking block has opaque signature and empty text", () => {
const model = deepseekModel({
provider: "opencode-go",
+9 -7
View File
@@ -155,7 +155,7 @@ function openAIGpt4oTarget(): Model<"openai-completions"> {
} satisfies ModelSpec<"openai-completions">);
}
describe("cross-API 3p ↔ 3p thinking-block preservation (#3434)", () => {
describe("cross-API thinking-block preservation (#3433/#3434)", () => {
it("emits reasoning_content on Z.AI Anthropic → Z.AI OpenAI cross-API switch", () => {
const target = zaiOpenAITarget();
const messages: Message[] = [
@@ -273,14 +273,16 @@ describe("cross-API 3p ↔ 3p thinking-block preservation (#3434)", () => {
expect(assistant.reasoning_content).toBe("Read README and answer.");
});
it("folds preserved thinking into content when the OpenCode base compat (thinking off) cannot surface reasoning_content", () => {
it("demotes prior thinking to content when the OpenCode base compat (thinking off) cannot surface reasoning_content", () => {
// Companion of the prior test: same OpenCode target, but the request
// runs against the BASE compat (thinking disabled, the path that bars
// `reasoning_content` per #1071). Without the encoder fallback,
// transform-messages would preserve the native thinking block and the
// encoder would silently drop it — a regression vs. the pre-#3434
// text-demotion behavior. The fallback folds the reasoning into the
// visible content so the next turn still sees the prior plan.
// `reasoning_content` per #1071). The cross-API preservation predicate
// reads this resolved base compat — which neither requires
// `reasoning_content` nor is a Z.AI-format host — so it preserves no
// native thinking block the encoder couldn't surface; the cross-API path
// instead text-demotes the prior reasoning into visible content. The
// reasoning still survives as conversation context, with no
// `reasoning_content` on the wire and no #1071 regression.
const target = opencodeGoKimiTarget();
const compat = target.compat;
expect(compat.requiresReasoningContentForToolCalls).toBe(false);
@@ -1224,6 +1224,75 @@ describe("kimi model detection via detectCompat", () => {
expect(assistant?.reasoning).toBeUndefined();
});
it("uses thinking-enabled compat when replaying cross-api reasoning on kimi opencode-go", async () => {
const model = kimiOpenCodeModel("kimi-k2.6");
expect(model.compat.requiresReasoningContentForToolCalls).toBe(false);
const priorAssistant: AssistantMessage = {
role: "assistant",
content: [
{
type: "thinking",
thinking: "Need to preserve cross-api reasoning.",
thinkingSignature: "sig_from_anthropic",
},
{
type: "toolCall",
id: "toolu_cross_api",
name: "read",
arguments: { path: "README.md" },
},
],
api: "anthropic-messages",
provider: "zai",
model: "claude-compatible",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
};
const { promise, resolve } = Promise.withResolvers<unknown>();
const fetchMock = createMockFetch(["[DONE]"]);
streamOpenAICompletions(
model,
{
messages: [
{ role: "user", content: "Summarize the README", timestamp: Date.now() },
priorAssistant,
{
role: "toolResult",
toolCallId: "toolu_cross_api",
toolName: "read",
content: [{ type: "text", text: "# Hello\n" }],
isError: false,
timestamp: Date.now(),
},
],
},
{
apiKey: "test-key",
fetch: fetchMock,
reasoning: "high",
signal: createAbortedSignal(),
onPayload: payload => resolve(payload),
},
);
const payload = (await promise) as { messages: Array<Record<string, unknown>> };
const assistant = payload.messages.find(m => m.role === "assistant");
expect(assistant).toBeDefined();
expect(assistant?.content).toBe(".");
expect(assistant?.reasoning_content).toBe("Need to preserve cross-api reasoning.");
expect(assistant?.reasoning).toBeUndefined();
expect(assistant?.reasoning_text).toBeUndefined();
});
// #1071 regression guard alongside the #1484 fix: with thinking disabled the
// override stays off so the gateway's `Extra inputs are not permitted` error
// can never reappear on tool-call replays.