@code-yeongyu/senpi-ai 2026.10.10 → 2026.10.12

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 (68) hide show
  1. package/dist/api/anthropic-messages.js +148 -72
  2. package/dist/api/anthropic-native-replay-blocks.d.ts +4 -0
  3. package/dist/api/anthropic-native-replay-blocks.js +28 -0
  4. package/dist/api/anthropic-tool-references.js +24 -18
  5. package/dist/api/anthropic-web-search-expiry.d.ts +10 -0
  6. package/dist/api/anthropic-web-search-expiry.js +104 -0
  7. package/dist/api/bedrock-converse-stream.js +5 -1
  8. package/dist/api/google-shared.js +101 -21
  9. package/dist/api/lazy.js +13 -0
  10. package/dist/api/mistral-conversations.js +18 -3
  11. package/dist/api/openai-codex-responses.js +2 -2
  12. package/dist/api/openai-completions.js +26 -2
  13. package/dist/api/openai-responses-allowed-tools.d.ts +33 -0
  14. package/dist/api/openai-responses-allowed-tools.js +108 -0
  15. package/dist/api/openai-responses-shared.js +19 -0
  16. package/dist/api/openai-responses.js +48 -59
  17. package/dist/auth/oauth/anthropic.js +4 -3
  18. package/dist/auth/oauth/chatgpt-subscription.js +3 -2
  19. package/dist/auth/oauth/cursor.js +3 -2
  20. package/dist/auth/oauth/devin-token.js +2 -1
  21. package/dist/auth/oauth/github-copilot.js +3 -2
  22. package/dist/auth/oauth/kimi-coding.js +5 -4
  23. package/dist/auth/oauth/openai-chatgpt.js +2 -1
  24. package/dist/auth/oauth/openrouter.js +2 -1
  25. package/dist/auth/oauth/radius.js +5 -5
  26. package/dist/auth/oauth/xai.js +7 -4
  27. package/dist/auth/oauth-refresh.js +10 -0
  28. package/dist/auth/resolve.js +5 -0
  29. package/dist/model.d.ts +6 -2
  30. package/dist/openai-responses-compat.d.ts +7 -2
  31. package/dist/openai-responses-compat.js +15 -2
  32. package/dist/providers/all.d.ts +1 -1
  33. package/dist/providers/data/.manifest.json +1 -1
  34. package/dist/providers/data/amazon-bedrock.json +1 -1
  35. package/dist/providers/data/anthropic.json +1 -1
  36. package/dist/providers/data/baseten.json +1 -1
  37. package/dist/providers/data/chatgpt-subscription.json +1 -1
  38. package/dist/providers/data/cloudflare-ai-gateway.json +1 -1
  39. package/dist/providers/data/cloudflare-workers-ai.json +1 -1
  40. package/dist/providers/data/github-copilot.json +1 -1
  41. package/dist/providers/data/google-vertex.json +1 -1
  42. package/dist/providers/data/huggingface.json +1 -1
  43. package/dist/providers/data/mistral.json +1 -1
  44. package/dist/providers/data/moonshotai-cn.json +1 -1
  45. package/dist/providers/data/moonshotai.json +1 -1
  46. package/dist/providers/data/opencode-go.json +1 -1
  47. package/dist/providers/data/opencode.json +1 -1
  48. package/dist/providers/data/opengateway.json +1 -1
  49. package/dist/providers/data/openrouter.json +1 -1
  50. package/dist/providers/data/radius.json +1 -1
  51. package/dist/providers/data/venice.json +1 -1
  52. package/dist/providers/data/vercel-ai-gateway.json +1 -1
  53. package/dist/providers/kimi-coding.models.d.ts +1 -1
  54. package/dist/providers/kimi-coding.models.js +1 -1
  55. package/dist/providers/opengateway-refresh.js +2 -1
  56. package/dist/providers/xai.js +7 -1
  57. package/dist/types.d.ts +25 -2
  58. package/dist/utils/oauth-refresh-error.d.ts +16 -0
  59. package/dist/utils/oauth-refresh-error.js +67 -0
  60. package/dist/utils/prompt-cache-ttl.d.ts +2 -1
  61. package/dist/utils/prompt-cache-ttl.js +4 -1
  62. package/dist/utils/provider-failure-description.d.ts +3 -1
  63. package/dist/utils/provider-failure-description.js +8 -0
  64. package/dist/utils/retry.d.ts +6 -0
  65. package/dist/utils/retry.js +10 -0
  66. package/dist/utils/tool-choice-fallback.d.ts +11 -0
  67. package/dist/utils/tool-choice-fallback.js +69 -2
  68. package/package.json +3 -3
@@ -27,8 +27,10 @@ import { normalizeToolCallId } from "../utils/tool-call-id.js";
27
27
  import { sendWithForcedToolChoiceFallback } from "../utils/tool-choice-fallback.js";
28
28
  import { resolveRootObjectSchema } from "../utils/tool-schema-compat.js";
29
29
  import { getCurrentTools, getDeclaredTools, getInitialSystemMessage, hasToolRedefinitions, normalizeContext, resolveTranscript, } from "../utils/transcript.js";
30
+ import { isAnthropicWebSearchReplayBlock, isReplayableAnthropicProviderNativeBlock, } from "./anthropic-native-replay-blocks.js";
30
31
  import { sanitizeAnthropicToolPairs } from "./anthropic-tool-pairs.js";
31
32
  import { demoteToolReferenceReplay, demoteUnavailableToolReferences } from "./anthropic-tool-references.js";
33
+ import { demoteExpiredWebSearchReplay, expiredWebSearchReplayIds, isExpiredWebSearchContentError, } from "./anthropic-web-search-expiry.js";
32
34
  import { resolveCloudflareBaseUrl } from "./cloudflare.js";
33
35
  import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling, } from "./constrained-sampling.js";
34
36
  import { withGitHubCopilotFailureNote } from "./github-copilot-errors.js";
@@ -71,7 +73,7 @@ function getCacheControl(model, cacheRetention, env) {
71
73
  // The bundled Claude Code version and the floor of the advertised `claude-cli/<version>`
72
74
  // (see utils/claude-code-version.ts). Keep this exact declaration: a downstream installer
73
75
  // (oh-my-openagent) rewrites it byte-for-byte in the installed dist and bundle.
74
- const claudeCodeVersion = "2.1.289";
76
+ const claudeCodeVersion = "2.1.292";
75
77
  // Claude Code 2.x tool names (canonical casing)
76
78
  // Source: https://cchistory.mariozechner.at/data/prompts-2.1.11.md
77
79
  // To update: https://github.com/badlogic/cchistory
@@ -164,6 +166,7 @@ const ADAPTIVE_THINKING_MODEL_MARKERS = [
164
166
  "opus-5",
165
167
  "sonnet-4-6",
166
168
  "sonnet-5",
169
+ "haiku-5-5",
167
170
  "fable-5",
168
171
  "mythos-5",
169
172
  ];
@@ -176,14 +179,20 @@ const NATIVE_XHIGH_EFFORT_MODEL_MARKERS = [
176
179
  "opus-4-8",
177
180
  "opus-5",
178
181
  "sonnet-5",
182
+ "haiku-5-5",
179
183
  "fable-5",
180
184
  "mythos-5",
181
185
  ];
182
186
  /**
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.
187
+ * Families senpi never sends `thinking: {type: "disabled"}` to. Fable 5, Opus 5.5 and Sonnet 5.5 reject it
188
+ * outright (verified live 400: `"thinking.type.disabled" is not supported for this model`); Mythos 5 is
189
+ * listed by family with Fable. Claude Haiku 5.5 is listed by choice, not because it rejects `disabled`: its
190
+ * effort docs accept `disabled` at effort `high` or below (400 only at `xhigh` / `max`) and reject a
191
+ * differing per-message effort while disabled, so a real thinking-off needs marker handling first
192
+ * (senpi#2927). Until then a thinking-off turn sends no `thinking` and effort `low`, which is valid on all of
193
+ * them. The generated catalog also encodes this as `compat.supportsDisabledThinking: false`, but
194
+ * `models.json` entries and third-party gateway rows carry no generated compat, so the family fact has to
195
+ * live here as well.
187
196
  */
188
197
  const DISABLED_THINKING_REJECTING_MODEL_MARKERS = [
189
198
  "fable-5",
@@ -192,6 +201,8 @@ const DISABLED_THINKING_REJECTING_MODEL_MARKERS = [
192
201
  "opus-5.5",
193
202
  "sonnet-5-5",
194
203
  "sonnet-5.5",
204
+ "haiku-5-5",
205
+ "haiku-5.5",
195
206
  ];
196
207
  const UNSUPPORTED_NATIVE_COMPUTER_TOOL_MODEL_MARKERS = [
197
208
  "opus-4-6",
@@ -234,6 +245,8 @@ function supportsMidConvoToolChanges(model) {
234
245
  const unsignedThinkingTextReplayFallbacks = new Set();
235
246
  // Endpoints that rejected a replayed tool_reference for one conversation (senpi #2568).
236
247
  const toolReferenceReplayFallbacks = new Set();
248
+ // `${fallbackKey}\u0000${tool_use_id}` of web search results whose encrypted payload expired (senpi #3201).
249
+ const expiredWebSearchReplays = new Set();
237
250
  registerSessionResourceCleanup((sessionId) => {
238
251
  // One small entry per (session, base URL, model) that ever hit the fallback;
239
252
  // drop the session's entries at teardown so long-lived hosts do not collect
@@ -241,10 +254,15 @@ registerSessionResourceCleanup((sessionId) => {
241
254
  if (sessionId === undefined) {
242
255
  unsignedThinkingTextReplayFallbacks.clear();
243
256
  toolReferenceReplayFallbacks.clear();
257
+ expiredWebSearchReplays.clear();
244
258
  return;
245
259
  }
246
260
  const prefix = `${sessionId}\u0000`;
247
- for (const fallbacks of [unsignedThinkingTextReplayFallbacks, toolReferenceReplayFallbacks]) {
261
+ for (const fallbacks of [
262
+ unsignedThinkingTextReplayFallbacks,
263
+ toolReferenceReplayFallbacks,
264
+ expiredWebSearchReplays,
265
+ ]) {
248
266
  for (const key of fallbacks) {
249
267
  if (key.startsWith(prefix))
250
268
  fallbacks.delete(key);
@@ -346,26 +364,6 @@ let federationClient;
346
364
  function isRecord(value) {
347
365
  return typeof value === "object" && value !== null;
348
366
  }
349
- const REPLAYABLE_ANTHROPIC_PROVIDER_NATIVE_TYPES = new Set([
350
- "server_tool_use",
351
- "web_search_tool_result",
352
- "web_fetch_tool_result",
353
- "code_execution_tool_result",
354
- "bash_code_execution_tool_result",
355
- "text_editor_code_execution_tool_result",
356
- "tool_search_tool_result",
357
- "container_upload",
358
- // The server-side fallback marker (`fallback`, server-side-fallback-2026-06-01
359
- // beta) is deliberately absent: the Messages API rejects it as an *input* tag
360
- // ("Input tag 'fallback' found using 'type' does not match any of the expected
361
- // tags"), so replaying it 400s every subsequent same-model request. The stored
362
- // marker is audit metadata and still drives declined-attempt pruning in
363
- // convertMessages (see lastAnthropicFallbackBoundary and
364
- // collectDiscardedFallbackToolCallIds); blocks after it replay verbatim.
365
- ]);
366
- function isReplayableAnthropicProviderNativeBlock(raw) {
367
- return isRecord(raw) && typeof raw.type === "string" && REPLAYABLE_ANTHROPIC_PROVIDER_NATIVE_TYPES.has(raw.type);
368
- }
369
367
  function isSameAnthropicModel(message, model) {
370
368
  return message.provider === model.provider && message.api === model.api && message.model === model.id;
371
369
  }
@@ -399,17 +397,6 @@ function isAnthropicServerToolUseBlock(raw) {
399
397
  function isProviderNativeToolUseBlock(raw) {
400
398
  return isRecord(raw) && (raw.type === "server_tool_use" || raw.type === "mcp_tool_use");
401
399
  }
402
- // Endpoints without `supportsWebSearch` reject replayed web-search server-tool
403
- // blocks (kimi-coding 400s with `tool_call_id is not found`), wedging every
404
- // subsequent request of the session. Dropping the pair loses the searched
405
- // context but keeps the conversation usable.
406
- function isAnthropicWebSearchReplayBlock(raw) {
407
- if (!isRecord(raw))
408
- return false;
409
- if (raw.type === "web_search_tool_result")
410
- return true;
411
- return raw.type === "server_tool_use" && raw.name === "web_search";
412
- }
413
400
  // The user turn a pending server tool can survive is one that carries only
414
401
  // tool results. Anything else the wire would emit — real text, an image —
415
402
  // closes the assistant turn (the API then 400s the still-open use). A blank
@@ -439,7 +426,7 @@ function collectProviderNativeToolPairing(messages, model, deferredToolNames, no
439
426
  if (block.type !== "providerNative" || !isRecord(block.raw))
440
427
  continue;
441
428
  const raw = block.raw;
442
- if (typeof raw.type !== "string" || !REPLAYABLE_ANTHROPIC_PROVIDER_NATIVE_TYPES.has(raw.type))
429
+ if (!isReplayableAnthropicProviderNativeBlock(raw))
443
430
  continue;
444
431
  if (isProviderNativeToolUseBlock(raw) && typeof raw.id === "string")
445
432
  pendingUseIds.add(raw.id);
@@ -448,7 +435,7 @@ function collectProviderNativeToolPairing(messages, model, deferredToolNames, no
448
435
  if (block.type !== "providerNative" || !isRecord(block.raw))
449
436
  continue;
450
437
  const raw = block.raw;
451
- if (typeof raw.type !== "string" || !REPLAYABLE_ANTHROPIC_PROVIDER_NATIVE_TYPES.has(raw.type))
438
+ if (!isReplayableAnthropicProviderNativeBlock(raw))
452
439
  continue;
453
440
  const toolUseId = raw.tool_use_id;
454
441
  if (typeof toolUseId !== "string")
@@ -922,7 +909,7 @@ export const stream = (model, context, options) => {
922
909
  const normalizedContext = resolveTranscript(context, supportsMidConvoSystemMessages(model));
923
910
  const currentTools = getCurrentTools(normalizedContext.messages);
924
911
  (async () => {
925
- const providerThinkingLevel = model.compat?.supportsMidConvoEffort ? (options?.effort ?? "high") : undefined;
912
+ const providerThinkingLevel = managedEffortForRequest(model, options);
926
913
  const output = {
927
914
  role: "assistant",
928
915
  content: [],
@@ -972,7 +959,7 @@ export const stream = (model, context, options) => {
972
959
  const cacheRetention = resolveCacheRetention(options?.cacheRetention ?? model.cacheRetention, options?.env, "short");
973
960
  const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
974
961
  openClient = () => {
975
- const created = createClient(model, apiKey, options?.interleavedThinking ?? true, shouldUseFineGrainedToolStreamingBeta(model, normalizedContext), options?.refusalFallbacks !== undefined, optionsHeaders, options?.fetch, copilotDynamicHeaders, cacheSessionId, options?.env, federation);
962
+ 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
963
  client = created.client;
977
964
  isOAuth = created.isOAuthToken;
978
965
  advertisedClaudeCodeVersion = created.claudeCodeVersion;
@@ -1007,6 +994,15 @@ export const stream = (model, context, options) => {
1007
994
  : getAnthropicCompat(model).unsignedThinkingReplay;
1008
995
  let demoteReferenceReplay = fallbackKey !== undefined && toolReferenceReplayFallbacks.has(fallbackKey);
1009
996
  let requestReplaysReferences = false;
997
+ const expiredSearchPrefix = fallbackKey === undefined ? undefined : `${fallbackKey}\u0000`;
998
+ const expiredSearchIds = new Set();
999
+ if (expiredSearchPrefix !== undefined) {
1000
+ for (const key of expiredWebSearchReplays) {
1001
+ if (key.startsWith(expiredSearchPrefix))
1002
+ expiredSearchIds.add(key.slice(expiredSearchPrefix.length));
1003
+ }
1004
+ }
1005
+ let lastSentParams;
1010
1006
  const createRequest = async () => {
1011
1007
  let params = buildParams(model, normalizedContext, isOAuth, options, unsignedThinkingReplay);
1012
1008
  const nextParams = await options?.onPayload?.(params, model);
@@ -1020,6 +1016,8 @@ export const stream = (model, context, options) => {
1020
1016
  requestReplaysReferences = withoutReferenceReplay !== params;
1021
1017
  if (demoteReferenceReplay)
1022
1018
  params = withoutReferenceReplay;
1019
+ params = demoteExpiredWebSearchReplay(params, expiredSearchIds);
1020
+ lastSentParams = params;
1023
1021
  params = sanitizeAnthropicToolPairs(params);
1024
1022
  const payloadRequestMetadata = extractPayloadRequestMetadata(params);
1025
1023
  params = payloadRequestMetadata.params;
@@ -1044,6 +1042,28 @@ export const stream = (model, context, options) => {
1044
1042
  });
1045
1043
  return { params: sent.params, response: sent.result };
1046
1044
  };
1045
+ // True when the error names web search payloads this request had not demoted yet.
1046
+ const markNewlyExpiredSearches = (error) => {
1047
+ if (!isExpiredWebSearchContentError(error) || lastSentParams === undefined)
1048
+ return false;
1049
+ const newlyExpired = expiredWebSearchReplayIds(lastSentParams, error).filter((id) => !expiredSearchIds.has(id));
1050
+ for (const id of newlyExpired) {
1051
+ expiredSearchIds.add(id);
1052
+ if (expiredSearchPrefix !== undefined)
1053
+ expiredWebSearchReplays.add(expiredSearchPrefix + id);
1054
+ }
1055
+ return newlyExpired.length > 0;
1056
+ };
1057
+ const createRequestDemotingExpiredSearches = async () => {
1058
+ try {
1059
+ return await createRequest();
1060
+ }
1061
+ catch (error) {
1062
+ if (markNewlyExpiredSearches(error))
1063
+ return createRequestDemotingExpiredSearches();
1064
+ throw error;
1065
+ }
1066
+ };
1047
1067
  let requestOutcome;
1048
1068
  try {
1049
1069
  requestOutcome = await retryProviderRequest(async () => {
@@ -1063,6 +1083,8 @@ export const stream = (model, context, options) => {
1063
1083
  toolReferenceReplayFallbacks.add(fallbackKey);
1064
1084
  return createRequest();
1065
1085
  }
1086
+ if (markNewlyExpiredSearches(error))
1087
+ return createRequestDemotingExpiredSearches();
1066
1088
  if (await retryWithNewerClaudeCode(error)) {
1067
1089
  try {
1068
1090
  return await createRequest();
@@ -1459,6 +1481,25 @@ function cannotDisableThinking(model, compat) {
1459
1481
  return true;
1460
1482
  return matchesModelMarker(model, DISABLED_THINKING_REJECTING_MODEL_MARKERS);
1461
1483
  }
1484
+ /**
1485
+ * Effort a per-message-effort request runs at, which is also the active marker and the level recorded
1486
+ * on the reply: the caller's effort with thinking on, `low` for a thinking-off turn on a family that
1487
+ * cannot disable thinking, and none when thinking is actually disabled (senpi#2912).
1488
+ */
1489
+ function managedEffortForRequest(model, options) {
1490
+ if (model.compat?.supportsMidConvoEffort !== true)
1491
+ return undefined;
1492
+ if (options?.thinkingEnabled === false) {
1493
+ return cannotDisableThinking(model, getAnthropicCompat(model)) ? "low" : undefined;
1494
+ }
1495
+ return options?.effort ?? "high";
1496
+ }
1497
+ function isEffortMarker(message) {
1498
+ return (message.role === "system" &&
1499
+ Array.isArray(message.content) &&
1500
+ message.content.length === 0 &&
1501
+ message.output_config?.effort !== undefined);
1502
+ }
1462
1503
  function disableThinkingForRequest(params, model, compat) {
1463
1504
  // A degraded/disabled turn must not retain the caller's higher effort.
1464
1505
  delete params.output_config;
@@ -1470,6 +1511,8 @@ function disableThinkingForRequest(params, model, compat) {
1470
1511
  return;
1471
1512
  }
1472
1513
  params.thinking = { type: "disabled" };
1514
+ // Per-message effort is rejected alongside disabled thinking (senpi#1399).
1515
+ params.messages = params.messages.filter((message) => !isEffortMarker(message));
1473
1516
  }
1474
1517
  function supportsAdaptiveThinking(model) {
1475
1518
  if (model.compat?.forceAdaptiveThinking !== undefined) {
@@ -1665,27 +1708,46 @@ export function buildAnthropicWarmPromptCacheParams(model, context, options) {
1665
1708
  max_tokens: 0,
1666
1709
  });
1667
1710
  }
1668
- function getBetaFeatures(model, context, isOAuthToken, nativeToolChanges, options) {
1669
- let configuredFeatures;
1711
+ function configuredBetaHeader(model, options) {
1712
+ let configured;
1670
1713
  for (const headers of [model.headers, options?.headers]) {
1671
1714
  for (const [name, value] of Object.entries(headers ?? {})) {
1672
1715
  if (name.toLowerCase() === "anthropic-beta")
1673
- configuredFeatures = value;
1716
+ configured = value;
1674
1717
  }
1675
1718
  }
1676
- if (configuredFeatures === null)
1719
+ return configured;
1720
+ }
1721
+ function getBetaFeatures(model, context, isOAuthToken, shape, options) {
1722
+ const configured = configuredBetaHeader(model, options);
1723
+ if (configured === null)
1677
1724
  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 = [];
1725
+ // Betas the request body depends on: the API rejects the request without them.
1726
+ const required = [];
1687
1727
  if (isOAuthToken)
1688
- features.push("claude-code-20250219", "oauth-2025-04-20");
1728
+ required.push("claude-code-20250219", "oauth-2025-04-20");
1729
+ if (shape.midConvoEffort)
1730
+ required.push(MID_CONVERSATION_OUTPUT_CONFIG_BETA);
1731
+ if (shape.thinkingBinding)
1732
+ required.push(THINKING_BINDING_CONTROLS_BETA);
1733
+ if (shape.nativeToolChanges)
1734
+ required.push(MID_CONVERSATION_TOOL_CHANGES_BETA);
1735
+ if (shape.refusalFallbacks)
1736
+ required.push(SERVER_SIDE_FALLBACK_BETA);
1737
+ // A configured header owns the optional betas and is merged with the required ones, never replacing them: a
1738
+ // proxy route's own header used to drop the effort beta, and Claude 5.5 models then 400ed (senpi#2957).
1739
+ // Interleaved thinking is stripped on adaptive models here too, as it is from the client headers.
1740
+ const optional = configured === undefined
1741
+ ? defaultOptionalBetas(model, context, options)
1742
+ : configured
1743
+ .split(",")
1744
+ .map((feature) => feature.trim())
1745
+ .filter(Boolean)
1746
+ .filter((feature) => !(feature === INTERLEAVED_THINKING_BETA && supportsAdaptiveThinking(model)));
1747
+ return [...new Set([...optional, ...required])];
1748
+ }
1749
+ function defaultOptionalBetas(model, context, options) {
1750
+ const features = [];
1689
1751
  if (shouldUseFineGrainedToolStreamingBeta(model, context))
1690
1752
  features.push(FINE_GRAINED_TOOL_STREAMING_BETA);
1691
1753
  if (model.reasoning &&
@@ -1696,12 +1758,7 @@ function getBetaFeatures(model, context, isOAuthToken, nativeToolChanges, option
1696
1758
  }
1697
1759
  if (shouldUseServerSideFallbackBeta(model))
1698
1760
  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)];
1761
+ return features;
1705
1762
  }
1706
1763
  function buildParams(model, context, isOAuthToken, options, unsignedThinkingReplay = getAnthropicCompat(model).unsignedThinkingReplay) {
1707
1764
  const compat = getAnthropicCompat(model);
@@ -1718,7 +1775,16 @@ function buildParams(model, context, isOAuthToken, options, unsignedThinkingRepl
1718
1775
  // and Anthropic rejects a tool list where every tool is deferred, so there must be an
1719
1776
  // initial active tool to anchor the deferred ones. Otherwise the current tool list is sent.
1720
1777
  const initialTools = initialSystemMessage?.toolsAdded ?? [];
1721
- const nativeToolChanges = supportsMidConvoSystemMessages(model) &&
1778
+ // A configured `anthropic-beta: null` asks for no betas at all, so the request takes a shape that needs none:
1779
+ // the current tool list instead of native tool changes, top-level effort instead of per-message markers, and
1780
+ // no server-side fallbacks (senpi#2957). OAuth still needs its identity betas; getBetaFeatures names them.
1781
+ const betasSuppressed = configuredBetaHeader(model, options) === null;
1782
+ if (betasSuppressed && isOAuthToken) {
1783
+ 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.`);
1784
+ }
1785
+ const midConvoEffort = model.compat?.supportsMidConvoEffort === true && !betasSuppressed;
1786
+ const nativeToolChanges = !betasSuppressed &&
1787
+ supportsMidConvoSystemMessages(model) &&
1722
1788
  supportsMidConvoToolChanges(model) &&
1723
1789
  initialTools.length > 0 &&
1724
1790
  !hasToolRedefinitions(context.messages);
@@ -1741,15 +1807,21 @@ function buildParams(model, context, isOAuthToken, options, unsignedThinkingRepl
1741
1807
  deferredTools = [];
1742
1808
  }
1743
1809
  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
1810
+ const activeEffort = managedEffortForRequest(model, options) ?? "high";
1811
+ const sendFallbacks = options?.refusalFallbacks !== undefined && !betasSuppressed;
1812
+ const betaFeatures = getBetaFeatures(model, context, isOAuthToken, {
1813
+ midConvoEffort,
1814
+ thinkingBinding: midConvoEffort && options?.thinkingEnabled !== false,
1815
+ nativeToolChanges,
1816
+ refusalFallbacks: sendFallbacks,
1817
+ }, options);
1818
+ const convertedMessages = convertMessages(conversationMessages, model, isOAuthToken, cacheControl, unsignedThinkingReplay, deferredToolNames, normalizeToolName, midConvoEffort ? model.provider : undefined, nativeToolChanges);
1819
+ const messages = midConvoEffort
1748
1820
  ? insertThinkingLevelMessages(convertedMessages, activeEffort)
1749
1821
  : convertedMessages.messages;
1750
1822
  // Effort markers are appended after history; re-run the fork's final-user cache checkpoint
1751
1823
  // against the actual last user/tool-result message so the marker cannot displace cache_control.
1752
- if (model.compat?.supportsMidConvoEffort === true && cacheControl) {
1824
+ if (midConvoEffort && cacheControl) {
1753
1825
  for (let index = messages.length - 1; index >= 0; index--) {
1754
1826
  const message = messages[index];
1755
1827
  // A native system update after the last turn already holds the checkpoint;
@@ -1826,9 +1898,10 @@ function buildParams(model, context, isOAuthToken, options, unsignedThinkingRepl
1826
1898
  ];
1827
1899
  }
1828
1900
  // 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) {
1901
+ // be dropped instead of surfacing as persistent 400 responses. A thinking-off turn
1902
+ // skips this branch: `disableThinkingForRequest` sends `disabled` where the family
1903
+ // accepts it and pins effort `low` where it does not (Opus/Sonnet/Haiku 5.5, Fable, Mythos).
1904
+ if (midConvoEffort && options?.thinkingEnabled !== false) {
1832
1905
  params.thinking = {
1833
1906
  type: "adaptive",
1834
1907
  display: options?.thinkingDisplay ?? "summarized",
@@ -1897,7 +1970,7 @@ function buildParams(model, context, isOAuthToken, options, unsignedThinkingRepl
1897
1970
  }
1898
1971
  }
1899
1972
  applyExtraBodyToAnthropicParams(params, options?.extraBody);
1900
- if (options?.refusalFallbacks !== undefined) {
1973
+ if (sendFallbacks && options?.refusalFallbacks !== undefined) {
1901
1974
  // AnthropicRefusalFallback models a readonly list; the SDK's BetaFallbacksParam
1902
1975
  // is mutable, so copy rather than widen the fork's public type.
1903
1976
  params.fallbacks = options.refusalFallbacks === "default" ? "default" : [...options.refusalFallbacks];
@@ -2070,7 +2143,6 @@ function convertMessages(transformedMessages, model, isOAuthToken, cacheControl,
2070
2143
  }
2071
2144
  }
2072
2145
  else if (msg.role === "assistant") {
2073
- flushPendingSystemMessages();
2074
2146
  const blocks = [];
2075
2147
  const isSameModel = isSameAnthropicModel(msg, model);
2076
2148
  // Blocks before the final fallback marker are the declined attempt; the
@@ -2156,8 +2228,12 @@ function convertMessages(transformedMessages, model, isOAuthToken, cacheControl,
2156
2228
  }
2157
2229
  }
2158
2230
  }
2231
+ // Pending system messages are emitted only in front of an assistant param that is actually sent: a reply
2232
+ // that converts to no blocks is skipped, and flushing for it would leave a `system` param directly before
2233
+ // the next `user` turn, which Anthropic rejects on every later request (senpi#2864).
2159
2234
  if (blocks.length === 0)
2160
2235
  continue;
2236
+ flushPendingSystemMessages();
2161
2237
  const messageIndex = params.length;
2162
2238
  params.push({
2163
2239
  role: "assistant",
@@ -0,0 +1,4 @@
1
+ import type { BetaContentBlockParam } from "@anthropic-ai/sdk/resources/beta/messages/messages.js";
2
+ export declare function isReplayableAnthropicProviderNativeBlock(raw: unknown): raw is BetaContentBlockParam;
3
+ export declare function isAnthropicWebSearchReplayBlock(raw: unknown): boolean;
4
+ //# sourceMappingURL=anthropic-native-replay-blocks.d.ts.map
@@ -0,0 +1,28 @@
1
+ const replayableTypes = new Set([
2
+ "server_tool_use",
3
+ "web_search_tool_result",
4
+ // GLM's Anthropic-compatible coding endpoint returns this server-result shape.
5
+ // Pairing still requires a preceding same-model server_tool_use.
6
+ "tool_result",
7
+ "web_fetch_tool_result",
8
+ "code_execution_tool_result",
9
+ "bash_code_execution_tool_result",
10
+ "text_editor_code_execution_tool_result",
11
+ "tool_search_tool_result",
12
+ "container_upload",
13
+ // Stored fallback markers are audit metadata, not valid Messages input tags.
14
+ ]);
15
+ function isRecord(value) {
16
+ return value !== null && typeof value === "object";
17
+ }
18
+ export function isReplayableAnthropicProviderNativeBlock(raw) {
19
+ return isRecord(raw) && typeof raw.type === "string" && replayableTypes.has(raw.type);
20
+ }
21
+ export function isAnthropicWebSearchReplayBlock(raw) {
22
+ if (!isRecord(raw))
23
+ return false;
24
+ if (raw.type === "web_search_tool_result" || raw.type === "tool_result")
25
+ return true;
26
+ return raw.type === "server_tool_use" && raw.name === "web_search";
27
+ }
28
+ //# sourceMappingURL=anthropic-native-replay-blocks.js.map
@@ -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
  }
@@ -0,0 +1,10 @@
1
+ import type { MessageCreateParamsStreaming } from "@anthropic-ai/sdk/resources/beta/messages/messages.js";
2
+ export declare function isExpiredWebSearchContentError(error: unknown): error is Error;
3
+ /**
4
+ * The `tool_use_id`s of the web search results the rejected request replayed
5
+ * at or before the message the API named. Without a parsable index every
6
+ * replayed result counts as expired.
7
+ */
8
+ export declare function expiredWebSearchReplayIds(params: MessageCreateParamsStreaming, error: Error): string[];
9
+ export declare function demoteExpiredWebSearchReplay(params: MessageCreateParamsStreaming, expired: ReadonlySet<string>): MessageCreateParamsStreaming;
10
+ //# sourceMappingURL=anthropic-web-search-expiry.d.ts.map
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The opaque `encrypted_content` of a native web search result is only valid
3
+ * for the account that ran the search (senpi #3201). Same-model replay keeps
4
+ * the payload byte-for-byte (#208), so once a request is served by another
5
+ * account (credential rotation, a multi-account gateway) every later request
6
+ * on that model fails with "messages.N.content.0: Invalid `encrypted_content` in
7
+ * `search_result` block" and the session only moves on through a model
8
+ * fallback. Stripping the field is not an option: the API requires it (#150).
9
+ *
10
+ * Recovery demotes expired search pairs to plain text: the `server_tool_use`
11
+ * becomes a note naming its query and the `web_search_tool_result` a list of
12
+ * the result titles and URLs. Only pairs whose result sits at or before the
13
+ * rejected message are demoted, because a search is never fresher than a
14
+ * later one; newer results keep replaying verbatim.
15
+ */
16
+ const EXPIRED_SEARCH_CONTENT = /Invalid `encrypted_content` in `search_result` block/i;
17
+ const REJECTED_MESSAGE_INDEX = /messages\.(\d+)\.content\.\d+: Invalid `encrypted_content`/i;
18
+ function isRecord(value) {
19
+ return typeof value === "object" && value !== null;
20
+ }
21
+ export function isExpiredWebSearchContentError(error) {
22
+ return (error instanceof Error &&
23
+ "status" in error &&
24
+ error.status === 400 &&
25
+ EXPIRED_SEARCH_CONTENT.test(error.message));
26
+ }
27
+ function webSearchResultIds(message) {
28
+ if (message.role !== "assistant" || !Array.isArray(message.content))
29
+ return [];
30
+ const ids = [];
31
+ for (const block of message.content) {
32
+ if (isRecord(block) && block.type === "web_search_tool_result" && typeof block.tool_use_id === "string") {
33
+ ids.push(block.tool_use_id);
34
+ }
35
+ }
36
+ return ids;
37
+ }
38
+ /**
39
+ * The `tool_use_id`s of the web search results the rejected request replayed
40
+ * at or before the message the API named. Without a parsable index every
41
+ * replayed result counts as expired.
42
+ */
43
+ export function expiredWebSearchReplayIds(params, error) {
44
+ const match = REJECTED_MESSAGE_INDEX.exec(error.message);
45
+ const lastIndex = match?.[1] === undefined ? params.messages.length - 1 : Number(match[1]);
46
+ return params.messages.slice(0, lastIndex + 1).flatMap(webSearchResultIds);
47
+ }
48
+ function searchQuery(block) {
49
+ const input = block.input;
50
+ return isRecord(input) && typeof input.query === "string" ? input.query : undefined;
51
+ }
52
+ function demotedResultText(block) {
53
+ const results = Array.isArray(block.content) ? block.content : [];
54
+ const lines = [];
55
+ for (const result of results) {
56
+ if (!isRecord(result) || result.type !== "web_search_result")
57
+ continue;
58
+ const title = typeof result.title === "string" ? result.title : "";
59
+ const url = typeof result.url === "string" ? result.url : "";
60
+ lines.push(`- ${title} (${url})`);
61
+ }
62
+ if (lines.length === 0)
63
+ return "Web search returned no results.";
64
+ return ["Web search results (page contents no longer available):", ...lines].join("\n");
65
+ }
66
+ function demoteBlock(block, expired) {
67
+ if (!isRecord(block))
68
+ return undefined;
69
+ if (block.type === "server_tool_use" && block.name === "web_search" && typeof block.id === "string") {
70
+ if (!expired.has(block.id))
71
+ return undefined;
72
+ const query = searchQuery(block);
73
+ return { type: "text", text: query === undefined ? "Searched the web." : `Searched the web for: ${query}` };
74
+ }
75
+ if (block.type === "web_search_tool_result" && typeof block.tool_use_id === "string") {
76
+ if (!expired.has(block.tool_use_id))
77
+ return undefined;
78
+ return { type: "text", text: demotedResultText(block) };
79
+ }
80
+ return undefined;
81
+ }
82
+ export function demoteExpiredWebSearchReplay(params, expired) {
83
+ if (expired.size === 0)
84
+ return params;
85
+ let changed = false;
86
+ const messages = params.messages.map((message) => {
87
+ if (message.role !== "assistant" || !Array.isArray(message.content))
88
+ return message;
89
+ let messageChanged = false;
90
+ const content = message.content.map((block) => {
91
+ const demoted = demoteBlock(block, expired);
92
+ if (demoted === undefined)
93
+ return block;
94
+ messageChanged = true;
95
+ return demoted;
96
+ });
97
+ if (!messageChanged)
98
+ return message;
99
+ changed = true;
100
+ return { ...message, content };
101
+ });
102
+ return changed ? { ...params, messages } : params;
103
+ }
104
+ //# sourceMappingURL=anthropic-web-search-expiry.js.map