@oh-my-pi/pi-ai 18.1.2 → 18.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.1.3] - 2026-09-02
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Gemini 3 sessions on Antigravity/Cloud Code Assist and Vertex AI getting permanently stuck on `400 INVALID_ARGUMENT` after a turn with parallel tool calls ([#9638](https://github.com/can1357/oh-my-pi/issues/9638)).
10
+ - Preserved Anthropic thinking now survives side requests, tool-description drift, turn-scoped reminders, and recoverable prefix mismatches without corrupting the conversation prefix.
11
+ - Fixed Anthropic-compatible endpoints backed by Amazon Bedrock permanently rejecting a session once an unsigned thinking block entered its history. The transport now recognizes Bedrock's `ValidationException … thinking.signature: Field required` as the same unsigned-thinking rejection it already heals for other signing proxies, so it demotes the unsigned block to text, retries once, and remembers the endpoint for the rest of the session instead of failing every turn and walking the model fallback chain.
12
+ - Fixed the DeepSeek DSML markup healer leaking orphan `</|DSML|parameter>`/`</|DSML|invoke>` close tags into visible text, which poisoned long-session history and reinforced the model's XML-protocol mimicry until tool calls stopped dispatching ([#10556](https://github.com/can1357/oh-my-pi/issues/10556)).
13
+ - Fixed repeated parallel tool-call batches bypassing the configured loop guard.
14
+ - Fixed Cursor tool-schema composition failures by projecting unsupported keywords only for confirmed Fable models; Grok and other Cursor models retain canonical schemas.
15
+ - Fixed API-key account rotation to honor provider-reported quota reset windows, including overlapping exhausted windows ([#10325](https://github.com/can1357/oh-my-pi/pull/10325) by [@usr-bin-roygbiv](https://github.com/usr-bin-roygbiv)).
16
+
5
17
  ## [18.1.2] - 2026-09-01
6
18
 
7
19
  ### Added
@@ -215,8 +215,8 @@ export declare function applyAnthropicUsageExtras(usage: Usage, source: Anthropi
215
215
  export declare function isThinkingPrefixBindingError(message: string): boolean;
216
216
  export declare function isInvalidThinkingSignatureError(message: string): boolean;
217
217
  /**
218
- * Prepend a pointed remediation to Anthropic's `Invalid signature in thinking
219
- * block` 400 when the model looks like an unmarked custom signing proxy
218
+ * Prepend a pointed remediation to a thinking-signature rejection 400 when the
219
+ * model looks like an unmarked custom signing proxy
220
220
  * (opaque baseUrl, `spec.reasoning: true`, no explicit
221
221
  * `compat.replayUnsignedThinking` override). The default is native replay for
222
222
  * the 3p reasoning majority (#2005); this hint turns the misconfigured-proxy
@@ -223,7 +223,7 @@ export declare function processInteractionUpdate(update: any, output: AssistantM
223
223
  * that reconstruction.
224
224
  */
225
225
  export declare function buildCursorRequestContextRules(systemPrompt: readonly string[] | undefined): CursorRule[];
226
- export declare function buildMcpToolDefinitions(tools: Tool[] | undefined): McpToolDefinition[];
226
+ export declare function buildMcpToolDefinitions(tools: Tool[] | undefined, requiresCursorToolSchemaProjection?: boolean): McpToolDefinition[];
227
227
  /**
228
228
  * Build `ConversationStateStructure.rootPromptMessagesJson` blob IDs for the
229
229
  * system prompt plus prior conversation history, as JSON blobs matching
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "18.1.2",
4
+ "version": "18.1.3",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -37,10 +37,10 @@
37
37
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/omptype": "18.1.2",
41
- "@oh-my-pi/pi-catalog": "18.1.2",
42
- "@oh-my-pi/pi-utils": "18.1.2",
43
- "@oh-my-pi/pi-wire": "18.1.2"
40
+ "@oh-my-pi/omptype": "18.1.3",
41
+ "@oh-my-pi/pi-catalog": "18.1.3",
42
+ "@oh-my-pi/pi-utils": "18.1.3",
43
+ "@oh-my-pi/pi-wire": "18.1.3"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/bun": "^1.3.14"
@@ -3873,7 +3873,7 @@ export class AuthStorage {
3873
3873
  return limits.some(limit => this.#isUsageLimitExhausted(limit));
3874
3874
  }
3875
3875
 
3876
- /** Extracts the earliest reset timestamp from exhausted windows (in ms). */
3876
+ /** Extracts when every currently exhausted window has reset (in ms). */
3877
3877
  #getUsageResetAtMs(limits: UsageLimit[], nowMs: number): number | undefined {
3878
3878
  const candidates: number[] = [];
3879
3879
  for (const limit of limits) {
@@ -3884,7 +3884,7 @@ export class AuthStorage {
3884
3884
  }
3885
3885
  }
3886
3886
  if (candidates.length === 0) return undefined;
3887
- return Math.min(...candidates);
3887
+ return Math.max(...candidates);
3888
3888
  }
3889
3889
 
3890
3890
  async #getUsageReport(
@@ -4572,7 +4572,7 @@ export class AuthStorage {
4572
4572
  const now = Date.now();
4573
4573
  let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
4574
4574
 
4575
- if (credentialType === "oauth" && target.credential.type === "oauth" && routing.strategy) {
4575
+ if (target && routing.strategy) {
4576
4576
  const report = await raceUsageWithSignal(
4577
4577
  this.#getUsageReport(provider, target.credential, options),
4578
4578
  options?.signal,
@@ -34,6 +34,10 @@ const DSML_TOOL_CALLS_OPEN_FULLWIDTH = "<|DSML|tool_calls>";
34
34
  const DSML_TOOL_CALLS_CLOSE_FULLWIDTH = "</|DSML|tool_calls>";
35
35
  const DSML_TOOL_CALLS_OPEN_ASCII = "<|DSML|tool_calls>";
36
36
  const DSML_TOOL_CALLS_CLOSE_ASCII = "</|DSML|tool_calls>";
37
+ const DSML_INVOKE_CLOSE_FULLWIDTH = "</|DSML|invoke>";
38
+ const DSML_INVOKE_CLOSE_ASCII = "</|DSML|invoke>";
39
+ const DSML_PARAMETER_CLOSE_FULLWIDTH = "</|DSML|parameter>";
40
+ const DSML_PARAMETER_CLOSE_ASCII = "</|DSML|parameter>";
37
41
 
38
42
  const CONTROL_TOKENS = [
39
43
  DEEPSEEK_BOS,
@@ -53,6 +57,18 @@ const CONTROL_TOKENS = [
53
57
  DEEPSEEK_TOOL_OUTPUT_END,
54
58
  ] as const;
55
59
 
60
+ // Bare DSML invoke/parameter close tags with no matching open — leaked into
61
+ // visible text once a session's history is poisoned (issue #10556). Stripped in
62
+ // the `outside` state so they never reach stored assistant content and reinforce
63
+ // the model's XML-protocol mimicry. Inside a well-formed envelope these closers
64
+ // are consumed by the `dsmlInvoke`/`dsmlParam` states and never reach `outside`.
65
+ const DSML_ORPHAN_CLOSE_TOKENS = [
66
+ DSML_INVOKE_CLOSE_FULLWIDTH,
67
+ DSML_INVOKE_CLOSE_ASCII,
68
+ DSML_PARAMETER_CLOSE_FULLWIDTH,
69
+ DSML_PARAMETER_CLOSE_ASCII,
70
+ ] as const;
71
+
56
72
  const OUTSIDE_TOKENS = [
57
73
  DEEPSEEK_TOOL_CALLS_BEGIN,
58
74
  DEEPSEEK_TOOL_CALLS_END,
@@ -63,6 +79,7 @@ const OUTSIDE_TOKENS = [
63
79
  DSML_TOOL_CALLS_OPEN_ASCII,
64
80
  DSML_TOOL_CALLS_CLOSE_FULLWIDTH,
65
81
  DSML_TOOL_CALLS_CLOSE_ASCII,
82
+ ...DSML_ORPHAN_CLOSE_TOKENS,
66
83
  ...CONTROL_TOKENS,
67
84
  ] as const;
68
85
 
@@ -73,8 +90,13 @@ const DSML_SECTION_TOKENS = [
73
90
  "<|DSML|invoke",
74
91
  "<|DSML|invoke",
75
92
  ] as const;
76
- const DSML_INVOKE_TOKENS = ["</|DSML|invoke>", "</|DSML|invoke>", "<|DSML|parameter", "<|DSML|parameter"] as const;
77
- const DSML_PARAMETER_CLOSE_TOKENS = ["</|DSML|parameter>", "</|DSML|parameter>"] as const;
93
+ const DSML_INVOKE_TOKENS = [
94
+ DSML_INVOKE_CLOSE_FULLWIDTH,
95
+ DSML_INVOKE_CLOSE_ASCII,
96
+ "<|DSML|parameter",
97
+ "<|DSML|parameter",
98
+ ] as const;
99
+ const DSML_PARAMETER_CLOSE_TOKENS = [DSML_PARAMETER_CLOSE_FULLWIDTH, DSML_PARAMETER_CLOSE_ASCII] as const;
78
100
 
79
101
  type State =
80
102
  | "outside"
@@ -216,6 +238,11 @@ export class DeepSeekInbandScanner implements InbandScanner {
216
238
  this.#state = "dsmlSection";
217
239
  return;
218
240
  }
241
+ const orphanClose = this.#matchingOrphanDsmlClose();
242
+ if (orphanClose) {
243
+ this.#buffer = this.#buffer.slice(orphanClose.length);
244
+ continue;
245
+ }
219
246
  const control = this.#matchingControlToken();
220
247
  if (control) {
221
248
  this.#buffer = this.#buffer.slice(control.length);
@@ -478,6 +505,13 @@ export class DeepSeekInbandScanner implements InbandScanner {
478
505
  return "";
479
506
  }
480
507
 
508
+ #matchingOrphanDsmlClose(): string | undefined {
509
+ for (const token of DSML_ORPHAN_CLOSE_TOKENS) {
510
+ if (this.#buffer.startsWith(token)) return token;
511
+ }
512
+ return undefined;
513
+ }
514
+
481
515
  #matchingControlToken(): string | undefined {
482
516
  if (this.#buffer.startsWith(DEEPSEEK_TOOL_CALLS_END)) return DEEPSEEK_TOOL_CALLS_END;
483
517
  if (this.#buffer.startsWith(THINK_CLOSE)) return THINK_CLOSE;
@@ -199,7 +199,6 @@ function resolveAnthropicControlBetas(
199
199
  ): string[] {
200
200
  const betas: string[] = [];
201
201
  if (prefixMismatchBehavior) betas.push(THINKING_BINDING_CONTROLS_BETA);
202
- if (model.compat.supportsMidConversationSystem) betas.push(midConversationSystemBeta);
203
202
  if (model.compat.supportsTurnScopedSystem) betas.push(midConversationSystemClearAtBeta);
204
203
  if (model.compat.supportsMidConversationToolChanges) betas.push(midConversationToolChangesBeta);
205
204
  if (model.compat.supportsPerMessageEffort) betas.push(midConversationOutputConfigBeta);
@@ -420,22 +419,7 @@ type AnthropicControlTransition = {
420
419
  effort?: AnthropicOutputEffort;
421
420
  };
422
421
 
423
- type AnthropicProviderSessionState = ProviderSessionState & {
424
- strictToolsDisabled: boolean;
425
- fastModeDisabled: boolean;
426
- /**
427
- * Runtime-learned: this endpoint returned `400 Invalid signature in
428
- * thinking block` for a replayed unsigned thinking block, so it must be
429
- * treated as a signing proxy from now on. All subsequent requests demote
430
- * unsigned thinking to text for this (baseUrl, modelId), same behavior as
431
- * an explicit `compat.replayUnsignedThinking: false`. Cleared on session
432
- * close.
433
- */
434
- replayUnsignedThinkingDisabled: boolean;
435
- /** Thinking blocks the API permanently dropped after a prefix mismatch. */
436
- prefixDroppedThinkingBlocks: Set<string>;
437
- /** Model the control baseline below was captured for; a switch re-baselines. */
438
- controlModelId: string | undefined;
422
+ type AnthropicControlState = {
439
423
  /** `tools` declared at baseline plus later `defer_loading` additions, in wire order. */
440
424
  declaredTools: AnthropicWireTool[] | undefined;
441
425
  activeToolNames: Set<string>;
@@ -447,13 +431,25 @@ type AnthropicProviderSessionState = ProviderSessionState & {
447
431
  currentEffort: AnthropicOutputEffort | undefined;
448
432
  };
449
433
 
450
- function createAnthropicProviderSessionState(): AnthropicProviderSessionState {
451
- const state: AnthropicProviderSessionState = {
452
- strictToolsDisabled: false,
453
- fastModeDisabled: false,
454
- replayUnsignedThinkingDisabled: false,
455
- prefixDroppedThinkingBlocks: new Set(),
456
- controlModelId: undefined,
434
+ type AnthropicProviderSessionState = ProviderSessionState & {
435
+ strictToolsDisabled: boolean;
436
+ fastModeDisabled: boolean;
437
+ /**
438
+ * Runtime-learned: this endpoint rejected a replayed unsigned thinking
439
+ * block, so it must be treated as a signing proxy from now on. All
440
+ * subsequent requests demote unsigned thinking to text for this (baseUrl,
441
+ * modelId), same behavior as an explicit
442
+ * `compat.replayUnsignedThinking: false`. Cleared on session close.
443
+ */
444
+ replayUnsignedThinkingDisabled: boolean;
445
+ /** Thinking blocks the API permanently dropped after a prefix mismatch. */
446
+ prefixDroppedThinkingBlocks: Set<string>;
447
+ /** Conversation-scoped control baselines, isolated from side requests and advisors. */
448
+ controlStates: Map<string, AnthropicControlState>;
449
+ };
450
+
451
+ function createAnthropicControlState(): AnthropicControlState {
452
+ return {
457
453
  declaredTools: undefined,
458
454
  activeToolNames: new Set(),
459
455
  stableSystemBlocks: undefined,
@@ -462,13 +458,22 @@ function createAnthropicProviderSessionState(): AnthropicProviderSessionState {
462
458
  baseEffort: undefined,
463
459
  baseEffortWire: undefined,
464
460
  currentEffort: undefined,
461
+ };
462
+ }
463
+
464
+ function createAnthropicProviderSessionState(): AnthropicProviderSessionState {
465
+ const state: AnthropicProviderSessionState = {
466
+ strictToolsDisabled: false,
467
+ fastModeDisabled: false,
468
+ replayUnsignedThinkingDisabled: false,
469
+ prefixDroppedThinkingBlocks: new Set(),
470
+ controlStates: new Map(),
465
471
  close: () => {
466
472
  state.strictToolsDisabled = false;
467
473
  state.fastModeDisabled = false;
468
474
  state.replayUnsignedThinkingDisabled = false;
469
475
  state.prefixDroppedThinkingBlocks.clear();
470
- state.controlModelId = undefined;
471
- resetAnthropicControlState(state);
476
+ state.controlStates.clear();
472
477
  },
473
478
  };
474
479
  return state;
@@ -496,8 +501,7 @@ function getAnthropicProviderSessionState(
496
501
  const existing = providerSessionState.get(key) as AnthropicProviderSessionState | undefined;
497
502
  if (existing) {
498
503
  existing.prefixDroppedThinkingBlocks ??= new Set();
499
- existing.activeToolNames ??= new Set();
500
- existing.controlTransitions ??= [];
504
+ existing.controlStates ??= new Map();
501
505
  return existing;
502
506
  }
503
507
  const created = createAnthropicProviderSessionState();
@@ -1863,11 +1867,20 @@ function calculateFallbackTurnCost(
1863
1867
  }
1864
1868
 
1865
1869
  /**
1866
- * Detects the Anthropic `400 Invalid `signature` in `thinking` block` failure
1867
- * a signing proxy returns when a stripped/unsigned prior thinking block is
1868
- * replayed as `signature: ""`. Exported for the compat tests.
1870
+ * Detects the two shapes a signature-enforcing endpoint uses to reject a
1871
+ * replayed unsigned thinking block (sent as `signature: ""`):
1872
+ *
1873
+ * - Anthropic and Anthropic-fronting proxies: `400 Invalid `signature` in
1874
+ * `thinking` block`.
1875
+ * - Bedrock-backed proxies: the empty string fails schema validation before
1876
+ * signature checking, so it comes back as `ValidationException: The model
1877
+ * returned the following errors: messages.N.content.M.thinking.signature:
1878
+ * Field required`.
1879
+ *
1880
+ * Exported for the compat tests.
1869
1881
  */
1870
1882
  const INVALID_THINKING_SIGNATURE_PATTERN = /invalid\s+`?signature`?\s+in\s+`?thinking`?(?:\s+block)?/i;
1883
+ const MISSING_THINKING_SIGNATURE_PATTERN = /thinking\.signature\b[^"\n]{0,32}\brequired\b/i;
1871
1884
  const THINKING_PREFIX_BINDING_PATTERN =
1872
1885
  /(?:bound to a different conversation|block_binding\.prefix_mismatch_behavior|prefix_mismatch_behavior)/i;
1873
1886
 
@@ -1877,10 +1890,11 @@ export function isThinkingPrefixBindingError(message: string): boolean {
1877
1890
  }
1878
1891
 
1879
1892
  export function isInvalidThinkingSignatureError(message: string): boolean {
1880
- return INVALID_THINKING_SIGNATURE_PATTERN.test(message);
1893
+ return INVALID_THINKING_SIGNATURE_PATTERN.test(message) || MISSING_THINKING_SIGNATURE_PATTERN.test(message);
1881
1894
  }
1882
1895
 
1883
1896
  const INPUT_TRANSFORMATION_PATH_PATTERN = /^messages\.(\d+)\.content\.(\d+)$/;
1897
+ const PREFIX_BINDING_ERROR_PATH_PATTERN = /messages\.(\d+)\.content\.(\d+)/;
1884
1898
 
1885
1899
  function thinkingReplayKey(block: ContentBlockParam): string | undefined {
1886
1900
  if (block.type === "thinking") return block.signature ? `thinking:${block.signature}` : undefined;
@@ -1923,6 +1937,31 @@ function rememberPrefixDroppedThinking(
1923
1937
  }
1924
1938
  }
1925
1939
 
1940
+ function rememberPrefixBindingFailure(
1941
+ params: MessageCreateParamsStreaming,
1942
+ message: string,
1943
+ state: AnthropicProviderSessionState | undefined,
1944
+ ): boolean {
1945
+ if (!state) return false;
1946
+ const match = PREFIX_BINDING_ERROR_PATH_PATTERN.exec(message);
1947
+ let path = match ? `messages.${match[1]}.content.${match[2]}` : undefined;
1948
+ if (!path) {
1949
+ for (let messageIndex = 0; messageIndex < params.messages.length && !path; messageIndex++) {
1950
+ const candidate = params.messages[messageIndex];
1951
+ if (!candidate || !Array.isArray(candidate.content)) continue;
1952
+ const blockIndex = candidate.content.findIndex(block => thinkingReplayKey(block) !== undefined);
1953
+ if (blockIndex >= 0) path = `messages.${messageIndex}.content.${blockIndex}`;
1954
+ }
1955
+ }
1956
+ if (!path) return false;
1957
+ rememberPrefixDroppedThinking(
1958
+ params,
1959
+ [{ type: "thinking_dropped", reason: "prefix_binding_mismatch", path }],
1960
+ state,
1961
+ );
1962
+ return true;
1963
+ }
1964
+
1926
1965
  function applyReportedInputTransformations(
1927
1966
  output: AssistantMessage,
1928
1967
  params: MessageCreateParamsStreaming,
@@ -1956,8 +1995,8 @@ function applyReportedInputTransformations(
1956
1995
  }
1957
1996
 
1958
1997
  /**
1959
- * Prepend a pointed remediation to Anthropic's `Invalid signature in thinking
1960
- * block` 400 when the model looks like an unmarked custom signing proxy
1998
+ * Prepend a pointed remediation to a thinking-signature rejection 400 when the
1999
+ * model looks like an unmarked custom signing proxy
1961
2000
  * (opaque baseUrl, `spec.reasoning: true`, no explicit
1962
2001
  * `compat.replayUnsignedThinking` override). The default is native replay for
1963
2002
  * the 3p reasoning majority (#2005); this hint turns the misconfigured-proxy
@@ -2028,6 +2067,7 @@ const streamAnthropicOnce = (
2028
2067
  let dropFastMode = providerSessionState?.fastModeDisabled ?? false;
2029
2068
  let forceDemoteUnsignedThinking = providerSessionState?.replayUnsignedThinkingDisabled ?? false;
2030
2069
  let dropAllThinking = false;
2070
+ let prefixBindingRetryAttempted = false;
2031
2071
  let prefixMismatchBehavior =
2032
2072
  model.thinking?.prefixBinding && model.compat.supportsThinkingBindingControls
2033
2073
  ? (options?.anthropicPrefixMismatchBehavior ?? "drop_block")
@@ -2935,7 +2975,8 @@ const streamAnthropicOnce = (
2935
2975
  const streamFailureMessage =
2936
2976
  streamFailure instanceof Error ? streamFailure.message : String(streamFailure);
2937
2977
  if (
2938
- !dropAllThinking &&
2978
+ !prefixBindingRetryAttempted &&
2979
+ options?.anthropicPrefixMismatchBehavior !== "error" &&
2939
2980
  firstTokenTime === undefined &&
2940
2981
  !streamedReplayUnsafeContent &&
2941
2982
  isThinkingPrefixBindingError(streamFailureMessage)
@@ -2945,8 +2986,9 @@ const streamAnthropicOnce = (
2945
2986
  model: model.id,
2946
2987
  baseUrl,
2947
2988
  });
2989
+ prefixBindingRetryAttempted = true;
2948
2990
  prefixMismatchBehavior = undefined;
2949
- dropAllThinking = true;
2991
+ dropAllThinking = !rememberPrefixBindingFailure(params, streamFailureMessage, providerSessionState);
2950
2992
  params = await prepareParams();
2951
2993
  providerRetryAttempt = 0;
2952
2994
  output.content.length = 0;
@@ -2968,7 +3010,7 @@ const streamAnthropicOnce = (
2968
3010
  isInvalidThinkingSignatureError(streamFailureMessage)
2969
3011
  ) {
2970
3012
  logger.warn(
2971
- "anthropic: signing proxy detected (Invalid signature in thinking block), demoting unsigned thinking and retrying",
3013
+ "anthropic: signing proxy detected (thinking signature rejected), demoting unsigned thinking and retrying",
2972
3014
  {
2973
3015
  provider: model.provider,
2974
3016
  model: model.id,
@@ -3460,10 +3502,11 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?:
3460
3502
  trailingMessage.content === "Continue." &&
3461
3503
  params.messages[trailingIndex - 1]?.role === "assistant";
3462
3504
  const messageEnd = hasTrailingAssistantPad ? trailingIndex - 1 : trailingIndex;
3463
- const start = Math.max(0, messageEnd - 1);
3464
- for (let index = messageEnd; index >= start; index--) {
3505
+ let eligibleMessages = 0;
3506
+ for (let index = messageEnd; index >= 0 && eligibleMessages < 2; index--) {
3465
3507
  const message = params.messages[index];
3466
- if (!message) continue;
3508
+ if (!message || message.clear_at === "next_user_message") continue;
3509
+ eligibleMessages++;
3467
3510
  if (typeof message.content === "string") {
3468
3511
  message.content = [
3469
3512
  { type: "text", text: message.content, cache_control: cloneAnthropicCacheControl(cacheControl) },
@@ -3525,7 +3568,9 @@ function extractClaudeCodeFirstUserMessageText(messages: readonly Message[]): st
3525
3568
  return "";
3526
3569
  }
3527
3570
 
3528
- function resetAnthropicControlState(state: AnthropicProviderSessionState): void {
3571
+ const MAX_ANTHROPIC_CONTROL_STATES = 16;
3572
+
3573
+ function resetAnthropicControlState(state: AnthropicControlState): void {
3529
3574
  state.declaredTools = undefined;
3530
3575
  state.activeToolNames.clear();
3531
3576
  state.stableSystemBlocks = undefined;
@@ -3536,10 +3581,51 @@ function resetAnthropicControlState(state: AnthropicProviderSessionState): void
3536
3581
  state.currentEffort = undefined;
3537
3582
  }
3538
3583
 
3584
+ function anthropicControlMessageProjection(message: MessageParam): MessageParam {
3585
+ if (message.role !== "assistant" || !Array.isArray(message.content)) return message;
3586
+ return {
3587
+ ...message,
3588
+ content: message.content.filter(block => block.type !== "thinking" && block.type !== "redacted_thinking"),
3589
+ };
3590
+ }
3591
+
3592
+ function getAnthropicControlState(
3593
+ state: AnthropicProviderSessionState | undefined,
3594
+ sessionId: string | undefined,
3595
+ system: readonly AnthropicSystemBlock[] | undefined,
3596
+ messages: readonly MessageParam[],
3597
+ ): AnthropicControlState | undefined {
3598
+ if (!state) return undefined;
3599
+ const root = messages[0];
3600
+ const fingerprint = String(
3601
+ Bun.hash(
3602
+ JSON.stringify([
3603
+ sessionId ?? "",
3604
+ system?.map(block => block.text) ?? null,
3605
+ root ? anthropicControlMessageProjection(root) : null,
3606
+ ]),
3607
+ ),
3608
+ );
3609
+ const existing = state.controlStates.get(fingerprint);
3610
+ if (existing) {
3611
+ state.controlStates.delete(fingerprint);
3612
+ state.controlStates.set(fingerprint, existing);
3613
+ return existing;
3614
+ }
3615
+ const created = createAnthropicControlState();
3616
+ state.controlStates.set(fingerprint, created);
3617
+ if (state.controlStates.size > MAX_ANTHROPIC_CONTROL_STATES) {
3618
+ const oldest = state.controlStates.keys().next().value;
3619
+ if (oldest !== undefined) state.controlStates.delete(oldest);
3620
+ }
3621
+ return created;
3622
+ }
3623
+
3539
3624
  /** Fingerprint of the wire message a control transition is attached after. */
3540
3625
  function anthropicControlAnchor(messages: readonly MessageParam[], messageCount: number): string {
3541
3626
  if (messageCount === 0) return "";
3542
- return String(Bun.hash(JSON.stringify(messages[messageCount - 1])));
3627
+ const message = messages[messageCount - 1];
3628
+ return message ? String(Bun.hash(JSON.stringify(anthropicControlMessageProjection(message)))) : "";
3543
3629
  }
3544
3630
 
3545
3631
  /**
@@ -3548,16 +3634,7 @@ function anthropicControlAnchor(messages: readonly MessageParam[], messageCount:
3548
3634
  * shrank or was rewritten under a recorded transition (compaction, branch
3549
3635
  * switch, `/clear`). The next request re-baselines from its own payload.
3550
3636
  */
3551
- function syncAnthropicControlState(
3552
- state: AnthropicProviderSessionState,
3553
- model: Model<"anthropic-messages">,
3554
- messages: readonly MessageParam[],
3555
- ): void {
3556
- if (state.controlModelId !== model.id) {
3557
- state.controlModelId = model.id;
3558
- resetAnthropicControlState(state);
3559
- return;
3560
- }
3637
+ function syncAnthropicControlState(state: AnthropicControlState, messages: readonly MessageParam[]): void {
3561
3638
  for (const transition of state.controlTransitions) {
3562
3639
  if (
3563
3640
  transition.messageCount > messages.length ||
@@ -3581,7 +3658,7 @@ function syncAnthropicControlState(
3581
3658
  */
3582
3659
  function planStableAnthropicSystem(
3583
3660
  current: AnthropicSystemBlock[] | undefined,
3584
- state: AnthropicProviderSessionState | undefined,
3661
+ state: AnthropicControlState | undefined,
3585
3662
  enabled: boolean,
3586
3663
  ): AnthropicSystemBlock[] | undefined {
3587
3664
  if (!state || !enabled) return current;
@@ -3600,11 +3677,16 @@ function planStableAnthropicSystem(
3600
3677
  function anthropicToolDefinitionKey(tool: AnthropicWireTool): string {
3601
3678
  const stable = { ...tool };
3602
3679
  delete stable.defer_loading;
3680
+ delete stable.description;
3603
3681
  return JSON.stringify(stable);
3604
3682
  }
3605
3683
 
3684
+ function cloneAnthropicTools(tools: readonly AnthropicWireTool[]): AnthropicWireTool[] {
3685
+ return tools.map(tool => ({ ...tool }));
3686
+ }
3687
+
3606
3688
  function recordAnthropicControlTransition(
3607
- state: AnthropicProviderSessionState,
3689
+ state: AnthropicControlState,
3608
3690
  messages: readonly MessageParam[],
3609
3691
  messageCount: number,
3610
3692
  content: ContentBlockParam[],
@@ -3634,14 +3716,14 @@ function recordAnthropicControlTransition(
3634
3716
  function planStableAnthropicTools(
3635
3717
  current: AnthropicWireTool[] | undefined,
3636
3718
  messages: readonly MessageParam[],
3637
- state: AnthropicProviderSessionState | undefined,
3719
+ state: AnthropicControlState | undefined,
3638
3720
  enabled: boolean,
3639
3721
  ): AnthropicWireTool[] | undefined {
3640
3722
  if (!state || !enabled || !current) return current;
3641
3723
  if (!state.declaredTools) {
3642
- state.declaredTools = current.map(tool => ({ ...tool }));
3724
+ state.declaredTools = cloneAnthropicTools(current);
3643
3725
  state.activeToolNames = new Set(current.map(tool => tool.name));
3644
- return state.declaredTools;
3726
+ return cloneAnthropicTools(state.declaredTools);
3645
3727
  }
3646
3728
 
3647
3729
  const declaredByName = new Map(state.declaredTools.map(tool => [tool.name, tool]));
@@ -3649,9 +3731,9 @@ function planStableAnthropicTools(
3649
3731
  const declared = declaredByName.get(tool.name);
3650
3732
  if (declared && anthropicToolDefinitionKey(declared) !== anthropicToolDefinitionKey(tool)) {
3651
3733
  resetAnthropicControlState(state);
3652
- state.declaredTools = current.map(candidate => ({ ...candidate }));
3734
+ state.declaredTools = cloneAnthropicTools(current);
3653
3735
  state.activeToolNames = new Set(current.map(candidate => candidate.name));
3654
- return state.declaredTools;
3736
+ return cloneAnthropicTools(state.declaredTools);
3655
3737
  }
3656
3738
  }
3657
3739
 
@@ -3678,7 +3760,7 @@ function planStableAnthropicTools(
3678
3760
  }
3679
3761
  if (changes.length > 0) recordAnthropicControlTransition(state, messages, messages.length, changes);
3680
3762
  state.activeToolNames = nextActive;
3681
- return state.declaredTools;
3763
+ return cloneAnthropicTools(state.declaredTools);
3682
3764
  }
3683
3765
 
3684
3766
  /**
@@ -3690,7 +3772,7 @@ function planStableAnthropicTools(
3690
3772
  function planStableAnthropicEffort(
3691
3773
  current: AnthropicOutputEffort | undefined,
3692
3774
  messages: readonly MessageParam[],
3693
- state: AnthropicProviderSessionState | undefined,
3775
+ state: AnthropicControlState | undefined,
3694
3776
  enabled: boolean,
3695
3777
  ): AnthropicOutputEffort | undefined {
3696
3778
  if (!state || !enabled) return current;
@@ -3712,7 +3794,7 @@ function planStableAnthropicEffort(
3712
3794
 
3713
3795
  function materializeAnthropicControlTransitions(
3714
3796
  messages: MessageParam[],
3715
- state: AnthropicProviderSessionState | undefined,
3797
+ state: AnthropicControlState | undefined,
3716
3798
  ): MessageParam[] {
3717
3799
  if (!state || state.controlTransitions.length === 0) return messages;
3718
3800
  const result = messages.slice();
@@ -3736,7 +3818,7 @@ function materializeAnthropicControlTransitions(
3736
3818
  }
3737
3819
  result.splice(index, 0, {
3738
3820
  role: "system",
3739
- content: transition.content,
3821
+ content: transition.content.map(block => ({ ...block })),
3740
3822
  ...(transition.effort === undefined ? {} : { output_config: { effort: transition.effort } }),
3741
3823
  });
3742
3824
  offset++;
@@ -3906,25 +3988,17 @@ function buildParams(
3906
3988
  dropAllThinking,
3907
3989
  droppedThinkingBlocks,
3908
3990
  });
3909
- if (providerSessionState) syncAnthropicControlState(providerSessionState, model, wireMessages);
3910
- systemBlocks = planStableAnthropicSystem(
3911
- systemBlocks,
3912
- providerSessionState,
3913
- model.compat.supportsMidConversationSystem,
3914
- );
3915
- tools = planStableAnthropicTools(
3916
- tools,
3917
- wireMessages,
3918
- providerSessionState,
3919
- model.compat.supportsMidConversationToolChanges,
3920
- );
3991
+ const controlState = getAnthropicControlState(providerSessionState, options?.sessionId, systemBlocks, wireMessages);
3992
+ if (controlState) syncAnthropicControlState(controlState, wireMessages);
3993
+ systemBlocks = planStableAnthropicSystem(systemBlocks, controlState, model.compat.supportsMidConversationSystem);
3994
+ tools = planStableAnthropicTools(tools, wireMessages, controlState, model.compat.supportsMidConversationToolChanges);
3921
3995
  const topLevelEffort = planStableAnthropicEffort(
3922
3996
  outputConfigEffort,
3923
3997
  wireMessages,
3924
- providerSessionState,
3998
+ controlState,
3925
3999
  model.compat.supportsPerMessageEffort,
3926
4000
  );
3927
- wireMessages = materializeAnthropicControlTransitions(wireMessages, providerSessionState);
4001
+ wireMessages = materializeAnthropicControlTransitions(wireMessages, controlState);
3928
4002
 
3929
4003
  const outputConfigEntries: AnthropicOutputConfig = {};
3930
4004
  if (topLevelEffort && model.compat.supportsOutputEffort) outputConfigEntries.effort = topLevelEffort;
@@ -4303,7 +4377,7 @@ export function convertAnthropicMessages(
4303
4377
  for (const block of blocks) {
4304
4378
  if (block.type === "tool_use") {
4305
4379
  sawToolUse = true;
4306
- } else if (sawToolUse) {
4380
+ } else if (sawToolUse && block.type !== "thinking" && block.type !== "redacted_thinking") {
4307
4381
  needsPartition = true;
4308
4382
  break;
4309
4383
  }
@@ -4366,7 +4440,7 @@ export function convertAnthropicMessages(
4366
4440
  // covers both the "followed by assistant / last" and "no consecutive system"
4367
4441
  // constraints. Anything that does not qualify stays a `user` message.
4368
4442
  if (developerParams.length > 0 && model.compat.supportsMidConversationSystem) {
4369
- for (const developer of developerParams) {
4443
+ for (const developer of developerParams.toReversed()) {
4370
4444
  const idx = developer.index;
4371
4445
  const followsUser = idx > 0 && params[idx - 1]?.role === "user";
4372
4446
  const next = params[idx + 1];
@@ -4378,18 +4452,35 @@ export function convertAnthropicMessages(
4378
4452
  block => block.type === "text" || block.type === "tool_addition" || block.type === "tool_removal",
4379
4453
  );
4380
4454
  const effortOnly = developer.payload?.effort !== undefined && Array.isArray(content) && content.length === 0;
4381
- if ((followsUser && lastOrBeforeAssistant && systemCompatible) || effortOnly) {
4382
- params[idx] = {
4383
- role: "system",
4384
- content,
4385
- ...(developer.payload?.clearAt !== undefined && model.compat.supportsTurnScopedSystem
4386
- ? { clear_at: developer.payload.clearAt }
4387
- : {}),
4388
- ...(developer.payload?.effort !== undefined && model.compat.supportsPerMessageEffort
4389
- ? { output_config: { effort: developer.payload.effort } }
4390
- : {}),
4391
- };
4455
+ if (!((followsUser && lastOrBeforeAssistant && systemCompatible) || effortOnly)) continue;
4456
+
4457
+ const turnScoped = developer.payload?.clearAt === "next_user_message" && model.compat.supportsTurnScopedSystem;
4458
+ const hasEffort = developer.payload?.effort !== undefined && model.compat.supportsPerMessageEffort;
4459
+ const hasToolChanges = (developer.payload?.toolChanges?.length ?? 0) > 0;
4460
+ if (turnScoped && (hasEffort || hasToolChanges) && Array.isArray(content)) {
4461
+ const scopedContent = content.filter(block => block.type === "text");
4462
+ const controlContent = content.filter(block => block.type !== "text");
4463
+ if (scopedContent.length > 0) {
4464
+ params[idx] = {
4465
+ role: "system",
4466
+ content: scopedContent,
4467
+ clear_at: "next_user_message",
4468
+ };
4469
+ params.splice(idx + 1, 0, {
4470
+ role: "system",
4471
+ content: controlContent,
4472
+ ...(hasEffort ? { output_config: { effort: developer.payload?.effort } } : {}),
4473
+ });
4474
+ continue;
4475
+ }
4392
4476
  }
4477
+
4478
+ params[idx] = {
4479
+ role: "system",
4480
+ content,
4481
+ ...(turnScoped && !hasEffort && !hasToolChanges ? { clear_at: "next_user_message" } : {}),
4482
+ ...(hasEffort ? { output_config: { effort: developer.payload?.effort } } : {}),
4483
+ };
4393
4484
  }
4394
4485
  }
4395
4486
  // Dropped empty user/developer turns can leave two assistant params adjacent;
@@ -702,7 +702,10 @@ function streamCursorWithWireMode(
702
702
  const { requestBytes, conversationState } = builtRequest;
703
703
  serializedFallbackWireModelId = builtRequest.fallbackWireModelId;
704
704
  conversationStateCache.set(conversationId, conversationState);
705
- const requestContextTools = buildMcpToolDefinitions(context.tools);
705
+ const requestContextTools = buildMcpToolDefinitions(
706
+ context.tools,
707
+ model.requiresCursorToolSchemaProjection === true,
708
+ );
706
709
  const requestContextRules = buildCursorRequestContextRules(context.systemPrompt);
707
710
 
708
711
  const baseUrl = model.baseUrl || CURSOR_API_URL;
@@ -4612,7 +4615,10 @@ function isJsonValue(value: unknown): value is JsonValue {
4612
4615
  return true;
4613
4616
  }
4614
4617
 
4615
- export function buildMcpToolDefinitions(tools: Tool[] | undefined): McpToolDefinition[] {
4618
+ export function buildMcpToolDefinitions(
4619
+ tools: Tool[] | undefined,
4620
+ requiresCursorToolSchemaProjection = false,
4621
+ ): McpToolDefinition[] {
4616
4622
  if (!tools || tools.length === 0) {
4617
4623
  return [];
4618
4624
  }
@@ -4632,7 +4638,8 @@ export function buildMcpToolDefinitions(tools: Tool[] | undefined): McpToolDefin
4632
4638
  const forwarded = writeTool ? [...advertisedTools, writeTool] : advertisedTools;
4633
4639
 
4634
4640
  return forwarded.map(tool => {
4635
- const jsonSchema = sanitizeSchemaForCursor(toolWireSchema(tool));
4641
+ const wireSchema = toolWireSchema(tool);
4642
+ const jsonSchema = requiresCursorToolSchemaProjection ? sanitizeSchemaForCursor(wireSchema) : wireSchema;
4636
4643
  const schemaValue: JsonValue =
4637
4644
  jsonSchema !== null && !Array.isArray(jsonSchema) && isJsonValue(jsonSchema)
4638
4645
  ? jsonSchema
@@ -244,6 +244,13 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
244
244
  }
245
245
  } else if (block.type === "toolCall") {
246
246
  emittedToolCallNames.set(block.id, block.name);
247
+ // Gemini 3 requires a thought signature on function calls it makes. For
248
+ // calls we cannot sign (cross-model replay, secret-redacted args, or the
249
+ // secondary calls of a parallel turn), the public Gemini API accepts the
250
+ // `skip_thought_signature_validator` sentinel as a bypass. Cloud Code
251
+ // Assist / Antigravity and Vertex AI reject that sentinel with 400
252
+ // INVALID_ARGUMENT — permanently wedging any session whose history
253
+ // contains one — so for those transports we omit the field instead. (#9638)
247
254
  const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature);
248
255
  const effectiveSignature =
249
256
  thoughtSignature || (model.compat.requiresSkipThoughtSignature ? SKIP_THOUGHT_SIGNATURE : undefined);
@@ -79,29 +79,39 @@ export class ToolCallLoopGuard {
79
79
  /** Records one completed turn and returns the threshold hit, if any. */
80
80
  recordTurn(turn: ToolCallLoopTurn): RepeatedToolCallDetection | null {
81
81
  const toolCalls = turn.message.content.filter((part): part is ToolCall => part.type === "toolCall");
82
- if (toolCalls.length !== 1 || this.#exemptTools.has(toolCalls[0]!.name)) {
82
+ if (toolCalls.length === 0) {
83
+ this.#lastHash = undefined;
84
+ this.#count = 0;
85
+ return null;
86
+ }
87
+ if (toolCalls.every(tc => this.#exemptTools.has(tc.name))) {
83
88
  this.#lastHash = undefined;
84
89
  this.#count = 0;
85
90
  return null;
86
91
  }
87
92
 
88
- const toolCall = toolCalls[0]!;
89
- const canonicalArgs = JSON.stringify(canonicalizeToolCallValue(toolCall.arguments));
90
- const hash = `${toolCall.name}:${canonicalArgs}`;
91
- if (hash === this.#lastHash) {
93
+ const canonicalCalls = toolCalls
94
+ .map(tc => JSON.stringify([tc.name, canonicalizeToolCallValue(tc.arguments)]))
95
+ .sort();
96
+ const turnHash = JSON.stringify(canonicalCalls);
97
+ if (turnHash === this.#lastHash) {
92
98
  this.#count++;
93
99
  } else {
94
- this.#lastHash = hash;
100
+ this.#lastHash = turnHash;
95
101
  this.#count = 1;
96
102
  }
97
103
 
98
104
  if (this.#count !== this.#threshold) return null;
105
+ const reportCall = toolCalls.find(tc => !this.#exemptTools.has(tc.name)) ?? toolCalls[0]!;
99
106
  return {
100
107
  kind: "repeated_tool_call",
101
- toolName: toolCall.name,
108
+ toolName: reportCall.name,
102
109
  count: this.#count,
103
- resultSummary: summarizeToolResult(turn.toolResults, toolCall.id),
104
- argumentsSummary: summarizeText(canonicalArgs, ARGUMENT_SUMMARY_LIMIT),
110
+ resultSummary: summarizeToolResult(turn.toolResults, reportCall.id),
111
+ argumentsSummary: summarizeText(
112
+ JSON.stringify(canonicalizeToolCallValue(reportCall.arguments)),
113
+ ARGUMENT_SUMMARY_LIMIT,
114
+ ),
105
115
  };
106
116
  }
107
117
  }