@earendil-works/pi-ai 0.84.2 → 0.84.4

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.
Files changed (109) hide show
  1. package/README.md +5 -3
  2. package/dist/api/anthropic-messages.d.ts.map +1 -1
  3. package/dist/api/anthropic-messages.js +34 -19
  4. package/dist/api/anthropic-messages.js.map +1 -1
  5. package/dist/api/azure-openai-responses.d.ts +2 -0
  6. package/dist/api/azure-openai-responses.d.ts.map +1 -1
  7. package/dist/api/azure-openai-responses.js +9 -2
  8. package/dist/api/azure-openai-responses.js.map +1 -1
  9. package/dist/api/bedrock-converse-stream.d.ts.map +1 -1
  10. package/dist/api/bedrock-converse-stream.js +131 -7
  11. package/dist/api/bedrock-converse-stream.js.map +1 -1
  12. package/dist/api/google-generative-ai.d.ts +2 -2
  13. package/dist/api/google-generative-ai.d.ts.map +1 -1
  14. package/dist/api/google-generative-ai.js +17 -13
  15. package/dist/api/google-generative-ai.js.map +1 -1
  16. package/dist/api/google-shared.d.ts +5 -2
  17. package/dist/api/google-shared.d.ts.map +1 -1
  18. package/dist/api/google-shared.js +16 -0
  19. package/dist/api/google-shared.js.map +1 -1
  20. package/dist/api/google-vertex.d.ts +2 -2
  21. package/dist/api/google-vertex.d.ts.map +1 -1
  22. package/dist/api/google-vertex.js +15 -11
  23. package/dist/api/google-vertex.js.map +1 -1
  24. package/dist/api/mistral-conversations.d.ts.map +1 -1
  25. package/dist/api/mistral-conversations.js +7 -2
  26. package/dist/api/mistral-conversations.js.map +1 -1
  27. package/dist/api/openai-codex-responses.d.ts.map +1 -1
  28. package/dist/api/openai-codex-responses.js +4 -1
  29. package/dist/api/openai-codex-responses.js.map +1 -1
  30. package/dist/api/openai-completions.d.ts +3 -2
  31. package/dist/api/openai-completions.d.ts.map +1 -1
  32. package/dist/api/openai-completions.js +160 -86
  33. package/dist/api/openai-completions.js.map +1 -1
  34. package/dist/api/openai-responses.d.ts.map +1 -1
  35. package/dist/api/openai-responses.js +6 -2
  36. package/dist/api/openai-responses.js.map +1 -1
  37. package/dist/api/pi-messages.d.ts.map +1 -1
  38. package/dist/api/pi-messages.js +1 -1
  39. package/dist/api/pi-messages.js.map +1 -1
  40. package/dist/api/simple-options.d.ts +4 -0
  41. package/dist/api/simple-options.d.ts.map +1 -1
  42. package/dist/api/simple-options.js +17 -10
  43. package/dist/api/simple-options.js.map +1 -1
  44. package/dist/auth/oauth/device-code.d.ts +1 -0
  45. package/dist/auth/oauth/device-code.d.ts.map +1 -1
  46. package/dist/auth/oauth/device-code.js +1 -1
  47. package/dist/auth/oauth/device-code.js.map +1 -1
  48. package/dist/auth/oauth/github-copilot.d.ts.map +1 -1
  49. package/dist/auth/oauth/github-copilot.js +98 -34
  50. package/dist/auth/oauth/github-copilot.js.map +1 -1
  51. package/dist/auth/oauth/kimi-coding.d.ts.map +1 -1
  52. package/dist/auth/oauth/kimi-coding.js +1 -14
  53. package/dist/auth/oauth/kimi-coding.js.map +1 -1
  54. package/dist/image-models.generated.d.ts +75 -0
  55. package/dist/image-models.generated.d.ts.map +1 -1
  56. package/dist/image-models.generated.js +75 -0
  57. package/dist/image-models.generated.js.map +1 -1
  58. package/dist/index.d.ts +1 -1
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js.map +1 -1
  61. package/dist/providers/cloudflare-ai-gateway.d.ts +3 -1
  62. package/dist/providers/cloudflare-ai-gateway.d.ts.map +1 -1
  63. package/dist/providers/cloudflare-ai-gateway.js.map +1 -1
  64. package/dist/providers/data/.manifest.json +1 -1
  65. package/dist/providers/data/amazon-bedrock.json +1 -1
  66. package/dist/providers/data/anthropic.json +1 -1
  67. package/dist/providers/data/azure-openai-responses.json +1 -1
  68. package/dist/providers/data/baseten.json +1 -1
  69. package/dist/providers/data/cerebras.json +1 -1
  70. package/dist/providers/data/cloudflare-ai-gateway.json +1 -1
  71. package/dist/providers/data/cloudflare-workers-ai.json +1 -1
  72. package/dist/providers/data/deepseek.json +1 -1
  73. package/dist/providers/data/fireworks.json +1 -1
  74. package/dist/providers/data/github-copilot.json +1 -1
  75. package/dist/providers/data/google-vertex.json +1 -1
  76. package/dist/providers/data/google.json +1 -1
  77. package/dist/providers/data/huggingface.json +1 -1
  78. package/dist/providers/data/minimax-cn.json +1 -1
  79. package/dist/providers/data/minimax.json +1 -1
  80. package/dist/providers/data/mistral.json +1 -1
  81. package/dist/providers/data/nvidia.json +1 -1
  82. package/dist/providers/data/openai.json +1 -1
  83. package/dist/providers/data/opencode-go.json +1 -1
  84. package/dist/providers/data/opencode.json +1 -1
  85. package/dist/providers/data/openrouter.json +1 -1
  86. package/dist/providers/data/qwen-token-plan-cn.json +1 -1
  87. package/dist/providers/data/qwen-token-plan-individual.json +1 -1
  88. package/dist/providers/data/qwen-token-plan.json +1 -1
  89. package/dist/providers/data/together.json +1 -1
  90. package/dist/providers/data/vercel-ai-gateway.json +1 -1
  91. package/dist/providers/data/xai.json +1 -1
  92. package/dist/providers/data/xiaomi-token-plan-ams.json +1 -1
  93. package/dist/providers/data/xiaomi-token-plan-cn.json +1 -1
  94. package/dist/providers/data/xiaomi-token-plan-sgp.json +1 -1
  95. package/dist/providers/data/xiaomi.json +1 -1
  96. package/dist/providers/data/zai-coding-cn.json +1 -1
  97. package/dist/providers/data/zai.json +1 -1
  98. package/dist/providers/xai.d.ts +1 -1
  99. package/dist/providers/xai.d.ts.map +1 -1
  100. package/dist/providers/xai.js +1 -5
  101. package/dist/providers/xai.js.map +1 -1
  102. package/dist/types.d.ts +29 -4
  103. package/dist/types.d.ts.map +1 -1
  104. package/dist/types.js.map +1 -1
  105. package/dist/utils/sleep.d.ts +2 -0
  106. package/dist/utils/sleep.d.ts.map +1 -0
  107. package/dist/utils/sleep.js +15 -0
  108. package/dist/utils/sleep.js.map +1 -0
  109. package/package.json +3 -4
@@ -5,13 +5,14 @@ import { AssistantMessageEventStream } from "../utils/event-stream.js";
5
5
  import { shortHash } from "../utils/hash.js";
6
6
  import { headersToRecord } from "../utils/headers.js";
7
7
  import { parseStreamingJson } from "../utils/json-parse.js";
8
+ import { getPiUserAgent } from "../utils/pi-user-agent.js";
8
9
  import { getProviderEnvValue } from "../utils/provider-env.js";
9
10
  import { retryProviderRequest } from "../utils/provider-retry.js";
10
11
  import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
11
12
  import { appendGrammarToolInputJsonDelta, createGrammarToolInputProperties, getGrammarToolInput, getJsonSchemaToolParameters, resolveGrammarConstrainedSampling, resolveJsonSchemaStrictSampling, } from "./constrained-sampling.js";
12
13
  import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
13
14
  import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js";
14
- import { buildBaseOptions, clampReasoning, MIN_ANSWER_TOKENS } from "./simple-options.js";
15
+ import { buildBaseOptions, clampThinkingBudgetToAnswerRoom, thinkingBudgetForLevel } from "./simple-options.js";
15
16
  import { transformMessages } from "./transform-messages.js";
16
17
  /**
17
18
  * Check if conversation messages contain tool calls or tool results.
@@ -79,16 +80,81 @@ function isToolCallBlock(block) {
79
80
  function isImageContentBlock(block) {
80
81
  return block.type === "image";
81
82
  }
82
- function isEncryptedReasoningDetail(detail) {
83
- if (typeof detail !== "object" || detail === null) {
83
+ function isReasoningDetailObject(detail) {
84
+ return typeof detail === "object" && detail !== null && !Array.isArray(detail);
85
+ }
86
+ function hasValidCommonReasoningDetailFields(candidate) {
87
+ return ((candidate.id === undefined || candidate.id === null || typeof candidate.id === "string") &&
88
+ (candidate.format === undefined || typeof candidate.format === "string") &&
89
+ (candidate.index === undefined || typeof candidate.index === "number"));
90
+ }
91
+ function isOpenAIReasoningDetail(detail) {
92
+ if (!isReasoningDetailObject(detail) || !hasValidCommonReasoningDetailFields(detail)) {
84
93
  return false;
85
94
  }
86
- const candidate = detail;
87
- return (candidate.type === "reasoning.encrypted" &&
88
- typeof candidate.id === "string" &&
89
- candidate.id.length > 0 &&
90
- typeof candidate.data === "string" &&
91
- candidate.data.length > 0);
95
+ switch (detail.type) {
96
+ case "reasoning.summary":
97
+ return typeof detail.summary === "string";
98
+ case "reasoning.encrypted":
99
+ return typeof detail.data === "string";
100
+ case "reasoning.text":
101
+ return (typeof detail.text === "string" &&
102
+ (detail.signature === undefined || detail.signature === null || typeof detail.signature === "string"));
103
+ default:
104
+ return false;
105
+ }
106
+ }
107
+ function parseOpenAIReasoningDetails(signature) {
108
+ if (!signature)
109
+ return undefined;
110
+ try {
111
+ const parsed = JSON.parse(signature);
112
+ return Array.isArray(parsed) && parsed.length > 0 && parsed.every(isOpenAIReasoningDetail) ? parsed : undefined;
113
+ }
114
+ catch {
115
+ return undefined;
116
+ }
117
+ }
118
+ function parseLegacyEncryptedReasoningDetail(signature) {
119
+ if (!signature)
120
+ return undefined;
121
+ try {
122
+ const parsed = JSON.parse(signature);
123
+ return isOpenAIReasoningDetail(parsed) &&
124
+ parsed.type === "reasoning.encrypted" &&
125
+ typeof parsed.id === "string" &&
126
+ parsed.id.length > 0 &&
127
+ parsed.data.length > 0
128
+ ? parsed
129
+ : undefined;
130
+ }
131
+ catch {
132
+ return undefined;
133
+ }
134
+ }
135
+ function fillMissingCommonReasoningDetailFields(target, source) {
136
+ target.id ??= source.id;
137
+ target.format ||= source.format;
138
+ target.index ??= source.index;
139
+ }
140
+ function appendOpenAIReasoningDetail(details, detail) {
141
+ const lastDetail = details[details.length - 1];
142
+ if (detail.type === "reasoning.text" && lastDetail?.type === "reasoning.text") {
143
+ lastDetail.text += detail.text;
144
+ lastDetail.signature ||= detail.signature;
145
+ fillMissingCommonReasoningDetailFields(lastDetail, detail);
146
+ return;
147
+ }
148
+ if (detail.type === "reasoning.summary" && lastDetail?.type === "reasoning.summary") {
149
+ lastDetail.summary += detail.summary;
150
+ fillMissingCommonReasoningDetailFields(lastDetail, detail);
151
+ return;
152
+ }
153
+ details.push({ ...detail });
154
+ }
155
+ const OPENAI_COMPLETIONS_REASONING_FIELDS = ["reasoning", "reasoning_content", "reasoning_text"];
156
+ function isOpenAICompletionsReasoningField(field) {
157
+ return OPENAI_COMPLETIONS_REASONING_FIELDS.includes(field);
92
158
  }
93
159
  function resolveCacheRetention(cacheRetention, env) {
94
160
  if (cacheRetention) {
@@ -119,6 +185,14 @@ export const stream = (model, context, options) => {
119
185
  stopReason: "pending",
120
186
  timestamp: Date.now(),
121
187
  };
188
+ // `reasoning_details` are replay metadata, not user-visible stream deltas.
189
+ // Keep them in memory during streaming and serialize once when the block is finalized.
190
+ let streamedReasoningDetails;
191
+ const applyStreamedReasoningDetails = (block) => {
192
+ if (streamedReasoningDetails !== undefined) {
193
+ block.thinkingSignature = JSON.stringify(streamedReasoningDetails);
194
+ }
195
+ };
122
196
  try {
123
197
  const apiKey = getClientApiKey(model.provider, options?.apiKey, options?.headers);
124
198
  const compat = getCompat(model);
@@ -148,7 +222,6 @@ export const stream = (model, context, options) => {
148
222
  let hasFinishReason = false;
149
223
  const toolCallBlocksByIndex = new Map();
150
224
  const toolCallBlocksById = new Map();
151
- const pendingReasoningDetailsByToolCallId = new Map();
152
225
  const blocks = output.content;
153
226
  const getContentIndex = (block) => blocks.indexOf(block);
154
227
  const getCustomToolCallInput = (block) => {
@@ -180,6 +253,7 @@ export const stream = (model, context, options) => {
180
253
  });
181
254
  }
182
255
  else if (block.type === "thinking") {
256
+ applyStreamedReasoningDetails(block);
183
257
  stream.push({
184
258
  type: "thinking_end",
185
259
  contentIndex,
@@ -235,16 +309,6 @@ export const stream = (model, context, options) => {
235
309
  }
236
310
  return thinkingBlock;
237
311
  };
238
- const applyPendingReasoningDetail = (block) => {
239
- if (!block.id) {
240
- return;
241
- }
242
- const pendingReasoningDetail = pendingReasoningDetailsByToolCallId.get(block.id);
243
- if (pendingReasoningDetail) {
244
- block.thoughtSignature = pendingReasoningDetail;
245
- pendingReasoningDetailsByToolCallId.delete(block.id);
246
- }
247
- };
248
312
  const ensureToolCallBlock = (toolCall) => {
249
313
  const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined;
250
314
  const name = toolCall.function?.name ?? toolCall.custom?.name ?? "";
@@ -300,7 +364,6 @@ export const stream = (model, context, options) => {
300
364
  };
301
365
  delete block.partialArgs;
302
366
  }
303
- applyPendingReasoningDetail(block);
304
367
  return block;
305
368
  };
306
369
  for await (const chunk of openaiStream) {
@@ -407,16 +470,14 @@ export const stream = (model, context, options) => {
407
470
  const reasoningDetails = choice.delta.reasoning_details;
408
471
  if (Array.isArray(reasoningDetails)) {
409
472
  for (const detail of reasoningDetails) {
410
- if (isEncryptedReasoningDetail(detail)) {
411
- const serializedDetail = JSON.stringify(detail);
412
- const matchingToolCall = toolCallBlocksById.get(detail.id);
413
- if (matchingToolCall) {
414
- matchingToolCall.thoughtSignature = serializedDetail;
415
- }
416
- else {
417
- pendingReasoningDetailsByToolCallId.set(detail.id, serializedDetail);
418
- }
419
- }
473
+ if (!isOpenAIReasoningDetail(detail))
474
+ continue;
475
+ ensureThinkingBlock("");
476
+ streamedReasoningDetails ??= [];
477
+ // Keep provider replay data in the existing signature slot. OpenRouter streams
478
+ // reasoning_details as deltas: consecutive text/summary deltas are merged into
479
+ // logical entries, while encrypted entries remain opaque and discrete.
480
+ appendOpenAIReasoningDetail(streamedReasoningDetails, detail);
420
481
  }
421
482
  }
422
483
  }
@@ -444,6 +505,9 @@ export const stream = (model, context, options) => {
444
505
  }
445
506
  catch (error) {
446
507
  for (const block of output.content) {
508
+ if (block.type === "thinking") {
509
+ applyStreamedReasoningDetails(block);
510
+ }
447
511
  delete block.index;
448
512
  // Streaming scratch buffers are only used during parsing; never persist them.
449
513
  delete block.partialArgs;
@@ -468,19 +532,20 @@ export const stream = (model, context, options) => {
468
532
  };
469
533
  export const streamSimple = (model, context, options) => {
470
534
  getClientApiKey(model.provider, options?.apiKey, options?.headers);
471
- const base = buildBaseOptions(model, context, options, options?.apiKey);
535
+ const base = {
536
+ ...buildBaseOptions(model, context, options, options?.apiKey),
537
+ toolChoice: options?.toolChoice,
538
+ };
472
539
  const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined;
473
540
  const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;
474
- const toolChoice = options?.toolChoice;
475
541
  return stream(model, context, {
476
542
  ...base,
477
543
  reasoningEffort,
478
- toolChoice,
479
544
  thinkingBudgets: options?.thinkingBudgets,
480
545
  });
481
546
  };
482
547
  function createClient(model, context, apiKey, optionsHeaders, fetch, sessionId, compat = getCompat(model)) {
483
- const headers = { ...model.headers };
548
+ const headers = { "User-Agent": getPiUserAgent(), ...model.headers };
484
549
  if (model.provider === "github-copilot") {
485
550
  const hasImages = hasCopilotVisionInput(context.messages);
486
551
  const copilotHeaders = buildCopilotDynamicHeaders({
@@ -561,6 +626,8 @@ function buildParams(model, context, options, compat = getCompat(model), cacheRe
561
626
  if (options?.toolChoice) {
562
627
  params.tool_choice = options.toolChoice;
563
628
  }
629
+ const thinkingTokenBudgetField = resolveThinkingTokenBudgetField(compat);
630
+ const thinkingBudget = resolveClampedThinkingBudget(model, options, params);
564
631
  if (compat.thinkingFormat === "zai" && model.reasoning) {
565
632
  const zaiParams = params;
566
633
  zaiParams.thinking = options?.reasoningEffort ? { type: "enabled", clear_thinking: false } : { type: "disabled" };
@@ -588,14 +655,14 @@ function buildParams(model, context, options, compat = getCompat(model), cacheRe
588
655
  };
589
656
  }
590
657
  else if (compat.thinkingFormat === "chat-template" && model.reasoning) {
591
- const chatTemplateKwargs = buildChatTemplateValues(model, options, compat.chatTemplateKwargs);
658
+ const chatTemplateKwargs = buildChatTemplateValues(model, options, compat.chatTemplateKwargs, thinkingBudget);
592
659
  if (chatTemplateKwargs) {
593
660
  params.chat_template_kwargs = chatTemplateKwargs;
594
661
  }
595
662
  }
596
663
  else if (compat.thinkingFormat === "baseten" && model.reasoning) {
597
664
  const basetenParams = params;
598
- const chatTemplateArgs = buildChatTemplateValues(model, options, compat.chatTemplateArgs);
665
+ const chatTemplateArgs = buildChatTemplateValues(model, options, compat.chatTemplateArgs, thinkingBudget);
599
666
  if (chatTemplateArgs) {
600
667
  basetenParams.chat_template_args = chatTemplateArgs;
601
668
  }
@@ -664,25 +731,12 @@ function buildParams(model, context, options, compat = getCompat(model), cacheRe
664
731
  params.reasoning_effort = offValue;
665
732
  }
666
733
  }
667
- // vLLM caps reasoning with a top-level thinking_token_budget. Independent of
668
- // thinkingFormat: the same server can serve zai, qwen or chat-template models.
669
- // Reasoning and the answer share max_tokens here, so an uncapped reasoning
670
- // phase can consume the whole response and leave no answer and no tool call.
671
- if (compat.supportsThinkingTokenBudget && options?.reasoningEffort && model.reasoning) {
672
- const level = clampReasoning(options.reasoningEffort);
673
- const budgets = {
674
- minimal: 1024,
675
- low: 2048,
676
- medium: 8192,
677
- high: 16384,
678
- ...options.thinkingBudgets,
679
- };
680
- const ceiling = params.max_tokens ?? params.max_completion_tokens ?? model.maxTokens;
681
- // Always leave room for the answer, otherwise the budget recreates the bug it prevents.
682
- const budget = Math.min(budgets[level], Math.max(0, ceiling - MIN_ANSWER_TOKENS));
683
- if (budget > 0) {
684
- params.thinking_token_budget = budget;
685
- }
734
+ // Cap reasoning with a top-level budget field. Independent of thinkingFormat: the
735
+ // same server can serve zai, qwen or chat-template models. Reasoning and the answer
736
+ // share max_tokens here, so an uncapped reasoning phase can consume the whole
737
+ // response and leave no answer and no tool call.
738
+ if (thinkingTokenBudgetField && thinkingBudget !== undefined) {
739
+ Object.assign(params, { [thinkingTokenBudgetField]: thinkingBudget });
686
740
  }
687
741
  // OpenRouter provider routing preferences
688
742
  if (model.compat?.openRouterRouting) {
@@ -706,17 +760,31 @@ function buildParams(model, context, options, compat = getCompat(model), cacheRe
706
760
  }
707
761
  return params;
708
762
  }
709
- function buildChatTemplateValues(model, options, values) {
763
+ function resolveThinkingTokenBudgetField(compat) {
764
+ if (compat.thinkingTokenBudgetField)
765
+ return compat.thinkingTokenBudgetField;
766
+ if (compat.supportsThinkingTokenBudget)
767
+ return "thinking_token_budget";
768
+ return undefined;
769
+ }
770
+ function resolveClampedThinkingBudget(model, options, params) {
771
+ if (!options?.reasoningEffort || !model.reasoning)
772
+ return undefined;
773
+ const ceiling = params.max_tokens ?? params.max_completion_tokens ?? model.maxTokens;
774
+ const budget = clampThinkingBudgetToAnswerRoom(thinkingBudgetForLevel(options.reasoningEffort, options.thinkingBudgets), ceiling);
775
+ return budget > 0 ? budget : undefined;
776
+ }
777
+ function buildChatTemplateValues(model, options, values, thinkingBudget) {
710
778
  const resolvedValues = {};
711
779
  for (const [key, value] of Object.entries(values)) {
712
- const resolved = resolveChatTemplateKwargValue(model, options, value);
780
+ const resolved = resolveChatTemplateKwargValue(model, options, value, thinkingBudget);
713
781
  if (resolved !== undefined) {
714
782
  resolvedValues[key] = resolved;
715
783
  }
716
784
  }
717
785
  return Object.keys(resolvedValues).length > 0 ? resolvedValues : undefined;
718
786
  }
719
- function resolveChatTemplateKwargValue(model, options, value) {
787
+ function resolveChatTemplateKwargValue(model, options, value, thinkingBudget) {
720
788
  if (typeof value !== "object" || value === null) {
721
789
  return value;
722
790
  }
@@ -727,6 +795,9 @@ function resolveChatTemplateKwargValue(model, options, value) {
727
795
  if (value.$var === "thinking.enabled") {
728
796
  return !!reasoningEffort;
729
797
  }
798
+ if (value.$var === "thinking.budget") {
799
+ return thinkingBudget;
800
+ }
730
801
  const mappedValue = reasoningEffort ? model.thinkingLevelMap?.[reasoningEffort] : model.thinkingLevelMap?.off;
731
802
  return mappedValue === undefined ? reasoningEffort : typeof mappedValue === "string" ? mappedValue : undefined;
732
803
  }
@@ -894,9 +965,16 @@ export function convertMessages(model, context, compat, options) {
894
965
  text: sanitizeSurrogates(block.text),
895
966
  }));
896
967
  const assistantText = assistantTextParts.map((part) => part.text).join("");
897
- const nonEmptyThinkingBlocks = msg.content
898
- .filter(isThinkingContentBlock)
899
- .filter((block) => block.thinking.trim().length > 0);
968
+ const thinkingBlocks = msg.content.filter(isThinkingContentBlock);
969
+ const toolCalls = msg.content.filter(isToolCallBlock);
970
+ const signedReasoningDetails = thinkingBlocks
971
+ .map((block) => parseOpenAIReasoningDetails(block.thinkingSignature))
972
+ .find((details) => details !== undefined);
973
+ const legacyReasoningDetails = toolCalls
974
+ .map((toolCall) => parseLegacyEncryptedReasoningDetail(toolCall.thoughtSignature))
975
+ .filter((detail) => detail !== undefined);
976
+ const preservedReasoningDetails = signedReasoningDetails ?? (legacyReasoningDetails.length > 0 ? legacyReasoningDetails : undefined);
977
+ const nonEmptyThinkingBlocks = thinkingBlocks.filter((block) => block.thinking.trim().length > 0);
900
978
  if (nonEmptyThinkingBlocks.length > 0) {
901
979
  if (compat.requiresThinkingAsText) {
902
980
  // Convert thinking blocks to plain text (no tags to avoid model mimicking them)
@@ -914,13 +992,16 @@ export function convertMessages(model, context, compat, options) {
914
992
  if (assistantText.length > 0) {
915
993
  assistantMsg.content = assistantText;
916
994
  }
917
- // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss)
918
- let signature = nonEmptyThinkingBlocks[0].thinkingSignature;
919
- if (model.provider === "opencode-go" && signature === "reasoning") {
920
- signature = "reasoning_content";
921
- }
922
- if (signature && signature.length > 0) {
923
- assistantMsg[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n");
995
+ // reasoning_details is the structured alternative to a raw reasoning field.
996
+ if (!preservedReasoningDetails) {
997
+ // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss)
998
+ let signature = nonEmptyThinkingBlocks[0].thinkingSignature;
999
+ if (model.provider === "opencode-go" && signature === "reasoning") {
1000
+ signature = "reasoning_content";
1001
+ }
1002
+ if (signature && isOpenAICompletionsReasoningField(signature)) {
1003
+ assistantMsg[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n");
1004
+ }
924
1005
  }
925
1006
  }
926
1007
  }
@@ -932,7 +1013,6 @@ export function convertMessages(model, context, compat, options) {
932
1013
  // output, producing recursive nesting like [{'type':'text','text':'[{...}]'}].
933
1014
  assistantMsg.content = assistantText;
934
1015
  }
935
- const toolCalls = msg.content.filter(isToolCallBlock);
936
1016
  if (toolCalls.length > 0) {
937
1017
  assistantMsg.tool_calls = toolCalls.map((tc) => {
938
1018
  const customInputProperty = options?.grammarToolInputProperties?.get(tc.name);
@@ -955,20 +1035,9 @@ export function convertMessages(model, context, compat, options) {
955
1035
  },
956
1036
  };
957
1037
  });
958
- const reasoningDetails = toolCalls
959
- .filter((tc) => tc.thoughtSignature)
960
- .map((tc) => {
961
- try {
962
- return JSON.parse(tc.thoughtSignature);
963
- }
964
- catch {
965
- return null;
966
- }
967
- })
968
- .filter(Boolean);
969
- if (reasoningDetails.length > 0) {
970
- assistantMsg.reasoning_details = reasoningDetails;
971
- }
1038
+ }
1039
+ if (preservedReasoningDetails) {
1040
+ assistantMsg.reasoning_details = preservedReasoningDetails;
972
1041
  }
973
1042
  if (compat.requiresReasoningContentOnAssistantMessages &&
974
1043
  model.reasoning &&
@@ -1105,10 +1174,13 @@ function convertTools(tools, compat) {
1105
1174
  }
1106
1175
  function parseChunkUsage(rawUsage, model) {
1107
1176
  const promptTokens = rawUsage.prompt_tokens || 0;
1108
- const cacheReadTokens = rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? 0;
1177
+ const cacheReadTokens = rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? rawUsage.cached_tokens ?? 0;
1109
1178
  const cacheWriteTokens = rawUsage.prompt_tokens_details?.cache_write_tokens || 0;
1110
1179
  // Follow documented OpenAI/OpenRouter semantics: cached_tokens is cache-read
1111
- // tokens (hits). OpenAI does not document or emit cache_write_tokens, but
1180
+ // tokens (hits). Providers disagree on placement: OpenAI/OpenRouter use
1181
+ // prompt_tokens_details.cached_tokens, DeepSeek uses prompt_cache_hit_tokens,
1182
+ // and Kimi documents top-level usage.cached_tokens on the final usage chunk.
1183
+ // OpenAI does not document or emit cache_write_tokens, but
1112
1184
  // OpenRouter-compatible providers can include it as a separate write count.
1113
1185
  // OpenRouter's own provider/tests affirm the separate mapping:
1114
1186
  // https://github.com/OpenRouterTeam/ai-sdk-provider/pull/409
@@ -1227,6 +1299,7 @@ function detectCompat(model) {
1227
1299
  chatTemplateArgs: {},
1228
1300
  zaiToolStream: false,
1229
1301
  supportsThinkingTokenBudget: false,
1302
+ thinkingTokenBudgetField: undefined,
1230
1303
  supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia,
1231
1304
  supportsOpenAIGrammarTools: false,
1232
1305
  cacheControlFormat,
@@ -1267,6 +1340,7 @@ function getCompat(model) {
1267
1340
  chatTemplateArgs: model.compat.chatTemplateArgs ?? detected.chatTemplateArgs,
1268
1341
  zaiToolStream: model.compat.zaiToolStream ?? detected.zaiToolStream,
1269
1342
  supportsThinkingTokenBudget: model.compat.supportsThinkingTokenBudget ?? detected.supportsThinkingTokenBudget,
1343
+ thinkingTokenBudgetField: model.compat.thinkingTokenBudgetField ?? detected.thinkingTokenBudgetField,
1270
1344
  supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode,
1271
1345
  supportsOpenAIGrammarTools: model.compat.supportsOpenAIGrammarTools ?? detected.supportsOpenAIGrammarTools,
1272
1346
  cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat,