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.
- package/CHANGELOG.md +80 -0
- package/README.md +31 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +43 -0
- package/dist/cli/src/remote.js +263 -0
- package/dist/cli/src/schtasks.js +48 -40
- package/dist/cli/src/settings.js +2 -1
- package/dist/mod/hooks/i18n.ts +252 -6
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +238 -32
- package/dist/mod/hooks/session.ts +98 -1
- package/dist/mod/hooks/settings.js +7 -3
- package/dist/mod/hooks/settings.ts +9 -4
- package/dist/mod/hooks/stats.ts +25 -1
- package/dist/mod/hooks/ui/agents.tsx +858 -79
- package/dist/mod/hooks/ui/alerts.tsx +20 -17
- package/dist/mod/hooks/ui/art.ts +33 -7
- package/dist/mod/hooks/ui/band.tsx +262 -81
- package/dist/mod/hooks/ui/flat.ts +265 -0
- package/dist/mod/hooks/ui/kit.tsx +70 -55
- package/dist/mod/hooks/ui/look.ts +129 -0
- package/dist/mod/hooks/ui/settings.tsx +71 -46
- package/dist/mod/hooks/ui/setup.tsx +15 -13
- package/dist/mod/hooks/ui/theme.ts +3 -0
- package/dist/mod/hooks/ui/tools.ts +124 -0
- package/dist/mod/hooks/ui/view.ts +116 -3
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +375 -27
- package/dist/mod/types/index.d.ts +68 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +295 -0
- package/dist/remote/src/composer-menu.js +11 -0
- package/dist/remote/src/config.js +102 -0
- package/dist/remote/src/desktop.js +1018 -0
- package/dist/remote/src/e2ee.js +126 -0
- package/dist/remote/src/e2ee.ts +126 -0
- package/dist/remote/src/eventlog.js +298 -0
- package/dist/remote/src/expo-push.js +127 -0
- package/dist/remote/src/folders.js +79 -0
- package/dist/remote/src/fsutil.js +39 -0
- package/dist/remote/src/handoff.js +38 -0
- package/dist/remote/src/host-service.js +168 -0
- package/dist/remote/src/images.js +215 -0
- package/dist/remote/src/local-control.js +138 -0
- package/dist/remote/src/main.js +264 -0
- package/dist/remote/src/permissions.js +124 -0
- package/dist/remote/src/power.js +76 -0
- package/dist/remote/src/processes.js +24 -0
- package/dist/remote/src/protocol.js +32 -0
- package/dist/remote/src/protocol.ts +284 -0
- package/dist/remote/src/push-content.js +32 -0
- package/dist/remote/src/push.js +134 -0
- package/dist/remote/src/receipts.js +111 -0
- package/dist/remote/src/relay.js +438 -0
- package/dist/remote/src/router.js +208 -0
- package/dist/remote/src/sdk-launcher.js +18 -0
- package/dist/remote/src/server.js +706 -0
- package/dist/remote/src/service.js +157 -0
- package/dist/remote/src/sessions.js +1800 -0
- package/dist/remote/src/sidebar-storage.js +211 -0
- package/dist/remote/src/sidebar.js +173 -0
- package/dist/remote/src/tool-details.js +205 -0
- package/dist/remote/src/tool-details.ts +153 -0
- package/dist/remote/src/worktree.js +99 -0
- package/dist/remote/web/app.ts +746 -0
- package/dist/remote/web/conn.ts +193 -0
- package/dist/remote/web/demo.ts +134 -0
- package/dist/remote/web/dom.ts +124 -0
- package/dist/remote/web/esc.ts +4 -0
- package/dist/remote/web/i18n.ts +374 -0
- package/dist/remote/web/icon.svg +1 -0
- package/dist/remote/web/index.html +24 -0
- package/dist/remote/web/manifest.webmanifest +10 -0
- package/dist/remote/web/mascot.ts +60 -0
- package/dist/remote/web/md.ts +78 -0
- package/dist/remote/web/notify.ts +55 -0
- package/dist/remote/web/pushstate.ts +27 -0
- package/dist/remote/web/secure.ts +109 -0
- package/dist/remote/web/sidebar.ts +106 -0
- package/dist/remote/web/style.css +311 -0
- package/dist/remote/web/sw.ts +48 -0
- package/dist/remote/web/timeline.ts +224 -0
- package/dist/remote/web/views.ts +323 -0
- package/dist/router/src/admin.js +227 -4
- package/dist/router/src/config.js +30 -0
- package/dist/router/src/index.js +39 -0
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/presets.js +3 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/chatgpt/index.js +41 -11
- package/dist/router/src/providers/chatgpt/translate.js +147 -7
- package/dist/router/src/providers/google/index.js +26 -0
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- package/dist/router/src/providers/google/usage.js +257 -0
- package/dist/router/src/providers/grok/index.js +18 -0
- package/dist/router/src/providers/grok/usage.js +77 -0
- package/dist/router/src/providers/openai/index.js +29 -5
- package/dist/router/src/providers/openai/translate.js +260 -27
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/proxy.js +339 -258
- package/dist/router/src/reasoning.js +157 -0
- package/dist/router/src/remote-host.js +320 -0
- package/dist/router/src/remote-tunnel.js +320 -0
- package/dist/router/src/requestlog.js +48 -39
- package/dist/router/src/thinking.js +20 -9
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +436 -59
- package/dist/ui/i18n.js +184 -0
- package/dist/ui/index.html +24 -5
- package/dist/ui/style.css +39 -0
- package/docs/ARCHITECTURE.md +164 -12
- 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
|
-
|
|
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
|
|
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 } : {}),
|