@oh-my-pi/pi-ai 13.13.0 → 13.14.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,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.14.0] - 2026-03-20
6
+
7
+ ### Fixed
8
+
9
+ - Fixed resumed OpenAI Responses sessions to avoid replaying stale same-provider native history on the first follow-up after process restart ([#488](https://github.com/can1357/oh-my-pi/issues/488))
10
+
11
+ ### Added
12
+
13
+ - Added bundled GPT-5.4 mini model metadata for OpenAI, OpenAI Codex, and GitHub Copilot, including low-to-xhigh thinking support and GitHub Copilot premium multiplier metadata
14
+ - Added bundled GPT-5.4 nano model metadata for OpenAI and OpenAI Codex, including low-to-xhigh thinking support
15
+
16
+
17
+ ## [13.13.2] - 2026-03-18
18
+ ### Changed
19
+
20
+ - Modified tool result handling for aborted assistant messages to preserve existing tool results when already recorded, instead of always replacing them with synthetic 'aborted' results
21
+
5
22
  ## [13.13.0] - 2026-03-18
6
23
  ### Changed
7
24
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "13.13.0",
4
+ "version": "13.14.0",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,7 +41,7 @@
41
41
  "@aws-sdk/client-bedrock-runtime": "^3",
42
42
  "@bufbuild/protobuf": "^2.11",
43
43
  "@google/genai": "^1.43",
44
- "@oh-my-pi/pi-utils": "13.13.0",
44
+ "@oh-my-pi/pi-utils": "13.14.0",
45
45
  "@sinclair/typebox": "^0.34",
46
46
  "@smithy/node-http-handler": "^4.4",
47
47
  "ajv": "^8.18",
@@ -296,6 +296,12 @@ function applyOpenAICatalogPolicy(model: ApiModel<Api>, parsedModel: OpenAIModel
296
296
  // Codex models: 400K figure includes output budget; input window is 272K.
297
297
  if (parsedModel.variant.startsWith("codex") && parsedModel.variant !== "codex-spark") {
298
298
  model.contextWindow = 272000;
299
+ return;
300
+ }
301
+ // GPT-5.4 mini/nano use plain OpenAI IDs on the Codex transport, but Codex still
302
+ // enforces the lower prompt budget for these variants.
303
+ if (model.api === "openai-codex-responses" && (model.id === "gpt-5.4-mini" || model.id === "gpt-5.4-nano")) {
304
+ model.contextWindow = 272000;
299
305
  }
300
306
  }
301
307
 
@@ -374,7 +380,10 @@ function inferAnthropicSupportedEfforts<TApi extends Api>(
374
380
  parsedModel: AnthropicModel,
375
381
  model: ApiModel<TApi>,
376
382
  ): readonly Effort[] {
377
- if (model.api === "anthropic-messages" && semverGte(parsedModel.version, "4.6")) {
383
+ if (
384
+ (model.api === "anthropic-messages" || model.api === "bedrock-converse-stream") &&
385
+ semverGte(parsedModel.version, "4.6")
386
+ ) {
378
387
  return parsedModel.kind === "opus" ? DEFAULT_REASONING_EFFORTS_WITH_XHIGH : DEFAULT_REASONING_EFFORTS;
379
388
  }
380
389
  return inferFallbackEfforts(model);
@@ -427,6 +436,14 @@ function inferThinkingControlMode<TApi extends Api>(
427
436
  return "budget";
428
437
 
429
438
  case "bedrock-converse-stream":
439
+ if (parsedModel.family === "anthropic") {
440
+ if (semverGte(parsedModel.version, "4.6") && parsedModel.kind === "opus") {
441
+ return "anthropic-adaptive";
442
+ }
443
+ if (semverGte(parsedModel.version, "4.5")) {
444
+ return "anthropic-budget-effort";
445
+ }
446
+ }
430
447
  return "budget";
431
448
 
432
449
  default:
@@ -460,7 +477,7 @@ function parseGeminiModel(modelId: string): GeminiModel | null {
460
477
  }
461
478
 
462
479
  function parseAnthropicModel(modelId: string): AnthropicModel | null {
463
- const match = /claude-(opus|sonnet)-(\d+(?:[.-]\d+){0,2})\b/.exec(modelId);
480
+ const match = /claude-(opus|sonnet)-(\d{1,2}(?:[.-]\d{1,2}){0,2})\b/.exec(modelId);
464
481
  if (!match) {
465
482
  return null;
466
483
  }
package/src/models.json CHANGED
@@ -345,9 +345,9 @@
345
345
  "contextWindow": 1000000,
346
346
  "maxTokens": 128000,
347
347
  "thinking": {
348
- "mode": "budget",
348
+ "mode": "anthropic-adaptive",
349
349
  "minLevel": "minimal",
350
- "maxLevel": "high"
350
+ "maxLevel": "xhigh"
351
351
  }
352
352
  },
353
353
  "cohere.command-r-plus-v1:0": {
@@ -720,9 +720,9 @@
720
720
  "contextWindow": 1000000,
721
721
  "maxTokens": 128000,
722
722
  "thinking": {
723
- "mode": "budget",
723
+ "mode": "anthropic-adaptive",
724
724
  "minLevel": "minimal",
725
- "maxLevel": "high"
725
+ "maxLevel": "xhigh"
726
726
  }
727
727
  },
728
728
  "eu.anthropic.claude-sonnet-4-20250514-v1:0": {
@@ -890,9 +890,9 @@
890
890
  "contextWindow": 1000000,
891
891
  "maxTokens": 128000,
892
892
  "thinking": {
893
- "mode": "budget",
893
+ "mode": "anthropic-adaptive",
894
894
  "minLevel": "minimal",
895
- "maxLevel": "high"
895
+ "maxLevel": "xhigh"
896
896
  }
897
897
  },
898
898
  "global.anthropic.claude-sonnet-4-20250514-v1:0": {
@@ -1846,9 +1846,9 @@
1846
1846
  "contextWindow": 1000000,
1847
1847
  "maxTokens": 128000,
1848
1848
  "thinking": {
1849
- "mode": "budget",
1849
+ "mode": "anthropic-adaptive",
1850
1850
  "minLevel": "minimal",
1851
- "maxLevel": "high"
1851
+ "maxLevel": "xhigh"
1852
1852
  }
1853
1853
  },
1854
1854
  "us.anthropic.claude-sonnet-4-20250514-v1:0": {
@@ -5085,6 +5085,38 @@
5085
5085
  "maxLevel": "xhigh"
5086
5086
  }
5087
5087
  },
5088
+ "gpt-5.4-mini": {
5089
+ "id": "gpt-5.4-mini",
5090
+ "name": "GPT-5.4-mini",
5091
+ "api": "openai-responses",
5092
+ "provider": "github-copilot",
5093
+ "baseUrl": "https://api.individual.githubcopilot.com",
5094
+ "reasoning": true,
5095
+ "input": [
5096
+ "text",
5097
+ "image"
5098
+ ],
5099
+ "cost": {
5100
+ "input": 0,
5101
+ "output": 0,
5102
+ "cacheRead": 0,
5103
+ "cacheWrite": 0
5104
+ },
5105
+ "contextWindow": 400000,
5106
+ "maxTokens": 128000,
5107
+ "headers": {
5108
+ "User-Agent": "GitHubCopilotChat/0.35.0",
5109
+ "Editor-Version": "vscode/1.107.0",
5110
+ "Editor-Plugin-Version": "copilot-chat/0.35.0",
5111
+ "Copilot-Integration-Id": "vscode-chat"
5112
+ },
5113
+ "thinking": {
5114
+ "mode": "effort",
5115
+ "minLevel": "low",
5116
+ "maxLevel": "xhigh"
5117
+ },
5118
+ "premiumMultiplier": 0.33
5119
+ },
5088
5120
  "grok-code-fast-1": {
5089
5121
  "id": "grok-code-fast-1",
5090
5122
  "name": "Grok Code Fast 1",
@@ -29668,6 +29700,56 @@
29668
29700
  "maxLevel": "xhigh"
29669
29701
  }
29670
29702
  },
29703
+ "gpt-5.4-mini": {
29704
+ "id": "gpt-5.4-mini",
29705
+ "name": "GPT-5.4 Mini",
29706
+ "api": "openai-responses",
29707
+ "provider": "openai",
29708
+ "baseUrl": "https://api.openai.com/v1",
29709
+ "reasoning": true,
29710
+ "input": [
29711
+ "text",
29712
+ "image"
29713
+ ],
29714
+ "cost": {
29715
+ "input": 0.75,
29716
+ "output": 4.5,
29717
+ "cacheRead": 0.075,
29718
+ "cacheWrite": 0
29719
+ },
29720
+ "contextWindow": 400000,
29721
+ "maxTokens": 128000,
29722
+ "thinking": {
29723
+ "mode": "effort",
29724
+ "minLevel": "low",
29725
+ "maxLevel": "xhigh"
29726
+ }
29727
+ },
29728
+ "gpt-5.4-nano": {
29729
+ "id": "gpt-5.4-nano",
29730
+ "name": "GPT-5.4 Nano",
29731
+ "api": "openai-responses",
29732
+ "provider": "openai",
29733
+ "baseUrl": "https://api.openai.com/v1",
29734
+ "reasoning": true,
29735
+ "input": [
29736
+ "text",
29737
+ "image"
29738
+ ],
29739
+ "cost": {
29740
+ "input": 0.2,
29741
+ "output": 1.25,
29742
+ "cacheRead": 0.02,
29743
+ "cacheWrite": 0
29744
+ },
29745
+ "contextWindow": 400000,
29746
+ "maxTokens": 128000,
29747
+ "thinking": {
29748
+ "mode": "effort",
29749
+ "minLevel": "low",
29750
+ "maxLevel": "xhigh"
29751
+ }
29752
+ },
29671
29753
  "gpt-5.4-pro": {
29672
29754
  "id": "gpt-5.4-pro",
29673
29755
  "name": "GPT-5.4 Pro",
@@ -30220,6 +30302,60 @@
30220
30302
  "minLevel": "low",
30221
30303
  "maxLevel": "xhigh"
30222
30304
  }
30305
+ },
30306
+ "gpt-5.4-mini": {
30307
+ "id": "gpt-5.4-mini",
30308
+ "name": "GPT-5.4 Mini",
30309
+ "api": "openai-codex-responses",
30310
+ "provider": "openai-codex",
30311
+ "baseUrl": "https://chatgpt.com/backend-api",
30312
+ "reasoning": true,
30313
+ "input": [
30314
+ "text",
30315
+ "image"
30316
+ ],
30317
+ "cost": {
30318
+ "input": 0,
30319
+ "output": 0,
30320
+ "cacheRead": 0,
30321
+ "cacheWrite": 0
30322
+ },
30323
+ "contextWindow": 272000,
30324
+ "maxTokens": 128000,
30325
+ "preferWebsockets": true,
30326
+ "priority": 1,
30327
+ "thinking": {
30328
+ "mode": "effort",
30329
+ "minLevel": "low",
30330
+ "maxLevel": "xhigh"
30331
+ }
30332
+ },
30333
+ "gpt-5.4-nano": {
30334
+ "id": "gpt-5.4-nano",
30335
+ "name": "GPT-5.4 Nano",
30336
+ "api": "openai-codex-responses",
30337
+ "provider": "openai-codex",
30338
+ "baseUrl": "https://chatgpt.com/backend-api",
30339
+ "reasoning": true,
30340
+ "input": [
30341
+ "text",
30342
+ "image"
30343
+ ],
30344
+ "cost": {
30345
+ "input": 0,
30346
+ "output": 0,
30347
+ "cacheRead": 0,
30348
+ "cacheWrite": 0
30349
+ },
30350
+ "contextWindow": 272000,
30351
+ "maxTokens": 128000,
30352
+ "preferWebsockets": true,
30353
+ "priority": 2,
30354
+ "thinking": {
30355
+ "mode": "effort",
30356
+ "minLevel": "low",
30357
+ "maxLevel": "xhigh"
30358
+ }
30223
30359
  }
30224
30360
  },
30225
30361
  "opencode": {
@@ -173,13 +173,26 @@ function createBundledReferenceMap<TApi extends Api>(
173
173
  return references;
174
174
  }
175
175
 
176
+ function shouldReplaceGlobalReference(existing: Model<Api> | undefined, candidate: Model<Api>): boolean {
177
+ if (!existing) return true;
178
+ if (candidate.contextWindow !== existing.contextWindow) {
179
+ return candidate.contextWindow > existing.contextWindow;
180
+ }
181
+ if (candidate.maxTokens !== existing.maxTokens) {
182
+ return candidate.maxTokens > existing.maxTokens;
183
+ }
184
+ // When limits tie, prefer OpenAI as the canonical reference so generic OpenAI-family
185
+ // providers inherit OpenAI pricing/capabilities instead of Copilot-specific metadata.
186
+ return existing.provider !== "openai" && candidate.provider === "openai";
187
+ }
188
+
176
189
  function createGlobalReferenceMap(): Map<string, Model<Api>> {
177
190
  const references = new Map<string, Model<Api>>();
178
191
  for (const provider of getBundledProviders()) {
179
192
  for (const model of getBundledModels(provider as Parameters<typeof getBundledModels>[0])) {
180
193
  const candidate = model as Model<Api>;
181
194
  const existing = references.get(candidate.id);
182
- if (!existing || candidate.contextWindow > existing.contextWindow) {
195
+ if (shouldReplaceGlobalReference(existing, candidate)) {
183
196
  references.set(candidate.id, candidate);
184
197
  }
185
198
  }
@@ -2134,7 +2134,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
2134
2134
  } satisfies ResponseOutputMessage);
2135
2135
  continue;
2136
2136
  }
2137
- if (block.type === "toolCall" && msg.stopReason !== "error") {
2137
+ if (block.type === "toolCall") {
2138
2138
  const toolCall = block as ToolCall;
2139
2139
  const normalized = normalizeResponsesToolCallId(toolCall.id);
2140
2140
  outputItems.push({
@@ -102,6 +102,7 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
102
102
  model: Model<TApi>,
103
103
  msgIndex: number,
104
104
  knownCallIds: Set<string>,
105
+ includeThinkingSignatures = true,
105
106
  ): ResponseInput {
106
107
  const outputItems: ResponseInput = [];
107
108
  const isDifferentModel =
@@ -109,6 +110,9 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
109
110
 
110
111
  for (const block of assistantMsg.content) {
111
112
  if (block.type === "thinking" && assistantMsg.stopReason !== "error") {
113
+ if (!includeThinkingSignatures) {
114
+ continue;
115
+ }
112
116
  if (block.thinkingSignature) {
113
117
  outputItems.push(JSON.parse(block.thinkingSignature) as ResponseReasoningItem);
114
118
  }
@@ -134,7 +138,7 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
134
138
  continue;
135
139
  }
136
140
 
137
- if (block.type !== "toolCall" || assistantMsg.stopReason === "error") {
141
+ if (block.type !== "toolCall") {
138
142
  continue;
139
143
  }
140
144
 
@@ -14,6 +14,7 @@ import {
14
14
  isSpecialServiceTier,
15
15
  type MessageAttribution,
16
16
  type Model,
17
+ type ProviderSessionState,
17
18
  type ServiceTier,
18
19
  type StreamFunction,
19
20
  type StreamOptions,
@@ -74,6 +75,45 @@ export interface OpenAIResponsesOptions extends StreamOptions {
74
75
  strictResponsesPairing?: boolean;
75
76
  }
76
77
 
78
+ const OPENAI_RESPONSES_PROVIDER_SESSION_STATE_PREFIX = "openai-responses:";
79
+
80
+ interface OpenAIResponsesProviderSessionState extends ProviderSessionState {
81
+ nativeHistoryReplayWarmed: boolean;
82
+ }
83
+
84
+ function createOpenAIResponsesProviderSessionState(): OpenAIResponsesProviderSessionState {
85
+ const state: OpenAIResponsesProviderSessionState = {
86
+ nativeHistoryReplayWarmed: false,
87
+ close: () => {
88
+ state.nativeHistoryReplayWarmed = false;
89
+ },
90
+ };
91
+ return state;
92
+ }
93
+
94
+ function getOpenAIResponsesProviderSessionStateKey(model: Model<"openai-responses">): string {
95
+ return `${OPENAI_RESPONSES_PROVIDER_SESSION_STATE_PREFIX}${model.provider}`;
96
+ }
97
+
98
+ function getOpenAIResponsesProviderSessionState(
99
+ model: Model<"openai-responses">,
100
+ providerSessionState: Map<string, ProviderSessionState> | undefined,
101
+ ): OpenAIResponsesProviderSessionState | undefined {
102
+ if (!providerSessionState) return undefined;
103
+ const key = getOpenAIResponsesProviderSessionStateKey(model);
104
+ const existing = providerSessionState.get(key) as OpenAIResponsesProviderSessionState | undefined;
105
+ if (existing) return existing;
106
+ const created = createOpenAIResponsesProviderSessionState();
107
+ providerSessionState.set(key, created);
108
+ return created;
109
+ }
110
+
111
+ function canReplayOpenAIResponsesNativeHistory(
112
+ providerSessionState: OpenAIResponsesProviderSessionState | undefined,
113
+ ): boolean {
114
+ return providerSessionState?.nativeHistoryReplayWarmed ?? true;
115
+ }
116
+
77
117
  type OpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & {
78
118
  top_p?: number;
79
119
  top_k?: number;
@@ -126,7 +166,8 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
126
166
  options?.headers,
127
167
  options?.initiatorOverride,
128
168
  );
129
- const { params } = buildParams(model, context, options);
169
+ const providerSessionState = getOpenAIResponsesProviderSessionState(model, options?.providerSessionState);
170
+ const { params } = buildParams(model, context, options, providerSessionState);
130
171
  const requestAbortController = new AbortController();
131
172
  const requestSignal = options?.signal
132
173
  ? AbortSignal.any([options.signal, requestAbortController.signal])
@@ -174,6 +215,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
174
215
  }
175
216
 
176
217
  output.providerPayload = createOpenAIResponsesHistoryPayload(model.provider, nativeOutputItems);
218
+ if (providerSessionState) providerSessionState.nativeHistoryReplayWarmed = true;
177
219
 
178
220
  output.duration = Date.now() - startTime;
179
221
  if (firstTokenTime) output.ttft = firstTokenTime - startTime;
@@ -246,12 +288,18 @@ function createClient(
246
288
  function buildParams(
247
289
  model: Model<"openai-responses">,
248
290
  context: Context,
249
- options?: OpenAIResponsesOptions,
291
+ options: OpenAIResponsesOptions | undefined,
292
+ providerSessionState: OpenAIResponsesProviderSessionState | undefined,
250
293
  ): { conversationMessages: ResponseInput; params: OpenAIResponsesSamplingParams } {
251
294
  const strictResponsesPairing =
252
295
  options?.strictResponsesPairing ??
253
296
  (isAzureOpenAIBaseUrl(model.baseUrl ?? "") || model.provider === "github-copilot");
254
- const conversationMessages = convertConversationMessages(model, context, strictResponsesPairing);
297
+ const conversationMessages = convertConversationMessages(
298
+ model,
299
+ context,
300
+ strictResponsesPairing,
301
+ providerSessionState,
302
+ );
255
303
  const messages: ResponseInput = [...conversationMessages];
256
304
 
257
305
  if (context.systemPrompt) {
@@ -354,9 +402,11 @@ function convertConversationMessages(
354
402
  model: Model<"openai-responses">,
355
403
  context: Context,
356
404
  strictResponsesPairing: boolean,
405
+ providerSessionState: OpenAIResponsesProviderSessionState | undefined,
357
406
  ): ResponseInput {
358
407
  const messages: ResponseInput = [];
359
408
  let knownCallIds = new Set<string>();
409
+ const shouldReplayNativeHistory = canReplayOpenAIResponsesNativeHistory(providerSessionState);
360
410
  const transformedMessages = transformMessages(context.messages, model, normalizeResponsesToolCallIdForTransform);
361
411
 
362
412
  let msgIndex = 0;
@@ -364,7 +414,15 @@ function convertConversationMessages(
364
414
  if (msg.role === "user" || msg.role === "developer") {
365
415
  const providerPayload = (msg as { providerPayload?: AssistantMessage["providerPayload"] }).providerPayload;
366
416
  const historyItems = getOpenAIResponsesHistoryItems(providerPayload, model.provider);
367
- if (historyItems) {
417
+ const shouldReplayPayloadItems =
418
+ shouldReplayNativeHistory ||
419
+ (historyItems?.some(item => {
420
+ if (!item || typeof item !== "object") return false;
421
+ const candidate = item as { type?: unknown };
422
+ return candidate.type === "compaction" || candidate.type === "compaction_summary";
423
+ }) ??
424
+ false);
425
+ if (historyItems && shouldReplayPayloadItems) {
368
426
  messages.push(...sanitizeOpenAIResponsesHistoryItemsForReplay(historyItems));
369
427
  knownCallIds = collectKnownCallIds(messages);
370
428
  msgIndex++;
@@ -375,11 +433,9 @@ function convertConversationMessages(
375
433
  messages.push({ role: "user", content });
376
434
  } else if (msg.role === "assistant") {
377
435
  const assistantMsg = msg as AssistantMessage;
378
- const providerPayload = getOpenAIResponsesHistoryPayload(
379
- assistantMsg.providerPayload,
380
- model.provider,
381
- assistantMsg.provider,
382
- );
436
+ const providerPayload = shouldReplayNativeHistory
437
+ ? getOpenAIResponsesHistoryPayload(assistantMsg.providerPayload, model.provider, assistantMsg.provider)
438
+ : undefined;
383
439
  const historyItems = providerPayload?.items;
384
440
  if (historyItems) {
385
441
  const sanitizedHistoryItems = sanitizeOpenAIResponsesHistoryItemsForReplay(historyItems);
@@ -393,7 +449,13 @@ function convertConversationMessages(
393
449
  continue;
394
450
  }
395
451
 
396
- const outputItems = convertResponsesAssistantMessage(assistantMsg, model, msgIndex, knownCallIds);
452
+ const outputItems = convertResponsesAssistantMessage(
453
+ assistantMsg,
454
+ model,
455
+ msgIndex,
456
+ knownCallIds,
457
+ shouldReplayNativeHistory,
458
+ );
397
459
  if (outputItems.length === 0) continue;
398
460
  messages.push(...outputItems);
399
461
  } else if (msg.role === "toolResult") {
@@ -124,101 +124,105 @@ export function transformMessages<TApi extends Api>(
124
124
  });
125
125
 
126
126
  // Second pass: insert synthetic empty tool results for orphaned tool calls
127
- // This preserves thinking signatures and satisfies API requirements
127
+ // and preserve aborted/errored tool results when they were already persisted.
128
128
  const result: Message[] = [];
129
129
  let pendingToolCalls: ToolCall[] = [];
130
- // Track tool call status: whether resolved (has result) or aborted (skip real results)
130
+ let pendingAbortedToolCalls = new Map<string, ToolCall>();
131
+ let pendingAbortedTimestamp: number | undefined;
132
+ // Track tool call status: whether resolved (has result) or aborted (synthetic result injected, skip later real results)
131
133
  const toolCallStatus = new Map<string, ToolCallStatus>();
132
134
 
135
+ const flushPendingToolCalls = (timestamp: number): void => {
136
+ if (pendingToolCalls.length === 0) return;
137
+ for (const tc of pendingToolCalls) {
138
+ if (!toolCallStatus.has(tc.id)) {
139
+ result.push({
140
+ role: "toolResult",
141
+ toolCallId: tc.id,
142
+ toolName: tc.name,
143
+ content: [{ type: "text", text: "No result provided" }],
144
+ isError: true,
145
+ timestamp,
146
+ } as ToolResultMessage);
147
+ toolCallStatus.set(tc.id, ToolCallStatus.Resolved);
148
+ }
149
+ }
150
+ pendingToolCalls = [];
151
+ };
152
+
153
+ const flushPendingAbortedToolCalls = (): void => {
154
+ if (pendingAbortedTimestamp === undefined) return;
155
+ for (const tc of pendingAbortedToolCalls.values()) {
156
+ if (!toolCallStatus.has(tc.id)) {
157
+ result.push({
158
+ role: "toolResult",
159
+ toolCallId: tc.id,
160
+ toolName: tc.name,
161
+ content: [{ type: "text", text: "aborted" }],
162
+ isError: true,
163
+ timestamp: pendingAbortedTimestamp,
164
+ } as ToolResultMessage);
165
+ toolCallStatus.set(tc.id, ToolCallStatus.Aborted);
166
+ }
167
+ }
168
+ result.push({
169
+ role: "developer",
170
+ content: turnAbortedGuidance,
171
+ timestamp: pendingAbortedTimestamp + 1,
172
+ } as DeveloperMessage);
173
+ pendingAbortedToolCalls = new Map();
174
+ pendingAbortedTimestamp = undefined;
175
+ };
176
+
133
177
  for (let i = 0; i < transformed.length; i++) {
134
178
  const msg = transformed[i];
179
+ const messageTimestamp = "timestamp" in msg && typeof msg.timestamp === "number" ? msg.timestamp : Date.now();
135
180
 
136
181
  if (msg.role === "assistant") {
137
- // If we have pending orphaned tool calls from a previous assistant, insert synthetic results now
138
- if (pendingToolCalls.length > 0) {
139
- for (const tc of pendingToolCalls) {
140
- if (!toolCallStatus.has(tc.id)) {
141
- result.push({
142
- role: "toolResult",
143
- toolCallId: tc.id,
144
- toolName: tc.name,
145
- content: [{ type: "text", text: "No result provided" }],
146
- isError: true,
147
- timestamp: Date.now(),
148
- } as ToolResultMessage);
149
- toolCallStatus.set(tc.id, ToolCallStatus.Resolved);
150
- }
151
- }
152
- pendingToolCalls = [];
153
- }
182
+ flushPendingToolCalls(messageTimestamp);
183
+ flushPendingAbortedToolCalls();
154
184
 
155
- // For errored/aborted assistant messages: keep tool calls intact,
156
- // inject synthetic "aborted" results, and add guidance marker.
157
- // This preserves structure so the model knows what was attempted.
158
185
  const assistantMsg = msg as AssistantMessage;
159
186
  const toolCalls = assistantMsg.content.filter(b => b.type === "toolCall") as ToolCall[];
160
187
 
161
188
  if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
162
- // Push the assistant message with tool calls intact
189
+ // Keep the assistant message with tool calls intact. If real tool results follow, preserve them;
190
+ // otherwise synthesize aborted results before the next turn boundary.
163
191
  result.push(msg);
164
-
165
- // Inject synthetic "aborted" results for each tool call
166
- for (const tc of toolCalls) {
167
- toolCallStatus.set(tc.id, ToolCallStatus.Aborted);
168
- result.push({
169
- role: "toolResult",
170
- toolCallId: tc.id,
171
- toolName: tc.name,
172
- content: [{ type: "text", text: "aborted" }],
173
- isError: true,
174
- timestamp: assistantMsg.timestamp,
175
- } as ToolResultMessage);
176
- }
177
-
178
- // Inject turn-aborted guidance marker as developer message
179
- result.push({
180
- role: "developer",
181
- content: turnAbortedGuidance,
182
- timestamp: assistantMsg.timestamp + 1,
183
- } as DeveloperMessage);
184
-
192
+ pendingAbortedToolCalls = new Map(toolCalls.map(toolCall => [toolCall.id, toolCall] as const));
193
+ pendingAbortedTimestamp = assistantMsg.timestamp;
185
194
  continue;
186
195
  }
187
196
 
188
- // Track tool calls from this normal assistant message
189
197
  if (toolCalls.length > 0) {
190
198
  pendingToolCalls = toolCalls;
191
199
  }
192
200
 
193
201
  result.push(msg);
194
202
  } else if (msg.role === "toolResult") {
195
- // Skip tool results for aborted tool calls (we already injected synthetic ones)
203
+ if (pendingAbortedToolCalls.has(msg.toolCallId)) {
204
+ pendingAbortedToolCalls.delete(msg.toolCallId);
205
+ toolCallStatus.set(msg.toolCallId, ToolCallStatus.Resolved);
206
+ result.push(msg);
207
+ continue;
208
+ }
209
+
196
210
  if (toolCallStatus.get(msg.toolCallId) === ToolCallStatus.Aborted) continue;
197
211
  toolCallStatus.set(msg.toolCallId, ToolCallStatus.Resolved);
198
212
  result.push(msg);
199
213
  } else if (msg.role === "user" || msg.role === "developer") {
200
- // User/developer message interrupts tool flow - insert synthetic results for orphaned calls
201
- if (pendingToolCalls.length > 0) {
202
- for (const tc of pendingToolCalls) {
203
- if (!toolCallStatus.has(tc.id)) {
204
- result.push({
205
- role: "toolResult",
206
- toolCallId: tc.id,
207
- toolName: tc.name,
208
- content: [{ type: "text", text: "No result provided" }],
209
- isError: true,
210
- timestamp: Date.now(),
211
- } as ToolResultMessage);
212
- toolCallStatus.set(tc.id, ToolCallStatus.Resolved);
213
- }
214
- }
215
- pendingToolCalls = [];
216
- }
214
+ flushPendingToolCalls(messageTimestamp);
215
+ flushPendingAbortedToolCalls();
217
216
  result.push(msg);
218
217
  } else {
218
+ flushPendingToolCalls(messageTimestamp);
219
+ flushPendingAbortedToolCalls();
219
220
  result.push(msg);
220
221
  }
221
222
  }
222
223
 
224
+ flushPendingToolCalls(Date.now());
225
+ flushPendingAbortedToolCalls();
226
+
223
227
  return result;
224
228
  }
package/src/stream.ts CHANGED
@@ -502,6 +502,10 @@ function mapOptionsForApi<TApi extends Api>(
502
502
  thinkingBudgets: options?.thinkingBudgets,
503
503
  toolChoice: mapAnthropicToolChoice(options?.toolChoice),
504
504
  };
505
+ // Adaptive mode sends effort directly, no budget_tokens — skip budget inflation.
506
+ if (model.thinking?.mode === "anthropic-adaptive") {
507
+ return castApi<"bedrock-converse-stream">(bedrockBase);
508
+ }
505
509
  const budgetInfo = resolveBedrockThinkingBudget(model as Model<"bedrock-converse-stream">, options);
506
510
  if (!budgetInfo) return bedrockBase as OptionsForApi<TApi>;
507
511
  let maxTokens = bedrockBase.maxTokens ?? model.maxTokens;