clauderipple 0.9.4 → 0.10.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 (118) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +31 -0
  3. package/dist/app/package.json +1 -1
  4. package/dist/cli/src/hooks/agent-title.js +7 -4
  5. package/dist/cli/src/index.js +43 -0
  6. package/dist/cli/src/remote.js +263 -0
  7. package/dist/cli/src/schtasks.js +48 -40
  8. package/dist/cli/src/settings.js +2 -1
  9. package/dist/mod/hooks/i18n.ts +252 -6
  10. package/dist/mod/hooks/judge.ts +35 -17
  11. package/dist/mod/hooks/modes.ts +56 -1
  12. package/dist/mod/hooks/register.tsx +238 -32
  13. package/dist/mod/hooks/session.ts +98 -1
  14. package/dist/mod/hooks/settings.js +7 -3
  15. package/dist/mod/hooks/settings.ts +9 -4
  16. package/dist/mod/hooks/stats.ts +25 -1
  17. package/dist/mod/hooks/ui/agents.tsx +858 -79
  18. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  19. package/dist/mod/hooks/ui/art.ts +33 -7
  20. package/dist/mod/hooks/ui/band.tsx +262 -81
  21. package/dist/mod/hooks/ui/flat.ts +265 -0
  22. package/dist/mod/hooks/ui/kit.tsx +70 -55
  23. package/dist/mod/hooks/ui/look.ts +129 -0
  24. package/dist/mod/hooks/ui/settings.tsx +71 -46
  25. package/dist/mod/hooks/ui/setup.tsx +15 -13
  26. package/dist/mod/hooks/ui/theme.ts +3 -0
  27. package/dist/mod/hooks/ui/tools.ts +124 -0
  28. package/dist/mod/hooks/ui/view.ts +116 -3
  29. package/dist/mod/hooks/workers.ts +33 -6
  30. package/dist/mod/tests/register.test.tsx +155 -11
  31. package/dist/mod/tests/ui.test.tsx +375 -27
  32. package/dist/mod/types/index.d.ts +68 -2
  33. package/dist/remote/package.json +28 -0
  34. package/dist/remote/src/away.js +85 -0
  35. package/dist/remote/src/bridge-client.js +295 -0
  36. package/dist/remote/src/composer-menu.js +11 -0
  37. package/dist/remote/src/config.js +102 -0
  38. package/dist/remote/src/desktop.js +1018 -0
  39. package/dist/remote/src/e2ee.js +126 -0
  40. package/dist/remote/src/e2ee.ts +126 -0
  41. package/dist/remote/src/eventlog.js +298 -0
  42. package/dist/remote/src/expo-push.js +127 -0
  43. package/dist/remote/src/folders.js +79 -0
  44. package/dist/remote/src/fsutil.js +39 -0
  45. package/dist/remote/src/handoff.js +38 -0
  46. package/dist/remote/src/host-service.js +168 -0
  47. package/dist/remote/src/images.js +215 -0
  48. package/dist/remote/src/local-control.js +138 -0
  49. package/dist/remote/src/main.js +264 -0
  50. package/dist/remote/src/permissions.js +124 -0
  51. package/dist/remote/src/power.js +76 -0
  52. package/dist/remote/src/processes.js +24 -0
  53. package/dist/remote/src/protocol.js +32 -0
  54. package/dist/remote/src/protocol.ts +284 -0
  55. package/dist/remote/src/push-content.js +32 -0
  56. package/dist/remote/src/push.js +134 -0
  57. package/dist/remote/src/receipts.js +111 -0
  58. package/dist/remote/src/relay.js +438 -0
  59. package/dist/remote/src/router.js +208 -0
  60. package/dist/remote/src/sdk-launcher.js +18 -0
  61. package/dist/remote/src/server.js +706 -0
  62. package/dist/remote/src/service.js +157 -0
  63. package/dist/remote/src/sessions.js +1800 -0
  64. package/dist/remote/src/sidebar-storage.js +211 -0
  65. package/dist/remote/src/sidebar.js +173 -0
  66. package/dist/remote/src/tool-details.js +205 -0
  67. package/dist/remote/src/tool-details.ts +153 -0
  68. package/dist/remote/src/worktree.js +99 -0
  69. package/dist/remote/web/app.ts +746 -0
  70. package/dist/remote/web/conn.ts +193 -0
  71. package/dist/remote/web/demo.ts +134 -0
  72. package/dist/remote/web/dom.ts +124 -0
  73. package/dist/remote/web/esc.ts +4 -0
  74. package/dist/remote/web/i18n.ts +374 -0
  75. package/dist/remote/web/icon.svg +1 -0
  76. package/dist/remote/web/index.html +24 -0
  77. package/dist/remote/web/manifest.webmanifest +10 -0
  78. package/dist/remote/web/mascot.ts +60 -0
  79. package/dist/remote/web/md.ts +78 -0
  80. package/dist/remote/web/notify.ts +55 -0
  81. package/dist/remote/web/pushstate.ts +27 -0
  82. package/dist/remote/web/secure.ts +109 -0
  83. package/dist/remote/web/sidebar.ts +106 -0
  84. package/dist/remote/web/style.css +311 -0
  85. package/dist/remote/web/sw.ts +48 -0
  86. package/dist/remote/web/timeline.ts +224 -0
  87. package/dist/remote/web/views.ts +323 -0
  88. package/dist/router/src/admin.js +227 -4
  89. package/dist/router/src/config.js +30 -0
  90. package/dist/router/src/index.js +39 -0
  91. package/dist/router/src/ingress/translate.js +6 -2
  92. package/dist/router/src/phone.js +82 -0
  93. package/dist/router/src/presets.js +3 -0
  94. package/dist/router/src/providers/anthropic-usage.js +33 -1
  95. package/dist/router/src/providers/chatgpt/index.js +41 -11
  96. package/dist/router/src/providers/chatgpt/translate.js +147 -7
  97. package/dist/router/src/providers/google/index.js +26 -0
  98. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  99. package/dist/router/src/providers/google/usage.js +257 -0
  100. package/dist/router/src/providers/grok/index.js +18 -0
  101. package/dist/router/src/providers/grok/usage.js +77 -0
  102. package/dist/router/src/providers/openai/index.js +29 -5
  103. package/dist/router/src/providers/openai/translate.js +260 -27
  104. package/dist/router/src/providers/opencode-usage.js +116 -0
  105. package/dist/router/src/proxy.js +339 -258
  106. package/dist/router/src/reasoning.js +157 -0
  107. package/dist/router/src/remote-host.js +320 -0
  108. package/dist/router/src/remote-tunnel.js +320 -0
  109. package/dist/router/src/requestlog.js +48 -39
  110. package/dist/router/src/thinking.js +20 -9
  111. package/dist/router/src/usage-history.js +227 -0
  112. package/dist/router/src/version.js +1 -1
  113. package/dist/ui/app.js +436 -59
  114. package/dist/ui/i18n.js +184 -0
  115. package/dist/ui/index.html +24 -5
  116. package/dist/ui/style.css +39 -0
  117. package/docs/ARCHITECTURE.md +164 -12
  118. package/package.json +7 -1
@@ -8,6 +8,7 @@ import { SseParser } from "./sse.js";
8
8
  import { fetchWithRetry } from "../retry.js";
9
9
  import { looksLikeAuth } from "../openai/index.js";
10
10
  import { StreamMapper, conversationKey, estimateTokens, formatSse, serverToolNames, toResponsesRequest, toolNameRestoreMap } from "./translate.js";
11
+ import { refusesReplayedReasoning } from "../../reasoning.js";
11
12
  import { providerExhaustion } from "../../health.js";
12
13
  import { credentialHeaderValues, redactErrorText } from "../../redact.js";
13
14
  import { codexClientVersion, latestCodexVersion, parseCodexCatalog } from "./catalog.js";
@@ -963,6 +964,27 @@ export class ChatGptAdapter {
963
964
  res.end();
964
965
  return { status: upstream.status, bytes, ...(usage ? { usage } : {}), note: `codex passthrough account=${owner ? owner.slice(0, 8) : "caller"}` };
965
966
  }
967
+ /**
968
+ * The options one turn is translated with. The conversation is the storage (reasoning.ts), so what
969
+ * to replay is read out of the request itself; `carried` is filled in as the input is built and the
970
+ * outcome lands in the request log. `replay: false` is the retry after the backend refused that
971
+ * carrier, and it puts back exactly the request this adapter sent before any of this existed.
972
+ */
973
+ responsesOptions(carried, model, effort, replay = true) {
974
+ return {
975
+ model,
976
+ effort: effort ?? this.cfg.defaultEffort ?? "high",
977
+ identity: this.cfg.identity ?? true,
978
+ ...(this.cfg.instructionsAppend ? { instructionsAppend: this.cfg.instructionsAppend } : {}),
979
+ replay,
980
+ // The envelope names this provider and this model, and the request builder refuses anything
981
+ // else: reasoning outlives the turn that made it, and a conversation moved to another model
982
+ // must not carry the old one's sealed state with it (reasoning.ts).
983
+ env: { p: this.name, m: model },
984
+ carried,
985
+ log: (line) => this.log.info(`chatgpt ${this.name}: ${line} (${model})`),
986
+ };
987
+ }
966
988
  /** Handle a fully-read Messages request. `model`/`effort` already resolved by routing. */
967
989
  async handle(req, res, path, json, model, effort) {
968
990
  if (path.startsWith("/v1/messages/count_tokens")) {
@@ -975,13 +997,9 @@ export class ChatGptAdapter {
975
997
  const serverTools = serverToolNames(json.tools);
976
998
  if (serverTools.size > 0)
977
999
  this.log.warn(`chatgpt ${this.name}: dropped server tools for ${model}: ${[...serverTools].join(", ")} (Anthropic runs these; this provider cannot)`);
978
- const upstreamReq = toResponsesRequest(json, {
979
- model,
980
- effort: effort ?? this.cfg.defaultEffort ?? "high",
981
- identity: this.cfg.identity ?? true,
982
- ...(this.cfg.instructionsAppend ? { instructionsAppend: this.cfg.instructionsAppend } : {}),
983
- });
984
- const body = JSON.stringify(upstreamReq);
1000
+ const carried = { replayed: 0, undecodable: 0 };
1001
+ let upstreamReq = toResponsesRequest(json, this.responsesOptions(carried, model, effort));
1002
+ let body = JSON.stringify(upstreamReq);
985
1003
  const cacheKey = upstreamReq.prompt_cache_key;
986
1004
  // Input estimate for message_start: a conversation only grows, so the last measured total is a floor.
987
1005
  const startInput = Math.max(estimateTokens(json), this.lastInputByKey.get(cacheKey) ?? 0);
@@ -991,7 +1009,7 @@ export class ChatGptAdapter {
991
1009
  // One account answers the turn. Which one is decided here, and a refusal before any byte has
992
1010
  // reached the client moves the same turn to the next account, so the client is answered on its
993
1011
  // first ask. The conversation stays on the account that answered: moving it costs the cache.
994
- const sent = await this.sendToAnAccount({
1012
+ const ask = () => this.sendToAnAccount({
995
1013
  conversation: cacheKey,
996
1014
  path: "/codex/responses",
997
1015
  body,
@@ -1014,6 +1032,18 @@ export class ChatGptAdapter {
1014
1032
  };
1015
1033
  },
1016
1034
  });
1035
+ let sent = await ask();
1036
+ // A 4xx that names reasoning is the backend refusing the sealed reasoning we replayed — the blob
1037
+ // was minted by the model that is no longer answering, or by another account. The summary is
1038
+ // worth nothing without it, so the turn is asked once without: replaying is an optimisation and
1039
+ // must never be the reason a user's turn fails (reasoning.ts).
1040
+ if (carried.replayed > 0 && sent.kind === "refused" && refusesReplayedReasoning(sent.status, sent.text)) {
1041
+ this.log.warn(`chatgpt ${this.name}: upstream ${sent.status} refused ${carried.replayed} replayed reasoning item(s) for ${model}; asking again without them: ${sent.text.slice(0, 200)}`);
1042
+ upstreamReq = toResponsesRequest(json, this.responsesOptions({ replayed: 0, undecodable: 0 }, model, effort, false));
1043
+ body = JSON.stringify(upstreamReq);
1044
+ carried.replayed = 0;
1045
+ sent = await ask();
1046
+ }
1017
1047
  if (sent.kind !== "ok") {
1018
1048
  res.off("close", onClose);
1019
1049
  if (sent.kind === "aborted")
@@ -1061,7 +1091,7 @@ export class ChatGptAdapter {
1061
1091
  if (this.cfg.debugDump === "all")
1062
1092
  this.dump(upstream.status, json, upstreamReq, "");
1063
1093
  const wantStream = json.stream === true;
1064
- const mapper = new StreamMapper(model, startInput, toolNameRestoreMap(json));
1094
+ const mapper = new StreamMapper(model, startInput, toolNameRestoreMap(json), this.name);
1065
1095
  const parser = new SseParser();
1066
1096
  let bytes = 0;
1067
1097
  let ping = null;
@@ -1179,8 +1209,8 @@ export class ChatGptAdapter {
1179
1209
  exhaustion,
1180
1210
  bytes,
1181
1211
  note: failure
1182
- ? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens})`
1183
- : `in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} stop=${mapper.stopReason}${upstreamReq.service_tier ? " fast" : ""}`,
1212
+ ? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} replayed=${carried.replayed} reasoning=${mapper.reasoningCount})`
1213
+ : `in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} stop=${mapper.stopReason} replayed=${carried.replayed} reasoning=${mapper.reasoningCount}${upstreamReq.service_tier ? " fast" : ""}`,
1184
1214
  usage: {
1185
1215
  input: u.input_tokens,
1186
1216
  cached: u.cache_read_input_tokens,
@@ -3,11 +3,15 @@
3
3
  // Prompt-cache rule: the Responses `input` we build must be a byte-stable prefix of the
4
4
  // next turn's `input`. Claude Code resends the whole history every turn, so the mapping
5
5
  // has to be deterministic and must not inject anything that varies (timestamps, salts,
6
- // re-signed reasoning). Thinking blocks from earlier assistant turns are dropped for the
7
- // same reason. `prompt_cache_key` is derived from the conversation's first user message — or,
6
+ // re-signed reasoning). A reasoning item that came back from the backend is the exception
7
+ // that proves the rule: it is put back exactly as it arrived, so the bytes are the
8
+ // backend's own, and a turn that carried one maps the same way every time. Thinking
9
+ // blocks with no stored item still have nothing to put back and are dropped as before.
10
+ // `prompt_cache_key` is derived from the conversation's first user message — or,
8
11
  // for a request that is not a conversation at all, from its system prompt (see `conversationKey`).
9
12
  import crypto from "node:crypto";
10
13
  import { identityLine } from "../../identity.js";
14
+ import { encodeEnvelope, envelopeFor, isOurSignature, replayableReasoning } from "../../reasoning.js";
11
15
  function blockText(c) {
12
16
  if (typeof c === "string")
13
17
  return c;
@@ -235,6 +239,7 @@ export function toResponsesRequest(req, opts) {
235
239
  // whose call_id has no function_call in the same input ("No tool call found…", measured 2026-09-13).
236
240
  // Such results are sent as plain user text instead.
237
241
  const knownCalls = new Set();
242
+ let lastCallId;
238
243
  for (const m of req.messages) {
239
244
  const role = m.role === "assistant" ? "assistant" : "user";
240
245
  if (typeof m.content === "string") {
@@ -251,6 +256,32 @@ export function toResponsesRequest(req, opts) {
251
256
  pending = [];
252
257
  };
253
258
  for (const b of m.content) {
259
+ // The reasoning this turn came back with, put back exactly where it was: the CLI replays the
260
+ // block in front of the function calls it belongs to, so iterating in order reproduces the
261
+ // original positions, parallel calls included. `lastCallId` is the call the carrier arrived
262
+ // after — the response path addresses the next turn's summary to it (see `onArrival`) — and is
263
+ // undefined for a lead-in item, which is where such a summary belongs.
264
+ const decoded = envelopeFor(b, "responses", opts.env, (reason) => {
265
+ (opts.carried ?? (opts.carried = { replayed: 0, undecodable: 0 })).undecodable++;
266
+ if (opts.replay !== false)
267
+ opts.log?.(`dropped a reasoning carrier: ${reason}`);
268
+ });
269
+ // A carrier for the other wire is not ours to touch and falls through to the drop below.
270
+ if (decoded) {
271
+ if (opts.replay !== false) {
272
+ const item = replayableReasoning(decoded.r);
273
+ if (item) {
274
+ flush();
275
+ input.push(item);
276
+ (opts.carried ?? (opts.carried = { replayed: 0, undecodable: 0 })).replayed++;
277
+ opts.onArrival?.(item, lastCallId);
278
+ }
279
+ else {
280
+ (opts.carried ?? (opts.carried = { replayed: 0, undecodable: 0 })).undecodable++;
281
+ }
282
+ }
283
+ continue;
284
+ }
254
285
  switch (b.type) {
255
286
  case "text": {
256
287
  const t = b.text;
@@ -272,6 +303,7 @@ export function toResponsesRequest(req, opts) {
272
303
  flush();
273
304
  const tu = b;
274
305
  knownCalls.add(tu.id);
306
+ lastCallId = tu.id;
275
307
  input.push({ type: "function_call", call_id: tu.id, name: toolNameForResponses(tu.name), arguments: typeof tu.input === "string" ? tu.input : JSON.stringify(tu.input ?? {}) });
276
308
  break;
277
309
  }
@@ -293,7 +325,8 @@ export function toResponsesRequest(req, opts) {
293
325
  break;
294
326
  }
295
327
  default:
296
- // thinking / redacted_thinking / unknown: dropped on purpose (see header comment)
328
+ // thinking, an unmarked redacted_thinking, anything unknown: no stored item to put back,
329
+ // so dropped as before (see header comment)
297
330
  break;
298
331
  }
299
332
  }
@@ -327,6 +360,11 @@ export function toResponsesRequest(req, opts) {
327
360
  prompt_cache_key: conversationId(req),
328
361
  client_metadata: { session_id: conversationId(req), thread_id: conversationId(req), turn_id: crypto.randomUUID(), "x-codex-window-id": `${conversationId(req)}:0` },
329
362
  };
363
+ // On every turn, as Codex asks. With `store: false` the backend keeps nothing, and without
364
+ // `include` it hands back no sealed item either — so a conversation that waited until it already
365
+ // carried reasoning before asking would never carry any. Only the retry after a refusal leaves it off.
366
+ if (opts.replay !== false)
367
+ out.include = ["reasoning.encrypted_content"];
330
368
  if (tools.length > 0) {
331
369
  out.tools = tools;
332
370
  out.parallel_tool_calls = !(tc?.disable_parallel_tool_use ?? false);
@@ -348,6 +386,7 @@ export function estimateTokens(req) {
348
386
  export class StreamMapper {
349
387
  started = false;
350
388
  blockIndex = -1;
389
+ /** `signed` marks a thinking block that already carries its signature — the envelope we wrote. */
351
390
  open = null;
352
391
  sawToolCall = false;
353
392
  finished = false;
@@ -360,14 +399,30 @@ export class StreamMapper {
360
399
  /** Accumulated content for non-streaming responses. */
361
400
  content = [];
362
401
  stopReason = "end_turn";
402
+ /**
403
+ * How many reasoning items this response carried whole — the ones the client will replay. The
404
+ * `reasoning=` figure the request log shows next to `replayed=`.
405
+ */
406
+ reasoningCount = 0;
407
+ /** The reasoning item being assembled: its identity so far and the call it followed. */
408
+ pendingReasoning = null;
409
+ /** The most recent function_call item added, which is what a following reasoning item describes. */
410
+ lastCallId;
363
411
  /** Input-token figure announced in message_start (the real one only arrives with response.completed). */
364
412
  startInput;
365
413
  /** Mangled tool name → the name Claude Code knows, from `toolNameRestoreMap`. */
366
414
  toolNames;
367
- constructor(model, startInput = 0, toolNames = new Map()) {
415
+ /**
416
+ * The provider name that goes in each reasoning envelope (see reasoning.ts). A blob sealed here is
417
+ * only sent back here: reasoning outlives the request that made it, and a conversation moved to
418
+ * another provider must not carry this one's sealed state with it.
419
+ */
420
+ provider;
421
+ constructor(model, startInput = 0, toolNames = new Map(), provider = "") {
368
422
  this.model = model;
369
423
  this.startInput = startInput;
370
424
  this.toolNames = toolNames;
425
+ this.provider = provider;
371
426
  this.messageId = `msg_${crypto.randomBytes(12).toString("hex")}`;
372
427
  }
373
428
  get isFinished() {
@@ -393,11 +448,17 @@ export class StreamMapper {
393
448
  },
394
449
  ];
395
450
  }
451
+ /**
452
+ * A thinking block must carry a signature even when there is none to give — an unsigned one is
453
+ * refused the moment the session moves to a Claude model (`thinking.ts`) — so a block that was
454
+ * opened without one gets an empty `signature_delta` as it closes. A block that already carries
455
+ * the envelope is left alone: a second, empty delta would replace it on the client.
456
+ */
396
457
  closeBlock() {
397
458
  if (!this.open)
398
459
  return [];
399
460
  const evs = [];
400
- if (this.open.kind === "thinking")
461
+ if (this.open.kind === "thinking" && !this.open.signed)
401
462
  evs.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "signature_delta", signature: "" } } });
402
463
  evs.push({ event: "content_block_stop", data: { type: "content_block_stop", index: this.blockIndex } });
403
464
  this.open = null;
@@ -410,6 +471,56 @@ export class StreamMapper {
410
471
  evs.push({ event: "content_block_start", data: { type: "content_block_start", index: this.blockIndex, content_block: block } });
411
472
  return evs;
412
473
  }
474
+ /**
475
+ * Finish the reasoning item being assembled and hand the client the block that carries it: a
476
+ * thinking block whose text is the summary (empty when the backend sent none — measured
477
+ * 2026-10-14: Claude Code keeps and replays such a block, signature and all) and whose signature
478
+ * is the envelope around the sealed reasoning.
479
+ *
480
+ * An item with nothing sealed is only worth its summary, and then only if it has one: there is
481
+ * nothing to replay, and an empty thinking block is refused by Anthropic the moment the session
482
+ * switches to a Claude model (`thinking.ts`).
483
+ */
484
+ sealReasoning() {
485
+ const pending = this.pendingReasoning;
486
+ if (!pending)
487
+ return [];
488
+ this.pendingReasoning = null;
489
+ const text = pending.text.trim() || (typeof pending.summary === "string" ? pending.summary.trim() : "");
490
+ const events = [];
491
+ if (!pending.encrypted) {
492
+ if (text) {
493
+ events.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }));
494
+ events.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "thinking_delta", thinking: text } } });
495
+ events.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "signature_delta", signature: "" } } });
496
+ events.push(...this.closeBlock());
497
+ this.content.push({ type: "thinking", thinking: text, signature: "" });
498
+ }
499
+ return events;
500
+ }
501
+ const item = { type: "reasoning", encrypted_content: pending.encrypted };
502
+ if (pending.id)
503
+ item.id = pending.id;
504
+ if (Array.isArray(pending.summary))
505
+ item.summary = pending.summary;
506
+ const signature = encodeEnvelope(this.envelopeFor(item));
507
+ events.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }));
508
+ if (text)
509
+ events.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "thinking_delta", thinking: text } } });
510
+ // The signature must arrive on this very block: it is the carrier, and the client keeps the block
511
+ // whole or not at all. It goes last, after the text, which is the order Anthropic's own blocks use.
512
+ events.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "signature_delta", signature } } });
513
+ if (this.open)
514
+ this.open.signed = true;
515
+ events.push(...this.closeBlock());
516
+ this.content.push({ type: "thinking", thinking: text, signature });
517
+ this.reasoningCount++;
518
+ return events;
519
+ }
520
+ /** Where this item's envelope says it came from — this adapter's own provider and model. */
521
+ envelopeFor(item) {
522
+ return { w: "responses", p: this.provider, m: this.model, r: item };
523
+ }
413
524
  /** Map one upstream event. Returns Anthropic events to emit (possibly none). */
414
525
  feed(ev) {
415
526
  const type = ev.type;
@@ -426,12 +537,23 @@ export class StreamMapper {
426
537
  // The model echoes the mangled name; Claude Code only recognises the original.
427
538
  const called = item.name ?? "tool";
428
539
  const name = this.toolNames.get(called) ?? called;
540
+ this.lastCallId = id;
429
541
  this.content.push({ type: "tool_use", id, name, input: {}, _args: "" });
430
542
  out.push(...this.openBlock("tool", { type: "tool_use", id, name, input: {} }, item.id));
431
543
  }
432
544
  else if (item.type === "reasoning") {
433
- this.content.push({ type: "thinking", thinking: "", signature: "" });
434
- out.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }, item.id));
545
+ // The item's reasoning is held until it is complete and handed over whole (see
546
+ // `sealReasoning`): one thinking block whose signature carries the envelope. The summary,
547
+ // when the backend sends one, is that block's text — and the summary deltas below fill it
548
+ // in. Passing the summary through `openBlock` here as well would split one item across two
549
+ // blocks, so this opens nothing.
550
+ this.pendingReasoning = {
551
+ ...(item.id ? { id: item.id } : {}),
552
+ ...(typeof item.encrypted_content === "string" && item.encrypted_content ? { encrypted: item.encrypted_content } : {}),
553
+ ...(item.summary !== undefined ? { summary: item.summary } : {}),
554
+ text: "",
555
+ callId: this.lastCallId,
556
+ };
435
557
  }
436
558
  else if (item.type === "message") {
437
559
  this.content.push({ type: "text", text: "" });
@@ -454,6 +576,13 @@ export class StreamMapper {
454
576
  case "response.reasoning_summary_text.delta":
455
577
  case "response.reasoning_text.delta": {
456
578
  const delta = String(ev.delta ?? "");
579
+ // The summary is the text of the reasoning item's block, and that block also carries the
580
+ // envelope — both are written when the item is done, so the block is not opened here. One
581
+ // item is one block; streaming the text into a block of its own would hand the client two.
582
+ if (this.pendingReasoning) {
583
+ this.pendingReasoning.text += delta;
584
+ break;
585
+ }
457
586
  if (!this.open || this.open.kind !== "thinking") {
458
587
  this.content.push({ type: "thinking", thinking: "", signature: "" });
459
588
  out.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }));
@@ -488,7 +617,18 @@ export class StreamMapper {
488
617
  break;
489
618
  }
490
619
  case "response.output_item.done": {
620
+ const done = ev.item;
621
+ // The backend may put `encrypted_content` only on the completed item, so the seal is taken
622
+ // here as well as from `response.output_item.added`, and the item is only handed over now
623
+ // anyway — a half-assembled reasoning block must not reach the client.
624
+ if (done?.type === "reasoning" && this.pendingReasoning) {
625
+ if (typeof done.encrypted_content === "string" && done.encrypted_content)
626
+ this.pendingReasoning.encrypted = done.encrypted_content;
627
+ if (done.summary !== undefined)
628
+ this.pendingReasoning.summary = done.summary;
629
+ }
491
630
  out.push(...this.closeBlock());
631
+ out.push(...this.sealReasoning());
492
632
  break;
493
633
  }
494
634
  case "response.completed":
@@ -25,6 +25,8 @@ import { ThoughtSignatureStore } from "./thoughtSignatures.js";
25
25
  import { conversationKey, serverToolNames, toolNameRestoreMap } from "../chatgpt/translate.js";
26
26
  import { GoogleAccountPool } from "./accounts.js";
27
27
  import { fetchAvailableModels, staticAntigravityModels } from "./antigravity.js";
28
+ import { GoogleUsage } from "./usage.js";
29
+ import { ANTIGRAVITY_PROMPT_REFUSAL_MESSAGE, isAntigravityPromptRefusal } from "./prompt-refusal.js";
28
30
  const PING_MS = 15_000;
29
31
  /** How long the CCA model list is reused before asking again. */
30
32
  const MODELS_CACHE_MS = 60 * 60 * 1000;
@@ -113,6 +115,8 @@ export class GoogleAdapter {
113
115
  cfg;
114
116
  log;
115
117
  accounts;
118
+ /** Per-account quota, for the status bar's limits row (`GET /api/limits`). */
119
+ usage;
116
120
  /** Cooldowns and conversation stickiness, shared with the proxy's other credential pools. */
117
121
  pool;
118
122
  lastInputByKey = new Map();
@@ -130,6 +134,7 @@ export class GoogleAdapter {
130
134
  this.log = log;
131
135
  this.pool = pool;
132
136
  this.accounts = new GoogleAccountPool({ home, log, ...(fetchImpl ? { fetch: fetchImpl } : {}) });
137
+ this.usage = new GoogleUsage(this.accounts, { warn: (message) => log.warn(message), ...(fetchImpl ? { fetch: fetchImpl } : {}) });
133
138
  }
134
139
  describeAuth() {
135
140
  const all = this.accounts.summaries();
@@ -161,6 +166,13 @@ export class GoogleAdapter {
161
166
  };
162
167
  });
163
168
  }
169
+ /**
170
+ * Each account's quota groups by owner id, for the status bar's limits row. Never a token; a
171
+ * failed lookup keeps that account's last good groups.
172
+ */
173
+ usageSnapshot() {
174
+ return this.usage.snapshot();
175
+ }
164
176
  /**
165
177
  * Ask Google again whether an account still needs its verification page (dashboard action). The
166
178
  * mark otherwise clears only when the account next answers a turn, so a person who had just
@@ -338,6 +350,8 @@ export class GoogleAdapter {
338
350
  const sent = await this.sendToAnAccount({
339
351
  conversation: key,
340
352
  signal: controller.signal,
353
+ system: upstreamRequest.systemInstruction,
354
+ model,
341
355
  build: (credential) => {
342
356
  const built = transport.build(upstreamRequest, {
343
357
  model,
@@ -352,6 +366,11 @@ export class GoogleAdapter {
352
366
  res.off("close", onClose);
353
367
  if (sent.kind === "aborted")
354
368
  return { status: 0, bytes: 0, note: "client closed" };
369
+ if (sent.kind === "prompt-refused") {
370
+ const err = anthropicError(400, "invalid_request_error", ANTIGRAVITY_PROMPT_REFUSAL_MESSAGE);
371
+ return { kind: "prompt-incompatible", status: 400, bytes: 0, errorBody: err.body,
372
+ note: "upstream 429: suspected Antigravity SDK prompt refusal with remaining quota", refusal: "prompt-incompatible" };
373
+ }
355
374
  if (sent.kind === "unreachable") {
356
375
  const err = anthropicError(502, "api_error", `Google provider unreachable: ${sent.error.message}`);
357
376
  if (!res.headersSent)
@@ -557,6 +576,13 @@ export class GoogleAdapter {
557
576
  const safeText = redactErrorText(text, secrets);
558
577
  const status = upstream.status;
559
578
  this.log.warn(`google ${this.name}: account ${credential.ownerId.slice(0, 8)} answered ${status}: ${safeText.slice(0, 400)}`);
579
+ // The generic 429 is also real exhaustion. Only fresh quota evidence for this
580
+ // account/model permits a request-specific refusal; otherwise keep ordinary
581
+ // cooldown and account rotation. The proxy can still try a route fallback.
582
+ if (isAntigravityPromptRefusal(spec.system, status, text, upstream.headers.get("retry-after")) &&
583
+ this.usage.hasRemainingQuota(credential, spec.model)) {
584
+ return { kind: "prompt-refused" };
585
+ }
560
586
  // Not a rest and not a refresh: the account works again the moment its person finishes
561
587
  // Google's page, so it stays in rotation and the next turn finds out.
562
588
  const verifyUrl = status === 403 ? googleValidationUrl(text) : undefined;
@@ -0,0 +1,29 @@
1
+ const SDK_PARAGRAPH = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
2
+ const GENERIC_EXHAUSTED = "Resource has been exhausted (e.g. check quota).";
3
+ export const ANTIGRAVITY_PROMPT_REFUSAL_MESSAGE = `Google Antigravity rejected this Claude Agent SDK system prompt (upstream 429: ${GENERIC_EXHAUSTED}). ` +
4
+ "This request appears incompatible with Antigravity; retrying or waiting on this account is unlikely to fix it. " +
5
+ "Choose another provider for this Claude Code request.";
6
+ /**
7
+ * Measured 2026-10-16: the same Gemini 3.8 Flash / Claude Opus 5.5 request got 429
8
+ * with this standalone SDK paragraph and 200 without it. This is response-based
9
+ * classification, never a prompt rewrite or a prediction before Google answers.
10
+ * Keep any explicit quota/reset details on the ordinary rate-limit path. This shape
11
+ * alone also occurs on real exhaustion; callers must require fresh quota evidence.
12
+ */
13
+ export function isAntigravityPromptRefusal(system, status, text, retryAfter) {
14
+ if (status !== 429 || retryAfter !== null)
15
+ return false;
16
+ if (!system?.parts.some(part => part.text?.split(/\r?\n\s*\r?\n/).some(paragraph => paragraph.trim() === SDK_PARAGRAPH)))
17
+ return false;
18
+ try {
19
+ const error = JSON.parse(text)?.error;
20
+ if (!error || typeof error !== "object" || Array.isArray(error))
21
+ return false;
22
+ const e = error;
23
+ return e.code === 429 && e.status === "RESOURCE_EXHAUSTED" && e.message === GENERIC_EXHAUSTED &&
24
+ (e.details === undefined || (Array.isArray(e.details) && e.details.length === 0));
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }