@oh-my-pi/pi-ai 18.3.4 → 18.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +35 -48
  2. package/dist/types/auth/blocks.d.ts +10 -19
  3. package/dist/types/auth/cascade.d.ts +1 -1
  4. package/dist/types/auth/rotation.d.ts +6 -3
  5. package/dist/types/auth/types.d.ts +23 -3
  6. package/dist/types/auth/usage-cache.d.ts +14 -2
  7. package/dist/types/auth/usage.d.ts +2 -2
  8. package/dist/types/auth-broker/client.d.ts +2 -1
  9. package/dist/types/auth-broker/remote-store.d.ts +5 -6
  10. package/dist/types/auth-broker/types.d.ts +3 -1
  11. package/dist/types/auth-broker/wire-schemas.d.ts +2 -1
  12. package/dist/types/auth-retry.d.ts +22 -13
  13. package/dist/types/images/types.d.ts +9 -0
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/judgment/types.d.ts +3 -2
  16. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  17. package/dist/types/providers/cursor-pi-args.d.ts +10 -0
  18. package/dist/types/providers/cursor.d.ts +6 -2
  19. package/dist/types/providers/google-shared.d.ts +13 -2
  20. package/dist/types/types.d.ts +0 -10
  21. package/dist/types/usage/claude-reset.d.ts +6 -0
  22. package/dist/types/usage/claude.d.ts +6 -0
  23. package/dist/types/usage.d.ts +2 -0
  24. package/dist/types/utils/proxy.d.ts +2 -2
  25. package/package.json +6 -6
  26. package/src/auth/blocks.ts +79 -37
  27. package/src/auth/cascade.ts +5 -4
  28. package/src/auth/resets.ts +104 -40
  29. package/src/auth/rotation.ts +62 -21
  30. package/src/auth/select.ts +9 -2
  31. package/src/auth/types.ts +28 -3
  32. package/src/auth/usage-cache.ts +45 -2
  33. package/src/auth/usage.ts +52 -25
  34. package/src/auth-broker/client.ts +13 -0
  35. package/src/auth-broker/remote-store.ts +128 -27
  36. package/src/auth-broker/server.ts +20 -0
  37. package/src/auth-broker/types.ts +4 -1
  38. package/src/auth-broker/wire-schemas.ts +7 -0
  39. package/src/auth-retry.ts +50 -28
  40. package/src/images/openai-hosted.ts +18 -3
  41. package/src/images/types.ts +9 -0
  42. package/src/index.ts +1 -0
  43. package/src/judgment/types.ts +10 -6
  44. package/src/judgment/typesafe.ts +2 -2
  45. package/src/providers/anthropic.ts +54 -123
  46. package/src/providers/cursor/exec-modern.ts +1 -0
  47. package/src/providers/cursor/proto/agent.proto +5 -0
  48. package/src/providers/cursor-pi-args.ts +14 -0
  49. package/src/providers/cursor.ts +66 -28
  50. package/src/providers/google-gemini-cli.ts +2 -19
  51. package/src/providers/google-shared.ts +30 -22
  52. package/src/providers/ollama.ts +2 -0
  53. package/src/registry/oauth/oauth.html +206 -185
  54. package/src/stream.ts +2 -284
  55. package/src/types.ts +0 -10
  56. package/src/usage/claude-reset.ts +45 -11
  57. package/src/usage/claude.ts +79 -49
  58. package/src/usage.ts +2 -0
  59. package/src/utils/proxy.ts +3 -3
  60. package/src/utils/schema/CONSTRAINTS.md +30 -28
  61. package/src/utils/schema/normalize.ts +1 -1
  62. package/src/utils/schema/wire.ts +3 -3
@@ -86,8 +86,8 @@ const BACKOFF_MAX_MS = 5_000;
86
86
  interface SystemOneResponse {
87
87
  model: string;
88
88
  answers: Record<string, Answer>;
89
- /** OpenRouter adds the billed `cost` in USD; TypeSafe reports tokens only. */
90
- usage: { input_tokens: number; output_tokens: number; cost?: number };
89
+ /** OpenRouter adds the billed `cost` in USD; some routes omit token counts. */
90
+ usage: { input_tokens?: number; output_tokens?: number; cost?: number };
91
91
  }
92
92
 
93
93
  /** Server hint wins (capped); otherwise exponential backoff from {@link BACKOFF_BASE_MS}. */
@@ -1590,31 +1590,6 @@ export function applyAnthropicUsageExtras(usage: Usage, source: AnthropicUsageLi
1590
1590
  }
1591
1591
  }
1592
1592
 
1593
- function parseAnthropicWireUsage(value: unknown): AnthropicWireUsage | undefined {
1594
- if (!isRecord(value)) return undefined;
1595
- const cacheCreation = isRecord(value.cache_creation)
1596
- ? {
1597
- ...(typeof value.cache_creation.ephemeral_5m_input_tokens === "number"
1598
- ? { ephemeral_5m_input_tokens: value.cache_creation.ephemeral_5m_input_tokens }
1599
- : {}),
1600
- ...(typeof value.cache_creation.ephemeral_1h_input_tokens === "number"
1601
- ? { ephemeral_1h_input_tokens: value.cache_creation.ephemeral_1h_input_tokens }
1602
- : {}),
1603
- }
1604
- : undefined;
1605
- return {
1606
- ...(typeof value.input_tokens === "number" ? { input_tokens: value.input_tokens } : {}),
1607
- ...(typeof value.output_tokens === "number" ? { output_tokens: value.output_tokens } : {}),
1608
- ...(typeof value.cache_read_input_tokens === "number"
1609
- ? { cache_read_input_tokens: value.cache_read_input_tokens }
1610
- : {}),
1611
- ...(typeof value.cache_creation_input_tokens === "number"
1612
- ? { cache_creation_input_tokens: value.cache_creation_input_tokens }
1613
- : {}),
1614
- ...(cacheCreation === undefined ? {} : { cache_creation: cacheCreation }),
1615
- };
1616
- }
1617
-
1618
1593
  function parseAnthropicFallbackWireBlock(value: unknown): AnthropicFallbackContent | undefined {
1619
1594
  if (!isRecord(value) || value.type !== "fallback") return undefined;
1620
1595
  const from = isRecord(value.from) && typeof value.from.model === "string" ? value.from.model : undefined;
@@ -1636,6 +1611,28 @@ function isReplayableAnthropicCompaction(
1636
1611
  return payload?.type === "anthropicCompaction" && payload.provider === model.provider && payload.content.length > 0;
1637
1612
  }
1638
1613
 
1614
+ /** Which persisted compaction summaries a request replays as native blocks. */
1615
+ interface AnthropicCompactionReplay {
1616
+ model: Model<"anthropic-messages">;
1617
+ /** Replay persisted legacy threshold blocks (encrypted content) too. */
1618
+ legacy: boolean;
1619
+ }
1620
+
1621
+ /**
1622
+ * Whether `payload` goes on the wire as a `compaction` block under `replay`:
1623
+ * replayable for the model, and carrying a signature or (when legacy replay is
1624
+ * on) the legacy ciphertext. Anything else is sent as the summary's text.
1625
+ */
1626
+ function replaysAnthropicCompactionBlock(
1627
+ payload: ProviderPayload | undefined,
1628
+ replay: AnthropicCompactionReplay,
1629
+ ): payload is AnthropicCompactionPayload {
1630
+ return (
1631
+ isReplayableAnthropicCompaction(payload, replay.model) &&
1632
+ (payload.signature !== undefined || (replay.legacy && payload.encryptedContent !== undefined))
1633
+ );
1634
+ }
1635
+
1639
1636
  /** The wire block for a replayed compaction payload, opaque state included. */
1640
1637
  function compactionBlockParam(payload: AnthropicCompactionPayload): CompactionBlockParam {
1641
1638
  const { content, signature, encryptedContent } = payload;
@@ -2071,7 +2068,6 @@ const streamAnthropicOnce = (
2071
2068
  });
2072
2069
  }
2073
2070
 
2074
- const zeroOutputCacheRefresh = options?.anthropicCacheRefreshRequest === true;
2075
2071
  let client: AnthropicMessagesClientLike;
2076
2072
  let isOAuthToken: boolean;
2077
2073
  // Retained so a Claude Code version bump can rebuild the client's fingerprint headers.
@@ -2207,7 +2203,7 @@ const streamAnthropicOnce = (
2207
2203
  model,
2208
2204
  apiKey,
2209
2205
  extraBetas,
2210
- stream: !zeroOutputCacheRefresh,
2206
+ stream: true,
2211
2207
  interleavedThinking: options?.interleavedThinking ?? true,
2212
2208
  headers: options?.headers,
2213
2209
  dynamicHeaders: copilotDynamicHeaders?.headers,
@@ -2321,89 +2317,6 @@ const streamAnthropicOnce = (
2321
2317
  const requestTimeoutMs =
2322
2318
  firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
2323
2319
 
2324
- if (zeroOutputCacheRefresh) {
2325
- const refreshParams: MessageCreateParams = { ...params, max_tokens: 0, stream: false };
2326
- // Anthropic rejects `tool_choice: {type:"tool"|"any"}` with `max_tokens: 0`
2327
- // ("tool_choice ... cannot be used when max_tokens is 0", #12597). A refresh
2328
- // replays the captured turn's payload, which can carry a forced selector
2329
- // (e.g. a forced yield). A zero-output keep-alive produces no tokens, so the
2330
- // forced choice is meaningless here — drop it so the request is accepted.
2331
- const refreshChoiceType = refreshParams.tool_choice?.type;
2332
- if (refreshChoiceType === "tool" || refreshChoiceType === "any") {
2333
- delete refreshParams.tool_choice;
2334
- }
2335
- rawRequestDump = {
2336
- provider: model.provider,
2337
- api: output.api,
2338
- model: model.id,
2339
- method: "POST",
2340
- url: `${baseUrl}/v1/messages${isOAuthToken ? "?beta=true" : ""}`,
2341
- body: refreshParams,
2342
- };
2343
- const { requestSignal } = activeAbortTracker;
2344
- // A replayed compaction block needs the beta on injected clients too.
2345
- // Route by the client's own endpoint when it exposes one.
2346
- const refreshBetaRouteUrl =
2347
- options?.client !== undefined ? (injectedClientBaseUrl(options.client) ?? baseUrl) : baseUrl;
2348
- let refreshHeaders: Record<string, string> | undefined;
2349
- if (options?.client !== undefined && !isVertexRawPredictUrl(refreshBetaRouteUrl)) {
2350
- if (carriesSignedCompaction(refreshParams)) {
2351
- refreshHeaders = mergeAnthropicBetaHeader(refreshHeaders ?? mergedCallerHeaders, COMPACTION_BETA);
2352
- }
2353
- if (carriesLegacyCompactionEdit(refreshParams)) {
2354
- refreshHeaders = mergeAnthropicBetaHeader(
2355
- refreshHeaders ?? mergedCallerHeaders,
2356
- LEGACY_COMPACTION_BETA,
2357
- );
2358
- }
2359
- }
2360
- const requestOptions = {
2361
- ...createSdkStreamRequestOptions(requestSignal, requestTimeoutMs),
2362
- maxRetries: 0,
2363
- ...(refreshHeaders ? { headers: refreshHeaders } : {}),
2364
- };
2365
- const request: unknown =
2366
- isOAuthToken && client.beta
2367
- ? client.beta.messages.create(refreshParams, requestOptions)
2368
- : client.messages.create(refreshParams, requestOptions);
2369
- if (!hasAnthropicRawResponseRequest(request)) {
2370
- throw new AIError.AnthropicStreamEnvelopeError(
2371
- "Anthropic cache refresh request did not expose a raw response",
2372
- );
2373
- }
2374
- const response = await request.asResponse();
2375
- await notifyProviderResponse(options, response, model, response.headers.get("request-id"));
2376
- const body: unknown = await response.json();
2377
- if (!isRecord(body)) {
2378
- throw new AIError.AnthropicStreamEnvelopeError("Anthropic cache refresh returned a malformed response");
2379
- }
2380
- const wireUsage = parseAnthropicWireUsage(body.usage);
2381
- if (!wireUsage) {
2382
- throw new AIError.AnthropicStreamEnvelopeError("Anthropic cache refresh response omitted usage");
2383
- }
2384
- if (typeof body.id === "string") output.responseId = body.id;
2385
- applyReportedInputTransformations(
2386
- output,
2387
- params,
2388
- providerSessionState,
2389
- body.input_transformations,
2390
- seenInputTransformations,
2391
- );
2392
- output.usage.input = wireUsage.input_tokens ?? 0;
2393
- output.usage.output = wireUsage.output_tokens ?? 0;
2394
- output.usage.cacheRead = wireUsage.cache_read_input_tokens ?? 0;
2395
- output.usage.cacheWrite = wireUsage.cache_creation_input_tokens ?? 0;
2396
- applyAnthropicUsageExtras(output.usage, wireUsage);
2397
- output.usage.totalTokens =
2398
- output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
2399
- calculateCost(model, output.usage, output.timestamp);
2400
- output.duration = performance.now() - startTime;
2401
- stream.push({ type: "start", partial: output });
2402
- stream.push({ type: "done", reason: "stop", message: output });
2403
- stream.end();
2404
- return;
2405
- }
2406
-
2407
2320
  // Opt-in flag: the response parser only honors `fallback` content
2408
2321
  // blocks and `usage.iterations` when the current request opted into
2409
2322
  // server-side-fallback beta chain. Leaving `fallbacks` unset preserves
@@ -4302,12 +4215,26 @@ function collectAnthropicControlRecords(messages: readonly Message[]): Anthropic
4302
4215
  * lands between a `tool_use` and its `tool_result`, where `transformMessages`
4303
4216
  * would flush synthetic aborted results; a mid-turn change therefore takes
4304
4217
  * effect from the next step.
4218
+ *
4219
+ * A summary replayed as a `compaction` block is never a slot: the block must
4220
+ * open the request, so nothing may precede it. A response opened by the block
4221
+ * (no real user turn between them) takes the change from its next step. A
4222
+ * summary sent as text is an ordinary user turn.
4305
4223
  */
4306
- function anthropicEffortInsertIndex(messages: readonly Message[], end: number): number {
4224
+ function anthropicEffortInsertIndex(
4225
+ messages: readonly Message[],
4226
+ end: number,
4227
+ compactionReplay: AnthropicCompactionReplay | undefined,
4228
+ ): number {
4307
4229
  for (let i = end - 1; i >= 0; i--) {
4308
- const role = messages[i]?.role;
4309
- if (role === "user") return i;
4310
- if (role === "assistant") return end;
4230
+ const message = messages[i];
4231
+ if (message?.role === "assistant") return end;
4232
+ if (message?.role !== "user") continue;
4233
+ if (!compactionReplay || !replaysAnthropicCompactionBlock(message.providerPayload, compactionReplay)) return i;
4234
+ let next = end;
4235
+ if (messages[next]?.role === "assistant") next++;
4236
+ while (messages[next]?.role === "toolResult") next++;
4237
+ return next;
4311
4238
  }
4312
4239
  return end;
4313
4240
  }
@@ -4420,6 +4347,7 @@ function planAnthropicEffortControls(
4420
4347
  messages: readonly Message[],
4421
4348
  records: readonly AnthropicControlRecord[],
4422
4349
  enabled: boolean,
4350
+ compactionReplay: AnthropicCompactionReplay | undefined,
4423
4351
  ): {
4424
4352
  topLevel: AnthropicOutputEffort | undefined;
4425
4353
  inserts: AnthropicControlInsert[];
@@ -4444,7 +4372,7 @@ function planAnthropicEffortControls(
4444
4372
  const recorded = record.effort.tail;
4445
4373
  if (recorded !== null && recorded !== tail) {
4446
4374
  inserts.push({
4447
- index: anthropicEffortInsertIndex(messages, record.index),
4375
+ index: anthropicEffortInsertIndex(messages, record.index, compactionReplay),
4448
4376
  spec: { toolChanges: [], effort: recorded },
4449
4377
  });
4450
4378
  }
@@ -4452,7 +4380,7 @@ function planAnthropicEffortControls(
4452
4380
  }
4453
4381
  if (current !== undefined && current !== tail) {
4454
4382
  inserts.push({
4455
- index: anthropicEffortInsertIndex(messages, messages.length),
4383
+ index: anthropicEffortInsertIndex(messages, messages.length, compactionReplay),
4456
4384
  spec: { toolChanges: [], effort: current },
4457
4385
  });
4458
4386
  tail = current;
@@ -4696,11 +4624,15 @@ function buildParams(
4696
4624
  // the `effort-2025-11-24` beta, which that adapter can only accept in the body
4697
4625
  // (`anthropic_beta`), never as the `anthropic-beta` HTTP header this path sets
4698
4626
  // — so the field is dropped alongside the beta to avoid a 400 (#5614).
4627
+ const compactionReplay: AnthropicCompactionReplay | undefined = compactionSupported
4628
+ ? { model: effectiveModel, legacy: !compactionRequest && !signedReplay }
4629
+ : undefined;
4699
4630
  const effortPlan = planAnthropicEffortControls(
4700
4631
  outputConfigEffort,
4701
4632
  context.messages,
4702
4633
  records,
4703
4634
  model.compat.supportsPerMessageEffort === true,
4635
+ compactionReplay,
4704
4636
  );
4705
4637
  const wireMessages = convertAnthropicMessages(
4706
4638
  insertAnthropicControlMarkers(context.messages, [...toolPlan.inserts, ...effortPlan.inserts]),
@@ -4709,7 +4641,7 @@ function buildParams(
4709
4641
  {
4710
4642
  serverSideFallbackEnabled: !!fallbacks?.length,
4711
4643
  replayCompaction: compactionSupported,
4712
- replayLegacyCompaction: !compactionRequest && !signedReplay,
4644
+ replayLegacyCompaction: compactionReplay?.legacy,
4713
4645
  dropAllThinking,
4714
4646
  droppedThinkingBlocks,
4715
4647
  credentialId: options?.credentialId,
@@ -5027,9 +4959,7 @@ export function convertAnthropicMessages(
5027
4959
  if (
5028
4960
  opts?.replayCompaction &&
5029
4961
  (msg.role === "user" || msg.role === "developer") &&
5030
- isReplayableAnthropicCompaction(msg.providerPayload, model) &&
5031
- (msg.providerPayload.signature !== undefined ||
5032
- (opts.replayLegacyCompaction !== false && msg.providerPayload.encryptedContent !== undefined))
4962
+ replaysAnthropicCompactionBlock(msg.providerPayload, { model, legacy: opts.replayLegacyCompaction !== false })
5033
4963
  ) {
5034
4964
  const compactionParam: AnthropicMessageParam = {
5035
4965
  role: "assistant",
@@ -5113,9 +5043,10 @@ export function convertAnthropicMessages(
5113
5043
  // replayed turn.
5114
5044
  if (
5115
5045
  opts?.replayCompaction &&
5116
- isReplayableAnthropicCompaction(msg.providerPayload, model) &&
5117
- (msg.providerPayload.signature !== undefined ||
5118
- (opts.replayLegacyCompaction !== false && msg.providerPayload.encryptedContent !== undefined))
5046
+ replaysAnthropicCompactionBlock(msg.providerPayload, {
5047
+ model,
5048
+ legacy: opts.replayLegacyCompaction !== false,
5049
+ })
5119
5050
  ) {
5120
5051
  blocks.push(compactionBlockParam(msg.providerPayload));
5121
5052
  }
@@ -86,6 +86,7 @@ export {
86
86
  piReadPath,
87
87
  piReadPathHasRange,
88
88
  piTimeout,
89
+ shellTimeoutSeconds,
89
90
  } from "../cursor-pi-args";
90
91
 
91
92
  /** Flatten a tool result's content into the single `output` string the Pi frames carry. */
@@ -836,6 +836,11 @@ message ShellOutputDeltaUpdate {
836
836
  }
837
837
 
838
838
  message TurnEndedUpdate {
839
+ optional int64 input_tokens = 1;
840
+ optional int64 output_tokens = 2;
841
+ optional int64 cache_read_tokens = 3;
842
+ optional int64 cache_write_tokens = 4;
843
+ optional int64 reasoning_tokens = 5;
839
844
  }
840
845
 
841
846
  // Only: user message appended update
@@ -200,6 +200,20 @@ export function piTimeout(timeout: number | undefined): number | undefined {
200
200
  return timeout !== undefined && timeout >= 0 ? timeout : undefined;
201
201
  }
202
202
 
203
+ /**
204
+ * Convert a legacy `ShellArgs`/`ShellStreamArgs` timeout into bash-tool seconds.
205
+ *
206
+ * Cursor states that budget in milliseconds — its own `ShellTimeout` result
207
+ * echoes it as `timeout_ms`, and `hard_timeout` documents the same unit — while
208
+ * the bash tool takes seconds and rejects anything past 3600, so forwarding the
209
+ * raw value turned a model-requested 15 s into `requested 15000s`. Sub-second
210
+ * budgets round up: 0 seconds means "no deadline" to the bash tool.
211
+ */
212
+ export function shellTimeoutSeconds(timeoutMs: number | undefined): number | undefined {
213
+ if (!timeoutMs || timeoutMs <= 0) return undefined;
214
+ return Math.max(1, Math.round(timeoutMs / 1000));
215
+ }
216
+
203
217
  /**
204
218
  * Drop keys whose value is `undefined` so optional local-tool kwargs stay
205
219
  * absent rather than present-as-undefined.
@@ -139,6 +139,7 @@ import {
139
139
  SubagentResultSchema,
140
140
  ThinkingMessageSchema,
141
141
  ToolCallSchema,
142
+ type TurnEndedUpdate,
142
143
  UserMessageActionSchema,
143
144
  UserMessageSchema,
144
145
  WebFetchAllowlistPrecheckResultSchema,
@@ -193,6 +194,7 @@ import type {
193
194
  Tool,
194
195
  ToolCall,
195
196
  ToolResultMessage,
197
+ Usage,
196
198
  } from "../types";
197
199
  import { normalizeSystemPrompts, normalizeToolCallId } from "../utils";
198
200
  import {
@@ -241,6 +243,7 @@ import {
241
243
  piReadDisplayPath,
242
244
  piReadPathHasRange,
243
245
  piTimeout,
246
+ shellTimeoutSeconds,
244
247
  } from "./cursor/exec-modern";
245
248
  import { handleInteractionQuery } from "./cursor/interaction-query";
246
249
 
@@ -1199,7 +1202,7 @@ export async function handleServerMessage(
1199
1202
  // permission prompt).
1200
1203
  handleInteractionQuery(msg.message.value, h2Request);
1201
1204
  } else if (msgCase === "conversationCheckpointUpdate") {
1202
- handleConversationCheckpointUpdate(msg.message.value, output, usageState, onConversationCheckpoint);
1205
+ handleConversationCheckpointUpdate(msg.message.value, output, onConversationCheckpoint);
1203
1206
  }
1204
1207
  }
1205
1208
 
@@ -1804,12 +1807,11 @@ async function handleExecServerMessage(
1804
1807
  if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1805
1808
  const normalizedArgs: ShellArgs = { ...args, workingDirectory: args.workingDirectory || process.cwd() };
1806
1809
  // Match the bridge (`CursorExecHandlers.shell`): map `workingDirectory`
1807
- // → `cwd`, drop non-positive timeouts.
1808
- const shellTimeout = args.timeout && args.timeout > 0 ? args.timeout : undefined;
1810
+ // → `cwd`, convert the millisecond budget to bash-tool seconds.
1809
1811
  synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
1810
1812
  command: args.command,
1811
1813
  cwd: args.workingDirectory || undefined,
1812
- timeout: shellTimeout,
1814
+ timeout: shellTimeoutSeconds(args.timeout),
1813
1815
  });
1814
1816
  const { execResult } = await resolveExecHandler(
1815
1817
  args,
@@ -1827,11 +1829,10 @@ async function handleExecServerMessage(
1827
1829
  case "shellStreamArgs": {
1828
1830
  const args = execMsg.message.value;
1829
1831
  if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1830
- const shellStreamTimeout = args.timeout && args.timeout > 0 ? args.timeout : undefined;
1831
1832
  synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
1832
1833
  command: args.command,
1833
1834
  cwd: args.workingDirectory || undefined,
1834
- timeout: shellStreamTimeout,
1835
+ timeout: shellTimeoutSeconds(args.timeout),
1835
1836
  });
1836
1837
  await handleShellStreamArgs(args, execMsg, h2Request, execHandlers, onToolResult);
1837
1838
  return;
@@ -1932,12 +1933,21 @@ async function handleExecServerMessage(
1932
1933
  );
1933
1934
  return;
1934
1935
  }
1935
- if (execHandlers?.mcp) {
1936
+ // Without a local MCP handler an external executor — an auth-gateway
1937
+ // client whose own tools Cursor sees as MCP tools — is the one that
1938
+ // runs the call, so the block must reach that client unresolved:
1939
+ // `isClientToolUse` (anthropic-messages-server) and the OpenAI chat
1940
+ // finish-reason mapper both report a handoff only for a toolCall that
1941
+ // carries no `kCursorExecResolved` marker. Answering the frame while
1942
+ // emitting nothing made the turn look like plain text that ended on
1943
+ // `stop`, so the client never saw the call it was meant to execute.
1944
+ const externalHandoff = externalToolExecutor && !execHandlers?.mcp;
1945
+ if (execHandlers?.mcp || externalHandoff) {
1936
1946
  const existingBlock = output.content.find(
1937
1947
  block => block.type === "toolCall" && block.id === mcpCall.toolCallId,
1938
1948
  );
1939
1949
  if (existingBlock) {
1940
- markCursorExecResolved(existingBlock);
1950
+ if (!externalHandoff) markCursorExecResolved(existingBlock);
1941
1951
  } else {
1942
1952
  synthesizeCursorExecToolCall(
1943
1953
  output,
@@ -1946,8 +1956,9 @@ async function handleExecServerMessage(
1946
1956
  mcpCall.toolCallId,
1947
1957
  mcpCall.toolName || mcpCall.name,
1948
1958
  mcpCall.args,
1959
+ { executed: !externalHandoff },
1949
1960
  );
1950
- state.resolvedMcpToolCallIds.add(mcpCall.toolCallId);
1961
+ if (!externalHandoff) state.resolvedMcpToolCallIds.add(mcpCall.toolCallId);
1951
1962
  }
1952
1963
  }
1953
1964
  const { execResult } = await resolveExecHandler(
@@ -1955,10 +1966,7 @@ async function handleExecServerMessage(
1955
1966
  execHandlers?.mcp?.bind(execHandlers),
1956
1967
  onToolResult,
1957
1968
  toolResult => buildMcpResultFromToolResult(mcpCall, toolResult),
1958
- _reason =>
1959
- externalToolExecutor && !execHandlers?.mcp
1960
- ? buildMcpExternalHandoffResult()
1961
- : buildMcpToolNotFoundResult(mcpCall),
1969
+ _reason => (externalHandoff ? buildMcpExternalHandoffResult() : buildMcpToolNotFoundResult(mcpCall)),
1962
1970
  error => buildMcpErrorResult(error),
1963
1971
  execHandlers?.mcp ? { toolCallId: mcpCall.toolCallId, toolName: mcpCall.toolName } : null,
1964
1972
  );
@@ -2302,7 +2310,7 @@ async function handleExecServerMessage(
2302
2310
  synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
2303
2311
  command: args.command,
2304
2312
  cwd: args.workingDirectory || undefined,
2305
- timeout: args.timeout && args.timeout > 0 ? args.timeout : undefined,
2313
+ timeout: shellTimeoutSeconds(args.timeout),
2306
2314
  });
2307
2315
  const { execResult } = await resolveExecHandler(
2308
2316
  normalizedArgs,
@@ -4135,7 +4143,9 @@ function endCurrentThinkingBlock(
4135
4143
  * `agent-loop.ts` execution pass skips it — Cursor's server-driven exec
4136
4144
  * channel already ran the tool via the bridge and buffered the result, so
4137
4145
  * treating this block as runnable would re-execute the same side-effecting
4138
- * tool a second time.
4146
+ * tool a second time. Pass `{ executed: false }` for a frame nothing ran: a
4147
+ * call handed to an external executor is only a handoff while the marker is
4148
+ * absent, and the consumer that runs it needs a runnable block.
4139
4149
  *
4140
4150
  * Exported for tests to exercise ordering with adjacent text/thinking blocks.
4141
4151
  */
@@ -4146,6 +4156,7 @@ export function synthesizeCursorExecToolCall(
4146
4156
  toolCallId: string,
4147
4157
  toolName: string,
4148
4158
  args: Record<string, unknown>,
4159
+ { executed = true }: { executed?: boolean } = {},
4149
4160
  ): void {
4150
4161
  endCurrentTextBlock(output, stream, state);
4151
4162
  endCurrentThinkingBlock(output, stream, state);
@@ -4159,7 +4170,7 @@ export function synthesizeCursorExecToolCall(
4159
4170
  arguments: omitUndefinedArgs(args),
4160
4171
  [kStreamingBlockIndex]: output.content.length,
4161
4172
  [kStreamingBlockKind]: "cursor-exec",
4162
- [kCursorExecResolved]: true,
4173
+ ...(executed ? { [kCursorExecResolved]: true as const } : {}),
4163
4174
  };
4164
4175
  output.content.push(block);
4165
4176
  const idx = output.content.length - 1;
@@ -4295,8 +4306,12 @@ export function processInteractionUpdate(
4295
4306
  if (mcpCall) {
4296
4307
  const args = mcpCall.args || {};
4297
4308
  const id = args.toolCallId || crypto.randomUUID();
4298
- const resolvedByExec = state.resolvedMcpToolCallIds.delete(id);
4299
- if (resolvedByExec && output.content.some(block => block.type === "toolCall" && block.id === id)) {
4309
+ state.resolvedMcpToolCallIds.delete(id);
4310
+ // The exec channel may have emitted this block first — executed
4311
+ // (marked resolved) or handed to an external executor (deliberately
4312
+ // unmarked). Either way the call is already in the transcript, so a
4313
+ // second block for the same id would duplicate it.
4314
+ if (output.content.some(block => block.type === "toolCall" && block.id === id)) {
4300
4315
  return;
4301
4316
  }
4302
4317
  const block: ToolCallState = {
@@ -4311,9 +4326,6 @@ export function processInteractionUpdate(
4311
4326
  [kStreamingBlockKind]: "mcp",
4312
4327
  [kStreamingEnvelopeId]: update.message.value.callId || undefined,
4313
4328
  };
4314
- if (resolvedByExec) {
4315
- markCursorExecResolved(block);
4316
- }
4317
4329
  output.content.push(block);
4318
4330
  retainStreamedCall(state, block, update.message.value.callId);
4319
4331
  stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
@@ -4549,6 +4561,7 @@ export function processInteractionUpdate(
4549
4561
  }
4550
4562
  } else if (updateCase === "turnEnded") {
4551
4563
  output.stopReason = "stop";
4564
+ applyTurnEndedUsage(output.usage, update.message.value);
4552
4565
  if (
4553
4566
  classifyModel("cursor", output.model).family === "k3" &&
4554
4567
  !output.content.some(item => item.type === "thinking" && item.thinking.length > 0)
@@ -4566,23 +4579,48 @@ export function processInteractionUpdate(
4566
4579
  }
4567
4580
  }
4568
4581
 
4582
+ /**
4583
+ * Adopt the authoritative per-turn counters Cursor sends on `TurnEndedUpdate`.
4584
+ *
4585
+ * `tokenDelta` frames carry a running output estimate only — a captured turn
4586
+ * summed 22 against a final 36 — and never report input, cache, or reasoning
4587
+ * tokens, so every bucket the final frame reports replaces the streamed
4588
+ * estimate. Unreported counters decode as `undefined`; a frame that reports
4589
+ * nothing at all leaves the streamed totals untouched.
4590
+ */
4591
+ function applyTurnEndedUsage(usage: Usage, update: TurnEndedUpdate): void {
4592
+ const input = Number(update.inputTokens ?? 0n);
4593
+ const output = Number(update.outputTokens ?? 0n);
4594
+ const cacheRead = Number(update.cacheReadTokens ?? 0n);
4595
+ const cacheWrite = Number(update.cacheWriteTokens ?? 0n);
4596
+ const reasoning = Number(update.reasoningTokens ?? 0n);
4597
+ if (input <= 0 && output <= 0 && cacheRead <= 0 && cacheWrite <= 0) return;
4598
+ if (input > 0) usage.input = input;
4599
+ if (output > 0) usage.output = output;
4600
+ if (cacheRead > 0) usage.cacheRead = cacheRead;
4601
+ if (cacheWrite > 0) usage.cacheWrite = cacheWrite;
4602
+ if (reasoning > 0) usage.reasoningTokens = reasoning;
4603
+ usage.totalTokens = usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
4604
+ }
4605
+
4606
+ /**
4607
+ * Record the context occupancy Cursor reports on every conversation checkpoint.
4608
+ *
4609
+ * `tokenDetails.usedTokens` counts the whole conversation, not this turn's
4610
+ * output, so it is independent of the `tokenDelta` estimate: compaction and
4611
+ * handoff read `usage.contextTokens` and otherwise see an output-only turn.
4612
+ */
4569
4613
  function handleConversationCheckpointUpdate(
4570
4614
  checkpoint: ConversationStateStructure,
4571
4615
  output: AssistantMessage,
4572
- usageState: UsageState,
4573
4616
  onConversationCheckpoint?: (checkpoint: ConversationStateStructure) => void,
4574
4617
  ): void {
4575
4618
  onConversationCheckpoint?.(checkpoint);
4576
- if (usageState.sawTokenDelta) {
4577
- return;
4578
- }
4579
4619
  const usedTokens = checkpoint.tokenDetails?.usedTokens ?? 0;
4580
4620
  if (usedTokens <= 0) {
4581
4621
  return;
4582
4622
  }
4583
- if (output.usage.contextTokens !== usedTokens) {
4584
- output.usage.contextTokens = usedTokens;
4585
- }
4623
+ output.usage.contextTokens = usedTokens;
4586
4624
  }
4587
4625
 
4588
4626
  function createBlobId(data: Uint8Array): Uint8Array {
@@ -45,6 +45,7 @@ import {
45
45
  hasMeaningfulGoogleContent,
46
46
  isThinkingPart,
47
47
  MAX_EMPTY_STREAM_RETRIES,
48
+ mapGoogleUsage,
48
49
  mapStopReasonString,
49
50
  mapToolChoice,
50
51
  nextToolCallId,
@@ -873,25 +874,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
873
874
  }
874
875
 
875
876
  if (responseData.usageMetadata) {
876
- // promptTokenCount includes cachedContentTokenCount, so subtract to get fresh input
877
- const promptTokens = responseData.usageMetadata.promptTokenCount || 0;
878
- const cacheReadTokens = responseData.usageMetadata.cachedContentTokenCount || 0;
879
- const thinkingTokens = responseData.usageMetadata.thoughtsTokenCount || 0;
880
- output.usage = {
881
- input: promptTokens - cacheReadTokens,
882
- output: (responseData.usageMetadata.candidatesTokenCount || 0) + thinkingTokens,
883
- cacheRead: cacheReadTokens,
884
- cacheWrite: 0,
885
- totalTokens: responseData.usageMetadata.totalTokenCount || 0,
886
- ...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}),
887
- cost: {
888
- input: 0,
889
- output: 0,
890
- cacheRead: 0,
891
- cacheWrite: 0,
892
- total: 0,
893
- },
894
- };
877
+ output.usage = mapGoogleUsage(responseData.usageMetadata);
895
878
  calculateCost(model, output.usage, output.timestamp);
896
879
  }
897
880
  }
@@ -22,6 +22,7 @@ import type {
22
22
  ThinkingContent,
23
23
  Tool,
24
24
  ToolCall,
25
+ Usage,
25
26
  } from "../types";
26
27
  import { shouldSendServiceTier } from "../types";
27
28
  import { normalizeSystemPrompts } from "../utils";
@@ -38,6 +39,7 @@ import type {
38
39
  Part,
39
40
  ThinkingConfig,
40
41
  ThinkingLevel,
42
+ UsageMetadata,
41
43
  } from "./google-types";
42
44
  import { transformMessages } from "./transform-messages";
43
45
  import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
@@ -473,6 +475,33 @@ function resetGoogleStreamOutputForRetry(output: AssistantMessage): void {
473
475
  output.timestamp = Date.now();
474
476
  }
475
477
 
478
+ /**
479
+ * Maps Gemini `usageMetadata` onto {@link Usage} (cost zeroed for `calculateCost`).
480
+ *
481
+ * `promptTokenCount` includes `cachedContentTokenCount`, so input = prompt − cached,
482
+ * matching the OpenAI convention where input + cacheRead = total prompt tokens.
483
+ * Upstream sometimes omits `promptTokenCount` or reports a cache count above the
484
+ * prompt (Antigravity), so the prompt falls back to `total − candidates − thoughts`
485
+ * and cached tokens are clamped to it: input is never negative.
486
+ * Ref: https://ai.google.dev/api/generate-content#v1beta.GenerateContentResponse.UsageMetadata
487
+ */
488
+ export function mapGoogleUsage(metadata: UsageMetadata): Usage {
489
+ const candidates = metadata.candidatesTokenCount || 0;
490
+ const thinking = metadata.thoughtsTokenCount || 0;
491
+ const total = metadata.totalTokenCount || 0;
492
+ const prompt = metadata.promptTokenCount || Math.max(0, total - candidates - thinking);
493
+ const cacheRead = Math.min(metadata.cachedContentTokenCount || 0, prompt);
494
+ return {
495
+ input: prompt - cacheRead,
496
+ output: candidates + thinking,
497
+ cacheRead,
498
+ cacheWrite: 0,
499
+ totalTokens: total,
500
+ ...(thinking > 0 ? { reasoningTokens: thinking } : {}),
501
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
502
+ };
503
+ }
504
+
476
505
  /**
477
506
  * Module-local counter for generating unique tool call IDs across Google providers.
478
507
  * Shared so that a single monotonically-increasing sequence is used regardless of which
@@ -739,28 +768,7 @@ export async function consumeGoogleStream<T extends GoogleApiType>(args: {
739
768
  }
740
769
 
741
770
  if (chunk.usageMetadata) {
742
- // promptTokenCount includes cachedContentTokenCount when cached content is used.
743
- // Subtract to get non-cached input, matching the OpenAI convention where
744
- // input = uncached prompt tokens and cacheRead = cached tokens so that
745
- // input + cacheRead = total prompt tokens (no double-counting).
746
- // Ref: https://ai.google.dev/api/generate-content#v1beta.GenerateContentResponse.UsageMetadata
747
- const cachedTokens = chunk.usageMetadata.cachedContentTokenCount || 0;
748
- const thinkingTokens = chunk.usageMetadata.thoughtsTokenCount || 0;
749
- output.usage = {
750
- input: (chunk.usageMetadata.promptTokenCount || 0) - cachedTokens,
751
- output: (chunk.usageMetadata.candidatesTokenCount || 0) + thinkingTokens,
752
- cacheRead: cachedTokens,
753
- cacheWrite: 0,
754
- totalTokens: chunk.usageMetadata.totalTokenCount || 0,
755
- ...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}),
756
- cost: {
757
- input: 0,
758
- output: 0,
759
- cacheRead: 0,
760
- cacheWrite: 0,
761
- total: 0,
762
- },
763
- };
771
+ output.usage = mapGoogleUsage(chunk.usageMetadata);
764
772
  calculateCost(model, output.usage, output.timestamp);
765
773
  }
766
774
  }