@code-yeongyu/senpi-ai 2026.10.10 → 2026.10.11

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 (58) hide show
  1. package/dist/api/anthropic-messages.js +101 -38
  2. package/dist/api/anthropic-tool-references.js +24 -18
  3. package/dist/api/bedrock-converse-stream.js +5 -1
  4. package/dist/api/google-shared.js +101 -21
  5. package/dist/api/lazy.js +13 -0
  6. package/dist/api/mistral-conversations.js +18 -3
  7. package/dist/api/openai-codex-responses.js +2 -2
  8. package/dist/api/openai-completions.js +26 -2
  9. package/dist/api/openai-responses-allowed-tools.d.ts +33 -0
  10. package/dist/api/openai-responses-allowed-tools.js +108 -0
  11. package/dist/api/openai-responses.js +43 -58
  12. package/dist/auth/oauth/anthropic.js +4 -3
  13. package/dist/auth/oauth/chatgpt-subscription.js +3 -2
  14. package/dist/auth/oauth/cursor.js +3 -2
  15. package/dist/auth/oauth/devin-token.js +2 -1
  16. package/dist/auth/oauth/github-copilot.js +3 -2
  17. package/dist/auth/oauth/kimi-coding.js +5 -4
  18. package/dist/auth/oauth/openai-chatgpt.js +2 -1
  19. package/dist/auth/oauth/openrouter.js +2 -1
  20. package/dist/auth/oauth/radius.js +5 -5
  21. package/dist/auth/oauth/xai.js +7 -4
  22. package/dist/auth/oauth-refresh.js +10 -0
  23. package/dist/auth/resolve.js +5 -0
  24. package/dist/openai-responses-compat.d.ts +7 -2
  25. package/dist/openai-responses-compat.js +15 -2
  26. package/dist/providers/all.d.ts +1 -1
  27. package/dist/providers/data/.manifest.json +1 -1
  28. package/dist/providers/data/amazon-bedrock.json +1 -1
  29. package/dist/providers/data/anthropic.json +1 -1
  30. package/dist/providers/data/baseten.json +1 -1
  31. package/dist/providers/data/chatgpt-subscription.json +1 -1
  32. package/dist/providers/data/cloudflare-ai-gateway.json +1 -1
  33. package/dist/providers/data/cloudflare-workers-ai.json +1 -1
  34. package/dist/providers/data/github-copilot.json +1 -1
  35. package/dist/providers/data/google-vertex.json +1 -1
  36. package/dist/providers/data/huggingface.json +1 -1
  37. package/dist/providers/data/mistral.json +1 -1
  38. package/dist/providers/data/moonshotai-cn.json +1 -1
  39. package/dist/providers/data/moonshotai.json +1 -1
  40. package/dist/providers/data/opencode-go.json +1 -1
  41. package/dist/providers/data/opencode.json +1 -1
  42. package/dist/providers/data/opengateway.json +1 -1
  43. package/dist/providers/data/openrouter.json +1 -1
  44. package/dist/providers/data/radius.json +1 -1
  45. package/dist/providers/data/venice.json +1 -1
  46. package/dist/providers/data/vercel-ai-gateway.json +1 -1
  47. package/dist/providers/kimi-coding.models.d.ts +1 -1
  48. package/dist/providers/kimi-coding.models.js +1 -1
  49. package/dist/providers/opengateway-refresh.js +2 -1
  50. package/dist/types.d.ts +3 -2
  51. package/dist/utils/oauth-refresh-error.d.ts +16 -0
  52. package/dist/utils/oauth-refresh-error.js +67 -0
  53. package/dist/utils/prompt-cache-ttl.js +3 -1
  54. package/dist/utils/retry.d.ts +6 -0
  55. package/dist/utils/retry.js +10 -0
  56. package/dist/utils/tool-choice-fallback.d.ts +11 -0
  57. package/dist/utils/tool-choice-fallback.js +69 -2
  58. package/package.json +2 -2
@@ -71,7 +71,7 @@ function getCacheControl(model, cacheRetention, env) {
71
71
  // The bundled Claude Code version and the floor of the advertised `claude-cli/<version>`
72
72
  // (see utils/claude-code-version.ts). Keep this exact declaration: a downstream installer
73
73
  // (oh-my-openagent) rewrites it byte-for-byte in the installed dist and bundle.
74
- const claudeCodeVersion = "2.1.289";
74
+ const claudeCodeVersion = "2.1.292";
75
75
  // Claude Code 2.x tool names (canonical casing)
76
76
  // Source: https://cchistory.mariozechner.at/data/prompts-2.1.11.md
77
77
  // To update: https://github.com/badlogic/cchistory
@@ -164,6 +164,7 @@ const ADAPTIVE_THINKING_MODEL_MARKERS = [
164
164
  "opus-5",
165
165
  "sonnet-4-6",
166
166
  "sonnet-5",
167
+ "haiku-5-5",
167
168
  "fable-5",
168
169
  "mythos-5",
169
170
  ];
@@ -176,14 +177,20 @@ const NATIVE_XHIGH_EFFORT_MODEL_MARKERS = [
176
177
  "opus-4-8",
177
178
  "opus-5",
178
179
  "sonnet-5",
180
+ "haiku-5-5",
179
181
  "fable-5",
180
182
  "mythos-5",
181
183
  ];
182
184
  /**
183
- * Adaptive families that reject `thinking: {type: "disabled"}` outright (verified 400:
184
- * `"thinking.type.disabled" is not supported for this model`). The generated catalog also encodes
185
- * this as `compat.supportsDisabledThinking: false`, but `models.json` entries and third-party
186
- * gateway rows carry no generated compat, so the family fact has to live here as well.
185
+ * Families senpi never sends `thinking: {type: "disabled"}` to. Fable 5, Opus 5.5 and Sonnet 5.5 reject it
186
+ * outright (verified live 400: `"thinking.type.disabled" is not supported for this model`); Mythos 5 is
187
+ * listed by family with Fable. Claude Haiku 5.5 is listed by choice, not because it rejects `disabled`: its
188
+ * effort docs accept `disabled` at effort `high` or below (400 only at `xhigh` / `max`) and reject a
189
+ * differing per-message effort while disabled, so a real thinking-off needs marker handling first
190
+ * (senpi#2927). Until then a thinking-off turn sends no `thinking` and effort `low`, which is valid on all of
191
+ * them. The generated catalog also encodes this as `compat.supportsDisabledThinking: false`, but
192
+ * `models.json` entries and third-party gateway rows carry no generated compat, so the family fact has to
193
+ * live here as well.
187
194
  */
188
195
  const DISABLED_THINKING_REJECTING_MODEL_MARKERS = [
189
196
  "fable-5",
@@ -192,6 +199,8 @@ const DISABLED_THINKING_REJECTING_MODEL_MARKERS = [
192
199
  "opus-5.5",
193
200
  "sonnet-5-5",
194
201
  "sonnet-5.5",
202
+ "haiku-5-5",
203
+ "haiku-5.5",
195
204
  ];
196
205
  const UNSUPPORTED_NATIVE_COMPUTER_TOOL_MODEL_MARKERS = [
197
206
  "opus-4-6",
@@ -922,7 +931,7 @@ export const stream = (model, context, options) => {
922
931
  const normalizedContext = resolveTranscript(context, supportsMidConvoSystemMessages(model));
923
932
  const currentTools = getCurrentTools(normalizedContext.messages);
924
933
  (async () => {
925
- const providerThinkingLevel = model.compat?.supportsMidConvoEffort ? (options?.effort ?? "high") : undefined;
934
+ const providerThinkingLevel = managedEffortForRequest(model, options);
926
935
  const output = {
927
936
  role: "assistant",
928
937
  content: [],
@@ -972,7 +981,7 @@ export const stream = (model, context, options) => {
972
981
  const cacheRetention = resolveCacheRetention(options?.cacheRetention ?? model.cacheRetention, options?.env, "short");
973
982
  const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
974
983
  openClient = () => {
975
- const created = createClient(model, apiKey, options?.interleavedThinking ?? true, shouldUseFineGrainedToolStreamingBeta(model, normalizedContext), options?.refusalFallbacks !== undefined, optionsHeaders, options?.fetch, copilotDynamicHeaders, cacheSessionId, options?.env, federation);
984
+ const created = createClient(model, apiKey, options?.interleavedThinking ?? true, shouldUseFineGrainedToolStreamingBeta(model, normalizedContext), options?.refusalFallbacks !== undefined && configuredBetaHeader(model, options) !== null, optionsHeaders, options?.fetch, copilotDynamicHeaders, cacheSessionId, options?.env, federation);
976
985
  client = created.client;
977
986
  isOAuth = created.isOAuthToken;
978
987
  advertisedClaudeCodeVersion = created.claudeCodeVersion;
@@ -1459,6 +1468,25 @@ function cannotDisableThinking(model, compat) {
1459
1468
  return true;
1460
1469
  return matchesModelMarker(model, DISABLED_THINKING_REJECTING_MODEL_MARKERS);
1461
1470
  }
1471
+ /**
1472
+ * Effort a per-message-effort request runs at, which is also the active marker and the level recorded
1473
+ * on the reply: the caller's effort with thinking on, `low` for a thinking-off turn on a family that
1474
+ * cannot disable thinking, and none when thinking is actually disabled (senpi#2912).
1475
+ */
1476
+ function managedEffortForRequest(model, options) {
1477
+ if (model.compat?.supportsMidConvoEffort !== true)
1478
+ return undefined;
1479
+ if (options?.thinkingEnabled === false) {
1480
+ return cannotDisableThinking(model, getAnthropicCompat(model)) ? "low" : undefined;
1481
+ }
1482
+ return options?.effort ?? "high";
1483
+ }
1484
+ function isEffortMarker(message) {
1485
+ return (message.role === "system" &&
1486
+ Array.isArray(message.content) &&
1487
+ message.content.length === 0 &&
1488
+ message.output_config?.effort !== undefined);
1489
+ }
1462
1490
  function disableThinkingForRequest(params, model, compat) {
1463
1491
  // A degraded/disabled turn must not retain the caller's higher effort.
1464
1492
  delete params.output_config;
@@ -1470,6 +1498,8 @@ function disableThinkingForRequest(params, model, compat) {
1470
1498
  return;
1471
1499
  }
1472
1500
  params.thinking = { type: "disabled" };
1501
+ // Per-message effort is rejected alongside disabled thinking (senpi#1399).
1502
+ params.messages = params.messages.filter((message) => !isEffortMarker(message));
1473
1503
  }
1474
1504
  function supportsAdaptiveThinking(model) {
1475
1505
  if (model.compat?.forceAdaptiveThinking !== undefined) {
@@ -1665,27 +1695,46 @@ export function buildAnthropicWarmPromptCacheParams(model, context, options) {
1665
1695
  max_tokens: 0,
1666
1696
  });
1667
1697
  }
1668
- function getBetaFeatures(model, context, isOAuthToken, nativeToolChanges, options) {
1669
- let configuredFeatures;
1698
+ function configuredBetaHeader(model, options) {
1699
+ let configured;
1670
1700
  for (const headers of [model.headers, options?.headers]) {
1671
1701
  for (const [name, value] of Object.entries(headers ?? {})) {
1672
1702
  if (name.toLowerCase() === "anthropic-beta")
1673
- configuredFeatures = value;
1703
+ configured = value;
1674
1704
  }
1675
1705
  }
1676
- if (configuredFeatures === null)
1706
+ return configured;
1707
+ }
1708
+ function getBetaFeatures(model, context, isOAuthToken, shape, options) {
1709
+ const configured = configuredBetaHeader(model, options);
1710
+ if (configured === null)
1677
1711
  return [];
1678
- if (configuredFeatures !== undefined) {
1679
- return [
1680
- ...new Set(configuredFeatures
1681
- .split(",")
1682
- .map((feature) => feature.trim())
1683
- .filter(Boolean)),
1684
- ];
1685
- }
1686
- const features = [];
1712
+ // Betas the request body depends on: the API rejects the request without them.
1713
+ const required = [];
1687
1714
  if (isOAuthToken)
1688
- features.push("claude-code-20250219", "oauth-2025-04-20");
1715
+ required.push("claude-code-20250219", "oauth-2025-04-20");
1716
+ if (shape.midConvoEffort)
1717
+ required.push(MID_CONVERSATION_OUTPUT_CONFIG_BETA);
1718
+ if (shape.thinkingBinding)
1719
+ required.push(THINKING_BINDING_CONTROLS_BETA);
1720
+ if (shape.nativeToolChanges)
1721
+ required.push(MID_CONVERSATION_TOOL_CHANGES_BETA);
1722
+ if (shape.refusalFallbacks)
1723
+ required.push(SERVER_SIDE_FALLBACK_BETA);
1724
+ // A configured header owns the optional betas and is merged with the required ones, never replacing them: a
1725
+ // proxy route's own header used to drop the effort beta, and Claude 5.5 models then 400ed (senpi#2957).
1726
+ // Interleaved thinking is stripped on adaptive models here too, as it is from the client headers.
1727
+ const optional = configured === undefined
1728
+ ? defaultOptionalBetas(model, context, options)
1729
+ : configured
1730
+ .split(",")
1731
+ .map((feature) => feature.trim())
1732
+ .filter(Boolean)
1733
+ .filter((feature) => !(feature === INTERLEAVED_THINKING_BETA && supportsAdaptiveThinking(model)));
1734
+ return [...new Set([...optional, ...required])];
1735
+ }
1736
+ function defaultOptionalBetas(model, context, options) {
1737
+ const features = [];
1689
1738
  if (shouldUseFineGrainedToolStreamingBeta(model, context))
1690
1739
  features.push(FINE_GRAINED_TOOL_STREAMING_BETA);
1691
1740
  if (model.reasoning &&
@@ -1696,12 +1745,7 @@ function getBetaFeatures(model, context, isOAuthToken, nativeToolChanges, option
1696
1745
  }
1697
1746
  if (shouldUseServerSideFallbackBeta(model))
1698
1747
  features.push(SERVER_SIDE_FALLBACK_BETA);
1699
- if (model.compat?.supportsMidConvoEffort === true) {
1700
- features.push(MID_CONVERSATION_OUTPUT_CONFIG_BETA, THINKING_BINDING_CONTROLS_BETA);
1701
- }
1702
- if (nativeToolChanges)
1703
- features.push(MID_CONVERSATION_TOOL_CHANGES_BETA);
1704
- return [...new Set(features)];
1748
+ return features;
1705
1749
  }
1706
1750
  function buildParams(model, context, isOAuthToken, options, unsignedThinkingReplay = getAnthropicCompat(model).unsignedThinkingReplay) {
1707
1751
  const compat = getAnthropicCompat(model);
@@ -1718,7 +1762,16 @@ function buildParams(model, context, isOAuthToken, options, unsignedThinkingRepl
1718
1762
  // and Anthropic rejects a tool list where every tool is deferred, so there must be an
1719
1763
  // initial active tool to anchor the deferred ones. Otherwise the current tool list is sent.
1720
1764
  const initialTools = initialSystemMessage?.toolsAdded ?? [];
1721
- const nativeToolChanges = supportsMidConvoSystemMessages(model) &&
1765
+ // A configured `anthropic-beta: null` asks for no betas at all, so the request takes a shape that needs none:
1766
+ // the current tool list instead of native tool changes, top-level effort instead of per-message markers, and
1767
+ // no server-side fallbacks (senpi#2957). OAuth still needs its identity betas; getBetaFeatures names them.
1768
+ const betasSuppressed = configuredBetaHeader(model, options) === null;
1769
+ if (betasSuppressed && isOAuthToken) {
1770
+ throw new Error(`The anthropic-beta header is set to null, but ${model.provider}/${model.id} is called with an OAuth token, which needs the claude-code-20250219 and oauth-2025-04-20 betas. Remove the null, or list the betas you want instead; senpi adds the ones a request needs.`);
1771
+ }
1772
+ const midConvoEffort = model.compat?.supportsMidConvoEffort === true && !betasSuppressed;
1773
+ const nativeToolChanges = !betasSuppressed &&
1774
+ supportsMidConvoSystemMessages(model) &&
1722
1775
  supportsMidConvoToolChanges(model) &&
1723
1776
  initialTools.length > 0 &&
1724
1777
  !hasToolRedefinitions(context.messages);
@@ -1741,15 +1794,21 @@ function buildParams(model, context, isOAuthToken, options, unsignedThinkingRepl
1741
1794
  deferredTools = [];
1742
1795
  }
1743
1796
  const deferredToolNames = new Set(deferredTools.map((tool) => normalizeToolName(tool.name)));
1744
- const activeEffort = options?.effort ?? "high";
1745
- const betaFeatures = getBetaFeatures(model, context, isOAuthToken, nativeToolChanges, options);
1746
- const convertedMessages = convertMessages(conversationMessages, model, isOAuthToken, cacheControl, unsignedThinkingReplay, deferredToolNames, normalizeToolName, model.compat?.supportsMidConvoEffort === true ? model.provider : undefined, nativeToolChanges);
1747
- const messages = model.compat?.supportsMidConvoEffort === true
1797
+ const activeEffort = managedEffortForRequest(model, options) ?? "high";
1798
+ const sendFallbacks = options?.refusalFallbacks !== undefined && !betasSuppressed;
1799
+ const betaFeatures = getBetaFeatures(model, context, isOAuthToken, {
1800
+ midConvoEffort,
1801
+ thinkingBinding: midConvoEffort && options?.thinkingEnabled !== false,
1802
+ nativeToolChanges,
1803
+ refusalFallbacks: sendFallbacks,
1804
+ }, options);
1805
+ const convertedMessages = convertMessages(conversationMessages, model, isOAuthToken, cacheControl, unsignedThinkingReplay, deferredToolNames, normalizeToolName, midConvoEffort ? model.provider : undefined, nativeToolChanges);
1806
+ const messages = midConvoEffort
1748
1807
  ? insertThinkingLevelMessages(convertedMessages, activeEffort)
1749
1808
  : convertedMessages.messages;
1750
1809
  // Effort markers are appended after history; re-run the fork's final-user cache checkpoint
1751
1810
  // against the actual last user/tool-result message so the marker cannot displace cache_control.
1752
- if (model.compat?.supportsMidConvoEffort === true && cacheControl) {
1811
+ if (midConvoEffort && cacheControl) {
1753
1812
  for (let index = messages.length - 1; index >= 0; index--) {
1754
1813
  const message = messages[index];
1755
1814
  // A native system update after the last turn already holds the checkpoint;
@@ -1826,9 +1885,10 @@ function buildParams(model, context, isOAuthToken, options, unsignedThinkingRepl
1826
1885
  ];
1827
1886
  }
1828
1887
  // Managed effort models always use adaptive thinking so prefix mismatches can
1829
- // be dropped instead of surfacing as persistent 400 responses. Thinking-off is
1830
- // handled before this managed branch because these models accept `disabled`.
1831
- if (model.compat?.supportsMidConvoEffort === true && options?.thinkingEnabled !== false) {
1888
+ // be dropped instead of surfacing as persistent 400 responses. A thinking-off turn
1889
+ // skips this branch: `disableThinkingForRequest` sends `disabled` where the family
1890
+ // accepts it and pins effort `low` where it does not (Opus/Sonnet/Haiku 5.5, Fable, Mythos).
1891
+ if (midConvoEffort && options?.thinkingEnabled !== false) {
1832
1892
  params.thinking = {
1833
1893
  type: "adaptive",
1834
1894
  display: options?.thinkingDisplay ?? "summarized",
@@ -1897,7 +1957,7 @@ function buildParams(model, context, isOAuthToken, options, unsignedThinkingRepl
1897
1957
  }
1898
1958
  }
1899
1959
  applyExtraBodyToAnthropicParams(params, options?.extraBody);
1900
- if (options?.refusalFallbacks !== undefined) {
1960
+ if (sendFallbacks && options?.refusalFallbacks !== undefined) {
1901
1961
  // AnthropicRefusalFallback models a readonly list; the SDK's BetaFallbacksParam
1902
1962
  // is mutable, so copy rather than widen the fork's public type.
1903
1963
  params.fallbacks = options.refusalFallbacks === "default" ? "default" : [...options.refusalFallbacks];
@@ -2070,7 +2130,6 @@ function convertMessages(transformedMessages, model, isOAuthToken, cacheControl,
2070
2130
  }
2071
2131
  }
2072
2132
  else if (msg.role === "assistant") {
2073
- flushPendingSystemMessages();
2074
2133
  const blocks = [];
2075
2134
  const isSameModel = isSameAnthropicModel(msg, model);
2076
2135
  // Blocks before the final fallback marker are the declined attempt; the
@@ -2156,8 +2215,12 @@ function convertMessages(transformedMessages, model, isOAuthToken, cacheControl,
2156
2215
  }
2157
2216
  }
2158
2217
  }
2218
+ // Pending system messages are emitted only in front of an assistant param that is actually sent: a reply
2219
+ // that converts to no blocks is skipped, and flushing for it would leave a `system` param directly before
2220
+ // the next `user` turn, which Anthropic rejects on every later request (senpi#2864).
2159
2221
  if (blocks.length === 0)
2160
2222
  continue;
2223
+ flushPendingSystemMessages();
2161
2224
  const messageIndex = params.length;
2162
2225
  params.push({
2163
2226
  role: "assistant",
@@ -73,6 +73,27 @@ export function demoteUnavailableToolReferences(params) {
73
73
  }
74
74
  let changed = false;
75
75
  const availableToolNames = [...available];
76
+ // A native search pair whose every reference stopped resolving is demoted as a
77
+ // unit: the result decides, and its `server_tool_use` follows. The pair can span
78
+ // two assistant messages (a deferred server tool resumes in the continuation), so
79
+ // the decision is collected over the whole request before any message is rewritten.
80
+ const droppedSearchUseIds = new Set();
81
+ const droppedSearchNames = new Map();
82
+ for (const message of messages) {
83
+ if (message.role !== "assistant" || !Array.isArray(message.content))
84
+ continue;
85
+ for (const block of message.content) {
86
+ if (!isNativeToolSearchResultBlock(block))
87
+ continue;
88
+ const names = block.content.tool_references
89
+ .filter((item) => isRecord(item) && item.type === "tool_reference")
90
+ .map((item) => (typeof item.tool_name === "string" ? item.tool_name : ""));
91
+ if (names.length > 0 && names.every((name) => resolve(name) === undefined)) {
92
+ droppedSearchUseIds.add(block.tool_use_id);
93
+ droppedSearchNames.set(block.tool_use_id, names);
94
+ }
95
+ }
96
+ }
76
97
  const seenDemotedCallNames = new Set();
77
98
  const rewrittenMessages = [];
78
99
  for (const message of messages) {
@@ -81,23 +102,6 @@ export function demoteUnavailableToolReferences(params) {
81
102
  continue;
82
103
  }
83
104
  let messageChanged = false;
84
- // A native search pair whose every reference stopped resolving is demoted
85
- // as a unit: the result decides, and its `server_tool_use` follows.
86
- const droppedSearchUseIds = new Set();
87
- const droppedSearchNames = new Map();
88
- if (message.role === "assistant") {
89
- for (const block of message.content) {
90
- if (!isNativeToolSearchResultBlock(block))
91
- continue;
92
- const names = block.content.tool_references
93
- .filter((item) => isRecord(item) && item.type === "tool_reference")
94
- .map((item) => (typeof item.tool_name === "string" ? item.tool_name : ""));
95
- if (names.length > 0 && names.every((name) => resolve(name) === undefined)) {
96
- droppedSearchUseIds.add(block.tool_use_id);
97
- droppedSearchNames.set(block.tool_use_id, names);
98
- }
99
- }
100
- }
101
105
  const content = [];
102
106
  for (const block of message.content) {
103
107
  if (message.role === "assistant" && isRecord(block) && block.type === "tool_use") {
@@ -168,7 +172,9 @@ export function demoteUnavailableToolReferences(params) {
168
172
  }
169
173
  content.push(block);
170
174
  }
171
- if (content.length === 0) {
175
+ // Only a message this pass emptied is dropped; a message that arrived empty (a per-message
176
+ // effort marker carries `content: []` by design) passes through untouched (senpi#2912).
177
+ if (messageChanged && content.length === 0) {
172
178
  changed = true;
173
179
  continue;
174
180
  }
@@ -590,6 +590,7 @@ function supportsAdaptiveThinking(modelId, modelName) {
590
590
  s.includes("opus-5") ||
591
591
  s.includes("sonnet-4-6") ||
592
592
  s.includes("sonnet-5") ||
593
+ s.includes("haiku-5-5") ||
593
594
  s.includes("fable-5") ||
594
595
  s.includes("mythos-5"));
595
596
  }
@@ -599,6 +600,7 @@ function supportsNativeXhighEffort(model) {
599
600
  s.includes("opus-4-8") ||
600
601
  s.includes("opus-5") ||
601
602
  s.includes("sonnet-5") ||
603
+ s.includes("haiku-5-5") ||
602
604
  s.includes("fable-5") ||
603
605
  s.includes("mythos-5"));
604
606
  }
@@ -616,7 +618,9 @@ function rejectsDisabledThinking(model) {
616
618
  s.includes("opus-5-5") ||
617
619
  s.includes("opus-5.5") ||
618
620
  s.includes("sonnet-5-5") ||
619
- s.includes("sonnet-5.5"));
621
+ s.includes("sonnet-5.5") ||
622
+ s.includes("haiku-5-5") ||
623
+ s.includes("haiku-5.5"));
620
624
  }
621
625
  function mapThinkingLevelToEffort(model, level) {
622
626
  if (level === "xhigh" && supportsNativeXhighEffort(model))
@@ -127,6 +127,17 @@ export function requiresToolCallId(modelId) {
127
127
  modelId.startsWith("gpt-oss-") ||
128
128
  (geminiMajorVersion !== undefined && geminiMajorVersion >= 3));
129
129
  }
130
+ /**
131
+ * Gemini 3+ strictly validates replayed tool calls: the first functionCall part of every step
132
+ * in the current turn must carry the thoughtSignature the model returned, or the API answers
133
+ * 400 "Function call is missing a thought_signature in functionCall parts" (Vertex and AI
134
+ * Studio both; enforced even at MINIMAL thinking). See:
135
+ * https://docs.cloud.google.com/vertex-ai/generative-ai/docs/thought-signatures
136
+ */
137
+ function requiresThoughtSignatureReplay(modelId) {
138
+ const geminiMajorVersion = getGeminiMajorVersion(modelId);
139
+ return geminiMajorVersion !== undefined && geminiMajorVersion >= 3;
140
+ }
130
141
  function getGeminiMajorVersion(modelId) {
131
142
  const match = modelId.toLowerCase().match(/^gemini(?:-live)?-(\d+)/);
132
143
  if (!match)
@@ -155,6 +166,25 @@ function appendContent(contents, content) {
155
166
  }
156
167
  contents.push(content);
157
168
  }
169
+ // The current turn starts after the last user message that carries content. An image-only
170
+ // user message (a pasted screenshot with no caption) starts a new turn exactly like a text
171
+ // prompt does: assistant steps after it are current-turn steps. Empty user messages carry
172
+ // no content and never act as the boundary.
173
+ function findLastUserContentMessageIndex(messages) {
174
+ for (let i = messages.length - 1; i >= 0; i--) {
175
+ const msg = messages[i];
176
+ if (msg.role !== "user")
177
+ continue;
178
+ if (typeof msg.content === "string") {
179
+ if (msg.content.trim().length > 0)
180
+ return i;
181
+ }
182
+ else if (msg.content.some((item) => (item.type === "text" ? item.text.trim().length > 0 : true))) {
183
+ return i;
184
+ }
185
+ }
186
+ return -1;
187
+ }
158
188
  /**
159
189
  * Convert internal messages to Gemini Content[] format.
160
190
  */
@@ -162,6 +192,9 @@ export function convertMessages(model, context, options = {}) {
162
192
  // Gemini has no mid-conversation system messages; the leading prompt is sent as systemInstruction.
163
193
  const conversation = withoutInitialSystemMessage(collapseSystemMessages(context).messages);
164
194
  const contents = [];
195
+ // Tool-call blocks replayed as text under a strict-signature model; their paired tool
196
+ // results must follow the same shape so no unpaired functionResponse part is emitted.
197
+ const textToolCallIds = new Set();
165
198
  const normalizeId = (id) => {
166
199
  if (!requiresToolCallId(model.id))
167
200
  return id;
@@ -170,7 +203,9 @@ export function convertMessages(model, context, options = {}) {
170
203
  const transformedMessages = transformMessages(conversation, model, normalizeId, {
171
204
  preserveThinking: options.preserveThinking,
172
205
  });
173
- for (const msg of transformedMessages) {
206
+ const lastUserContentIndex = findLastUserContentMessageIndex(transformedMessages);
207
+ for (let msgIndex = 0; msgIndex < transformedMessages.length; msgIndex++) {
208
+ const msg = transformedMessages[msgIndex];
174
209
  if (msg.role === "user") {
175
210
  if (typeof msg.content === "string") {
176
211
  appendContent(contents, {
@@ -204,6 +239,17 @@ export function convertMessages(model, context, options = {}) {
204
239
  const parts = [];
205
240
  // Check if message is from same provider and model - only then keep thinking blocks
206
241
  const isSameProviderAndModel = msg.provider === model.provider && msg.model === model.id;
242
+ // Strict validation (requiring a thoughtSignature on the first functionCall part) applies
243
+ // to the current turn — assistant steps after the last user message carrying content.
244
+ // Earlier turns preserve structured function calls. In the current turn, if the first
245
+ // toolCall block lacks a valid signature, the whole step is replayed as text, because
246
+ // the documented skip_thought_signature_validator sentinel is rejected by Vertex (pi-mono #4032)
247
+ // and an unsigned functionCall part is a hard 400 on Gemini 3+.
248
+ const isCurrentTurn = msgIndex > lastUserContentIndex;
249
+ const firstToolCall = msg.content.find((b) => b.type === "toolCall");
250
+ const hasValidFirstSignature = firstToolCall !== undefined &&
251
+ resolveThoughtSignature(isSameProviderAndModel, firstToolCall.thoughtSignature) !== undefined;
252
+ const replayUnsignedToolCallsAsText = requiresThoughtSignatureReplay(model.id) && isCurrentTurn && !hasValidFirstSignature;
207
253
  for (const block of msg.content) {
208
254
  if (block.type === "text") {
209
255
  const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.textSignature);
@@ -243,16 +289,24 @@ export function convertMessages(model, context, options = {}) {
243
289
  }
244
290
  }
245
291
  else if (block.type === "toolCall") {
246
- const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature);
247
- const part = {
248
- functionCall: {
249
- name: block.name,
250
- args: block.arguments ?? {},
251
- ...(requiresToolCallId(model.id) ? { id: block.id } : {}),
252
- },
253
- ...(thoughtSignature && { thoughtSignature }),
254
- };
255
- parts.push(part);
292
+ if (replayUnsignedToolCallsAsText) {
293
+ textToolCallIds.add(block.id);
294
+ parts.push({
295
+ text: sanitizeSurrogates(`[Tool Call: ${block.name} (id: ${block.id})]\nArguments: ${JSON.stringify(block.arguments ?? {}, null, 2)}`),
296
+ });
297
+ }
298
+ else {
299
+ const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature);
300
+ const part = {
301
+ functionCall: {
302
+ name: block.name,
303
+ args: block.arguments ?? {},
304
+ ...(requiresToolCallId(model.id) ? { id: block.id } : {}),
305
+ },
306
+ ...(thoughtSignature && { thoughtSignature }),
307
+ };
308
+ parts.push(part);
309
+ }
256
310
  }
257
311
  else if (block.type === "providerNative") {
258
312
  }
@@ -268,9 +322,41 @@ export function convertMessages(model, context, options = {}) {
268
322
  // Extract text and image content
269
323
  const textContent = msg.content.filter((c) => c.type === "text");
270
324
  const textResult = textContent.map((c) => c.text).join("\n");
271
- const imageContent = model.input.includes("image")
272
- ? msg.content.filter((c) => c.type === "image")
273
- : [];
325
+ const allImageBlocks = msg.content.filter((c) => c.type === "image");
326
+ const modelAcceptsImages = model.input.includes("image");
327
+ const imageContent = modelAcceptsImages ? allImageBlocks : [];
328
+ const imageParts = imageContent.map((imageBlock) => ({
329
+ inlineData: {
330
+ mimeType: imageBlock.mimeType,
331
+ data: imageBlock.data,
332
+ },
333
+ }));
334
+ const sanitizedToolName = sanitizeSurrogates(msg.toolName);
335
+ // A tool call replayed as text pairs with its result as text: an unpaired
336
+ // functionResponse part would break the model turn's call/response pairing.
337
+ if (textToolCallIds.has(msg.toolCallId)) {
338
+ // Unsupported tool images never arrive here as image blocks: transformMessages already
339
+ // replaced them with placeholder text for non-vision models, so an image-only result on
340
+ // such a model reaches this branch as text. A vision model's image-only result keeps an
341
+ // empty body and pairs with the inlineData parts appended below.
342
+ let bodyText = "";
343
+ if (textResult.length > 0) {
344
+ bodyText = `\n${sanitizeSurrogates(textResult)}`;
345
+ }
346
+ else if (allImageBlocks.length === 0) {
347
+ bodyText = "\n(no output)";
348
+ }
349
+ const errorMarker = msg.isError ? " (error)" : "";
350
+ const callIdPart = ` (id: ${msg.toolCallId})`;
351
+ appendContent(contents, {
352
+ role: "user",
353
+ parts: [
354
+ { text: `[Tool Result: ${sanitizedToolName}${callIdPart}]${errorMarker}${bodyText}` },
355
+ ...imageParts,
356
+ ],
357
+ });
358
+ continue;
359
+ }
274
360
  const hasText = textResult.length > 0;
275
361
  const hasImages = imageContent.length > 0;
276
362
  // Gemini 3+ models support multimodal function responses with images nested inside
@@ -279,16 +365,10 @@ export function convertMessages(model, context, options = {}) {
279
365
  const modelSupportsMultimodalFunctionResponse = supportsMultimodalFunctionResponse(model.id);
280
366
  // Use "output" key for success, "error" key for errors as per SDK documentation
281
367
  const responseValue = hasText ? sanitizeSurrogates(textResult) : hasImages ? "(see attached image)" : "";
282
- const imageParts = imageContent.map((imageBlock) => ({
283
- inlineData: {
284
- mimeType: imageBlock.mimeType,
285
- data: imageBlock.data,
286
- },
287
- }));
288
368
  const includeId = requiresToolCallId(model.id);
289
369
  const functionResponsePart = {
290
370
  functionResponse: {
291
- name: msg.toolName,
371
+ name: sanitizedToolName,
292
372
  response: msg.isError ? { error: responseValue } : { output: responseValue },
293
373
  ...(hasImages && modelSupportsMultimodalFunctionResponse && { parts: imageParts }),
294
374
  ...(includeId ? { id: msg.toolCallId } : {}),
package/dist/api/lazy.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { AssistantMessageEventStream } from "../utils/event-stream.js";
2
+ import { isOAuthRefreshUnavailableError } from "../utils/oauth-refresh-error.js";
2
3
  import { describeReplacedInstall } from "../utils/provider-failure-description.js";
4
+ import { OAUTH_REFRESH_UNAVAILABLE_DIAGNOSTIC } from "../utils/retry.js";
3
5
  function createSetupErrorMessage(model, error) {
4
6
  return {
5
7
  role: "assistant",
@@ -17,6 +19,17 @@ function createSetupErrorMessage(model, error) {
17
19
  },
18
20
  stopReason: "error",
19
21
  errorMessage: describeReplacedInstall(error) ?? (error instanceof Error ? error.message : String(error)),
22
+ ...(isOAuthRefreshUnavailableError(error)
23
+ ? {
24
+ diagnostics: [
25
+ {
26
+ type: OAUTH_REFRESH_UNAVAILABLE_DIAGNOSTIC,
27
+ timestamp: Date.now(),
28
+ details: { provider: model.provider },
29
+ },
30
+ ],
31
+ }
32
+ : {}),
20
33
  timestamp: Date.now(),
21
34
  };
22
35
  }
@@ -612,6 +612,21 @@ function stripSymbolKeys(value) {
612
612
  }
613
613
  return value;
614
614
  }
615
+ /**
616
+ * Consecutive user turns become one user message, as the OpenAI Chat converter does for
617
+ * non-OpenAI endpoints: a word message rebuilt after tool results (senpi#2920) is often followed
618
+ * by a steer or the next prompt, and Mistral's role-order validation is not documented to accept
619
+ * two user messages in a row.
620
+ */
621
+ function appendUserMessage(result, content) {
622
+ const previous = result[result.length - 1];
623
+ if (previous?.role !== "user") {
624
+ result.push({ role: "user", content });
625
+ return;
626
+ }
627
+ const chunks = (value) => typeof value === "string" ? [{ type: "text", text: value }] : (value ?? []);
628
+ previous.content = [...chunks(previous.content), ...chunks(content)];
629
+ }
615
630
  function toChatMessages(messages, supportsImages) {
616
631
  const result = [];
617
632
  for (const [index, msg] of messages.entries()) {
@@ -625,7 +640,7 @@ function toChatMessages(messages, supportsImages) {
625
640
  }
626
641
  if (msg.role === "user") {
627
642
  if (typeof msg.content === "string") {
628
- result.push({ role: "user", content: sanitizeSurrogates(msg.content) });
643
+ appendUserMessage(result, sanitizeSurrogates(msg.content));
629
644
  continue;
630
645
  }
631
646
  const hadImages = msg.content.some((item) => item.type === "image");
@@ -637,11 +652,11 @@ function toChatMessages(messages, supportsImages) {
637
652
  return { type: "image_url", imageUrl: `data:${item.mimeType};base64,${item.data}` };
638
653
  });
639
654
  if (content.length > 0) {
640
- result.push({ role: "user", content });
655
+ appendUserMessage(result, content);
641
656
  continue;
642
657
  }
643
658
  if (hadImages && !supportsImages) {
644
- result.push({ role: "user", content: "(image omitted: model does not support images)" });
659
+ appendUserMessage(result, "(image omitted: model does not support images)");
645
660
  }
646
661
  continue;
647
662
  }
@@ -450,9 +450,9 @@ function buildRequestBody(model, context, options, cacheSessionId, grammarToolIn
450
450
  function getServiceTierCostMultiplier(model, serviceTier) {
451
451
  switch (serviceTier) {
452
452
  case "ultrafast":
453
- // OpenAI publishes an Ultrafast price for GPT-6 Astra only: 6x Standard on every
453
+ // OpenAI prices GPT-6 Astra and GPT-6.1 Sol Ultrafast at 6x Standard on every
454
454
  // token class and context tier. Any other model keeps its base rate.
455
- return model.id === "gpt-6-astra" ? 6 : 1;
455
+ return ["gpt-6-astra", "gpt-6.1-sol"].includes(model.upstreamModelId ?? model.id) ? 6 : 1;
456
456
  case "flex":
457
457
  return 0.5;
458
458
  case "priority":