#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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user