@oh-my-pi/pi-ai 15.5.15 → 15.6.0

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,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.6.0] - 2026-05-30
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Anthropic adaptive-thinking replay preserving signed thinking blocks on the latest abandoned tool-use assistant message, avoiding `thinking blocks in the latest assistant message cannot be modified` 400s. ([#1531](https://github.com/can1357/oh-my-pi/issues/1531))
10
+
5
11
  ## [15.5.15] - 2026-05-30
6
12
 
7
13
  ### Added
@@ -91,3 +91,10 @@ export declare function hasOpus47ApiRestrictions(modelId: string): boolean;
91
91
  * @see https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages
92
92
  */
93
93
  export declare function supportsMidConversationSystemMessages(modelId: string): boolean;
94
+ /**
95
+ * Claude Opus 4.8 must emit at most one tool call per turn: the Anthropic
96
+ * Messages provider sends `tool_choice.disable_parallel_tool_use = true` for
97
+ * this model. Scoped to exactly 4.8 — earlier and later Opus versions keep
98
+ * Anthropic's default parallel tool-calling.
99
+ */
100
+ export declare function disablesParallelToolUse(modelId: string): boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "15.5.15",
4
+ "version": "15.6.0",
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,10 +38,10 @@
38
38
  "generate-models": "bun scripts/generate-models.ts"
39
39
  },
40
40
  "dependencies": {
41
- "@anthropic-ai/sdk": "^0.94.0",
41
+ "@anthropic-ai/sdk": "^0.99.0",
42
42
  "@bufbuild/protobuf": "^2.12.0",
43
- "@oh-my-pi/pi-utils": "15.5.15",
44
- "openai": "^6.36.0",
43
+ "@oh-my-pi/pi-utils": "15.6.0",
44
+ "openai": "^6.39.0",
45
45
  "partial-json": "^0.1.7",
46
46
  "zod": "4.4.3"
47
47
  },
@@ -281,7 +281,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
281
281
 
282
282
  async prepareForRequest(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
283
283
  const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
284
- if (!entry || entry.credential.type !== "oauth" || entry.rotatesInMs === null) return false;
284
+ if (entry?.credential.type !== "oauth" || entry.rotatesInMs === null) return false;
285
285
  const remainingMs = this.#snapshotReceivedAt + entry.rotatesInMs - Date.now();
286
286
  if (remainingMs > WAIT_THRESHOLD_MS) return false;
287
287
  return this.waitForFreshSnapshot(MAX_WAIT_MS, opts);
@@ -2984,7 +2984,7 @@ export class AuthStorage {
2984
2984
  if (!prepare) return true;
2985
2985
  const stored = this.#getStoredCredentials(provider);
2986
2986
  const selected = stored[selection.index];
2987
- if (!selected || selected.credential.type !== "oauth") return false;
2987
+ if (selected?.credential.type !== "oauth") return false;
2988
2988
 
2989
2989
  const prepared = await prepare(selected.id, { signal: options?.signal });
2990
2990
  if (!prepared) return true;
@@ -2996,7 +2996,7 @@ export class AuthStorage {
2996
2996
  const latestIndex = latestRows.findIndex(row => row.id === selected.id);
2997
2997
  if (latestIndex === -1) return false;
2998
2998
  const latest = latestRows[latestIndex];
2999
- if (!latest || latest.credential.type !== "oauth") return false;
2999
+ if (latest?.credential.type !== "oauth") return false;
3000
3000
  selection.index = latestIndex;
3001
3001
  selection.credential = latest.credential;
3002
3002
  return true;
@@ -379,6 +379,18 @@ export function supportsMidConversationSystemMessages(modelId: string): boolean
379
379
  return parsed.kind === "opus" && semverGte(parsed.version, "4.8");
380
380
  }
381
381
 
382
+ /**
383
+ * Claude Opus 4.8 must emit at most one tool call per turn: the Anthropic
384
+ * Messages provider sends `tool_choice.disable_parallel_tool_use = true` for
385
+ * this model. Scoped to exactly 4.8 — earlier and later Opus versions keep
386
+ * Anthropic's default parallel tool-calling.
387
+ */
388
+ export function disablesParallelToolUse(modelId: string): boolean {
389
+ const parsed = parseAnthropicModel(getCanonicalModelId(modelId));
390
+ if (!parsed) return false;
391
+ return parsed.kind === "opus" && semverEqual(parsed.version, "4.8");
392
+ }
393
+
382
394
  function anthropicModelHasRealXHighEffort<TApi extends Api>(model: ApiModel<TApi>): boolean {
383
395
  if (model.api !== "anthropic-messages") return false;
384
396
  const parsedModel = parseKnownModel(model.id);
@@ -2147,25 +2147,23 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana
2147
2147
  const reference = resolveReference(defaults.id);
2148
2148
  const copilotLimits = extractCopilotLimits(entry);
2149
2149
  // Copilot exposes token limits under capabilities.limits.*.
2150
- // max_prompt_tokens is the prompt capacity (what OMP calls contextWindow).
2151
- // max_context_window_tokens is the total window (prompt + output budget)
2152
- // and must NOT be used for contextWindow it inflates the limit and
2153
- // breaks compaction thresholds, overflow detection, and promotion.
2154
- // The OpenAI-compatible root-level `context_length` field mirrors the
2155
- // total window (e.g. 400k for gpt-5.4), so Copilot's max_prompt_tokens
2156
- // (the true prompt budget) must take precedence whenever it is present.
2157
- const contextWindowFallback = toPositiveNumber(
2158
- entry.context_length,
2159
- reference?.contextWindow ?? defaults.contextWindow,
2160
- );
2150
+ // max_context_window_tokens is the model's total usable window;
2151
+ // max_prompt_tokens is Copilot's prompt/summarization budget and
2152
+ // must only be a fallback when total-window fields are absent.
2161
2153
  const contextWindow = toPositiveNumber(
2162
- copilotLimits.maxPromptTokens,
2163
- reference ? Math.min(contextWindowFallback, reference.contextWindow) : contextWindowFallback,
2154
+ copilotLimits.maxContextWindowTokens,
2155
+ toPositiveNumber(
2156
+ entry.context_length,
2157
+ toPositiveNumber(
2158
+ copilotLimits.maxPromptTokens,
2159
+ reference?.contextWindow ?? defaults.contextWindow,
2160
+ ),
2161
+ ),
2164
2162
  );
2165
2163
  const maxTokens = toPositiveNumber(
2166
- entry.max_completion_tokens,
2164
+ copilotLimits.maxOutputTokens,
2167
2165
  toPositiveNumber(
2168
- copilotLimits.maxOutputTokens,
2166
+ entry.max_completion_tokens,
2169
2167
  toPositiveNumber(
2170
2168
  copilotLimits.maxNonStreamingOutputTokens,
2171
2169
  reference?.maxTokens ?? defaults.maxTokens,
@@ -22,6 +22,7 @@ import {
22
22
  readSseEvents,
23
23
  } from "@oh-my-pi/pi-utils";
24
24
  import {
25
+ disablesParallelToolUse,
25
26
  hasOpus47ApiRestrictions,
26
27
  mapEffortToAnthropicAdaptiveEffort,
27
28
  supportsMidConversationSystemMessages,
@@ -1774,7 +1775,7 @@ function disableThinkingIfToolChoiceForced(params: MessageCreateParamsStreaming)
1774
1775
 
1775
1776
  function ensureMaxTokensForThinking(params: MessageCreateParamsStreaming, model: Model<"anthropic-messages">): void {
1776
1777
  const thinking = params.thinking;
1777
- if (!thinking || thinking.type !== "enabled") return;
1778
+ if (thinking?.type !== "enabled") return;
1778
1779
 
1779
1780
  const budgetTokens = thinking.budget_tokens ?? 0;
1780
1781
  if (budgetTokens <= 0) return;
@@ -2133,6 +2134,21 @@ function buildParams(
2133
2134
  }
2134
2135
  }
2135
2136
 
2137
+ // Claude Opus 4.8 must emit at most one tool call per turn. Force
2138
+ // `disable_parallel_tool_use` onto the outgoing tool_choice (synthesizing an
2139
+ // `auto` choice when none is set). Gated on tools being present: Anthropic
2140
+ // rejects `tool_choice` without `tools`, and parallelism is moot otherwise.
2141
+ // `none` rejects the field, so leave it untouched. A fresh object is built
2142
+ // rather than mutated so the caller's `options.toolChoice` is never aliased.
2143
+ if (disablesParallelToolUse(model.id) && params.tools && params.tools.length > 0) {
2144
+ const current = params.tool_choice;
2145
+ if (!current) {
2146
+ params.tool_choice = { type: "auto", disable_parallel_tool_use: true };
2147
+ } else if (current.type !== "none") {
2148
+ params.tool_choice = { ...current, disable_parallel_tool_use: true };
2149
+ }
2150
+ }
2151
+
2136
2152
  const shouldInjectClaudeCodeInstruction = isOAuthToken && !model.id.startsWith("claude-3-5-haiku");
2137
2153
  const billingSystemPrompts = normalizeSystemPrompts(context.systemPrompt);
2138
2154
  const billingPayload = shouldInjectClaudeCodeInstruction
@@ -586,7 +586,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
586
586
  eventStream: AssistantMessageEventStream,
587
587
  text: string,
588
588
  ): void => {
589
- if (!currentBlock || currentBlock.type !== "text") {
589
+ if (currentBlock?.type !== "text") {
590
590
  finishCurrentBlock(currentBlock);
591
591
  currentBlock = { type: "text", text: "" };
592
592
  message.content.push(currentBlock);
@@ -607,8 +607,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
607
607
  signature?: string,
608
608
  ): void => {
609
609
  if (
610
- !currentBlock ||
611
- currentBlock.type !== "thinking" ||
610
+ currentBlock?.type !== "thinking" ||
612
611
  (signature !== undefined && currentBlock.thinkingSignature !== signature)
613
612
  ) {
614
613
  finishCurrentBlock(currentBlock);
@@ -815,7 +814,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
815
814
  }
816
815
 
817
816
  if (!block) {
818
- if (!currentBlock || currentBlock.type !== "toolCall") {
817
+ if (currentBlock?.type !== "toolCall") {
819
818
  finishCurrentBlock(currentBlock);
820
819
  }
821
820
  block = {
@@ -954,7 +954,7 @@ export function encodeStream(
954
954
  break;
955
955
  }
956
956
  case "text_delta": {
957
- if (!state.open || state.open.kind !== "message") break;
957
+ if (state.open?.kind !== "message") break;
958
958
  const cur: OpenMessage = state.open;
959
959
  cur.currentPartText += ev.delta;
960
960
  emit("response.output_text.delta", {
@@ -970,7 +970,7 @@ export function encodeStream(
970
970
  break;
971
971
  }
972
972
  case "text_end": {
973
- if (!state.open || state.open.kind !== "message") break;
973
+ if (state.open?.kind !== "message") break;
974
974
  const cur: OpenMessage = state.open;
975
975
  const text = ev.content ?? cur.currentPartText;
976
976
  emit("response.output_text.done", {
@@ -997,7 +997,7 @@ export function encodeStream(
997
997
  break;
998
998
  }
999
999
  case "thinking_delta": {
1000
- if (!state.open || state.open.kind !== "reasoning") break;
1000
+ if (state.open?.kind !== "reasoning") break;
1001
1001
  const cur: OpenReasoning = state.open;
1002
1002
  cur.reasoningText += ev.delta;
1003
1003
  emit("response.reasoning_summary_text.delta", {
@@ -1009,7 +1009,7 @@ export function encodeStream(
1009
1009
  break;
1010
1010
  }
1011
1011
  case "thinking_end": {
1012
- if (!state.open || state.open.kind !== "reasoning") break;
1012
+ if (state.open?.kind !== "reasoning") break;
1013
1013
  const cur: OpenReasoning = state.open;
1014
1014
  const text = ev.content ?? cur.reasoningText;
1015
1015
  cur.reasoningText = text;
@@ -1034,7 +1034,7 @@ export function encodeStream(
1034
1034
  break;
1035
1035
  }
1036
1036
  case "toolcall_delta": {
1037
- if (!state.open || state.open.kind !== "function_call") break;
1037
+ if (state.open?.kind !== "function_call") break;
1038
1038
  const cur: OpenFunctionCall = state.open;
1039
1039
  cur.argsText += ev.delta;
1040
1040
  if (cur.customWireName) {
@@ -1053,7 +1053,7 @@ export function encodeStream(
1053
1053
  break;
1054
1054
  }
1055
1055
  case "toolcall_end": {
1056
- if (!state.open || state.open.kind !== "function_call") break;
1056
+ if (state.open?.kind !== "function_call") break;
1057
1057
  const cur: OpenFunctionCall = state.open;
1058
1058
  // Promote possibly-late info from the canonical ToolCall.
1059
1059
  const tc = ev.toolCall;
@@ -17,6 +17,21 @@ const enum ToolCallStatus {
17
17
  Aborted = 2,
18
18
  }
19
19
 
20
+ function shouldDropTruncatedThinkingOnlyAssistant(msg: AssistantMessage): boolean {
21
+ const isTruncatedStop = msg.stopReason === "length" || msg.stopReason === "error" || msg.stopReason === "aborted";
22
+ return isTruncatedStop && !msg.content.some(block => block.type === "toolCall" || block.type === "text");
23
+ }
24
+
25
+ function getLatestSurvivingAssistantIndex(messages: readonly Message[]): number {
26
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
27
+ const msg = messages[index]!;
28
+ if (msg.role === "assistant" && !shouldDropTruncatedThinkingOnlyAssistant(msg)) {
29
+ return index;
30
+ }
31
+ }
32
+ return -1;
33
+ }
34
+
20
35
  /**
21
36
  * Normalize tool call ID for cross-provider compatibility.
22
37
  * OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
@@ -35,7 +50,7 @@ export function transformMessages<TApi extends Api>(
35
50
  // Build a map of original tool call IDs to normalized IDs
36
51
  const toolCallIdMap = new Map<string, string>();
37
52
 
38
- const latestAssistantIndex = messages.findLastIndex(msg => msg.role === "assistant");
53
+ const latestSurvivingAssistantIndex = getLatestSurvivingAssistantIndex(messages);
39
54
  // First pass: transform messages (thinking blocks, tool call ID normalization)
40
55
  const transformed = messages.map((msg, index) => {
41
56
  // User and developer messages pass through unchanged
@@ -61,34 +76,36 @@ export function transformMessages<TApi extends Api>(
61
76
  assistantMsg.model === model.id;
62
77
 
63
78
  const mustPreserveLatestAnthropicThinking =
64
- index === latestAssistantIndex &&
79
+ index === latestSurvivingAssistantIndex &&
65
80
  model.api === "anthropic-messages" &&
66
81
  assistantMsg.api === "anthropic-messages";
67
82
  // Aborted/errored messages may have partially-streamed thinking signatures.
68
83
  // A partial signature is invalid and will be rejected by the API, so we must
69
84
  // strip signatures from thinking blocks in these messages.
70
85
  //
71
- // Abandoned tool-use turns get the same treatment. When a turn carries
72
- // toolCall blocks but did NOT request tool execution (stopReason !== "toolUse"
73
- // e.g. adaptive-thinking Opus emitting tool calls and then ending the turn
74
- // on `end_turn`/`stop`), the agent loop pairs those calls with placeholder
75
- // tool_results to keep the tool_use/tool_result contract valid. Replaying the
76
- // turn's *signed* thinking in that tool_result continuation trips Anthropic's
77
- // "`thinking` blocks in the latest assistant message cannot be modified" — the
78
- // signature was bound to an end_turn context, not a tool-use one. Stripping it
79
- // downgrades the thinking to plain text downstream, which the API accepts.
80
- // Normal tool-use turns (stopReason "toolUse") never match this guard.
86
+ // Abandoned tool-use turns get the same treatment once they are no longer
87
+ // the latest assistant message. When a turn carries toolCall blocks but did
88
+ // NOT request tool execution (stopReason !== "toolUse" e.g.
89
+ // adaptive-thinking Opus emitting tool calls and then ending the turn on
90
+ // `end_turn`/`stop`), the agent loop pairs those calls with placeholder
91
+ // tool_results to keep the tool_use/tool_result contract valid. Historical
92
+ // abandoned turns cannot safely replay their end_turn-bound signatures in
93
+ // that continuation, so stripping downgrades them to plain text downstream.
94
+ // Latest abandoned turns are exempt because Anthropic requires thinking
95
+ // blocks from its most recent response to remain byte-for-byte unmodified.
96
+ const invalidStopReason = assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error";
81
97
  const abandonedToolUse =
82
- assistantMsg.stopReason !== "toolUse" && assistantMsg.content.some(b => b.type === "toolCall");
83
- const hasInvalidSignatures =
84
- assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error" || abandonedToolUse;
98
+ !invalidStopReason &&
99
+ assistantMsg.stopReason !== "toolUse" &&
100
+ assistantMsg.content.some(b => b.type === "toolCall");
101
+ const hasInvalidSignatures = invalidStopReason || abandonedToolUse;
85
102
 
86
103
  const transformedContent = assistantMsg.content.flatMap(block => {
87
104
  if (block.type === "thinking") {
88
- // Strip signature from aborted/errored messages it's likely incomplete
105
+ // Strip untrustworthy signatures so the encoder can downgrade to text.
89
106
  const sanitized =
90
107
  hasInvalidSignatures && block.thinkingSignature ? { ...block, thinkingSignature: undefined } : block;
91
- if (mustPreserveLatestAnthropicThinking) return sanitized;
108
+ if (mustPreserveLatestAnthropicThinking) return abandonedToolUse ? block : sanitized;
92
109
  // For same model: keep thinking blocks with signatures (needed for replay)
93
110
  // even if the thinking text is empty (OpenAI encrypted reasoning)
94
111
  if (isSameModel && sanitized.thinkingSignature) return sanitized;
@@ -236,7 +253,6 @@ export function transformMessages<TApi extends Api>(
236
253
  flushPendingAbortedToolCalls();
237
254
 
238
255
  const assistantMsg = msg as AssistantMessage;
239
- const toolCalls = assistantMsg.content.filter(b => b.type === "toolCall") as ToolCall[];
240
256
 
241
257
  // Drop assistant turns that carry no actionable content (no `text`, no `toolCall`)
242
258
  // AND were terminated by a truncating stop reason (`length` / `error` / `aborted`).
@@ -250,11 +266,8 @@ export function transformMessages<TApi extends Api>(
250
266
  // `stopReason: "stop"` thinking-only messages are intentionally preserved: they
251
267
  // represent reasoning-only assistant turns used for replay round-trips
252
268
  // (OpenAI completions `reasoning_text`, Google signed thought parts).
253
- const isTruncatedStop =
254
- assistantMsg.stopReason === "length" ||
255
- assistantMsg.stopReason === "error" ||
256
- assistantMsg.stopReason === "aborted";
257
- if (isTruncatedStop && toolCalls.length === 0 && !assistantMsg.content.some(b => b.type === "text")) {
269
+ const originalMsg = messages[i]!;
270
+ if (originalMsg.role === "assistant" && shouldDropTruncatedThinkingOnlyAssistant(originalMsg)) {
258
271
  if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
259
272
  // Still arm the aborted-turn note so downstream guidance fires.
260
273
  pendingAbortedToolCalls = new Map();
@@ -263,6 +276,8 @@ export function transformMessages<TApi extends Api>(
263
276
  continue;
264
277
  }
265
278
 
279
+ const toolCalls = assistantMsg.content.filter(b => b.type === "toolCall") as ToolCall[];
280
+
266
281
  if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
267
282
  // Keep the assistant message with tool calls intact. Real tool results are
268
283
  // emitted immediately if available; otherwise synthesize aborted results