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.
Files changed (40) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.es.md +22 -11
  3. package/README.ja.md +14 -5
  4. package/README.ko.md +19 -8
  5. package/README.md +22 -10
  6. package/README.pt-BR.md +22 -11
  7. package/README.zh-CN.md +14 -5
  8. package/dist/app/package.json +1 -1
  9. package/dist/cli/src/index.js +2 -1
  10. package/dist/mod/hooks/i18n.ts +36 -12
  11. package/dist/mod/hooks/judge.ts +17 -1
  12. package/dist/mod/hooks/register.tsx +39 -9
  13. package/dist/mod/hooks/session.ts +11 -3
  14. package/dist/mod/hooks/ui/alerts.tsx +21 -5
  15. package/dist/mod/hooks/ui/band.tsx +1 -1
  16. package/dist/mod/hooks/ui/view.ts +4 -1
  17. package/dist/mod/tests/register.test.tsx +53 -0
  18. package/dist/mod/tests/ui.test.tsx +10 -6
  19. package/dist/mod/types/index.d.ts +4 -1
  20. package/dist/router/src/admin.js +72 -2
  21. package/dist/router/src/agents.js +4 -0
  22. package/dist/router/src/config.js +24 -0
  23. package/dist/router/src/ingress/server.js +2 -2
  24. package/dist/router/src/mod-settings.js +2 -1
  25. package/dist/router/src/providers/chatgpt/index.js +19 -6
  26. package/dist/router/src/providers/chatgpt/translate.js +3 -0
  27. package/dist/router/src/providers/google/index.js +4 -3
  28. package/dist/router/src/providers/grok/auth.js +289 -0
  29. package/dist/router/src/providers/grok/catalog.js +52 -0
  30. package/dist/router/src/providers/grok/index.js +94 -0
  31. package/dist/router/src/providers/openai/index.js +71 -17
  32. package/dist/router/src/providers/openai/translate.js +11 -1
  33. package/dist/router/src/proxy.js +91 -21
  34. package/dist/router/src/redact.js +7 -2
  35. package/dist/router/src/requestlog.js +78 -20
  36. package/dist/router/src/version.js +1 -1
  37. package/dist/ui/app.js +105 -0
  38. package/dist/ui/i18n.js +6 -6
  39. package/docs/ARCHITECTURE.md +91 -0
  40. 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
- const candidate = json.error?.message ?? json.message ?? json.detail;
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
- export function mapHttpError(status, text) {
35
- const message = `OpenAI-compatible provider: ${vendorMessage(text)}`;
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", message);
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", message)
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(`openai ${this.name}: dropped server tools for ${model}: ${[...serverTools].join(", ")} (Anthropic runs these; this provider cannot)`);
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 upstreamHeaders = { "content-type": "application/json", accept: "text/event-stream", ...sessionHeader, ...(this.cfg.headers ?? {}) };
116
- const upstreamSecrets = credentialHeaderValues(Object.entries(upstreamHeaders));
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
- let upstream;
124
- try {
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
- upstream = await fetchWithRetry(endpoint(this.cfg.url, wire), {
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(`openai ${this.name}: ${line}`) });
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
- const out = anthropicError(502, "api_error", `OpenAI-compatible provider unreachable: ${error.message}`);
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
- const safeText = redactErrorText(text, upstreamSecrets);
147
- const out = mapHttpError(upstream.status, safeText);
148
- this.log.warn(`openai ${this.name}: upstream ${upstream.status} for ${model}: ${safeText.slice(0, 400)}`);
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(message, code) {
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";