clauderipple 0.9.5 → 0.11.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 (163) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.es.md +45 -0
  3. package/README.ja.md +17 -0
  4. package/README.ko.md +31 -0
  5. package/README.md +70 -0
  6. package/README.pt-BR.md +45 -0
  7. package/README.zh-CN.md +17 -0
  8. package/dist/app/assets/icon.icns +0 -0
  9. package/dist/app/assets/icon.ico +0 -0
  10. package/dist/app/assets/icon.png +0 -0
  11. package/dist/app/assets/trayDirectTemplate.png +0 -0
  12. package/dist/app/assets/trayDirectTemplate@2x.png +0 -0
  13. package/dist/app/assets/trayDownTemplate.png +0 -0
  14. package/dist/app/assets/trayDownTemplate@2x.png +0 -0
  15. package/dist/app/assets/trayTemplate.png +0 -0
  16. package/dist/app/assets/trayTemplate@2x.png +0 -0
  17. package/dist/app/assets/trayWarnTemplate.png +0 -0
  18. package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
  19. package/dist/app/assets/trayWin.png +0 -0
  20. package/dist/app/assets/trayWin@2x.png +0 -0
  21. package/dist/app/assets/trayWinDirect.png +0 -0
  22. package/dist/app/assets/trayWinDirect@2x.png +0 -0
  23. package/dist/app/assets/trayWinDown.png +0 -0
  24. package/dist/app/assets/trayWinDown@2x.png +0 -0
  25. package/dist/app/assets/trayWinWarn.png +0 -0
  26. package/dist/app/assets/trayWinWarn@2x.png +0 -0
  27. package/dist/app/dist/claude-desktop.js +67 -0
  28. package/dist/app/dist/main.js +423 -110
  29. package/dist/app/dist/preload.js +21 -0
  30. package/dist/app/dist/protocol.js +121 -0
  31. package/dist/app/dist/status-model.js +87 -0
  32. package/dist/app/dist/window.js +133 -0
  33. package/dist/app/package.json +1 -1
  34. package/dist/cli/src/awake-setup.js +314 -0
  35. package/dist/cli/src/bundle.js +9 -5
  36. package/dist/cli/src/direct.js +159 -0
  37. package/dist/cli/src/doctor.js +205 -0
  38. package/dist/cli/src/hooks/agent-title.js +7 -4
  39. package/dist/cli/src/index.js +227 -31
  40. package/dist/cli/src/launchd.js +29 -20
  41. package/dist/cli/src/launcher.js +215 -0
  42. package/dist/cli/src/power.js +182 -0
  43. package/dist/cli/src/registration.js +157 -0
  44. package/dist/cli/src/remote.js +263 -0
  45. package/dist/cli/src/schtasks.js +50 -40
  46. package/dist/cli/src/settings.js +26 -1
  47. package/dist/cli/src/tray.js +5 -2
  48. package/dist/mod/hooks/i18n.ts +473 -227
  49. package/dist/mod/hooks/judge.ts +35 -17
  50. package/dist/mod/hooks/modes.ts +56 -1
  51. package/dist/mod/hooks/register.tsx +237 -32
  52. package/dist/mod/hooks/session.ts +102 -1
  53. package/dist/mod/hooks/settings.js +7 -3
  54. package/dist/mod/hooks/settings.ts +9 -4
  55. package/dist/mod/hooks/stats.ts +25 -1
  56. package/dist/mod/hooks/ui/agents.tsx +903 -79
  57. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  58. package/dist/mod/hooks/ui/art.ts +33 -7
  59. package/dist/mod/hooks/ui/band.tsx +262 -81
  60. package/dist/mod/hooks/ui/flat.ts +265 -0
  61. package/dist/mod/hooks/ui/kit.tsx +70 -55
  62. package/dist/mod/hooks/ui/look.ts +129 -0
  63. package/dist/mod/hooks/ui/settings.tsx +71 -46
  64. package/dist/mod/hooks/ui/setup.tsx +15 -13
  65. package/dist/mod/hooks/ui/theme.ts +3 -0
  66. package/dist/mod/hooks/ui/tools.ts +124 -0
  67. package/dist/mod/hooks/ui/view.ts +120 -4
  68. package/dist/mod/hooks/workers.ts +33 -6
  69. package/dist/mod/tests/register.test.tsx +155 -11
  70. package/dist/mod/tests/ui.test.tsx +408 -27
  71. package/dist/mod/types/index.d.ts +66 -2
  72. package/dist/remote/package.json +28 -0
  73. package/dist/remote/src/away.js +85 -0
  74. package/dist/remote/src/bridge-client.js +336 -0
  75. package/dist/remote/src/composer-menu.js +11 -0
  76. package/dist/remote/src/config.js +102 -0
  77. package/dist/remote/src/desktop.js +1018 -0
  78. package/dist/remote/src/e2ee.js +126 -0
  79. package/dist/remote/src/e2ee.ts +126 -0
  80. package/dist/remote/src/eventlog.js +298 -0
  81. package/dist/remote/src/expo-push.js +127 -0
  82. package/dist/remote/src/folders.js +79 -0
  83. package/dist/remote/src/fsutil.js +39 -0
  84. package/dist/remote/src/handoff.js +38 -0
  85. package/dist/remote/src/host-service.js +168 -0
  86. package/dist/remote/src/images.js +215 -0
  87. package/dist/remote/src/local-control.js +138 -0
  88. package/dist/remote/src/main.js +264 -0
  89. package/dist/remote/src/permissions.js +124 -0
  90. package/dist/remote/src/power.js +76 -0
  91. package/dist/remote/src/processes.js +24 -0
  92. package/dist/remote/src/protocol.js +32 -0
  93. package/dist/remote/src/protocol.ts +284 -0
  94. package/dist/remote/src/push-content.js +32 -0
  95. package/dist/remote/src/push.js +134 -0
  96. package/dist/remote/src/receipts.js +111 -0
  97. package/dist/remote/src/relay.js +438 -0
  98. package/dist/remote/src/router.js +208 -0
  99. package/dist/remote/src/sdk-launcher.js +18 -0
  100. package/dist/remote/src/server.js +706 -0
  101. package/dist/remote/src/service.js +157 -0
  102. package/dist/remote/src/sessions.js +1800 -0
  103. package/dist/remote/src/sidebar-storage.js +211 -0
  104. package/dist/remote/src/sidebar.js +173 -0
  105. package/dist/remote/src/tool-details.js +205 -0
  106. package/dist/remote/src/tool-details.ts +153 -0
  107. package/dist/remote/src/worktree.js +99 -0
  108. package/dist/remote/web/app.ts +746 -0
  109. package/dist/remote/web/conn.ts +193 -0
  110. package/dist/remote/web/demo.ts +134 -0
  111. package/dist/remote/web/dom.ts +124 -0
  112. package/dist/remote/web/esc.ts +4 -0
  113. package/dist/remote/web/i18n.ts +374 -0
  114. package/dist/remote/web/icon.svg +1 -0
  115. package/dist/remote/web/index.html +24 -0
  116. package/dist/remote/web/manifest.webmanifest +10 -0
  117. package/dist/remote/web/mascot.ts +60 -0
  118. package/dist/remote/web/md.ts +78 -0
  119. package/dist/remote/web/notify.ts +55 -0
  120. package/dist/remote/web/pushstate.ts +27 -0
  121. package/dist/remote/web/secure.ts +109 -0
  122. package/dist/remote/web/sidebar.ts +106 -0
  123. package/dist/remote/web/style.css +311 -0
  124. package/dist/remote/web/sw.ts +48 -0
  125. package/dist/remote/web/timeline.ts +224 -0
  126. package/dist/remote/web/views.ts +323 -0
  127. package/dist/router/src/admin.js +331 -5
  128. package/dist/router/src/awake-win.js +590 -0
  129. package/dist/router/src/awake.js +432 -0
  130. package/dist/router/src/config.js +94 -0
  131. package/dist/router/src/health.js +86 -3
  132. package/dist/router/src/hold.js +107 -0
  133. package/dist/router/src/index.js +108 -6
  134. package/dist/router/src/ingress/server.js +26 -4
  135. package/dist/router/src/ingress/translate.js +6 -2
  136. package/dist/router/src/phone.js +82 -0
  137. package/dist/router/src/power.js +107 -0
  138. package/dist/router/src/providers/anthropic-usage.js +33 -1
  139. package/dist/router/src/providers/google/antigravity.js +73 -10
  140. package/dist/router/src/providers/google/index.js +191 -14
  141. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  142. package/dist/router/src/providers/google/thoughtSignatures.js +13 -0
  143. package/dist/router/src/providers/google/transport.js +1 -1
  144. package/dist/router/src/providers/google/usage.js +302 -0
  145. package/dist/router/src/providers/grok/index.js +18 -0
  146. package/dist/router/src/providers/grok/usage.js +77 -0
  147. package/dist/router/src/providers/openai/translate.js +13 -2
  148. package/dist/router/src/providers/opencode-usage.js +116 -0
  149. package/dist/router/src/providers/retry.js +4 -1
  150. package/dist/router/src/proxy.js +371 -261
  151. package/dist/router/src/remote-host.js +320 -0
  152. package/dist/router/src/remote-tunnel.js +320 -0
  153. package/dist/router/src/requestlog.js +48 -39
  154. package/dist/router/src/tether-win.js +211 -0
  155. package/dist/router/src/tether.js +712 -0
  156. package/dist/router/src/usage-history.js +227 -0
  157. package/dist/router/src/version.js +1 -1
  158. package/dist/ui/app.js +1083 -59
  159. package/dist/ui/i18n.js +670 -69
  160. package/dist/ui/index.html +33 -6
  161. package/dist/ui/style.css +177 -0
  162. package/docs/ARCHITECTURE.md +402 -14
  163. package/package.json +8 -2
@@ -31,6 +31,8 @@ export const GOOGLE_OAUTH = {
31
31
  prodApi: "https://cloudcode-pa.googleapis.com",
32
32
  /** Streaming and `onboardUser` use the daily host. Source: opencodex `src/oauth/google-antigravity.ts:24-25`. */
33
33
  dailyApi: "https://daily-cloudcode-pa.googleapis.com",
34
+ /** The two Cloud Code Assist hosts. They offer different models and count quota separately. */
35
+ hosts: ["https://daily-cloudcode-pa.googleapis.com", "https://cloudcode-pa.googleapis.com"],
34
36
  apiVersion: "v1internal",
35
37
  /** Preferred loopback port; the callback path is fixed. Source: opencodex `src/oauth/google-antigravity.ts:34-35`. */
36
38
  port: 51121,
@@ -91,8 +93,12 @@ export function resolveAntigravityThinkingLevel(effort) {
91
93
  * `ANTIGRAVITY_EFFORT_WIRE_MAP` / `ANTIGRAVITY_SUFFIX_TIER_MODELS`.
92
94
  */
93
95
  const SUFFIX_TIER_MODELS = {
96
+ // High rides on the medium tier: `-high` thinks with no budget (the catalogue's thinkingBudget -1,
97
+ // against 4000 for medium), and on 2026-10-17 the requests past 4000 output tokens took 64% of all
98
+ // request time. The owner chose speed over the deeper tier (2026-10-20). `-high` named outright
99
+ // still goes through as it is.
94
100
  "gemini-3.8-flash": {
95
- wire: { low: "gemini-3.8-flash-low", medium: "gemini-3.8-flash-medium", high: "gemini-3.8-flash-high" },
101
+ wire: { low: "gemini-3.8-flash-low", medium: "gemini-3.8-flash-medium", high: "gemini-3.8-flash-medium" },
96
102
  default: "medium",
97
103
  },
98
104
  };
@@ -221,14 +227,15 @@ function collapseWireId(wireId) {
221
227
  return wireId;
222
228
  }
223
229
  /**
224
- * Parse `POST {dailyApi}/v1internal:fetchAvailableModels`. The body groups agent-callable models
225
- * under `agentModelSorts[].groups[].modelIds`, with tiered Flash ids under `tieredModelIds.flash`, and
226
- * describes each in `models[wireId]` (`maxTokens`). Source: opencodex
230
+ * Parse one host's `POST {host}/v1internal:fetchAvailableModels`. The body groups agent-callable
231
+ * models under `agentModelSorts[].groups[].modelIds`, with tiered Flash ids under
232
+ * `tieredModelIds.flash`, and describes each in `models[wireId]` (`maxTokens`). Source: opencodex
227
233
  * `parseAntigravityAvailableModels` (src/providers/antigravity-models.ts:489-560).
228
234
  *
229
235
  * Returns null when the shape is unreadable, so the caller falls back instead of showing nothing.
236
+ * The wire ids are kept so per-model host routing can match an exact wire id as well as a base id.
230
237
  */
231
- export function parseAvailableModels(payload, limit = 200) {
238
+ export function parseHostCatalogue(payload, limit = 200) {
232
239
  if (!payload || typeof payload !== "object" || Array.isArray(payload))
233
240
  return null;
234
241
  const body = payload;
@@ -240,10 +247,12 @@ export function parseAvailableModels(payload, limit = 200) {
240
247
  // Collapse each wire id to its base id first, then take the context window and display name from
241
248
  // the first wire id that carried that base. A collapsed id is what routing understands.
242
249
  const collapsed = new Map();
250
+ const wireIds = [];
243
251
  const add = (wireId) => {
244
252
  if (typeof wireId !== "string" || !available.has(wireId))
245
253
  return;
246
254
  const id = collapseWireId(wireId);
255
+ wireIds.push(wireId);
247
256
  if (!collapsed.has(id))
248
257
  collapsed.set(id, wireId);
249
258
  };
@@ -278,7 +287,25 @@ export function parseAvailableModels(payload, limit = 200) {
278
287
  if (out.length >= limit)
279
288
  break;
280
289
  }
281
- return out.length > 0 ? out : null;
290
+ return out.length > 0 ? { models: out, wireIds } : null;
291
+ }
292
+ /** The picker rows of one host's body, or null when the shape is unreadable. */
293
+ export function parseAvailableModels(payload, limit = 200) {
294
+ return parseHostCatalogue(payload, limit)?.models ?? null;
295
+ }
296
+ /** Build the per-model host map from each host's catalogue; a host with no catalogue contributes nothing. */
297
+ export function buildHostMap(cataloguesByHost) {
298
+ const map = new Map();
299
+ for (const [host, catalogue] of cataloguesByHost) {
300
+ const names = new Set([...catalogue.models.map((m) => m.id), ...catalogue.wireIds]);
301
+ for (const name of names) {
302
+ const hosts = map.get(name) ?? [];
303
+ if (!hosts.includes(host))
304
+ hosts.push(host);
305
+ map.set(name, hosts);
306
+ }
307
+ }
308
+ return map;
282
309
  }
283
310
  function extractProjectId(data) {
284
311
  if (!data)
@@ -373,10 +400,10 @@ export async function fetchUserEmail(accessToken, fetchImpl = fetch, signal) {
373
400
  return undefined;
374
401
  }
375
402
  }
376
- /** The models a CCA account can reach now, or null when the endpoint cannot be read. */
377
- export async function fetchAvailableModels(accessToken, projectId, fetchImpl = fetch, signal) {
403
+ /** One host's catalogue, or null when the endpoint cannot be read. Never throws. */
404
+ export async function fetchHostCatalogue(accessToken, projectId, host, fetchImpl = fetch, signal) {
378
405
  try {
379
- const response = await fetchImpl(`${GOOGLE_OAUTH.dailyApi}/${GOOGLE_OAUTH.apiVersion}:fetchAvailableModels`, {
406
+ const response = await fetchImpl(`${host}/${GOOGLE_OAUTH.apiVersion}:fetchAvailableModels`, {
380
407
  method: "POST",
381
408
  headers: { authorization: `Bearer ${accessToken}`, accept: "*/*", "content-type": "application/json", "user-agent": antigravityUserAgent() },
382
409
  body: JSON.stringify({ project: projectId }),
@@ -384,9 +411,45 @@ export async function fetchAvailableModels(accessToken, projectId, fetchImpl = f
384
411
  });
385
412
  if (!response.ok)
386
413
  return null;
387
- return parseAvailableModels(await response.json().catch(() => undefined));
414
+ return parseHostCatalogue(await response.json().catch(() => undefined));
388
415
  }
389
416
  catch {
390
417
  return null;
391
418
  }
392
419
  }
420
+ /**
421
+ * The models a CCA account can reach, asked on every host that offers them (measured 2026-10-20:
422
+ * the prod host lists Gemini Pro models the daily host does not, and vice versa for some Flash
423
+ * tiers). Models are merged with the preferred host's row winning, and the returned host map is
424
+ * what routing uses to send a model to a host that actually offers it. Null when no host answered.
425
+ */
426
+ export async function fetchAvailableModels(accessToken, projectId, opts = {}) {
427
+ const hosts = opts.hosts ?? GOOGLE_OAUTH.hosts;
428
+ const results = await Promise.all(hosts.map(async (host) => [host, await fetchHostCatalogue(accessToken, projectId, host, opts.fetchImpl ?? fetch, opts.signal)]));
429
+ const byHost = new Map();
430
+ for (const [host, catalogue] of results)
431
+ if (catalogue)
432
+ byHost.set(host, catalogue);
433
+ if (byHost.size === 0)
434
+ return null;
435
+ // Host order is preference order (daily first), so the first host to list a model keeps its row
436
+ // and a failing host simply contributes nothing to the merge.
437
+ const models = [];
438
+ const seen = new Set();
439
+ for (const catalogue of byHost.values()) {
440
+ for (const model of catalogue.models) {
441
+ if (seen.has(model.id))
442
+ continue;
443
+ seen.add(model.id);
444
+ models.push(model);
445
+ }
446
+ }
447
+ return { models, hosts: buildHostMap(byHost), byHost };
448
+ }
449
+ /**
450
+ * The host a model's request should go to: the first host in `hosts` that offers the resolved wire
451
+ * id or the base id, else `fallback`. An unknown model is not hidden — it goes to the default only.
452
+ */
453
+ export function hostForModel(hosts, model, wire, fallback) {
454
+ return hosts.get(wire)?.[0] ?? hosts.get(model)?.[0] ?? fallback;
455
+ }
@@ -14,20 +14,50 @@
14
14
  // vendor sent. The antigravity path picks the account the conversation is sticky to and moves to the
15
15
  // next one only on a refusal that has not reached the client (§4 Credential pools).
16
16
  import http from "node:http";
17
+ import { setTimeout as sleep } from "node:timers/promises";
17
18
  import { providerExhaustion } from "../../health.js";
18
19
  import { CredentialPool, retryAfterMs } from "../../pool.js";
19
20
  import { credentialHeaderValues, redactErrorText } from "../../redact.js";
20
21
  import { SseParser } from "../chatgpt/sse.js";
21
22
  import { fetchWithRetry } from "../retry.js";
22
23
  import { estimateTokens, formatSse, GoogleStreamMapper, toGeminiRequest } from "./translate.js";
23
- import { createTransport, googleBaseUrl, resolveApiKey } from "./transport.js";
24
+ import { antigravityBaseUrl, createTransport, googleBaseUrl, resolveApiKey } from "./transport.js";
24
25
  import { ThoughtSignatureStore } from "./thoughtSignatures.js";
25
26
  import { conversationKey, serverToolNames, toolNameRestoreMap } from "../chatgpt/translate.js";
26
27
  import { GoogleAccountPool } from "./accounts.js";
27
- import { fetchAvailableModels, staticAntigravityModels } from "./antigravity.js";
28
+ import { fetchAvailableModels, hostForModel, resolveAntigravityWireModel, staticAntigravityModels, GOOGLE_OAUTH } from "./antigravity.js";
29
+ import { GoogleUsage } from "./usage.js";
30
+ import { ANTIGRAVITY_PROMPT_REFUSAL_MESSAGE, isAntigravityPromptRefusal } from "./prompt-refusal.js";
28
31
  const PING_MS = 15_000;
29
32
  /** How long the CCA model list is reused before asking again. */
30
33
  const MODELS_CACHE_MS = 60 * 60 * 1000;
34
+ /**
35
+ * How often a turn asks again when Google says it has no capacity for the model, and how long it
36
+ * waits first. A few slower asks rather than the ten-in-a-row ladder: each refusal took seconds to
37
+ * arrive (41–103 s for ten on 2026-10-20), and capacity does not come back in 250 ms. Mutable only so
38
+ * tests can shorten the waits.
39
+ */
40
+ export const capacityRetry = { delaysMs: [3_000, 6_000] };
41
+ /**
42
+ * Cloud Code Assist saying the **model** has no capacity on its servers — not the account, not its
43
+ * quota. Measured 2026-10-20 on the daily host for `gemini-3.8-flash-high`: HTTP 503,
44
+ * `{"error":{"code":503,"status":"UNAVAILABLE","message":"No capacity available for model … on the
45
+ * server","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":
46
+ * "MODEL_CAPACITY_EXHAUSTED","domain":"cloudcode-pa.googleapis.com","metadata":{"model":…,
47
+ * "OVERLOADED_TOO_MANY_RETRIES_PER_REQUEST":"true",…}}]}}`. Recognised by the ErrorInfo reason alone;
48
+ * any other 503 keeps the ordinary transient handling.
49
+ */
50
+ export function isModelCapacityExhausted(status, text) {
51
+ if (status !== 503)
52
+ return false;
53
+ try {
54
+ const details = JSON.parse(text)?.error?.details;
55
+ return Array.isArray(details) && details.some((d) => typeof d === "object" && d !== null && d.reason === "MODEL_CAPACITY_EXHAUSTED");
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
31
61
  function anthropicError(status, type, message) {
32
62
  return { status, body: JSON.stringify({ type: "error", error: { type, message } }) };
33
63
  }
@@ -113,6 +143,8 @@ export class GoogleAdapter {
113
143
  cfg;
114
144
  log;
115
145
  accounts;
146
+ /** Per-account quota, for the status bar's limits row (`GET /api/limits`). */
147
+ usage;
116
148
  /** Cooldowns and conversation stickiness, shared with the proxy's other credential pools. */
117
149
  pool;
118
150
  lastInputByKey = new Map();
@@ -124,12 +156,57 @@ export class GoogleAdapter {
124
156
  verifyUrls = new Map();
125
157
  modelsCache = null;
126
158
  modelsInFlight = null;
159
+ /** When the last listing attempt failed, so a turn does not retry an unreadable catalogue every time. */
160
+ modelsFailedAt = Number.NEGATIVE_INFINITY;
161
+ /** The fetch the adapter's side calls use (model listing, and the account store); injectable for tests. */
162
+ fetchImpl;
127
163
  constructor(name, cfg, home, log, pool = new CredentialPool(), fetchImpl) {
128
164
  this.name = name;
129
165
  this.cfg = cfg;
130
166
  this.log = log;
131
167
  this.pool = pool;
168
+ this.fetchImpl = fetchImpl;
132
169
  this.accounts = new GoogleAccountPool({ home, log, ...(fetchImpl ? { fetch: fetchImpl } : {}) });
170
+ // Quota: each Cloud Code Assist host counts its own, so the limits row reads every host a
171
+ // selected model lives on (resolved lazily from the cached model catalogue below).
172
+ this.usage = new GoogleUsage(this.accounts, {
173
+ baseUrl: antigravityBaseUrl(cfg.url),
174
+ hosts: () => this.quotaHosts(),
175
+ warn: (message) => log.warn(message),
176
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
177
+ });
178
+ }
179
+ /**
180
+ * The hosts whose quota the limits row must read: one per host that offers a model this provider
181
+ * routes (the configured model list, or the discovered catalogue when it lists none). A pinned
182
+ * `url` is the only host, because generation never leaves it.
183
+ */
184
+ quotaHosts() {
185
+ if (this.cfg.url)
186
+ return [antigravityBaseUrl(this.cfg.url)];
187
+ const hosts = this.modelsCache?.hosts;
188
+ // Without a catalogue we cannot tell which host the models go to; read the default one only,
189
+ // rather than showing an unused host's numbers.
190
+ if (!hosts)
191
+ return [GOOGLE_OAUTH.dailyApi];
192
+ const models = this.cfg.models ?? [];
193
+ // No configured model list means the provider routes whatever the client asks for; both hosts
194
+ // are then in use in principle, so both quotas are read.
195
+ if (models.length === 0)
196
+ return [...GOOGLE_OAUTH.hosts];
197
+ const fallback = antigravityBaseUrl(this.cfg.url);
198
+ const used = [];
199
+ for (const entry of models) {
200
+ // The host the model is actually sent to, not every host that lists it: a shared model goes
201
+ // to the preferred (daily) host, so only that host's quota is spent by this provider. The
202
+ // default effort stands in for the configured model because the config carries no per-turn
203
+ // effort; its tier's host is the representative one.
204
+ const wire = resolveAntigravityWireModel(entry.id, this.cfg.defaultEffort).wire;
205
+ const host = hostForModel(hosts, entry.id, wire, fallback);
206
+ if (!used.includes(host))
207
+ used.push(host);
208
+ }
209
+ return used.length ? used : [GOOGLE_OAUTH.dailyApi];
133
210
  }
134
211
  describeAuth() {
135
212
  const all = this.accounts.summaries();
@@ -161,6 +238,15 @@ export class GoogleAdapter {
161
238
  };
162
239
  });
163
240
  }
241
+ /**
242
+ * Each account's quota groups by owner id, for the status bar's limits row. Never a token; a
243
+ * failed lookup keeps that account's last good groups.
244
+ */
245
+ usageSnapshot() {
246
+ // Knowing which hosts hold the selected models needs the catalogue; a cold cache would read
247
+ // daily only and show the wrong host's numbers. The listing is cached for an hour.
248
+ return this.ensureHostMap().then(() => this.usage.snapshot());
249
+ }
164
250
  /**
165
251
  * Ask Google again whether an account still needs its verification page (dashboard action). The
166
252
  * mark otherwise clears only when the account next answers a turn, so a person who had just
@@ -173,7 +259,7 @@ export class GoogleAdapter {
173
259
  return "unknown";
174
260
  const model = this.cfg.models?.[0]?.id ?? staticAntigravityModels()[0].id;
175
261
  const transport = createTransport("antigravity", this.cfg.url ? { url: this.cfg.url } : {});
176
- const built = transport.build({ contents: [{ role: "user", parts: [{ text: "ping" }] }], generationConfig: { maxOutputTokens: 16 } }, { model, stream: false, antigravity: { accessToken: credential.accessToken, projectId: credential.projectId, conversationKey: `recheck:${ownerId}` } });
262
+ const built = transport.build({ contents: [{ role: "user", parts: [{ text: "ping" }] }], generationConfig: { maxOutputTokens: 16 } }, { model, stream: false, antigravity: { accessToken: credential.accessToken, projectId: credential.projectId, conversationKey: `recheck:${ownerId}` }, antigravityBaseUrl: this.hostFor(model, undefined) });
177
263
  try {
178
264
  const upstream = await fetch(built.url, { method: "POST", headers: built.headers, body: built.body, signal: AbortSignal.timeout(30_000) });
179
265
  const text = await upstream.text().catch(() => "");
@@ -216,15 +302,53 @@ export class GoogleAdapter {
216
302
  }
217
303
  async fetchModelsOnce() {
218
304
  const credential = await this.anyCredential();
305
+ // No account is not a failed listing: a sign-in must take effect at once, not an hour later.
219
306
  if (credential instanceof Error)
220
307
  return null;
221
- const models = await fetchAvailableModels(credential.accessToken, credential.projectId);
222
- if (!models) {
308
+ // Both Cloud Code Assist hosts are asked; one host failing must not hide the other's models.
309
+ const hosts = this.cfg.url ? [antigravityBaseUrl(this.cfg.url)] : GOOGLE_OAUTH.hosts;
310
+ const catalogue = await fetchAvailableModels(credential.accessToken, credential.projectId, {
311
+ hosts,
312
+ ...(this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}),
313
+ });
314
+ if (!catalogue) {
223
315
  this.log.warn(`google ${this.name}: fetchAvailableModels returned nothing; falling back to the static list`);
316
+ // A cold host is not asked again on every turn while it is unreadable.
317
+ this.modelsFailedAt = Date.now();
224
318
  return staticAntigravityModels();
225
319
  }
226
- this.modelsCache = { at: Date.now(), models };
227
- return models;
320
+ this.modelsCache = { at: Date.now(), models: catalogue.models, hosts: catalogue.hosts };
321
+ if (catalogue.byHost.size < hosts.length) {
322
+ const answered = [...catalogue.byHost.keys()].map((h) => new URL(h).host);
323
+ const missing = hosts.filter((h) => !catalogue.byHost.has(h)).map((h) => new URL(h).host);
324
+ this.log.warn(`google ${this.name}: fetchAvailableModels answered on ${answered.join(", ")}; ${missing.join(", ")} failed, its models are hidden`);
325
+ }
326
+ return catalogue.models;
327
+ }
328
+ /**
329
+ * Make sure the host map is loaded, within the same hour-long cache as `fetchModels`. A listing
330
+ * that just failed is not retried on every turn; the default host is used meanwhile.
331
+ */
332
+ async ensureHostMap() {
333
+ if (this.modelsCache || this.cfg.url)
334
+ return;
335
+ if (!this.accounts.signedIn())
336
+ return;
337
+ if (Date.now() - this.modelsFailedAt < MODELS_CACHE_MS)
338
+ return;
339
+ await this.fetchModels();
340
+ }
341
+ /**
342
+ * The host a turn for `model` must go to: the one that offers its resolved wire id, preferring
343
+ * daily when both do, else the provider's default. Unknown models and an unread catalogue fall
344
+ * back to the default, exactly as before the second host existed.
345
+ */
346
+ hostFor(model, effort) {
347
+ const fallback = antigravityBaseUrl(this.cfg.url);
348
+ if (this.cfg.url || !this.modelsCache)
349
+ return fallback;
350
+ const wire = resolveAntigravityWireModel(model, effort).wire;
351
+ return hostForModel(this.modelsCache.hosts, model, wire, fallback);
228
352
  }
229
353
  /** For side calls (models, project refresh) with no conversation: a usable account, else any. */
230
354
  async anyCredential() {
@@ -257,13 +381,23 @@ export class GoogleAdapter {
257
381
  const caps = effortLevels ? { effortLevels } : {};
258
382
  // A request that names no effort gets the provider's configured one, as the ChatGPT one does.
259
383
  const useEffort = effort ?? this.cfg.defaultEffort;
384
+ // A model that only one CCA host offers must be sent there. The catalogue is the only source of
385
+ // that routing, so a cold cache (first turn after a restart) is filled before the turn is built.
386
+ // It is a no-op within the hour after that, and a failure silently keeps the default host.
387
+ if (this.cfg.auth === "antigravity")
388
+ await this.ensureHostMap();
389
+ const host = this.cfg.auth === "antigravity" ? this.hostFor(model, useEffort) : undefined;
390
+ // Thought signatures are per host: CCA runs its state separately on each, and replaying one
391
+ // host's signature to the other is refused (see thoughtSignatures.ts). The API-key path keeps
392
+ // the unscoped store.
393
+ const signatures = host ? this.thoughtSignatures.scope(host) : this.thoughtSignatures;
260
394
  const upstreamRequest = toGeminiRequest(json, {
261
395
  model,
262
396
  ...(useEffort ? { effort: useEffort } : {}),
263
397
  caps,
264
398
  ...(this.cfg.identity === undefined ? {} : { identity: this.cfg.identity }),
265
399
  ...(this.cfg.instructionsAppend ? { instructionsAppend: this.cfg.instructionsAppend } : {}),
266
- thoughtSignatures: this.thoughtSignatures,
400
+ thoughtSignatures: signatures,
267
401
  // The CCA path never adds the sentinel: the signature goes back on the exact functionCall the
268
402
  // model signed (or nothing at all), rather than a fabricated validator bypass.
269
403
  signatureSentinel: this.cfg.auth === "api-key",
@@ -275,7 +409,7 @@ export class GoogleAdapter {
275
409
  const startInput = Math.max(estimateTokens(json), this.lastInputByKey.get(key) ?? 0);
276
410
  const transport = createTransport(this.cfg.auth, { ...(this.cfg.apiKey ? { apiKey: this.cfg.apiKey } : {}), ...(this.cfg.url ? { url: this.cfg.url } : {}) });
277
411
  if (this.cfg.auth === "antigravity") {
278
- return this.handleAntigravity(res, json, upstreamRequest, transport, model, useEffort, wantStream, key, startInput);
412
+ return this.handleAntigravity(res, json, upstreamRequest, transport, model, useEffort, wantStream, key, startInput, host, signatures);
279
413
  }
280
414
  const apiKey = resolveApiKey(this.cfg.apiKey);
281
415
  if (!apiKey) {
@@ -331,19 +465,23 @@ export class GoogleAdapter {
331
465
  * and its failure handling mirror the ChatGPT pool (accounts.ts), because the cache cost of moving
332
466
  * a conversation off its account is the same (§4).
333
467
  */
334
- async handleAntigravity(res, json, upstreamRequest, transport, model, effort, wantStream, key, startInput) {
468
+ async handleAntigravity(res, json, upstreamRequest, transport, model, effort, wantStream, key, startInput, host, signatures) {
335
469
  const controller = new AbortController();
336
470
  const onClose = () => controller.abort();
337
471
  res.on("close", onClose);
338
472
  const sent = await this.sendToAnAccount({
339
473
  conversation: key,
340
474
  signal: controller.signal,
475
+ system: upstreamRequest.systemInstruction,
476
+ model,
477
+ host,
341
478
  build: (credential) => {
342
479
  const built = transport.build(upstreamRequest, {
343
480
  model,
344
481
  stream: wantStream,
345
482
  ...(effort ? { effort } : {}),
346
483
  antigravity: { accessToken: credential.accessToken, projectId: credential.projectId, conversationKey: key },
484
+ antigravityBaseUrl: host,
347
485
  });
348
486
  return { built, secrets: [credential.accessToken, ...credentialHeaderValues(Object.entries(built.headers))] };
349
487
  },
@@ -352,6 +490,17 @@ export class GoogleAdapter {
352
490
  res.off("close", onClose);
353
491
  if (sent.kind === "aborted")
354
492
  return { status: 0, bytes: 0, note: "client closed" };
493
+ if (sent.kind === "prompt-refused") {
494
+ const err = anthropicError(400, "invalid_request_error", ANTIGRAVITY_PROMPT_REFUSAL_MESSAGE);
495
+ return { kind: "prompt-incompatible", status: 400, bytes: 0, errorBody: err.body,
496
+ note: "upstream 429: suspected Antigravity SDK prompt refusal with remaining quota", refusal: "prompt-incompatible" };
497
+ }
498
+ if (sent.kind === "no-capacity") {
499
+ // Overloaded, so Claude Code asks again on its own when no fallback takes the turn.
500
+ const err = anthropicError(529, "overloaded_error", `Google provider: ${vendorMessage(sent.text)} — Google is out of capacity for this model (MODEL_CAPACITY_EXHAUSTED), asked ${sent.asks} times. This is Google's server capacity, not your account or its quota; try again shortly, or give the route a fallback.`);
501
+ return { kind: "model-capacity", status: 529, bytes: 0, errorBody: err.body,
502
+ note: `upstream 503 MODEL_CAPACITY_EXHAUSTED after ${sent.asks} asks; account not rested` };
503
+ }
355
504
  if (sent.kind === "unreachable") {
356
505
  const err = anthropicError(502, "api_error", `Google provider unreachable: ${sent.error.message}`);
357
506
  if (!res.headersSent)
@@ -389,13 +538,13 @@ export class GoogleAdapter {
389
538
  ...(sent.kind === "no-account" ? { upstreamCalled: false, refusal: "no-account" } : {}),
390
539
  ...(sent.kind === "refused" ? { exhaustion: providerExhaustion(sent.status, sent.text, sent.retryAfterSeconds === undefined ? undefined : sent.retryAfterSeconds * 1000) } : {}), };
391
540
  }
392
- return this.streamBack(res, sent.upstream, transport, json, model, wantStream, key, startInput, controller, onClose);
541
+ return this.streamBack(res, sent.upstream, transport, json, model, wantStream, key, startInput, controller, onClose, signatures);
393
542
  }
394
543
  /** Stream (or buffer) an already-ok upstream response back to the client as Anthropic SSE. */
395
- async streamBack(res, upstream, transport, json, model, wantStream, key, startInput, controller, onClose) {
544
+ async streamBack(res, upstream, transport, json, model, wantStream, key, startInput, controller, onClose, signatures = this.thoughtSignatures) {
396
545
  const mapper = new GoogleStreamMapper(model, startInput, toolNameRestoreMap(json), (info) => {
397
546
  if (info.signature)
398
- this.thoughtSignatures.set(info.id, info.signature);
547
+ signatures.set(info.id, info.signature);
399
548
  });
400
549
  const parser = new SseParser();
401
550
  const reader = upstream.body.getReader();
@@ -503,6 +652,9 @@ export class GoogleAdapter {
503
652
  * - 401, or a 403 that reads as a credential refusal: one refresh of that account and a replay;
504
653
  * refused again with a 401, the account is marked for sign-in.
505
654
  * - 429 and 402: the account rests until its stated reset (or a default), and the next takes over.
655
+ * - A 503 `MODEL_CAPACITY_EXHAUSTED`: no rest and no other account — the model is out of capacity
656
+ * on Google's side, not this account. The same account is asked again after `capacityRetry`'s
657
+ * waits, then the turn ends unwritten so the proxy can try a route fallback (§5, 2026-10-20).
506
658
  * - Any other 403, 5xx, or no connection: a short rest, and the next account.
507
659
  * - Anything else is the request's fault; another account would refuse it the same way.
508
660
  *
@@ -514,6 +666,7 @@ export class GoogleAdapter {
514
666
  const refreshed = new Set();
515
667
  let last = null;
516
668
  let lastThrown = null;
669
+ let capacityAsks = 0;
517
670
  for (;;) {
518
671
  const credential = await this.pickAccount(spec.conversation, tried);
519
672
  if (credential === "none")
@@ -536,7 +689,7 @@ export class GoogleAdapter {
536
689
  headers: built.headers,
537
690
  body: built.body,
538
691
  signal: spec.signal,
539
- }, { log: (line) => this.log.info(`google ${this.name}: ${line}`) });
692
+ }, { log: (line) => this.log.info(`google ${this.name}: ${line}`), stopOn: isModelCapacityExhausted });
540
693
  }
541
694
  catch (e) {
542
695
  if (spec.signal.aborted)
@@ -557,6 +710,30 @@ export class GoogleAdapter {
557
710
  const safeText = redactErrorText(text, secrets);
558
711
  const status = upstream.status;
559
712
  this.log.warn(`google ${this.name}: account ${credential.ownerId.slice(0, 8)} answered ${status}: ${safeText.slice(0, 400)}`);
713
+ // The model, not the account, is out of capacity: resting the account refused every other turn
714
+ // with "all accounts resting" for its ten seconds (10 times in 3 minutes, 2026-10-20), and
715
+ // another account would meet the same model on the same host. So nothing is charged to it.
716
+ if (isModelCapacityExhausted(status, text)) {
717
+ capacityAsks++;
718
+ const delay = capacityRetry.delaysMs[capacityAsks - 1];
719
+ if (delay === undefined)
720
+ return { kind: "no-capacity", text: safeText, asks: capacityAsks };
721
+ this.log.info(`google ${this.name}: ${spec.model} out of capacity (ask ${capacityAsks}); asking again in ${delay}ms without resting the account`);
722
+ try {
723
+ await sleep(delay, undefined, { signal: spec.signal });
724
+ }
725
+ catch {
726
+ return { kind: "aborted" };
727
+ }
728
+ continue;
729
+ }
730
+ // The generic 429 is also real exhaustion. Only fresh quota evidence for this
731
+ // account/model permits a request-specific refusal; otherwise keep ordinary
732
+ // cooldown and account rotation. The proxy can still try a route fallback.
733
+ if (isAntigravityPromptRefusal(spec.system, status, text, upstream.headers.get("retry-after")) &&
734
+ this.usage.hasRemainingQuota(credential, spec.model, spec.host)) {
735
+ return { kind: "prompt-refused" };
736
+ }
560
737
  // Not a rest and not a refresh: the account works again the moment its person finishes
561
738
  // Google's page, so it stays in rotation and the next turn finds out.
562
739
  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
+ }
@@ -43,5 +43,18 @@ export class ThoughtSignatureStore {
43
43
  this.map.delete(oldest);
44
44
  }
45
45
  }
46
+ /**
47
+ * A view whose keys carry the Cloud Code Assist host the signature came from. Cloud Code Assist
48
+ * runs on two hosts with separate state, and replaying a signature minted on one to the other is
49
+ * what the backend refuses (TYPE_BYTES), so a conversation that moves between hosts must not
50
+ * find the other host's signature. The shared store keeps the LRU bound across both views.
51
+ */
52
+ scope(host) {
53
+ const prefix = `${host}\u0000`;
54
+ return {
55
+ get: (id) => this.get(prefix + id),
56
+ set: (id, signature) => this.set(prefix + id, signature),
57
+ };
58
+ }
46
59
  get size() { return this.map.size; }
47
60
  }
@@ -77,7 +77,7 @@ export function createTransport(mode, opts) {
77
77
  request,
78
78
  };
79
79
  return {
80
- url: `${base}/${GOOGLE_OAUTH.apiVersion}:${method}${query}`,
80
+ url: `${(o.antigravityBaseUrl ? o.antigravityBaseUrl.replace(/\/+$/, "") : base)}/${GOOGLE_OAUTH.apiVersion}:${method}${query}`,
81
81
  headers: {
82
82
  "content-type": "application/json",
83
83
  accept: o.stream ? "text/event-stream" : "application/json",