clauderipple 0.9.5 → 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 +67 -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/providers/anthropic-usage.js +33 -1
- 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/translate.js +13 -2
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/proxy.js +339 -258
- 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/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 +108 -8
- package/package.json +7 -1
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -163,13 +163,23 @@ export function toChatMessages(req, opts) {
|
|
|
163
163
|
messages.push({ role: "user", content: onlyText ? parts.map((part) => part.text).join("\n") : parts });
|
|
164
164
|
parts = [];
|
|
165
165
|
};
|
|
166
|
+
// A tool result's images (and the fallback text of an unknown call) wait until the run of tool
|
|
167
|
+
// results ends: every `tool` message must follow the assistant's tool_calls directly, and a user
|
|
168
|
+
// message between parallel results is refused (DeepSeek: "insufficient tool messages", #64).
|
|
169
|
+
let deferred = [];
|
|
170
|
+
const release = () => {
|
|
171
|
+
parts.push(...deferred);
|
|
172
|
+
deferred = [];
|
|
173
|
+
};
|
|
166
174
|
for (const block of message.content) {
|
|
167
175
|
if (block.type === "text") {
|
|
176
|
+
release();
|
|
168
177
|
const text = String(block.text ?? "");
|
|
169
178
|
if (text)
|
|
170
179
|
parts.push({ type: "text", text });
|
|
171
180
|
}
|
|
172
181
|
else if (block.type === "image") {
|
|
182
|
+
release();
|
|
173
183
|
const url = imageUrl(block);
|
|
174
184
|
if (url)
|
|
175
185
|
parts.push({ type: "image_url", image_url: { url } });
|
|
@@ -186,11 +196,12 @@ export function toChatMessages(req, opts) {
|
|
|
186
196
|
if (knownCalls.has(result.tool_use_id))
|
|
187
197
|
messages.push({ role: "tool", tool_call_id: result.tool_use_id, content: output });
|
|
188
198
|
else
|
|
189
|
-
|
|
199
|
+
deferred.push({ type: "text", text: `[Tool result]\n${output}` });
|
|
190
200
|
for (const url of images)
|
|
191
|
-
|
|
201
|
+
deferred.push({ type: "image_url", image_url: { url } });
|
|
192
202
|
}
|
|
193
203
|
}
|
|
204
|
+
release();
|
|
194
205
|
flush();
|
|
195
206
|
}
|
|
196
207
|
return messages;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// How much of an OpenCode Go subscription is used, for the status bar's limits row.
|
|
2
|
+
//
|
|
3
|
+
// One request per provider: GET {provider.url}/usage (measured 2026-10-15: https://opencode.ai/zen/go/v1/usage)
|
|
4
|
+
// with the provider's own config headers (the bearer key) and the same default User-Agent Node's
|
|
5
|
+
// fetch sends elsewhere — Cloudflare answers 403 `error 1010` to a request carrying no User-Agent
|
|
6
|
+
// and no browser fingerprint, but accepts Node's fetch as it is. Source of the route (behavioral
|
|
7
|
+
// only, no code copied): anomalyco/opencode packages/console/app/src/routes/zen/go/v1/usage.ts and
|
|
8
|
+
// lib/inference-proxy.ts (the `GET /zen/go/v1/usage` → `/go/v1/usage` mapping).
|
|
9
|
+
//
|
|
10
|
+
// Measured answer:
|
|
11
|
+
// {"usage":{"rolling":{"status":"ok","percent":3,"resetsAt":"2026-10-15T05:39:48Z"},
|
|
12
|
+
// "weekly":{"status":"ok","percent":52,"resetsAt":"2026-10-19T00:00:00Z"},
|
|
13
|
+
// "monthly":{"status":"ok","percent":92,"resetsAt":"2026-10-18T05:43:30Z"}}}
|
|
14
|
+
// `rolling` is the five-hour window, `weekly` the week, `monthly` roughly 30 days.
|
|
15
|
+
/** The five-hour, weekly and ~monthly window lengths in minutes. */
|
|
16
|
+
const FIVE_HOURS = 5 * 60;
|
|
17
|
+
const WEEK = 7 * 24 * 60;
|
|
18
|
+
const MONTH = 30 * 24 * 60;
|
|
19
|
+
/** The window lengths and read order of the endpoint's three keys. */
|
|
20
|
+
const KEYS = [
|
|
21
|
+
{ key: "rolling", kind: "five_hour", window_minutes: FIVE_HOURS },
|
|
22
|
+
{ key: "weekly", kind: "seven_day", window_minutes: WEEK },
|
|
23
|
+
{ key: "monthly", kind: "window", window_minutes: MONTH },
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Parse the `/usage` body into windows. A window missing a finite percent is dropped; null when the
|
|
27
|
+
* body carries no `usage` object at all (so the caller keeps its last good answer instead of
|
|
28
|
+
* blanking).
|
|
29
|
+
*/
|
|
30
|
+
export function opencodeWindowsFromBody(body) {
|
|
31
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
32
|
+
return null;
|
|
33
|
+
const usage = body.usage;
|
|
34
|
+
if (!usage || typeof usage !== "object" || Array.isArray(usage))
|
|
35
|
+
return null;
|
|
36
|
+
const source = usage;
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const { key, kind, window_minutes } of KEYS) {
|
|
39
|
+
const w = source[key];
|
|
40
|
+
if (!w || typeof w !== "object" || Array.isArray(w))
|
|
41
|
+
continue;
|
|
42
|
+
const win = w;
|
|
43
|
+
if (typeof win.percent !== "number" || !Number.isFinite(win.percent))
|
|
44
|
+
continue;
|
|
45
|
+
const at = typeof win.resetsAt === "string" ? Date.parse(win.resetsAt) : NaN;
|
|
46
|
+
out.push({ kind, used_percent: win.percent, window_minutes, ...(Number.isFinite(at) ? { reset_at: Math.round(at / 1000) } : {}) });
|
|
47
|
+
}
|
|
48
|
+
return out.length ? out : null;
|
|
49
|
+
}
|
|
50
|
+
/** Whether a provider is an OpenCode Go subscription: its preset, or an OpenCode Go endpoint. */
|
|
51
|
+
export function isOpenCodeGo(p) {
|
|
52
|
+
if (p.type === "openai-compatible" && p.preset === "opencode-go")
|
|
53
|
+
return true;
|
|
54
|
+
if (typeof p.url !== "string")
|
|
55
|
+
return false;
|
|
56
|
+
try {
|
|
57
|
+
const url = new URL(p.url);
|
|
58
|
+
return url.hostname === "opencode.ai" && /^\/zen\/go\/v1(?:\/|$)/.test(url.pathname);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Per-provider usage windows, fetched at most once per `ttlMs` per provider. A failed lookup keeps
|
|
66
|
+
* the last good windows rather than blanking them, and only the status code is logged.
|
|
67
|
+
*/
|
|
68
|
+
export class OpenCodeUsage {
|
|
69
|
+
last = new Map();
|
|
70
|
+
at = new Map();
|
|
71
|
+
inFlight = new Map();
|
|
72
|
+
opts;
|
|
73
|
+
constructor(opts) {
|
|
74
|
+
this.opts = opts;
|
|
75
|
+
}
|
|
76
|
+
/** The provider's windows, cached for `ttlMs`; the last good ones survive a failed lookup. */
|
|
77
|
+
windows(name, url, headers) {
|
|
78
|
+
const now = (this.opts.now ?? Date.now)();
|
|
79
|
+
const ttl = this.opts.ttlMs ?? 5 * 60_000;
|
|
80
|
+
if (now - (this.at.get(name) ?? Number.NEGATIVE_INFINITY) < ttl)
|
|
81
|
+
return Promise.resolve(this.last.get(name) ?? []);
|
|
82
|
+
const running = this.inFlight.get(name);
|
|
83
|
+
if (running)
|
|
84
|
+
return running;
|
|
85
|
+
const task = this.fetchOne(name, url, headers).finally(() => {
|
|
86
|
+
this.at.set(name, (this.opts.now ?? Date.now)());
|
|
87
|
+
this.inFlight.delete(name);
|
|
88
|
+
});
|
|
89
|
+
this.inFlight.set(name, task);
|
|
90
|
+
return task;
|
|
91
|
+
}
|
|
92
|
+
async fetchOne(name, url, headers) {
|
|
93
|
+
const keep = () => this.last.get(name) ?? [];
|
|
94
|
+
try {
|
|
95
|
+
const res = await (this.opts.fetch ?? fetch)(`${url.replace(/\/+$/, "")}/usage`, {
|
|
96
|
+
method: "GET",
|
|
97
|
+
headers: { accept: "application/json", ...headers },
|
|
98
|
+
signal: AbortSignal.timeout(this.opts.timeoutMs ?? 5000),
|
|
99
|
+
});
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
// The status alone: the body of an auth failure is not worth the risk of echoing.
|
|
102
|
+
this.opts.warn?.(`opencode usage ${name}: HTTP ${res.status}`);
|
|
103
|
+
return keep();
|
|
104
|
+
}
|
|
105
|
+
const windows = opencodeWindowsFromBody(await res.json());
|
|
106
|
+
if (!windows)
|
|
107
|
+
return keep();
|
|
108
|
+
this.last.set(name, windows);
|
|
109
|
+
return windows;
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
this.opts.warn?.(`opencode usage ${name}: ${e.message}`);
|
|
113
|
+
return keep();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|