clauderipple 0.9.0 → 0.9.2
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 +49 -0
- package/README.es.md +22 -11
- package/README.ja.md +14 -5
- package/README.ko.md +19 -8
- package/README.md +22 -10
- package/README.pt-BR.md +22 -11
- package/README.zh-CN.md +14 -5
- package/dist/app/package.json +1 -1
- package/dist/cli/src/index.js +2 -1
- package/dist/mod/hooks/i18n.ts +36 -12
- package/dist/mod/hooks/judge.ts +17 -1
- package/dist/mod/hooks/register.tsx +39 -9
- package/dist/mod/hooks/session.ts +11 -3
- package/dist/mod/hooks/ui/alerts.tsx +21 -5
- package/dist/mod/hooks/ui/band.tsx +1 -1
- package/dist/mod/hooks/ui/view.ts +4 -1
- package/dist/mod/tests/register.test.tsx +53 -0
- package/dist/mod/tests/ui.test.tsx +10 -6
- package/dist/mod/types/index.d.ts +4 -1
- package/dist/router/src/admin.js +72 -2
- package/dist/router/src/agents.js +4 -0
- package/dist/router/src/config.js +24 -0
- package/dist/router/src/ingress/server.js +2 -2
- package/dist/router/src/mod-settings.js +2 -1
- package/dist/router/src/providers/chatgpt/index.js +19 -6
- package/dist/router/src/providers/chatgpt/translate.js +3 -0
- package/dist/router/src/providers/google/index.js +4 -3
- package/dist/router/src/providers/grok/auth.js +289 -0
- package/dist/router/src/providers/grok/catalog.js +52 -0
- package/dist/router/src/providers/grok/index.js +94 -0
- package/dist/router/src/providers/openai/index.js +71 -17
- package/dist/router/src/providers/openai/translate.js +11 -1
- package/dist/router/src/proxy.js +91 -21
- package/dist/router/src/redact.js +7 -2
- package/dist/router/src/requestlog.js +78 -20
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +105 -0
- package/dist/ui/i18n.js +6 -6
- package/docs/ARCHITECTURE.md +91 -0
- package/package.json +1 -1
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// Grok subscription sign-in, borrowed from the Grok CLI. Nothing here signs anyone in.
|
|
2
|
+
//
|
|
3
|
+
// <grok home>/auth.json written by `grok login` ($GROK_HOME, else ~/.grok). One entry per issuer,
|
|
4
|
+
// keyed `https://auth.x.ai::<id>`; each has `key` (the session token, sent
|
|
5
|
+
// as a bearer), `expires_at` (RFC 3339) and a `refresh_token`.
|
|
6
|
+
//
|
|
7
|
+
// Read-only, for the same reason as `borrow-codex`: the CLI refreshes its grant under a lock of its
|
|
8
|
+
// own (`auth lock` in ~/.grok/logs), and if that refresh rotates the refresh token — not measured —
|
|
9
|
+
// refreshing it from here would race the CLI and could sign it out. Measured 2026-10-08 on CLI 1.0.46: a session token lives six hours, and a running
|
|
10
|
+
// CLI refreshes it about five minutes before expiry (`GROK_AUTH_EARLY_INVALIDATION_SECS`, default 300).
|
|
11
|
+
// With no CLI running the file simply goes stale. So when the token is near its end the router asks
|
|
12
|
+
// the CLI to do it: `grok models` with that same variable set to our own threshold makes the CLI see
|
|
13
|
+
// the token as expiring, refresh it under its lock, and write the file — which we then read again.
|
|
14
|
+
// `grok models` was measured to do exactly this (expiry moved six hours, file rewritten).
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
/** `x-grok-client-version` when the CLI cannot be asked. The proxy refuses a request without one (426). */
|
|
20
|
+
export const GROK_CLIENT_VERSION_FALLBACK = "1.0.46";
|
|
21
|
+
/** What the user can do about a missing or rejected session, appended to the errors they read. */
|
|
22
|
+
export const GROK_AUTH_HINT = "sign in with `grok login`, or run `grok` once so the CLI refreshes its session";
|
|
23
|
+
export function grokHome(env = process.env) {
|
|
24
|
+
return env.GROK_HOME ? env.GROK_HOME : path.join(os.homedir(), ".grok");
|
|
25
|
+
}
|
|
26
|
+
export function grokAuthPath(home) {
|
|
27
|
+
return path.join(home, "auth.json");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The session token in `file`: of the entries carrying one, the one that expires last. The entry key
|
|
31
|
+
* is the issuer plus an id, and the CLI's own README reads a different key than the one it writes
|
|
32
|
+
* (`https://accounts.x.ai/sign-in` vs `https://auth.x.ai::…`, 1.0.46), so no key is assumed.
|
|
33
|
+
*/
|
|
34
|
+
export function readGrokToken(file) {
|
|
35
|
+
let doc;
|
|
36
|
+
try {
|
|
37
|
+
doc = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc))
|
|
43
|
+
return null;
|
|
44
|
+
let best = null;
|
|
45
|
+
for (const value of Object.values(doc)) {
|
|
46
|
+
if (!value || typeof value !== "object")
|
|
47
|
+
continue;
|
|
48
|
+
const entry = value;
|
|
49
|
+
// A key that cannot be a header value would only fail inside fetch, which quotes it in the error.
|
|
50
|
+
if (typeof entry.key !== "string" || !entry.key || /[\0\r\n]|[^\0-\xff]/.test(entry.key))
|
|
51
|
+
continue;
|
|
52
|
+
const expiresAt = typeof entry.expires_at === "string" ? Date.parse(entry.expires_at) || 0 : 0;
|
|
53
|
+
if (best && expiresAt <= best.expiresAt)
|
|
54
|
+
continue;
|
|
55
|
+
best = { key: entry.key, expiresAt, ...(typeof entry.email === "string" ? { email: entry.email } : {}) };
|
|
56
|
+
}
|
|
57
|
+
return best;
|
|
58
|
+
}
|
|
59
|
+
function executable(file) {
|
|
60
|
+
try {
|
|
61
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
62
|
+
return fs.statSync(file).isFile();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The `grok` binary. The router runs under launchd with PATH=/usr/bin:/bin:/usr/sbin:/sbin (measured
|
|
70
|
+
* 2026-10-08), where the CLI never is, so the installer's own location comes before PATH.
|
|
71
|
+
*/
|
|
72
|
+
export function findGrokCli(home, explicit, env = process.env) {
|
|
73
|
+
if (explicit)
|
|
74
|
+
return executable(explicit) ? explicit : null;
|
|
75
|
+
const name = process.platform === "win32" ? "grok.exe" : "grok";
|
|
76
|
+
const candidates = [
|
|
77
|
+
path.join(home, "bin", name),
|
|
78
|
+
...(env.PATH ?? "").split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, name)),
|
|
79
|
+
path.join(env.HOME || os.homedir(), ".local", "bin", name),
|
|
80
|
+
];
|
|
81
|
+
return candidates.find(executable) ?? null;
|
|
82
|
+
}
|
|
83
|
+
const runCli = (file, args, env, timeoutMs) => new Promise((resolve, reject) => {
|
|
84
|
+
execFile(file, args, { env, timeout: timeoutMs, windowsHide: true, maxBuffer: 1024 * 1024 }, (error, stdout) => {
|
|
85
|
+
if (error)
|
|
86
|
+
reject(error);
|
|
87
|
+
else
|
|
88
|
+
resolve(String(stdout));
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
export class GrokAuthError extends Error {
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Bounded by the CLI's own: it waits up to 25s for its auth lock (`auth lock: attempting acquire
|
|
95
|
+
* (timeout=25000ms)`), so a refresh that cannot finish in 30s is not going to.
|
|
96
|
+
*/
|
|
97
|
+
const REFRESH_TIMEOUT_MS = 30_000;
|
|
98
|
+
/**
|
|
99
|
+
* A rejected token is not ours to keep, so the refresh that follows one treats whatever is there as
|
|
100
|
+
* expiring. A day is past any session's six hours, and is the value measured to make the CLI refresh.
|
|
101
|
+
*/
|
|
102
|
+
const FORCE_REFRESH_SECS = 24 * 3600;
|
|
103
|
+
/**
|
|
104
|
+
* A proxy that keeps refusing a freshly minted token is refusing the account, not the token, and
|
|
105
|
+
* forcing a refresh for every turn would only churn the grant. The CLI's README states the same rule
|
|
106
|
+
* for its own credential helpers: a token rejected within 30s of being fetched is not fetched again.
|
|
107
|
+
*/
|
|
108
|
+
const FORCED_REFRESH_GAP_MS = 60_000;
|
|
109
|
+
/**
|
|
110
|
+
* A proactive refresh that ran and still left the token short (the CLI failed, or the IdP is down) is
|
|
111
|
+
* not run again for every turn in the minutes the token has left: each run can take 30s.
|
|
112
|
+
*/
|
|
113
|
+
const REFRESH_RETRY_GAP_MS = 60_000;
|
|
114
|
+
export class GrokAuth {
|
|
115
|
+
file;
|
|
116
|
+
opts;
|
|
117
|
+
refreshAheadMs;
|
|
118
|
+
run;
|
|
119
|
+
now;
|
|
120
|
+
env;
|
|
121
|
+
cached;
|
|
122
|
+
refreshing;
|
|
123
|
+
lastForcedAt = -Infinity;
|
|
124
|
+
lastAttemptAt = -Infinity;
|
|
125
|
+
version;
|
|
126
|
+
constructor(opts) {
|
|
127
|
+
this.opts = opts;
|
|
128
|
+
this.file = grokAuthPath(opts.home);
|
|
129
|
+
this.refreshAheadMs = opts.refreshAheadMs ?? 10 * 60_000;
|
|
130
|
+
this.run = opts.run ?? runCli;
|
|
131
|
+
this.now = opts.now ?? Date.now;
|
|
132
|
+
this.env = opts.env ?? process.env;
|
|
133
|
+
}
|
|
134
|
+
/** The token on disk, read again only when the file changes. One stat per call. */
|
|
135
|
+
current() {
|
|
136
|
+
let st;
|
|
137
|
+
try {
|
|
138
|
+
st = fs.statSync(this.file);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
this.cached = undefined;
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
if (this.cached && this.cached.mtimeMs === st.mtimeMs && this.cached.size === st.size)
|
|
145
|
+
return this.cached.token;
|
|
146
|
+
const token = readGrokToken(this.file);
|
|
147
|
+
this.cached = { mtimeMs: st.mtimeMs, size: st.size, token };
|
|
148
|
+
return token;
|
|
149
|
+
}
|
|
150
|
+
usable(token, marginMs) {
|
|
151
|
+
return token !== null && (token.expiresAt === 0 || token.expiresAt - this.now() > marginMs);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* A token with at least `refreshAheadMs` to live, asking the CLI for a fresh one when the file's has
|
|
155
|
+
* less. A token that is merely close to its end is still used when the CLI cannot refresh it: it
|
|
156
|
+
* works until it expires, and refusing it early would fail a turn that could have run.
|
|
157
|
+
*/
|
|
158
|
+
async token() {
|
|
159
|
+
const now = this.current();
|
|
160
|
+
if (this.usable(now, this.refreshAheadMs))
|
|
161
|
+
return now;
|
|
162
|
+
if (now === null && !fs.existsSync(this.file))
|
|
163
|
+
throw new GrokAuthError(`no Grok session at ${this.file}: ${GROK_AUTH_HINT}`);
|
|
164
|
+
// A refresh already running is joined. Otherwise one that failed less than a minute ago is not
|
|
165
|
+
// run again, whether or not the token has expired since: with the IdP down every turn would
|
|
166
|
+
// spawn the CLI for nothing. The file is still read again, in case the CLI fixed it on its own.
|
|
167
|
+
if (this.refreshing)
|
|
168
|
+
await this.refreshing;
|
|
169
|
+
else if (this.now() - this.lastAttemptAt >= REFRESH_RETRY_GAP_MS)
|
|
170
|
+
await this.refresh(Math.ceil(this.refreshAheadMs / 1000));
|
|
171
|
+
const after = this.current();
|
|
172
|
+
if (this.usable(after, 0))
|
|
173
|
+
return after;
|
|
174
|
+
throw new GrokAuthError(`the Grok session in ${this.file} has expired: ${GROK_AUTH_HINT}`);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* After the proxy refused `key`: whether a different, live token is available now. The file is read
|
|
178
|
+
* first, since the CLI may already have rotated it; only when it still holds the refused token is
|
|
179
|
+
* the CLI asked to refresh, and that refresh is forced — the token was refused, however long it had.
|
|
180
|
+
*/
|
|
181
|
+
async rejected(key) {
|
|
182
|
+
const replaced = () => {
|
|
183
|
+
const now = this.current();
|
|
184
|
+
return now !== null && now.key !== key && this.usable(now, 0);
|
|
185
|
+
};
|
|
186
|
+
if (replaced())
|
|
187
|
+
return true;
|
|
188
|
+
// Concurrent turns refused together share the refresh the first one started, rather than the
|
|
189
|
+
// second one finding the once-a-minute limit spent and failing.
|
|
190
|
+
if (this.refreshing) {
|
|
191
|
+
await this.refreshing;
|
|
192
|
+
if (replaced())
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
if (this.now() - this.lastForcedAt < FORCED_REFRESH_GAP_MS)
|
|
196
|
+
return false;
|
|
197
|
+
this.lastForcedAt = this.now();
|
|
198
|
+
await this.refresh(FORCE_REFRESH_SECS);
|
|
199
|
+
return replaced();
|
|
200
|
+
}
|
|
201
|
+
/** One refresh at a time: concurrent turns that all find the token stale share the CLI run. */
|
|
202
|
+
refresh(earlySecs) {
|
|
203
|
+
if (this.refreshing)
|
|
204
|
+
return this.refreshing;
|
|
205
|
+
this.lastAttemptAt = this.now();
|
|
206
|
+
const cli = findGrokCli(this.opts.home, this.opts.cli, this.env);
|
|
207
|
+
if (!cli) {
|
|
208
|
+
this.opts.log.warn(`grok: session needs a refresh but no grok CLI was found${this.opts.cli ? ` at ${this.opts.cli}` : ""}`);
|
|
209
|
+
return Promise.resolve();
|
|
210
|
+
}
|
|
211
|
+
const env = {
|
|
212
|
+
...this.env,
|
|
213
|
+
GROK_AUTH_EARLY_INVALIDATION_SECS: String(earlySecs),
|
|
214
|
+
// The CLI reads its session from $GROK_HOME; a home given in the config has to reach it.
|
|
215
|
+
...(this.opts.home !== grokHome(this.env) ? { GROK_HOME: this.opts.home } : {}),
|
|
216
|
+
};
|
|
217
|
+
const started = this.now();
|
|
218
|
+
this.refreshing = this.run(cli, ["models"], env, REFRESH_TIMEOUT_MS)
|
|
219
|
+
.then(() => this.opts.log.info(`grok: session refreshed through the CLI in ${this.now() - started}ms`))
|
|
220
|
+
.catch((error) => this.opts.log.warn(`grok: CLI session refresh failed: ${error.message.split("\n")[0]?.slice(0, 200)}`))
|
|
221
|
+
// The CLI rewrites the file with a token of the same length, possibly within the mtime's
|
|
222
|
+
// resolution, so the read after a refresh must not trust the stat-keyed cache.
|
|
223
|
+
.finally(() => { this.refreshing = undefined; this.cached = undefined; });
|
|
224
|
+
return this.refreshing;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* `x-grok-client-version`: the installed CLI's own version, so the proxy sees the client the token
|
|
228
|
+
* was issued to. Read again when the binary changes: an update either replaces the file the symlink
|
|
229
|
+
* names or rewrites the binary in place, so the key is the resolved path with its mtime and size.
|
|
230
|
+
*/
|
|
231
|
+
clientVersion(pinned = this.opts.clientVersion) {
|
|
232
|
+
if (pinned)
|
|
233
|
+
return Promise.resolve(pinned);
|
|
234
|
+
const cli = findGrokCli(this.opts.home, this.opts.cli, this.env);
|
|
235
|
+
let binary = "";
|
|
236
|
+
try {
|
|
237
|
+
if (cli) {
|
|
238
|
+
const real = fs.realpathSync(cli);
|
|
239
|
+
const st = fs.statSync(real);
|
|
240
|
+
binary = `${real}\0${st.mtimeMs}\0${st.size}`;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
binary = cli ?? ""; /* keep the unresolved name */
|
|
245
|
+
}
|
|
246
|
+
if (this.version && this.version.binary === binary)
|
|
247
|
+
return this.version.value;
|
|
248
|
+
const value = !cli
|
|
249
|
+
? Promise.resolve(GROK_CLIENT_VERSION_FALLBACK)
|
|
250
|
+
: this.run(cli, ["--version"], this.env, 10_000)
|
|
251
|
+
.then((out) => /\b(\d+\.\d+\.\d+)\b/.exec(out)?.[1] ?? GROK_CLIENT_VERSION_FALLBACK)
|
|
252
|
+
.catch((error) => {
|
|
253
|
+
this.opts.log.warn(`grok: \`grok --version\` failed (${error.message.split("\n")[0]?.slice(0, 120)}); sending ${GROK_CLIENT_VERSION_FALLBACK}`);
|
|
254
|
+
return GROK_CLIENT_VERSION_FALLBACK;
|
|
255
|
+
});
|
|
256
|
+
this.version = { binary, value };
|
|
257
|
+
return value;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const shared = new Map();
|
|
261
|
+
/**
|
|
262
|
+
* The `GrokAuth` for one Grok home and binary, made once per process. The refresh is single-flight
|
|
263
|
+
* and backed off per instance, so the dashboard's probes and the turns that use the same session
|
|
264
|
+
* have to share one, or two of them spawn two CLI runs against the same lock. A pinned client version
|
|
265
|
+
* is not part of it: that is passed to `clientVersion()` by whoever sends the header.
|
|
266
|
+
*/
|
|
267
|
+
export function sharedGrokAuth(opts) {
|
|
268
|
+
const real = (file) => {
|
|
269
|
+
try {
|
|
270
|
+
return fs.realpathSync(file);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return file; /* not there yet: the resolved path names it */
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
const home = real(path.resolve(opts.home));
|
|
277
|
+
// The binary that would actually run, so a config that names the CLI the lookup would find anyway
|
|
278
|
+
// still shares the instance of one that leaves it out.
|
|
279
|
+
const cli = findGrokCli(home, opts.cli, opts.env);
|
|
280
|
+
const key = JSON.stringify([home, cli ? real(cli) : opts.cli ?? ""]);
|
|
281
|
+
let auth = shared.get(key);
|
|
282
|
+
if (!auth) {
|
|
283
|
+
// Built on the resolved home: one created through a symlink must not follow the link elsewhere
|
|
284
|
+
// later while still being found under the old target.
|
|
285
|
+
auth = new GrokAuth({ ...opts, home });
|
|
286
|
+
shared.set(key, auth);
|
|
287
|
+
}
|
|
288
|
+
return auth;
|
|
289
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The models a Grok subscription offers, as the CLI chat proxy lists them (`GET /v1/models`).
|
|
2
|
+
//
|
|
3
|
+
// Measured 2026-10-08 (CLI 1.0.46, SuperGrok): four models, each with `context_window`,
|
|
4
|
+
// `reasoning_efforts` (`[{id, value, label, description, default}]`, listed high to low) and
|
|
5
|
+
// `api_backend: "responses"`. The listing is the truth about the ladder: grok-4.5 lists low..high,
|
|
6
|
+
// the others low..xhigh. (Chat Completions accepted `xhigh` on grok-4.5 too, but a 200 means only
|
|
7
|
+
// "not refused" — §4c — so the listing wins.) `max` and the string "none" are refused with 400
|
|
8
|
+
// `invalid-argument` on every model; omitting the field is accepted and the model's default (`high`)
|
|
9
|
+
// applies.
|
|
10
|
+
export const GROK_DEFAULT_URL = "https://cli-chat-proxy.grok.com/v1";
|
|
11
|
+
/** The ladder for a model the listing has not described. Ascending, as the picker expects. */
|
|
12
|
+
export const GROK_EFFORT_LEVELS = ["low", "medium", "high", "xhigh"];
|
|
13
|
+
const ORDER = ["minimal", "low", "medium", "high", "xhigh", "max"];
|
|
14
|
+
/** What the listing said on 2026-10-08, for when it cannot be asked (no session yet, offline). */
|
|
15
|
+
export const GROK_FALLBACK_MODELS = [
|
|
16
|
+
{ id: "grok-4.7", name: "Grok 4.7", contextWindow: 256000, effortLevels: [...GROK_EFFORT_LEVELS] },
|
|
17
|
+
{ id: "grok-4.7-build-fast", name: "Grok 4.7 Fast", contextWindow: 256000, effortLevels: [...GROK_EFFORT_LEVELS] },
|
|
18
|
+
{ id: "grok-4.6", name: "Grok 4.6", contextWindow: 256000, effortLevels: [...GROK_EFFORT_LEVELS] },
|
|
19
|
+
{ id: "grok-4.5", name: "Grok 4.5", contextWindow: 256000, effortLevels: ["low", "medium", "high"] },
|
|
20
|
+
];
|
|
21
|
+
/**
|
|
22
|
+
* Model entries from a `/v1/models` body. Only `id`, `name`, `contextWindow` and `effortLevels` are
|
|
23
|
+
* kept: a Grok model never carries `wire`, `url` or `authHeader`, because `providerFor` would turn it
|
|
24
|
+
* into an openai-compatible provider without the session headers.
|
|
25
|
+
*/
|
|
26
|
+
export function grokModelsFromListing(body) {
|
|
27
|
+
const data = body && typeof body === "object" && Array.isArray(body.data) ? body.data : [];
|
|
28
|
+
return data.flatMap((item) => {
|
|
29
|
+
if (!item || typeof item !== "object")
|
|
30
|
+
return [];
|
|
31
|
+
const m = item;
|
|
32
|
+
if (typeof m.id !== "string" || !m.id)
|
|
33
|
+
return [];
|
|
34
|
+
const listed = Array.isArray(m.reasoning_efforts)
|
|
35
|
+
? m.reasoning_efforts.flatMap((effort) => {
|
|
36
|
+
const value = effort && typeof effort === "object" ? effort.value ?? effort.id : undefined;
|
|
37
|
+
return typeof value === "string" ? [value] : [];
|
|
38
|
+
})
|
|
39
|
+
: undefined;
|
|
40
|
+
const effortLevels = m.supports_reasoning_effort === false
|
|
41
|
+
? []
|
|
42
|
+
: listed && listed.length > 0
|
|
43
|
+
? [...new Set(listed)].sort((a, b) => (ORDER.indexOf(a) + 1 || 99) - (ORDER.indexOf(b) + 1 || 99))
|
|
44
|
+
: undefined;
|
|
45
|
+
return [{
|
|
46
|
+
id: m.id,
|
|
47
|
+
...(typeof m.name === "string" && m.name ? { name: m.name } : {}),
|
|
48
|
+
...(typeof m.context_window === "number" && m.context_window > 0 ? { contextWindow: m.context_window } : {}),
|
|
49
|
+
...(effortLevels !== undefined ? { effortLevels } : {}),
|
|
50
|
+
}];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Grok on a SuperGrok / X Premium subscription: Anthropic Messages translated to OpenAI Chat
|
|
2
|
+
// Completions and sent to the chat proxy the Grok CLI itself talks to, signed in with the CLI's own
|
|
3
|
+
// session. The translation, streaming, retries and error mapping are the openai-compatible adapter's;
|
|
4
|
+
// what this adds is the credential, which rotates under us, and the headers the proxy routes on.
|
|
5
|
+
//
|
|
6
|
+
// The proxy is documented for direct use in the CLI's README ("Using auth.json for API Access":
|
|
7
|
+
// `Authorization: Bearer`, `X-XAI-Token-Auth: xai-grok-cli`, `x-grok-model-override`). Measured
|
|
8
|
+
// 2026-10-08 that it also refuses a request without `x-grok-client-version` (426 "Your Grok CLI
|
|
9
|
+
// version (none) is outdated"), which the README does not mention — see ARCHITECTURE §4e.
|
|
10
|
+
import { OpenAiCompatibleAdapter } from "../openai/index.js";
|
|
11
|
+
import { GROK_AUTH_HINT, GrokAuth, GrokAuthError, grokHome, sharedGrokAuth } from "./auth.js";
|
|
12
|
+
import { GROK_DEFAULT_URL, GROK_EFFORT_LEVELS } from "./catalog.js";
|
|
13
|
+
/**
|
|
14
|
+
* Facts only, returned by the probe in a `warning` field like the Antigravity one. Nothing published
|
|
15
|
+
* by xAI says either way whether a third-party client may use the CLI's session.
|
|
16
|
+
*/
|
|
17
|
+
export const GROK_WARNING = "This uses the Grok CLI's own sign-in and the chat proxy the CLI talks to. xAI's README for the CLI " +
|
|
18
|
+
"shows that proxy being called directly with this session, but the version header it also requires " +
|
|
19
|
+
"is undocumented, and the proxy is not a published API: a CLI release or a change on xAI's side can " +
|
|
20
|
+
"break this without warning. ClaudeRipple is not affiliated with xAI.";
|
|
21
|
+
/** The headers the proxy authenticates and routes on. The model goes in a header, not just the body. */
|
|
22
|
+
export function grokHeaders(token, model, clientVersion) {
|
|
23
|
+
return {
|
|
24
|
+
authorization: `Bearer ${token}`,
|
|
25
|
+
"x-xai-token-auth": "xai-grok-cli",
|
|
26
|
+
"x-grok-model-override": model,
|
|
27
|
+
"x-grok-client-version": clientVersion,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function bearer(headers) {
|
|
31
|
+
return (headers.authorization ?? "").replace(/^Bearer\s+/i, "");
|
|
32
|
+
}
|
|
33
|
+
export class GrokAdapter {
|
|
34
|
+
name;
|
|
35
|
+
auth;
|
|
36
|
+
inner;
|
|
37
|
+
constructor(name, cfg, log, auth) {
|
|
38
|
+
this.name = name;
|
|
39
|
+
// Shared with the dashboard's probe and with any other provider on the same session, and kept
|
|
40
|
+
// across a config change that rebuilds this adapter.
|
|
41
|
+
this.auth = auth ?? sharedGrokAuth({ home: cfg.home ?? grokHome(), ...(cfg.cli ? { cli: cfg.cli } : {}), log });
|
|
42
|
+
// Chat Completions rather than the `responses` backend the listing names: on Chat the reasoning
|
|
43
|
+
// arrives as text the mapper turns into thinking blocks, and a stable prefix was cached without a
|
|
44
|
+
// conversation header (measured 2026-10-08, §4e). Effort per model comes from the listing the
|
|
45
|
+
// probe saved; a model it has not described gets the common ladder.
|
|
46
|
+
const translated = {
|
|
47
|
+
type: "openai-compatible",
|
|
48
|
+
url: cfg.url ?? GROK_DEFAULT_URL,
|
|
49
|
+
wire: "chat",
|
|
50
|
+
caps: { reasoning: "effort", effortLevels: [...GROK_EFFORT_LEVELS] },
|
|
51
|
+
...(cfg.models ? { models: cfg.models } : {}),
|
|
52
|
+
...(cfg.identity === undefined ? {} : { identity: cfg.identity }),
|
|
53
|
+
...(cfg.instructionsAppend ? { instructionsAppend: cfg.instructionsAppend } : {}),
|
|
54
|
+
};
|
|
55
|
+
this.inner = new OpenAiCompatibleAdapter(name, translated, log, {
|
|
56
|
+
label: "Grok",
|
|
57
|
+
logTag: "grok",
|
|
58
|
+
authHint: GROK_AUTH_HINT,
|
|
59
|
+
headers: async (model) => {
|
|
60
|
+
const token = this.auth.current();
|
|
61
|
+
return grokHeaders(token?.key ?? "", model, await this.auth.clientVersion(cfg.clientVersion));
|
|
62
|
+
},
|
|
63
|
+
unauthorized: async (_model, sent) => this.auth.rejected(bearer(sent)),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
async handle(req, res, path, json, model, effort) {
|
|
67
|
+
// Counting tokens is answered locally and needs no session.
|
|
68
|
+
if (!path.startsWith("/v1/messages/count_tokens")) {
|
|
69
|
+
// The wait can be a 30s CLI refresh. A client that leaves meanwhile has cancelled the turn, and
|
|
70
|
+
// the inner adapter only starts listening for that once it is called, so it is watched from here.
|
|
71
|
+
let closed = res.destroyed;
|
|
72
|
+
const onClose = () => { closed = true; };
|
|
73
|
+
res.on("close", onClose);
|
|
74
|
+
try {
|
|
75
|
+
await this.auth.token();
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (closed)
|
|
79
|
+
return { status: 0, bytes: 0, note: "client closed" };
|
|
80
|
+
if (!(error instanceof GrokAuthError))
|
|
81
|
+
throw error;
|
|
82
|
+
const body = JSON.stringify({ type: "error", error: { type: "authentication_error", message: `Grok: ${error.message}` } });
|
|
83
|
+
res.writeHead(401, { "content-type": "application/json", "content-length": String(Buffer.byteLength(body)) }).end(body);
|
|
84
|
+
return { status: 401, bytes: Buffer.byteLength(body), note: "no usable Grok session" };
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
res.off("close", onClose);
|
|
88
|
+
}
|
|
89
|
+
if (closed)
|
|
90
|
+
return { status: 0, bytes: 0, note: "client closed" };
|
|
91
|
+
}
|
|
92
|
+
return this.inner.handle(req, res, path, json, model, effort);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -20,7 +20,8 @@ function failureStatus(failure) {
|
|
|
20
20
|
function vendorMessage(text) {
|
|
21
21
|
try {
|
|
22
22
|
const json = JSON.parse(text);
|
|
23
|
-
|
|
23
|
+
// Most vendors nest the message; some (the Grok CLI proxy) send `{"error": "<message>"}`.
|
|
24
|
+
const candidate = typeof json.error === "string" ? json.error : json.error?.message ?? json.message ?? json.detail;
|
|
24
25
|
if (typeof candidate === "string")
|
|
25
26
|
return candidate.slice(0, 500);
|
|
26
27
|
}
|
|
@@ -31,16 +32,24 @@ function vendorMessage(text) {
|
|
|
31
32
|
export function looksLikeAuth(text) {
|
|
32
33
|
return /\b(api[_ -]?key|token|credential|unauthori[sz]ed|authentication|invalid[_ -]?key|expired)\b/i.test(text);
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
/**
|
|
36
|
+
* `label` names the provider in the message the user reads; `authHint`, when given, is appended to an
|
|
37
|
+
* authentication error to say what to do about it — a credential the user can fix in another tool is
|
|
38
|
+
* only fixable if the error names the tool.
|
|
39
|
+
*/
|
|
40
|
+
export function mapHttpError(status, text, label = "OpenAI-compatible provider", authHint, redact = (message) => message) {
|
|
41
|
+
// Cleaned after decoding: a credential escaped inside the JSON (`\u0074ok…`) only reads as itself
|
|
42
|
+
// once the string is parsed.
|
|
43
|
+
const message = `${label}: ${redact(vendorMessage(text))}`;
|
|
44
|
+
const authMessage = authHint ? `${message} — ${authHint}` : message;
|
|
36
45
|
if (status === 401)
|
|
37
|
-
return anthropicError(401, "authentication_error",
|
|
46
|
+
return anthropicError(401, "authentication_error", authMessage);
|
|
38
47
|
// A 403 is often not the credential at all: a data-sharing policy that needs opting into, a
|
|
39
48
|
// region, a model the account may not use. Calling it an authentication error sends the user
|
|
40
49
|
// back to check a key that was never the problem — which is most of an afternoon.
|
|
41
50
|
if (status === 403) {
|
|
42
51
|
return looksLikeAuth(text)
|
|
43
|
-
? anthropicError(401, "authentication_error",
|
|
52
|
+
? anthropicError(401, "authentication_error", authMessage)
|
|
44
53
|
: anthropicError(403, "permission_error", message);
|
|
45
54
|
}
|
|
46
55
|
if (status === 429)
|
|
@@ -62,11 +71,15 @@ export class OpenAiCompatibleAdapter {
|
|
|
62
71
|
name;
|
|
63
72
|
cfg;
|
|
64
73
|
log;
|
|
74
|
+
hooks;
|
|
75
|
+
tag;
|
|
65
76
|
lastInputByKey = new Map();
|
|
66
|
-
constructor(name, cfg, log) {
|
|
77
|
+
constructor(name, cfg, log, hooks = {}) {
|
|
67
78
|
this.name = name;
|
|
68
79
|
this.cfg = cfg;
|
|
69
80
|
this.log = log;
|
|
81
|
+
this.hooks = hooks;
|
|
82
|
+
this.tag = hooks.logTag ?? "openai";
|
|
70
83
|
}
|
|
71
84
|
rememberInput(key, usage) {
|
|
72
85
|
const total = usage.input_tokens + usage.cache_read_input_tokens;
|
|
@@ -96,7 +109,7 @@ export class OpenAiCompatibleAdapter {
|
|
|
96
109
|
// an empty answer with nothing logged anywhere.
|
|
97
110
|
const serverTools = serverToolNames(json.tools);
|
|
98
111
|
if (serverTools.size > 0)
|
|
99
|
-
this.log.warn(
|
|
112
|
+
this.log.warn(`${this.tag} ${this.name}: dropped server tools for ${model}: ${[...serverTools].join(", ")} (Anthropic runs these; this provider cannot)`);
|
|
100
113
|
const upstreamRequest = toOpenAiRequest(json, {
|
|
101
114
|
model,
|
|
102
115
|
wire,
|
|
@@ -112,46 +125,85 @@ export class OpenAiCompatibleAdapter {
|
|
|
112
125
|
const sessionHeader = this.cfg.sessionHeader
|
|
113
126
|
? { [this.cfg.sessionHeader]: conversationKey(json) }
|
|
114
127
|
: {};
|
|
115
|
-
const
|
|
116
|
-
const
|
|
128
|
+
const label = this.hooks.label ?? "OpenAI-compatible provider";
|
|
129
|
+
const headersNow = async () => ({
|
|
130
|
+
"content-type": "application/json",
|
|
131
|
+
accept: "text/event-stream",
|
|
132
|
+
...sessionHeader,
|
|
133
|
+
...(this.cfg.headers ?? {}),
|
|
134
|
+
...(this.hooks.headers ? await this.hooks.headers(model) : {}),
|
|
135
|
+
});
|
|
136
|
+
let upstreamHeaders = {};
|
|
137
|
+
// Every credential sent on any attempt: after a 401 retry the vendor's error may still echo the
|
|
138
|
+
// first one.
|
|
139
|
+
const upstreamSecrets = new Set();
|
|
140
|
+
const redact = (text) => redactErrorText(text, [...upstreamSecrets], 2000);
|
|
117
141
|
// Same input floor behavior as the ChatGPT adapter: the CLI snapshots message_start before usage arrives.
|
|
118
142
|
const key = JSON.stringify({ model, wire, system: json.system ?? "", user: json.messages.find((message) => message.role === "user")?.content ?? "" });
|
|
119
143
|
const startInput = Math.max(estimateTokens(json), this.lastInputByKey.get(key) ?? 0);
|
|
120
144
|
const controller = new AbortController();
|
|
121
145
|
const onClose = () => controller.abort();
|
|
122
146
|
res.on("close", onClose);
|
|
123
|
-
|
|
124
|
-
|
|
147
|
+
const ask = async () => {
|
|
148
|
+
upstreamHeaders = await headersNow();
|
|
149
|
+
for (const secret of credentialHeaderValues(Object.entries(upstreamHeaders)))
|
|
150
|
+
upstreamSecrets.add(secret);
|
|
125
151
|
// Nothing has been written to the client yet, so a failure another attempt could answer is
|
|
126
152
|
// asked again here rather than handed to the user as an error they would have to retry by
|
|
127
153
|
// hand. Once this returns, the response is written straight through (see `fetchWithRetry`).
|
|
128
|
-
|
|
154
|
+
return fetchWithRetry(endpoint(this.cfg.url, wire), {
|
|
129
155
|
method: "POST",
|
|
130
156
|
headers: upstreamHeaders,
|
|
131
157
|
body: requestBody,
|
|
132
158
|
signal: controller.signal,
|
|
133
|
-
}, { log: (line) => this.log.info(
|
|
159
|
+
}, { log: (line) => this.log.info(`${this.tag} ${this.name}: ${redact(line)}`), ...(this.hooks.attempts ? { attempts: this.hooks.attempts } : {}) });
|
|
160
|
+
};
|
|
161
|
+
let upstream;
|
|
162
|
+
try {
|
|
163
|
+
upstream = await ask();
|
|
164
|
+
// A credential that rotates under us can be refused for being the one we read a moment before
|
|
165
|
+
// its owner replaced it. Still nothing written, so the turn can be asked once more.
|
|
166
|
+
if (upstream.status === 401 && this.hooks.unauthorized) {
|
|
167
|
+
const refusal = await upstream.text().catch(() => "");
|
|
168
|
+
if (await this.hooks.unauthorized(model, upstreamHeaders)) {
|
|
169
|
+
this.log.info(`${this.tag} ${this.name}: 401 for ${model}; credential changed, asking again`);
|
|
170
|
+
upstream = await ask();
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
upstream = new Response(refusal, { status: 401 });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
134
176
|
}
|
|
135
177
|
catch (error) {
|
|
136
178
|
res.off("close", onClose);
|
|
137
179
|
if (controller.signal.aborted)
|
|
138
180
|
return { status: 0, bytes: 0, note: "client closed" };
|
|
139
|
-
|
|
181
|
+
// fetch names an invalid header value in full, credential included; the message also reaches the
|
|
182
|
+
// request log through whoever catches this.
|
|
183
|
+
error.message = redact(error.message);
|
|
184
|
+
const out = anthropicError(502, "api_error", `${label} unreachable: ${error.message}`);
|
|
140
185
|
if (!res.headersSent)
|
|
141
186
|
res.writeHead(out.status, { "content-type": "application/json" }).end(out.body);
|
|
142
187
|
throw error;
|
|
143
188
|
}
|
|
144
189
|
if (!upstream.ok || !upstream.body) {
|
|
145
190
|
const text = await upstream.text().catch(() => "");
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
191
|
+
// Re-serialised first, so an escaped credential in a JSON body is spelled out before it is matched.
|
|
192
|
+
let plain = text;
|
|
193
|
+
try {
|
|
194
|
+
plain = JSON.stringify(JSON.parse(text));
|
|
195
|
+
}
|
|
196
|
+
catch { /* not JSON: matched as it came */ }
|
|
197
|
+
const safeText = redactErrorText(plain, [...upstreamSecrets]);
|
|
198
|
+
const out = mapHttpError(upstream.status, text, label, this.hooks.authHint, redact);
|
|
199
|
+
this.log.warn(`${this.tag} ${this.name}: upstream ${upstream.status} for ${model}: ${safeText.slice(0, 400)}`);
|
|
149
200
|
res.off("close", onClose);
|
|
150
201
|
res.writeHead(out.status, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out.body)) }).end(out.body);
|
|
151
202
|
return { status: out.status, bytes: Buffer.byteLength(out.body), note: `upstream ${upstream.status}`, exhaustion: providerExhaustion(upstream.status, text, retryAfterMs(Object.fromEntries(upstream.headers))) };
|
|
152
203
|
}
|
|
153
204
|
const wantStream = json.stream === true;
|
|
154
205
|
const mapper = new OpenAiStreamMapper(model, startInput, toolNameRestoreMap(json));
|
|
206
|
+
mapper.redactError = redact;
|
|
155
207
|
const parser = new SseParser();
|
|
156
208
|
const reader = upstream.body.getReader();
|
|
157
209
|
const decoder = new TextDecoder();
|
|
@@ -235,6 +287,8 @@ export class OpenAiCompatibleAdapter {
|
|
|
235
287
|
: `in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens} stop=${mapper.stopReason}`,
|
|
236
288
|
usage: { input: usage.input_tokens, cached: usage.cache_read_input_tokens, output: usage.output_tokens },
|
|
237
289
|
stopReason: mapper.stopReason,
|
|
290
|
+
...(wantStream || !failure ? { messageId: mapper.messageId } : {}),
|
|
291
|
+
...(mapper.servedTier !== undefined ? { servedTier: mapper.servedTier } : {}),
|
|
238
292
|
};
|
|
239
293
|
}
|
|
240
294
|
}
|
|
@@ -283,6 +283,7 @@ export class OpenAiStreamMapper {
|
|
|
283
283
|
responseItems = [];
|
|
284
284
|
responseItemsById = new Map();
|
|
285
285
|
messageId = `msg_${crypto.randomBytes(12).toString("hex")}`;
|
|
286
|
+
servedTier;
|
|
286
287
|
content = [];
|
|
287
288
|
usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
|
|
288
289
|
stopReason = "end_turn";
|
|
@@ -290,6 +291,8 @@ export class OpenAiStreamMapper {
|
|
|
290
291
|
startInput;
|
|
291
292
|
/** Mangled tool name → the name Claude Code knows, from `toolNameRestoreMap`. */
|
|
292
293
|
toolNames;
|
|
294
|
+
/** Applied to every error message before it is stored or sent. */
|
|
295
|
+
redactError = (message) => message;
|
|
293
296
|
constructor(model, startInput = 0, toolNames = new Map()) {
|
|
294
297
|
this.model = model;
|
|
295
298
|
this.startInput = startInput;
|
|
@@ -359,6 +362,8 @@ export class OpenAiStreamMapper {
|
|
|
359
362
|
}
|
|
360
363
|
feedChat(ev) {
|
|
361
364
|
const out = [...this.start()];
|
|
365
|
+
if (typeof ev.service_tier === "string")
|
|
366
|
+
this.servedTier = ev.service_tier;
|
|
362
367
|
const usage = ev.usage;
|
|
363
368
|
if (usage)
|
|
364
369
|
this.setUsage(usage);
|
|
@@ -513,6 +518,8 @@ export class OpenAiStreamMapper {
|
|
|
513
518
|
}
|
|
514
519
|
else if (type === "response.completed" || type === "response.incomplete") {
|
|
515
520
|
const response = ev.response;
|
|
521
|
+
if (typeof response?.service_tier === "string")
|
|
522
|
+
this.servedTier = response.service_tier;
|
|
516
523
|
this.setUsage(response?.usage);
|
|
517
524
|
if (response?.incomplete_details?.reason === "max_output_tokens")
|
|
518
525
|
this.finishReason = "length";
|
|
@@ -543,10 +550,13 @@ export class OpenAiStreamMapper {
|
|
|
543
550
|
this.stopReason = this.sawTool || this.finishReason === "tool_calls" ? "tool_use" : this.finishReason === "length" || this.finishReason === "max_output_tokens" ? "max_tokens" : "end_turn";
|
|
544
551
|
return [...this.start(), ...this.closeBlock(), { event: "message_delta", data: { type: "message_delta", delta: { stop_reason: this.stopReason, stop_sequence: null }, usage: this.usage } }, { event: "message_stop", data: { type: "message_stop" } }];
|
|
545
552
|
}
|
|
546
|
-
fail(
|
|
553
|
+
fail(rawMessage, code) {
|
|
547
554
|
if (this.finished)
|
|
548
555
|
return [];
|
|
549
556
|
this.finished = true;
|
|
557
|
+
// A vendor's error text can echo the credential it was sent; the client, the non-streamed body
|
|
558
|
+
// and the request log all take `failure.message`, so it is cleaned once, here.
|
|
559
|
+
const message = this.redactError(rawMessage);
|
|
550
560
|
// `overloaded_error` is the one mid-stream error Claude Code retries on its own (CLI 2.1.278
|
|
551
561
|
// matches `"type":"overloaded_error"` in the message); `api_error` ends the turn for good.
|
|
552
562
|
const type = code === "rate_limit_exceeded" ? "rate_limit_error" : code === "server_is_overloaded" ? "overloaded_error" : "api_error";
|