@oh-my-pi/pi-ai 16.1.18 → 16.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.19] - 2026-06-25
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Ollama/llama.cpp chat payloads serializing user-attributed mid-conversation developer messages (auto-learn capture nudge, advisor cards, file-mention companions) as `system` turns; they now serialize as `user` so llama.cpp can reuse the warm prompt prefix instead of forcing full re-processing. Agent-owned developer reminders (`attribution: "agent"` — empty/unexpected-stop retries, checkpoint rewind warning, todo reminders) keep their `system` priority. ([#3456](https://github.com/can1357/oh-my-pi/issues/3456))
10
+ - 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))
11
+ - Fixed Bedrock cross-region inference profiles routing to `us-east-1` regardless of their geo prefix: a profile such as `eu.anthropic.claude-…` (or `apac.`/`au.`/`jp.`) sent to the hardcoded `us-east-1` endpoint returned HTTP 400 `The provided model identifier is invalid`. `streamBedrock` now derives the runtime region from the profile's geo prefix — honoring an ambient `AWS_REGION`/`AWS_DEFAULT_REGION` only when it can serve that geo and falling back to the geo's default region otherwise — while explicit per-request and ARN-embedded regions still win and region-agnostic `global.` profiles stay unchanged.
12
+ - Fixed malformed tool calls (empty `name`) wedging entire sessions in HTTP 400 loops: when a model occasionally emits `{ "name": "", "arguments": "{}" }` (observed: GLM-5.2 + thinking on long turns), the agent rejected the call at execution time with `Tool not found`, but the malformed block plus its error `toolResult` stayed in conversation history and every subsequent request 400'd on `tool_use.name`/`tool_calls[i].function.name` validation until the user ran `/clear`. `transformMessages` — the canonical sanitize boundary every provider passes through — now drops `toolCall` blocks with empty/whitespace `name`, pairs them with their `toolResult` messages only inside the same assistant→tool-result window (per-id FIFO queue cleared at non-result boundaries, so stale malformed calls without a result cannot consume later valid duplicate-id outputs), and drops the assistant turn when it has no replayable content left. Defensive (provider-agnostic, fires regardless of model), idempotent (no-op on a clean history), and self-healing (one round-trip after the fix lands sanitizes an already-poisoned session). ([#3458](https://github.com/can1357/oh-my-pi/issues/3458))
13
+
5
14
  ## [16.1.18] - 2026-06-25
6
15
 
7
16
  ### Added
@@ -8,4 +8,4 @@ import type { Api, AssistantMessage, Message, Model } from "../types";
8
8
  * - Preserves tool call structure (unlike converting to text summaries)
9
9
  * - Injects synthetic "aborted" tool results
10
10
  */
11
- export declare function transformMessages<TApi extends Api>(messages: Message[], model: Model<TApi>, normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string, maxNormalizedToolCallIdLength?: number, duplicateToolCallIdSuffixPrefix?: string): Message[];
11
+ export declare function transformMessages<TApi extends Api>(messages: Message[], model: Model<TApi>, normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string, maxNormalizedToolCallIdLength?: number, duplicateToolCallIdSuffixPrefix?: string, targetCompat?: Model<TApi>["compat"]): Message[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.1.18",
4
+ "version": "16.1.19",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.1.18",
42
- "@oh-my-pi/pi-utils": "16.1.18",
43
- "@oh-my-pi/pi-wire": "16.1.18",
41
+ "@oh-my-pi/pi-catalog": "16.1.19",
42
+ "@oh-my-pi/pi-utils": "16.1.19",
43
+ "@oh-my-pi/pi-wire": "16.1.19",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -87,6 +87,76 @@ function inferRegionFromBedrockArn(modelId: string): string | undefined {
87
87
  return region || undefined;
88
88
  }
89
89
 
90
+ /**
91
+ * Default AWS region for each Bedrock cross-region inference-profile geo prefix.
92
+ * A geo-prefixed profile (e.g. `eu.anthropic.claude-…`) is only servable from
93
+ * regions in its own geo, so routing one to `us-east-1` yields HTTP 400 "The
94
+ * provided model identifier is invalid." `global.` profiles are anchored in the
95
+ * us regions and intentionally absent here (they resolve fine via `us-east-1`).
96
+ */
97
+ const INFERENCE_PROFILE_GEO_DEFAULT_REGION: Record<string, string> = {
98
+ us: "us-east-1",
99
+ "us-gov": "us-gov-west-1",
100
+ eu: "eu-west-1",
101
+ apac: "ap-southeast-1",
102
+ au: "ap-southeast-2",
103
+ jp: "ap-northeast-1",
104
+ };
105
+
106
+ /** Geo prefix of a cross-region inference-profile id, e.g. `eu.anthropic.…` → `eu`. */
107
+ function inferenceProfileGeo(modelId: string): string | undefined {
108
+ const dot = modelId.indexOf(".");
109
+ if (dot <= 0) return undefined;
110
+ const prefix = modelId.slice(0, dot);
111
+ return prefix in INFERENCE_PROFILE_GEO_DEFAULT_REGION ? prefix : undefined;
112
+ }
113
+
114
+ /**
115
+ * Whether a concrete AWS region can serve a given inference-profile geo. The
116
+ * `ap-` regions overlap across `apac`/`au`/`jp` profiles, so the Australia and
117
+ * Japan geos pin their specific source regions rather than matching all `ap-*`.
118
+ */
119
+ function regionServesGeo(region: string, geo: string): boolean {
120
+ switch (geo) {
121
+ case "us-gov":
122
+ return region.startsWith("us-gov-");
123
+ case "us":
124
+ return region.startsWith("us-") && !region.startsWith("us-gov-");
125
+ case "eu":
126
+ return region.startsWith("eu-");
127
+ case "apac":
128
+ return region.startsWith("ap-");
129
+ case "au":
130
+ return region === "ap-southeast-2" || region === "ap-southeast-4";
131
+ case "jp":
132
+ return region === "ap-northeast-1" || region === "ap-northeast-3";
133
+ default:
134
+ return false;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Resolve the Bedrock runtime region for a request. An explicit per-request
140
+ * region and an ARN-embedded region win outright. Otherwise, for a geo-prefixed
141
+ * cross-region inference profile (`us.`/`eu.`/`apac.`/`au.`/`jp.`/`us-gov.`), an
142
+ * ambient region (`AWS_REGION` / `AWS_DEFAULT_REGION`) is honored only when it
143
+ * can serve the profile's geo; a mismatched or absent ambient region is
144
+ * corrected to the geo default so an `eu.`/`apac.` profile never POSTs to a `us`
145
+ * endpoint (and vice versa). `global.` profiles have no geo entry, so the
146
+ * ambient region (or `us-east-1`) is used unchanged.
147
+ */
148
+ function resolveBedrockRegion(modelId: string, options: BedrockOptions): string {
149
+ const explicit = options.region || inferRegionFromBedrockArn(modelId);
150
+ if (explicit) return explicit;
151
+ const ambient = $env.AWS_REGION || $env.AWS_DEFAULT_REGION;
152
+ const geo = inferenceProfileGeo(modelId);
153
+ if (geo) {
154
+ if (ambient && regionServesGeo(ambient, geo)) return ambient;
155
+ return INFERENCE_PROFILE_GEO_DEFAULT_REGION[geo];
156
+ }
157
+ return ambient || "us-east-1";
158
+ }
159
+
90
160
  type Block = (TextContent | ThinkingContent | ToolCall) & {
91
161
  index?: number;
92
162
  partialJson?: string;
@@ -235,12 +305,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
235
305
 
236
306
  const blocks = output.content as Block[];
237
307
  let rawRequestDump: RawHttpRequestDump | undefined;
238
- const region =
239
- options.region ||
240
- inferRegionFromBedrockArn(model.id) ||
241
- $env.AWS_REGION ||
242
- $env.AWS_DEFAULT_REGION ||
243
- "us-east-1";
308
+ const region = resolveBedrockRegion(model.id, options);
244
309
 
245
310
  try {
246
311
  const cacheRetention = resolveCacheRetention(options.cacheRetention);
@@ -192,14 +192,18 @@ function toPlainContent(
192
192
  };
193
193
  }
194
194
 
195
- function convertMessage(message: Message, supportsImages: boolean): OllamaMessage {
195
+ function convertMessage(
196
+ message: Message,
197
+ supportsImages: boolean,
198
+ developerRole: "system" | "user" = "user",
199
+ ): OllamaMessage {
196
200
  if (message.role === "user") {
197
201
  const converted = toPlainContent(message.content, supportsImages);
198
202
  return { role: "user", ...converted };
199
203
  }
200
204
  if (message.role === "developer") {
201
205
  const converted = toPlainContent(message.content, supportsImages);
202
- return { role: "system", ...converted };
206
+ return { role: developerRole, ...converted };
203
207
  }
204
208
  if (message.role === "toolResult") {
205
209
  const converted = toPlainContent(message.content, supportsImages);
@@ -240,23 +244,27 @@ function convertMessage(message: Message, supportsImages: boolean): OllamaMessag
240
244
  }
241
245
 
242
246
  function convertMessages(model: Model<"ollama-chat">, context: Context): OllamaMessage[] {
243
- const messages: Message[] = [];
244
- // Emit one developer message per ordered system prompt. The wire role is mapped to "system"
245
- // by `convertMessage`, but keeping the prompts separate preserves prefix-cache stability:
246
- // if only the trailing prompt changes between calls, the leading system messages keep
247
- // their identical token prefix so KV-cache reuse covers them.
248
- for (const systemPrompt of normalizeSystemPrompts(context.systemPrompt)) {
249
- messages.push({
250
- role: "developer",
251
- content: systemPrompt,
252
- timestamp: Date.now(),
253
- });
254
- }
255
- messages.push(...context.messages);
247
+ const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
248
+ const systemMessages: Message[] = systemPrompts.map(systemPrompt => ({
249
+ role: "developer",
250
+ content: systemPrompt,
251
+ timestamp: Date.now(),
252
+ }));
253
+ const messages: Message[] = [...systemMessages, ...context.messages];
256
254
  const isCloud = model.provider === "ollama-cloud";
257
255
  const supportsImages = model.input.includes("image");
258
- return transformMessages(messages, model).map(msg => {
259
- const converted = convertMessage(msg, supportsImages);
256
+ return transformMessages(messages, model).map((msg, index) => {
257
+ // Real `systemPrompt` entries (always emitted first) stay on Ollama's
258
+ // `system` role. After the static prefix, a developer turn keeps `system`
259
+ // when it's an agent-owned control instruction (empty/unexpected-stop
260
+ // retries, checkpoint rewind warning, todo reminders — all carry
261
+ // `attribution: "agent"`), but a user-attributed developer turn (auto-learn
262
+ // capture nudge, advisor cards, file-mention companions) drops to `user`.
263
+ // That keeps the in-conversation byte prefix stable for prefix caches
264
+ // (llama.cpp, #3456) without demoting mandatory agent reminders.
265
+ const developerRole =
266
+ msg.role === "developer" && (index < systemPrompts.length || msg.attribution !== "user") ? "system" : "user";
267
+ const converted = convertMessage(msg, supportsImages, developerRole);
260
268
  // Ollama cloud rejects requests when assistant history messages contain the `thinking`
261
269
  // field — it's valid in model responses but not accepted as a history input. Strip it
262
270
  // to prevent HTTP 400 errors. Local Ollama instances are unaffected.
@@ -1629,6 +1629,7 @@ export function convertMessages(
1629
1629
  id => normalizeToolCallId(id),
1630
1630
  maxNormalizedToolCallIdLength,
1631
1631
  duplicateToolCallIdSuffixPrefix,
1632
+ compat,
1632
1633
  );
1633
1634
 
1634
1635
  const remappedToolCallIds = new Map<string, string[]>();
@@ -1793,6 +1794,17 @@ export function convertMessages(
1793
1794
  if (wireField) {
1794
1795
  assistantMsg[wireField] = nonEmptyThinkingBlocks.map(b => b.thinking).join("\n");
1795
1796
  }
1797
+ } else if (compat.thinkingFormat === "zai" && model.reasoning) {
1798
+ // Z.AI / Zhipu / Moonshot Kimi (native) / Xiaomi MiMo accept
1799
+ // `reasoning_content` as a continuation hint even when they don't
1800
+ // strictly require it. Surfacing the preserved thinking text here
1801
+ // keeps cross-API replays (Z.AI Anthropic → Z.AI OpenAI, etc.)
1802
+ // shipping reasoning as structured `reasoning_content` rather than
1803
+ // folded into conversation text (#3434). Signature is irrelevant on
1804
+ // this path: `transform-messages` strips the source wire-format
1805
+ // signature on cross-API replays before the block reaches us.
1806
+ const reasoningField = compat.reasoningContentField ?? "reasoning_content";
1807
+ assistantMsg[reasoningField] = nonEmptyThinkingBlocks.map(b => b.thinking).join("\n");
1796
1808
  }
1797
1809
  }
1798
1810
 
@@ -124,6 +124,88 @@ function deduplicateToolCallIds(
124
124
  });
125
125
  }
126
126
 
127
+ /**
128
+ * Drop assistant `toolCall` blocks whose `name` is empty or whitespace-only,
129
+ * the `toolResult` messages they point at, and any assistant turn that has no
130
+ * replayable content left.
131
+ *
132
+ * Models occasionally emit `{ "name": "", "arguments": "{}" }` (observed:
133
+ * GLM-5.2 + thinking on long turns, #3458). The agent loop rejects the call
134
+ * at execution time with `Tool not found`, but the malformed block and its
135
+ * error tool-result stay in `currentContext.messages`, so every subsequent
136
+ * request replays them. Every provider validates the function name —
137
+ * Anthropic 400s on `tool_use.name` (alongside an orphan `tool_result`),
138
+ * OpenAI Chat Completions 400s on `tool_calls[i].function.name` — wedging the
139
+ * session in a 400 loop until manual `/clear`.
140
+ *
141
+ * Run before any other transform so the rest of the pipeline never sees a
142
+ * malformed call. Idempotent: a re-run on an already-sanitized list returns
143
+ * the input untouched. Provider-agnostic — any wire model could surface this.
144
+ */
145
+ function isMalformedToolCallName(name: string | undefined): boolean {
146
+ return !name || name.trim().length === 0;
147
+ }
148
+
149
+ function sanitizeMalformedToolCalls(messages: Message[]): Message[] {
150
+ // Fast path: skip the rewrite entirely when nothing is malformed.
151
+ let hasMalformed = false;
152
+ outer: for (const msg of messages) {
153
+ if (msg.role !== "assistant") continue;
154
+ for (const block of msg.content) {
155
+ if (block.type === "toolCall" && isMalformedToolCallName(block.name)) {
156
+ hasMalformed = true;
157
+ break outer;
158
+ }
159
+ }
160
+ }
161
+ if (!hasMalformed) return messages;
162
+
163
+ // Positional FIFO pairing within one assistant→tool-result window: a tool-call
164
+ // id can repeat across history when an OpenAI-Responses composite id
165
+ // (`callId|itemId`) collapses on the wire to the same `callId` (see
166
+ // `deduplicateToolCallIds` + `transform-messages-dedup`). A set-based "drop
167
+ // every result for this id" loses the real output for the surviving valid
168
+ // occurrence whenever one duplicate is malformed. Track each `toolCall`
169
+ // occurrence's malformed-ness on a per-id queue and pop on matching
170
+ // `toolResult`, but clear the queues at every non-result boundary so a
171
+ // malformed call whose rejection result never arrived cannot consume a later
172
+ // valid call's real result when the id is reused.
173
+ const dropQueues = new Map<string, boolean[]>();
174
+ const result: Message[] = [];
175
+ for (const msg of messages) {
176
+ if (msg.role === "assistant") {
177
+ dropQueues.clear();
178
+ const filtered: AssistantMessage["content"] = [];
179
+ for (const block of msg.content) {
180
+ if (block.type === "toolCall") {
181
+ const malformed = isMalformedToolCallName(block.name);
182
+ const queue = dropQueues.get(block.id);
183
+ if (queue) queue.push(malformed);
184
+ else dropQueues.set(block.id, [malformed]);
185
+ if (malformed) continue;
186
+ }
187
+ filtered.push(block);
188
+ }
189
+ if (filtered.length === 0) continue;
190
+ result.push(filtered.length === msg.content.length ? msg : { ...msg, content: filtered });
191
+ continue;
192
+ }
193
+ if (msg.role === "toolResult") {
194
+ const queue = dropQueues.get(msg.toolCallId);
195
+ if (queue && queue.length > 0) {
196
+ const drop = queue.shift() === true;
197
+ if (queue.length === 0) dropQueues.delete(msg.toolCallId);
198
+ if (drop) continue;
199
+ }
200
+ result.push(msg);
201
+ continue;
202
+ }
203
+ dropQueues.clear();
204
+ result.push(msg);
205
+ }
206
+ return result;
207
+ }
208
+
127
209
  function shouldDropTruncatedThinkingOnlyAssistant(msg: AssistantMessage): boolean {
128
210
  const isTruncatedStop = msg.stopReason === "length" || msg.stopReason === "error" || msg.stopReason === "aborted";
129
211
  return isTruncatedStop && !msg.content.some(block => block.type === "toolCall" || block.type === "text");
@@ -143,6 +225,44 @@ function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messa
143
225
  return model.api === "anthropic-messages";
144
226
  }
145
227
 
228
+ /**
229
+ * Cross-API `openai-completions` targets that can replay a prior turn's
230
+ * reasoning as a native, signature-stripped `thinking` block on the wire.
231
+ * Anthropic's same-API path (`replayUnsignedThinking`) covers
232
+ * `anthropic-messages` targets directly; this is the analogue for the
233
+ * `openai-completions` branch of the cross-API path (#3433/#3434). 3p ↔ 3p
234
+ * replays between an Anthropic-compatible source (Z.AI Anthropic, Kimi
235
+ * Anthropic, …) and an OpenAI-compat reasoning target on the same vendor must
236
+ * keep reasoning as structured `reasoning_content` instead of degrading it to
237
+ * conversation text.
238
+ *
239
+ * `compat` MUST be the request-time RESOLVED compat that `convertMessages`
240
+ * threads into `transformMessages`, not `model.compat`. OpenCode-hosted
241
+ * reasoning models (`opencode-go`/`opencode-zen`) keep
242
+ * `requiresReasoningContentForToolCalls` off on the base compat to dodge the
243
+ * thinking-off `Extra inputs are not permitted` 400 (#1071) and reactivate it
244
+ * on `compat.whenThinking` for thinking-engaged requests to dodge the
245
+ * `thinking is enabled but reasoning_content is missing` 400 (#1484).
246
+ * `resolveOpenAICompatPolicy` already swaps in `whenThinking` for thinking-on
247
+ * requests, so basing this decision on the resolved compat keeps the predicate
248
+ * and the encoder in lockstep; reading `model.compat` would re-open #1484 for
249
+ * every cross-API switch into an OpenCode reasoning model.
250
+ *
251
+ * The downstream encoder MUST then surface the preserved block on the wire via
252
+ * `reasoningContentField` — see `openai-completions.ts` for the matching
253
+ * branch.
254
+ */
255
+ function openAICompletionsReplaysUnsignedThinking(model: Model, compat: Model["compat"]): boolean {
256
+ if (model.api !== "openai-completions" || !model.reasoning) return false;
257
+ if (compat === undefined || !("requiresReasoningContentForToolCalls" in compat)) return false;
258
+ if (compat.requiresThinkingAsText) return false;
259
+ // Hosts that REQUIRE `reasoning_content` on tool-call turns (DeepSeek
260
+ // reasoning, Kimi, OpenRouter reasoning, OpenCode thinking-on) already
261
+ // accept the replay; Z.AI-format hosts (Z.AI, Zhipu, Moonshot Kimi native,
262
+ // Xiaomi MiMo) advertise `reasoning_content` as a continuation hint.
263
+ return compat.requiresReasoningContentForToolCalls || compat.thinkingFormat === "zai";
264
+ }
265
+
146
266
  const ANTHROPIC_TOOL_CALL_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
147
267
 
148
268
  function isValidAnthropicToolCallId(id: string): boolean {
@@ -181,7 +301,13 @@ export function transformMessages<TApi extends Api>(
181
301
  normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string,
182
302
  maxNormalizedToolCallIdLength = MAX_TOOL_CALL_ID_LENGTH,
183
303
  duplicateToolCallIdSuffixPrefix = "_dup",
304
+ targetCompat: Model<TApi>["compat"] = model.compat,
184
305
  ): Message[] {
306
+ // Drop assistant `toolCall` blocks with empty/whitespace `name` (and their
307
+ // matched `toolResult` messages) before anything else looks at the history.
308
+ // Replays of these would 400 every provider — see `sanitizeMalformedToolCalls`.
309
+ messages = sanitizeMalformedToolCalls(messages);
310
+
185
311
  // Build a map of original tool call IDs to normalized IDs
186
312
  const toolCallIdMap = new Map<string, string>();
187
313
 
@@ -316,13 +442,34 @@ export function transformMessages<TApi extends Api>(
316
442
  }
317
443
  return sanitized;
318
444
  }
319
- // Cross-API target: keep the existing text-demotion fallback.
320
- // For same model: keep thinking blocks with signatures (needed for replay)
321
- // even if the thinking text is empty (OpenAI encrypted reasoning)
445
+ // Cross-API target: same-model replay keeps signatures untouched
446
+ // (the encoder needs them for native replay; an OpenAI encrypted
447
+ // reasoning blob has empty text but a load-bearing signature).
322
448
  if (isSameModel && sanitized.thinkingSignature) return sanitized;
323
- // Skip empty thinking blocks, convert others to plain text
449
+ // Nothing left for the next turn to replay: drop empty/no-anchor
450
+ // thinking blocks before the cross-model paths.
324
451
  if (!sanitized.thinking || sanitized.thinking.trim() === "") return [];
325
452
  if (isSameModel) return sanitized;
453
+ // Cross-model + cross-API: preserve as a native, signature-stripped
454
+ // `thinking` block whenever the target encoder can re-emit it on the
455
+ // wire (today: `openai-completions` reasoning targets that accept
456
+ // `reasoning_content` as a continuation hint — Z.AI, Zhipu, DeepSeek
457
+ // reasoning, Kimi native, MiMo, OpenRouter reasoning, …). The source
458
+ // signature is always dropped because it is bound to the source
459
+ // wire-format (Anthropic crypto sig / OpenAI Responses encrypted
460
+ // blob) and would be rejected by the target. Without this branch
461
+ // every cross-API 3p ↔ 3p switch (Z.AI Anthropic → Z.AI OpenAI,
462
+ // Kimi Anthropic → Kimi OpenAI, etc.) demoted prior reasoning to
463
+ // conversation text and lost it as structured reasoning context
464
+ // (#3433/#3434).
465
+ if (openAICompletionsReplaysUnsignedThinking(model, targetCompat)) {
466
+ return sanitized.thinkingSignature ? { ...sanitized, thinkingSignature: undefined } : sanitized;
467
+ }
468
+ // Other cross-API targets (openai-responses encrypted blobs, google
469
+ // signed thought parts, anthropic-target from a non-Anthropic source,
470
+ // or any reasoning-disabled target) can't usefully replay an unsigned
471
+ // thinking block. Demote to text so the reasoning survives at least
472
+ // as visible conversation context.
326
473
  return {
327
474
  type: "text" as const,
328
475
  text: sanitized.thinking,