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
@@ -0,0 +1,257 @@
1
+ // How much of each Antigravity (Cloud Code Assist) account's quota is left, for the dashboard and
2
+ // the status bar's limits row. One request per account, like ClaudeUsage (anthropic-usage.ts): the
3
+ // accounts store already refreshes the OAuth token, so this only reads.
4
+ //
5
+ // Measured 2026-10-15 against a live Antigravity subscription:
6
+ // POST {prodApi}/v1internal:retrieveUserQuotaSummary
7
+ // headers: authorization: Bearer <accessToken>, content-type: application/json, user-agent
8
+ // body: {"project": "<projectId>"}
9
+ // answer: {"groups":[{"displayName":"Gemini Models","buckets":[
10
+ // {"bucketId":"gemini-weekly","displayName":"Weekly","window":"weekly",
11
+ // "resetTime":"…","remainingFraction":0.98},
12
+ // {"bucketId":"gemini-5h","displayName":"5h","window":"5h",…}]},
13
+ // {"displayName":"Claude and GPT models","buckets":[
14
+ // {"bucketId":"3p-weekly",…},{"bucketId":"3p-5h",…}]}]}
15
+ // `remainingFraction` is what is left (1 = untouched), so used = (1 - remainingFraction) * 100. The
16
+ // older `retrieveUserQuota` returns meaningless values and is deliberately not used.
17
+ import { GOOGLE_OAUTH, antigravityUserAgent } from "./antigravity.js";
18
+ /** The five-hour and weekly window lengths in minutes, for the limits row's `window_minutes`. */
19
+ const FIVE_HOURS = 5 * 60;
20
+ const WEEK = 7 * 24 * 60;
21
+ /**
22
+ * Parse the `retrieveUserQuotaSummary` body into groups. A group or bucket missing the fields the
23
+ * row reads is dropped rather than guessed; null when the body is not the shape at all (so the
24
+ * caller keeps its last good answer instead of blanking).
25
+ */
26
+ export function parseQuotaSummary(body) {
27
+ if (!body || typeof body !== "object" || Array.isArray(body))
28
+ return null;
29
+ const groups = body.groups;
30
+ if (!Array.isArray(groups))
31
+ return null;
32
+ const out = [];
33
+ for (const g of groups) {
34
+ if (!g || typeof g !== "object" || Array.isArray(g))
35
+ continue;
36
+ const group = g;
37
+ if (typeof group.displayName !== "string" || !group.displayName)
38
+ continue;
39
+ const buckets = [];
40
+ if (Array.isArray(group.buckets)) {
41
+ for (const b of group.buckets) {
42
+ if (!b || typeof b !== "object" || Array.isArray(b))
43
+ continue;
44
+ const bucket = b;
45
+ if (typeof bucket.remainingFraction !== "number" || !Number.isFinite(bucket.remainingFraction))
46
+ continue;
47
+ if (typeof bucket.window !== "string" || !bucket.window)
48
+ continue;
49
+ buckets.push({
50
+ bucketId: typeof bucket.bucketId === "string" ? bucket.bucketId : "",
51
+ displayName: typeof bucket.displayName === "string" ? bucket.displayName : "",
52
+ window: bucket.window,
53
+ ...(typeof bucket.resetTime === "string" ? { resetTime: bucket.resetTime } : {}),
54
+ remainingFraction: bucket.remainingFraction,
55
+ });
56
+ }
57
+ }
58
+ if (buckets.length)
59
+ out.push({ displayName: group.displayName, buckets });
60
+ }
61
+ return out;
62
+ }
63
+ /** The window length in minutes, from the endpoint's own name ("5h", "weekly"). */
64
+ function windowMinutes(window) {
65
+ if (window === "5h")
66
+ return FIVE_HOURS;
67
+ if (window === "weekly")
68
+ return WEEK;
69
+ return undefined;
70
+ }
71
+ const RANK = { five_hour: 0, seven_day: 1, scoped: 2, window: 3 };
72
+ /** Five-hour, then weekly, then the rest: the same order the row shows Claude's own windows in. */
73
+ function ordered(windows) {
74
+ return windows.map((w, i) => [w, i]).sort((a, b) => (RANK[a[0].kind] - RANK[b[0].kind]) || (a[1] - b[1])).map(([w]) => w);
75
+ }
76
+ /**
77
+ * The endpoint's groups as windows the limits row reads. "Gemini Models" carries the plan's own
78
+ * five-hour and weekly windows; every other group (the measured "Claude and GPT models") is a
79
+ * third-party window, shown as a `scoped` "3P" with its length in `window_minutes`.
80
+ */
81
+ export function googleWindowsFromGroups(groups) {
82
+ const out = [];
83
+ for (const group of groups) {
84
+ const gemini = /gemini/i.test(group.displayName);
85
+ for (const bucket of group.buckets) {
86
+ const minutes = windowMinutes(bucket.window);
87
+ const used = Math.round((1 - bucket.remainingFraction) * 1000) / 10;
88
+ const at = bucket.resetTime ? Math.round(Date.parse(bucket.resetTime) / 1000) : NaN;
89
+ const reset = Number.isFinite(at) ? { reset_at: at } : {};
90
+ if (gemini) {
91
+ const kind = bucket.window === "5h" ? "five_hour" : bucket.window === "weekly" ? "seven_day" : "window";
92
+ out.push({ kind, used_percent: used, ...(minutes !== undefined ? { window_minutes: minutes } : {}), ...reset });
93
+ }
94
+ else {
95
+ out.push({ kind: "scoped", label: "3P", used_percent: used, ...(minutes !== undefined ? { window_minutes: minutes } : {}), ...reset });
96
+ }
97
+ }
98
+ }
99
+ return ordered(out);
100
+ }
101
+ /**
102
+ * The whole body as groups, or null when ANY group or bucket is malformed. The display parser drops
103
+ * what it cannot read; evidence must not, because a dropped bucket could be the exhausted one.
104
+ */
105
+ function strictQuotaGroups(body) {
106
+ const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
107
+ if (!isObject(body) || !Array.isArray(body.groups))
108
+ return null;
109
+ const out = [];
110
+ for (const g of body.groups) {
111
+ if (!isObject(g) || typeof g.displayName !== "string" || !g.displayName || !Array.isArray(g.buckets))
112
+ return null;
113
+ const buckets = [];
114
+ for (const b of g.buckets) {
115
+ if (!isObject(b) || typeof b.bucketId !== "string" || typeof b.window !== "string" || !b.window)
116
+ return null;
117
+ if (typeof b.remainingFraction !== "number" || !Number.isFinite(b.remainingFraction))
118
+ return null;
119
+ if (b.resetTime !== undefined && typeof b.resetTime !== "string")
120
+ return null;
121
+ buckets.push({ bucketId: b.bucketId, displayName: typeof b.displayName === "string" ? b.displayName : "", window: b.window, ...(typeof b.resetTime === "string" ? { resetTime: b.resetTime } : {}), remainingFraction: b.remainingFraction });
122
+ }
123
+ out.push({ displayName: g.displayName, buckets });
124
+ }
125
+ return out;
126
+ }
127
+ /** The measured group names and their bucket ids; the endpoint reports no per-model quota. */
128
+ const GEMINI_GROUP = { name: "gemini models", ids: { "5h": "gemini-5h", weekly: "gemini-weekly" } };
129
+ const THIRD_PARTY_GROUP = { name: "claude and gpt models", ids: { "5h": "3p-5h", weekly: "3p-weekly" } };
130
+ /** gemini-* draws on "Gemini Models"; claude-* and gpt-oss* on "Claude and GPT models"; else unknown. */
131
+ function groupFor(groups, model) {
132
+ const id = model.toLowerCase();
133
+ const known = /^gemini-/.test(id) ? GEMINI_GROUP : /^claude-/.test(id) || /^gpt-oss(-|$)/.test(id) ? THIRD_PARTY_GROUP : undefined;
134
+ if (!known)
135
+ return undefined;
136
+ const found = groups.filter((g) => g.displayName.trim().toLowerCase() === known.name);
137
+ return found.length === 1 ? { group: found[0], ids: known.ids } : undefined;
138
+ }
139
+ /** True when the group is exactly its 5h and weekly buckets, each with 0 < fraction <= 1 and a reset after `now`. */
140
+ function groupHasQuota(group, ids, now) {
141
+ if (group.buckets.length !== 2)
142
+ return false;
143
+ return ["5h", "weekly"].every((window) => {
144
+ const matches = group.buckets.filter((b) => b.window === window && b.bucketId === ids[window]);
145
+ const bucket = matches[0];
146
+ if (matches.length !== 1 || !bucket)
147
+ return false;
148
+ if (bucket.remainingFraction <= 0 || bucket.remainingFraction > 1)
149
+ return false;
150
+ const reset = bucket.resetTime ? Date.parse(bucket.resetTime) : Number.NaN;
151
+ return Number.isFinite(reset) && reset > now;
152
+ });
153
+ }
154
+ /**
155
+ * Per-account quota groups, fetched at most once per `ttlMs` per account. A failed lookup keeps the
156
+ * last good groups rather than blanking them, and only the status code is logged.
157
+ */
158
+ export class GoogleUsage {
159
+ last = new Map();
160
+ /** Per account, only the last SUCCESSFUL lookup; dropped as soon as a refresh fails. */
161
+ evidence = new Map();
162
+ inFlight = null;
163
+ fetchedAt = Number.NEGATIVE_INFINITY;
164
+ source;
165
+ opts;
166
+ constructor(source, opts) {
167
+ this.source = source;
168
+ this.opts = opts;
169
+ }
170
+ /** Groups by account owner id, from the store's own account ids. */
171
+ snapshot() {
172
+ const now = (this.opts.now ?? Date.now)();
173
+ if (now - this.fetchedAt < (this.opts.ttlMs ?? 5 * 60_000))
174
+ return Promise.resolve(Object.fromEntries(this.last));
175
+ this.inFlight ??= this.fetchAll().finally(() => {
176
+ this.fetchedAt = (this.opts.now ?? Date.now)();
177
+ this.inFlight = null;
178
+ });
179
+ return this.inFlight;
180
+ }
181
+ /**
182
+ * Whether the cached quota still shows room for `model` on this account and project. Reads the
183
+ * cache only (never fetches), and is true only when the last refresh of this account succeeded
184
+ * within the TTL, the model's group is recognized, and both its 5h and weekly buckets have a
185
+ * remaining fraction in (0, 1] and a reset time in the future. Everything else is false: this
186
+ * is positive evidence, not a guess, so "false" means "unknown or spent", not "refused".
187
+ */
188
+ hasRemainingQuota(credential, model) {
189
+ const evidence = this.evidence.get(credential.ownerId);
190
+ if (!evidence || evidence.projectId !== credential.projectId)
191
+ return false;
192
+ const now = (this.opts.now ?? Date.now)();
193
+ const age = now - evidence.observedAt;
194
+ if (!(age >= 0 && age < (this.opts.ttlMs ?? 5 * 60_000)))
195
+ return false;
196
+ const match = groupFor(evidence.groups, model);
197
+ return !!match && groupHasQuota(match.group, match.ids, now);
198
+ }
199
+ async fetchAll() {
200
+ let credentials;
201
+ try {
202
+ credentials = await this.source.credentials();
203
+ }
204
+ catch (e) {
205
+ this.opts.warn?.(`google usage: no credentials: ${e.message}`);
206
+ this.evidence.clear();
207
+ return Object.fromEntries(this.last);
208
+ }
209
+ await Promise.all(credentials.map(async (credential) => {
210
+ const fetched = await this.fetchOne(credential);
211
+ if (fetched) {
212
+ this.last.set(credential.ownerId, fetched.groups);
213
+ // A body with any malformed group or bucket is shown as far as it parses, but proves nothing.
214
+ if (fetched.strict)
215
+ this.evidence.set(credential.ownerId, { projectId: credential.projectId, observedAt: (this.opts.now ?? Date.now)(), groups: fetched.strict });
216
+ else
217
+ this.evidence.delete(credential.ownerId);
218
+ }
219
+ else {
220
+ // The displayed numbers stay, but they are no longer proof of anything.
221
+ this.evidence.delete(credential.ownerId);
222
+ }
223
+ }));
224
+ // An account that was removed must not keep its old numbers on the screen.
225
+ const owners = new Set(credentials.map((c) => c.ownerId));
226
+ for (const owner of [...this.last.keys()])
227
+ if (!owners.has(owner))
228
+ this.last.delete(owner);
229
+ for (const owner of [...this.evidence.keys()])
230
+ if (!owners.has(owner))
231
+ this.evidence.delete(owner);
232
+ return Object.fromEntries(this.last);
233
+ }
234
+ async fetchOne(credential) {
235
+ const owner = credential.ownerId.slice(0, 8);
236
+ try {
237
+ const res = await (this.opts.fetch ?? fetch)(`${GOOGLE_OAUTH.prodApi}/${GOOGLE_OAUTH.apiVersion}:retrieveUserQuotaSummary`, {
238
+ method: "POST",
239
+ headers: { authorization: `Bearer ${credential.accessToken}`, "content-type": "application/json", accept: "*/*", "user-agent": antigravityUserAgent() },
240
+ body: JSON.stringify({ project: credential.projectId }),
241
+ signal: AbortSignal.timeout(this.opts.timeoutMs ?? 5000),
242
+ });
243
+ if (!res.ok) {
244
+ // The status alone: the body of an auth failure is not worth the risk of echoing.
245
+ this.opts.warn?.(`google usage ${owner}: HTTP ${res.status}`);
246
+ return null;
247
+ }
248
+ const body = await res.json();
249
+ const groups = parseQuotaSummary(body);
250
+ return groups ? { groups, strict: strictQuotaGroups(body) } : null;
251
+ }
252
+ catch (e) {
253
+ this.opts.warn?.(`google usage ${owner}: ${e.message}`);
254
+ return null;
255
+ }
256
+ }
257
+ }
@@ -10,6 +10,7 @@
10
10
  import { OpenAiCompatibleAdapter } from "../openai/index.js";
11
11
  import { GROK_AUTH_HINT, GrokAuth, GrokAuthError, grokHome, sharedGrokAuth } from "./auth.js";
12
12
  import { GROK_DEFAULT_URL, GROK_EFFORT_LEVELS } from "./catalog.js";
13
+ import { GrokUsage } from "./usage.js";
13
14
  /**
14
15
  * Facts only, returned by the probe in a `warning` field like the Antigravity one. Nothing published
15
16
  * by xAI says either way whether a third-party client may use the CLI's session.
@@ -34,8 +35,14 @@ export class GrokAdapter {
34
35
  name;
35
36
  auth;
36
37
  inner;
38
+ url;
39
+ clientVersion;
40
+ usageReader;
37
41
  constructor(name, cfg, log, auth) {
38
42
  this.name = name;
43
+ this.url = cfg.url ?? GROK_DEFAULT_URL;
44
+ this.clientVersion = cfg.clientVersion;
45
+ this.usageReader = new GrokUsage(name, { warn: (message) => log.warn(message) });
39
46
  // Shared with the dashboard's probe and with any other provider on the same session, and kept
40
47
  // across a config change that rebuilds this adapter.
41
48
  this.auth = auth ?? sharedGrokAuth({ home: cfg.home ?? grokHome(), ...(cfg.cli ? { cli: cfg.cli } : {}), log });
@@ -63,6 +70,17 @@ export class GrokAdapter {
63
70
  unauthorized: async (_model, sent) => this.auth.rejected(bearer(sent)),
64
71
  });
65
72
  }
73
+ /**
74
+ * The subscription's usage pool, for the status bar's limits row: what the CLI's `/usage` reads,
75
+ * with this adapter's own session and headers. Empty while no session is usable.
76
+ */
77
+ usage() {
78
+ return this.usageReader.windows(this.url, async () => {
79
+ const token = await this.auth.token();
80
+ const { "x-grok-model-override": _model, ...headers } = grokHeaders(token.key, "", await this.auth.clientVersion(this.clientVersion));
81
+ return headers;
82
+ }).catch(() => []);
83
+ }
66
84
  async handle(req, res, path, json, model, effort) {
67
85
  // Counting tokens is answered locally and needs no session.
68
86
  if (!path.startsWith("/v1/messages/count_tokens")) {
@@ -0,0 +1,77 @@
1
+ // How much of a Grok subscription's usage pool is spent, for the status bar's limits row.
2
+ //
3
+ // The Grok CLI's `/usage` (alias `/cost`) shows it; the CLI reads it from the chat proxy it talks to
4
+ // with the same session (CLI 1.0.5 strings: "/billing?format=credits", "Monthly limit", "Weekly
5
+ // limit", "Next reset:", field names creditUsagePercent, currentPeriod, billingPeriodEnd).
6
+ // Measured 2026-10-15, signed in on a SuperGrok account with nothing used that week:
7
+ // GET https://cli-chat-proxy.grok.com/v1/billing?format=credits (the adapter's headers) → 200
8
+ // {"config":{"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","start":"…","end":"2026-10-17T04:12:41Z"},
9
+ // "onDemandCap":{"val":0},"onDemandUsed":{"val":0},"isUnifiedBillingUser":true,"prepaidBalance":{"val":0},
10
+ // "billingPeriodStart":"…","billingPeriodEnd":"2026-10-17T04:12:41Z"}}
11
+ // The body is protobuf JSON, which leaves zero values out: `creditUsagePercent` was absent at 0%
12
+ // use, so absent reads as 0. Its scale (0–100, by its name) is not yet seen on a non-zero week.
13
+ const WEEK = 7 * 24 * 60;
14
+ const MONTH = 30 * 24 * 60;
15
+ /** The usage pool's current period as a window, or null when the body names none. */
16
+ export function grokWindowsFromBilling(body) {
17
+ const config = body?.config;
18
+ if (!config || typeof config !== "object" || Array.isArray(config))
19
+ return null;
20
+ const c = config;
21
+ const period = c.currentPeriod;
22
+ if (!period || typeof period !== "object")
23
+ return null;
24
+ const weekly = period.type === "USAGE_PERIOD_TYPE_WEEKLY";
25
+ const raw = c.creditUsagePercent;
26
+ const used = typeof raw === "number" && Number.isFinite(raw) ? raw : raw === undefined ? 0 : NaN;
27
+ if (!Number.isFinite(used))
28
+ return null;
29
+ const end = typeof period.end === "string" ? Date.parse(period.end) : typeof c.billingPeriodEnd === "string" ? Date.parse(c.billingPeriodEnd) : NaN;
30
+ return [{ kind: weekly ? "seven_day" : "window", used_percent: used, window_minutes: weekly ? WEEK : MONTH, ...(Number.isFinite(end) ? { reset_at: Math.round(end / 1000) } : {}) }];
31
+ }
32
+ /**
33
+ * One provider's pool, fetched at most once per `ttlMs`. A failed lookup keeps the last good
34
+ * window rather than blanking it, and only the status code is logged (never the session).
35
+ */
36
+ export class GrokUsage {
37
+ last = [];
38
+ at = Number.NEGATIVE_INFINITY;
39
+ inFlight = null;
40
+ name;
41
+ opts;
42
+ constructor(name, opts = {}) {
43
+ this.name = name;
44
+ this.opts = opts;
45
+ }
46
+ windows(url, headers) {
47
+ const now = (this.opts.now ?? Date.now)();
48
+ if (now - this.at < (this.opts.ttlMs ?? 5 * 60_000))
49
+ return Promise.resolve(this.last);
50
+ this.inFlight ??= this.fetchOne(url, headers).finally(() => {
51
+ this.at = (this.opts.now ?? Date.now)();
52
+ this.inFlight = null;
53
+ });
54
+ return this.inFlight;
55
+ }
56
+ async fetchOne(url, headers) {
57
+ try {
58
+ const res = await (this.opts.fetch ?? fetch)(`${url.replace(/\/+$/, "")}/billing?format=credits`, {
59
+ method: "GET",
60
+ headers: { accept: "application/json", ...(await headers()) },
61
+ signal: AbortSignal.timeout(this.opts.timeoutMs ?? 5000),
62
+ });
63
+ if (!res.ok) {
64
+ this.opts.warn?.(`grok usage ${this.name}: HTTP ${res.status}`);
65
+ return this.last;
66
+ }
67
+ const windows = grokWindowsFromBilling(await res.json());
68
+ if (windows)
69
+ this.last = windows;
70
+ return this.last;
71
+ }
72
+ catch (e) {
73
+ this.opts.warn?.(`grok usage ${this.name}: ${e.message}`);
74
+ return this.last;
75
+ }
76
+ }
77
+ }
@@ -8,6 +8,8 @@ import { credentialHeaderValues, redactErrorText } from "../../redact.js";
8
8
  import { SseParser } from "../chatgpt/sse.js";
9
9
  import { fetchWithRetry } from "../retry.js";
10
10
  import { estimateTokens, formatSse, OpenAiStreamMapper, toOpenAiRequest } from "./translate.js";
11
+ import { refusesReplayedReasoning } from "../../reasoning.js";
12
+ import { PRESETS } from "../../presets.js";
11
13
  import { conversationKey, serverToolNames, toolNameRestoreMap } from "../chatgpt/translate.js";
12
14
  const PING_MS = 15_000;
13
15
  function anthropicError(status, type, message) {
@@ -110,15 +112,25 @@ export class OpenAiCompatibleAdapter {
110
112
  const serverTools = serverToolNames(json.tools);
111
113
  if (serverTools.size > 0)
112
114
  this.log.warn(`${this.tag} ${this.name}: dropped server tools for ${model}: ${[...serverTools].join(", ")} (Anthropic runs these; this provider cannot)`);
113
- const upstreamRequest = toOpenAiRequest(json, {
115
+ // The conversation is the storage for a reasoning model's reasoning (reasoning.ts), so what to
116
+ // replay is read out of the request itself and the tally is reported in the request log. `env`
117
+ // names this provider and model: reasoning sealed by another model is never replayed here.
118
+ const carried = { replayed: 0, undecodable: 0 };
119
+ const translate = (replay = true) => toOpenAiRequest(json, {
114
120
  model,
115
121
  wire,
116
122
  ...(effort ? { effort } : {}),
117
123
  caps,
124
+ replay,
125
+ env: { p: this.name, m: model },
126
+ ...(PRESETS.find((entry) => entry.id === this.cfg.preset)?.sealedReasoning ? { sealedReasoning: true } : {}),
127
+ carried,
128
+ log: (line) => this.log.info(`${this.tag} ${this.name}: ${line} (${model})`),
118
129
  ...(this.cfg.identity === undefined ? {} : { identity: this.cfg.identity }),
119
130
  ...(this.cfg.instructionsAppend ? { instructionsAppend: this.cfg.instructionsAppend } : {}),
120
131
  });
121
- const requestBody = JSON.stringify(upstreamRequest);
132
+ const upstreamRequest = translate();
133
+ let requestBody = JSON.stringify(upstreamRequest);
122
134
  // Some vendors key their prompt cache on a session header rather than on the request's own
123
135
  // shape, and hand a cold cache to anyone who does not send one. `conversationKey` is the value
124
136
  // this codebase already trusts to be stable for one conversation and different between two.
@@ -173,6 +185,17 @@ export class OpenAiCompatibleAdapter {
173
185
  upstream = new Response(refusal, { status: 401 });
174
186
  }
175
187
  }
188
+ // A 4xx that names reasoning is this vendor refusing the reasoning the conversation carried
189
+ // back — a field it does not take, or a value from another model. The turn goes again without
190
+ // it: replaying is an optimisation the user's turn must never depend on (reasoning.ts).
191
+ if (carried.replayed > 0 && refusesReplayedReasoning(upstream.status, await upstream.clone().text().catch(() => ""))) {
192
+ const refusal = await upstream.text().catch(() => "");
193
+ this.log.warn(`${this.tag} ${this.name}: upstream ${upstream.status} refused ${carried.replayed} replayed reasoning turn(s) for ${model}; asking again without them: ${refusal.replace(/\s+/g, " ").slice(0, 200)}`);
194
+ carried.replayed = 0;
195
+ carried.undecodable = 0;
196
+ requestBody = JSON.stringify(translate(false));
197
+ upstream = await ask();
198
+ }
176
199
  }
177
200
  catch (error) {
178
201
  res.off("close", onClose);
@@ -202,7 +225,8 @@ export class OpenAiCompatibleAdapter {
202
225
  return { status: out.status, bytes: Buffer.byteLength(out.body), note: `upstream ${upstream.status}`, exhaustion: providerExhaustion(upstream.status, text, retryAfterMs(Object.fromEntries(upstream.headers))) };
203
226
  }
204
227
  const wantStream = json.stream === true;
205
- const mapper = new OpenAiStreamMapper(model, startInput, toolNameRestoreMap(json));
228
+ const mapper = new OpenAiStreamMapper(model, startInput, toolNameRestoreMap(json), { p: this.name, m: model });
229
+ mapper.wire = wire;
206
230
  mapper.redactError = redact;
207
231
  const parser = new SseParser();
208
232
  const reader = upstream.body.getReader();
@@ -283,8 +307,8 @@ export class OpenAiCompatibleAdapter {
283
307
  exhaustion: failure ? providerExhaustion(429, `${failure.code ?? ""} ${failure.message}`, retryAfterMs(Object.fromEntries(upstream.headers))) : undefined,
284
308
  bytes,
285
309
  note: failure
286
- ? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens})`
287
- : `in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens} stop=${mapper.stopReason}`,
310
+ ? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens} replayed=${carried.replayed} reasoning=${mapper.reasoningCount})`
311
+ : `in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens} stop=${mapper.stopReason} replayed=${carried.replayed} reasoning=${mapper.reasoningCount}`,
288
312
  usage: { input: usage.input_tokens, cached: usage.cache_read_input_tokens, output: usage.output_tokens },
289
313
  stopReason: mapper.stopReason,
290
314
  ...(wantStream || !failure ? { messageId: mapper.messageId } : {}),