clauderipple 0.9.1 → 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.
@@ -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();
@@ -291,6 +291,8 @@ export class OpenAiStreamMapper {
291
291
  startInput;
292
292
  /** Mangled tool name → the name Claude Code knows, from `toolNameRestoreMap`. */
293
293
  toolNames;
294
+ /** Applied to every error message before it is stored or sent. */
295
+ redactError = (message) => message;
294
296
  constructor(model, startInput = 0, toolNames = new Map()) {
295
297
  this.model = model;
296
298
  this.startInput = startInput;
@@ -548,10 +550,13 @@ export class OpenAiStreamMapper {
548
550
  this.stopReason = this.sawTool || this.finishReason === "tool_calls" ? "tool_use" : this.finishReason === "length" || this.finishReason === "max_output_tokens" ? "max_tokens" : "end_turn";
549
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" } }];
550
552
  }
551
- fail(message, code) {
553
+ fail(rawMessage, code) {
552
554
  if (this.finished)
553
555
  return [];
554
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);
555
560
  // `overloaded_error` is the one mid-stream error Claude Code retries on its own (CLI 2.1.278
556
561
  // matches `"type":"overloaded_error"` in the message); `api_error` ends the turn for good.
557
562
  const type = code === "rate_limit_exceeded" ? "rate_limit_error" : code === "server_is_overloaded" ? "overloaded_error" : "api_error";
@@ -30,6 +30,7 @@ import { PRESETS } from "./presets.js";
30
30
  import { ChatGptAdapter } from "./providers/chatgpt/index.js";
31
31
  import { OpenAiCompatibleAdapter } from "./providers/openai/index.js";
32
32
  import { GoogleAdapter } from "./providers/google/index.js";
33
+ import { GrokAdapter } from "./providers/grok/index.js";
33
34
  import { conversationKey } from "./providers/chatgpt/translate.js";
34
35
  import { providerFor, searchProviderFor, terminateHosts } from "./config.js";
35
36
  import { chatPickerModels, injectPickerModels, isBootstrapPath } from "./picker.js";
@@ -186,6 +187,7 @@ export class Proxy {
186
187
  chatgptAdapters = new Map();
187
188
  openaiAdapters = new Map();
188
189
  googleAdapters = new Map();
190
+ grokAdapters = new Map();
189
191
  claudeAccounts;
190
192
  /** Each Claude subscription account's usage, for the admin status (never a token). */
191
193
  claudeUsage;
@@ -304,6 +306,15 @@ export class Proxy {
304
306
  this.openaiAdapters.set(name, { key, adapter });
305
307
  return adapter;
306
308
  }
309
+ grok(name, cfg) {
310
+ const key = JSON.stringify(cfg);
311
+ const cur = this.grokAdapters.get(name);
312
+ if (cur && cur.key === key)
313
+ return cur.adapter;
314
+ const adapter = new GrokAdapter(name, cfg, this.deps.log);
315
+ this.grokAdapters.set(name, { key, adapter });
316
+ return adapter;
317
+ }
307
318
  google(name, cfg) {
308
319
  const key = JSON.stringify(cfg);
309
320
  const cur = this.googleAdapters.get(name);
@@ -881,6 +892,38 @@ export class Proxy {
881
892
  }
882
893
  return;
883
894
  }
895
+ if (provider.type === "grok") {
896
+ // The chat proxy keeps no server-side thread, so a `continue` is refused like on the other
897
+ // translated providers and the CLI resends the turn with the full history.
898
+ const td = threadDecision(json);
899
+ if (td === "refuse") {
900
+ const out = JSON.stringify(THREAD_UNSUPPORTED);
901
+ res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
902
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true });
903
+ return;
904
+ }
905
+ if (td === "strip")
906
+ stripThreadFields(json);
907
+ record = { ...record, target: route.model, provider: route.provider };
908
+ const routeEffort = effortOf(json);
909
+ if (routeEffort)
910
+ record.effort = routeEffort;
911
+ tag = `GROK ${route.tag} effort=${routeEffort ?? "-"}`;
912
+ try {
913
+ const o = await this.grok(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
914
+ this.recordOutcome(route.provider, o.status);
915
+ finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
916
+ }
917
+ catch (e) {
918
+ this.recordOutcome(route.provider, 0);
919
+ finish("-", 0, `grok error ${e.code ?? ""} ${e.message}`);
920
+ if (!res.headersSent)
921
+ res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
922
+ else
923
+ res.destroy();
924
+ }
925
+ return;
926
+ }
884
927
  if (provider.type === "anthropic") {
885
928
  if (!provider.accountPool || provider.auth !== "claude-code") {
886
929
  finish("400", 0, "native Anthropic provider is available through OpenAI ingress only", false);
@@ -11,8 +11,13 @@ function credentialHeaderName(name) {
11
11
  export function credentialHeaderValues(headers) {
12
12
  const secrets = new Set();
13
13
  for (const [rawName, value] of headers) {
14
- if (credentialHeaderName(rawName) && value)
15
- secrets.add(value);
14
+ if (!credentialHeaderName(rawName) || !value)
15
+ continue;
16
+ secrets.add(value);
17
+ // A vendor that echoes the credential echoes the token, not the scheme in front of it.
18
+ const bare = /^(?:Bearer|Basic|Token)\s+(\S+)$/i.exec(value)?.[1];
19
+ if (bare)
20
+ secrets.add(bare);
16
21
  }
17
22
  return [...secrets].sort((a, b) => b.length - a.length);
18
23
  }
@@ -5,4 +5,4 @@
5
5
  // from the manifests. Until 0.1.1 the router and the CLI each carried their own literal, and both
6
6
  // still said "0.1.0" in the 0.1.1 release: after an update nobody could tell which router was
7
7
  // running, and the tray app had nothing to compare (2026-09-15, reported from a Windows install).
8
- export const VERSION = "0.9.1";
8
+ export const VERSION = "0.9.2";
package/dist/ui/app.js CHANGED
@@ -105,6 +105,8 @@ function fallbackEffortLevels(provider, model) {
105
105
  const explicit = modelEffortLevels(provider, model);
106
106
  if (explicit !== undefined) return explicit;
107
107
  if (provider.type === "chatgpt") return CHATGPT_ULTRA_MODELS.has(model) ? [...CHATGPT_EFFORT_LEVELS, "ultra"] : [...CHATGPT_EFFORT_LEVELS];
108
+ // What the router's grok adapter sends a model the proxy's listing has not described.
109
+ if (provider.type === "grok") return ["low", "medium", "high", "xhigh"];
108
110
  const preset = provider.preset && presetById(provider.preset);
109
111
  if (provider.type === "openai-compatible") return provider.caps && provider.caps.reasoning === "effort" && Array.isArray(provider.caps.effortLevels) ? provider.caps.effortLevels : [];
110
112
  return provider.caps && Array.isArray(provider.caps.effortLevels) ? provider.caps.effortLevels : (preset && preset.effortLevels) || [];
@@ -946,6 +948,8 @@ async function probeProvider(name, provider, onComplete) {
946
948
  ? { type: "anthropic", auth: provider.auth, ...(provider.auth === "api-key" && provider.apiKey ? { apiKey: provider.apiKey } : {}) }
947
949
  : provider.type === "google"
948
950
  ? { type: "google", auth: provider.auth, ...(provider.apiKey ? { apiKey: provider.apiKey } : {}), ...(provider.url ? { url: provider.url } : {}) }
951
+ : provider.type === "grok"
952
+ ? { type: "grok", ...Object.fromEntries(["home", "cli", "url", "clientVersion"].filter((key) => provider[key]).map((key) => [key, provider[key]])) }
949
953
  : {
950
954
  type: provider.type,
951
955
  url: provider.url,
@@ -1057,6 +1061,7 @@ function providerKind(name, provider) {
1057
1061
  const preset = provider.preset && presetById(provider.preset);
1058
1062
  if (provider.type === "chatgpt") return t("providers.chatgpt");
1059
1063
  if (provider.type === "google") return `${t("providers.google")} · ${provider.auth === "antigravity" ? t("providers.googleAntigravity") : t("providers.apiKey")}`;
1064
+ if (provider.type === "grok") return t("providers.grok");
1060
1065
  if (provider.type === "anthropic") return provider.auth === "claude-code"
1061
1066
  ? `${t("providers.anthropic")} · ${provider.accountPool ? t("providers.rotationOn") : t("providers.rotationOff")}`
1062
1067
  : `${t("providers.anthropic")} · ${t("providers.apiKey")}`;
@@ -1763,6 +1768,7 @@ function openProviderChooser() {
1763
1768
  tile(t("providers.anthropic"), t("providers.anthropicHelp"), { kind: "anthropic" }),
1764
1769
  tile(t("providers.chatgpt"), t("providers.chatgptHelp"), { kind: "chatgpt" }),
1765
1770
  tile(t("providers.google"), t("providers.googleHelp"), { kind: "google" }),
1771
+ tile(t("providers.grok"), t("providers.grokHelp"), { kind: "grok" }),
1766
1772
  ], null, true);
1767
1773
  const native = presets.filter((preset) => (preset.kind || "anthropic-compatible") === "anthropic-compatible");
1768
1774
  group(t("providers.anthropicGroup"), native.map((preset) => tile(preset.name, null, { preset })), t("providers.presetHelp"));
@@ -1947,6 +1953,104 @@ function openAnthropicProviderForm(options) {
1947
1953
  showModal(form, () => { formActive = false; });
1948
1954
  if (auth.value === "claude-code") void runProbe();
1949
1955
  }
1956
+ /**
1957
+ * What a Grok form's check leaves ticked. A handful of models, so a new provider's first listing
1958
+ * starts with all of them ticked. Every later check — and every check of a saved provider — keeps
1959
+ * what is ticked at that moment, so a model the user unticked stays unticked.
1960
+ */
1961
+ function grokTickedAfterCheck(firstListing, tickedIds, listedIds) {
1962
+ return new Set(firstListing ? listedIds : tickedIds);
1963
+ }
1964
+
1965
+ /**
1966
+ * A Grok subscription: nothing to type. The router reads the Grok CLI's own session, so the form
1967
+ * checks that it is there, lists what the proxy offers and saves the ticked models. The check runs
1968
+ * as soon as the form opens, since there is no key to wait for.
1969
+ */
1970
+ function openGrokProviderForm(options) {
1971
+ let formActive = true;
1972
+ const existing = options.provider;
1973
+ const nameInput = el("input", { value: options.name || t("providers.grok"), maxlength: "60" });
1974
+ const result = el("div", { class: "probe-result" });
1975
+ const probeButton = el("button", { class: "btn secondary", type: "button", text: t("providers.check") });
1976
+ let foundModels = modelsOf(existing);
1977
+ const currentChecked = new Set(foundModels.map((model) => model.id));
1978
+ let listed = false;
1979
+ const modelArea = el("div", { class: "form-field" });
1980
+ function renderModels(checked, help) {
1981
+ modelArea.replaceChildren(el("span", { text: t("providers.models") }), hint(help || t("providers.modelsHelp")), modelChecklist(foundModels, checked, { allowCustom: true }));
1982
+ }
1983
+ renderModels(currentChecked);
1984
+ // Fields this form has no control for (a home, a binary, a pinned version) go to the probe as saved.
1985
+ const probeBody = () => ({ type: "grok", ...Object.fromEntries(["home", "cli", "url", "clientVersion"].filter((key) => existing && existing[key]).map((key) => [key, existing[key]])) });
1986
+ async function runProbe() {
1987
+ probeButton.disabled = true;
1988
+ result.textContent = t("providers.checking");
1989
+ try {
1990
+ const response = await api("/api/providers/probe", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(probeBody()) });
1991
+ if (!formActive) return;
1992
+ const failed = response.auth === "missing" || response.auth === "bad-key" ? t("providers.grokSignInFirst") : t("providers.probeFailed");
1993
+ result.replaceChildren(...[
1994
+ el("span", { class: response.ok ? "ok-text" : "bad-text", text: response.ok ? t("providers.probeOk") : failed }),
1995
+ response.error ? el("div", { class: "small", text: response.error }) : null,
1996
+ ].filter(Boolean));
1997
+ if (Array.isArray(response.models) && response.models.length) {
1998
+ const ticked = modelArea.querySelector(".model-picker").selected();
1999
+ const keep = grokTickedAfterCheck(!existing && !listed, ticked.map((model) => model.id), response.models.map((model) => model.id));
2000
+ listed = true;
2001
+ foundModels = [...ticked.filter((model) => !response.models.some((m) => m.id === model.id)), ...response.models];
2002
+ renderModels(keep);
2003
+ }
2004
+ } catch (error) { if (formActive) result.replaceChildren(el("span", { class: "bad-text", text: t("providers.probeFailed") }), el("div", { class: "small", text: error.message })); }
2005
+ finally { if (formActive) probeButton.disabled = false; }
2006
+ }
2007
+ probeButton.addEventListener("click", () => void runProbe());
2008
+ const advanced = el("details", { class: "details" }, [el("summary", { text: t("common.advanced") })]);
2009
+ const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
2010
+ const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
2011
+ advanced.appendChild(el("div", { class: "advanced-content" }, [
2012
+ el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
2013
+ inputRow(t("providers.append"), append, t("providers.appendHelp")),
2014
+ ]));
2015
+ const pickerInput = el("input", { type: "checkbox", checked: existing ? modelsOf(existing).some((model) => ((currentConfig.cli && currentConfig.cli.extraModels) || []).some((extra) => extra.model === model.id)) : true });
2016
+ const form = el("div", { class: "provider-form" }, [
2017
+ el("h1", { id: "modal-title", text: existing ? t("providers.edit") : t("providers.addTitle") }),
2018
+ inputRow(t("providers.name"), nameInput, t("providers.nameHelp")),
2019
+ el("div", { class: "form-field" }, [el("span", { text: t("providers.credentials") }), hint(t("providers.grokFormHelp")), el("small", { class: "warn-text", text: t("providers.grokWarningShort") })]),
2020
+ probeButton, result, modelArea,
2021
+ el("label", { class: "check picker-check" }, [pickerInput, el("span", { text: t("providers.showInPicker") })]),
2022
+ pickerModeOn() ? null : hint(t("providers.pickerOffHint")),
2023
+ advanced,
2024
+ ]);
2025
+ const saveButton = el("button", { class: "btn", type: "button", "data-default-action": "", text: existing ? t("common.save") : t("providers.add") });
2026
+ saveButton.addEventListener("click", async () => {
2027
+ const typedName = nameInput.value.trim();
2028
+ if (!typedName) { toast(t("providers.nameRequired"), true); return; }
2029
+ const next = clone(await latestConfig());
2030
+ const providerName = existing ? options.name : uniqueName(typedName, next.providers);
2031
+ const saved = existing && (next.providers[options.name] || existing);
2032
+ const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), saved);
2033
+ const formKeys = new Set(["type", "identity", "instructionsAppend", "models"]);
2034
+ const kept = saved ? Object.fromEntries(Object.entries(saved).filter(([key]) => !formKeys.has(key))) : {};
2035
+ next.providers[providerName] = {
2036
+ ...kept,
2037
+ type: "grok",
2038
+ identity: identity.checked,
2039
+ ...(append.value.trim() ? { instructionsAppend: append.value.trim() } : {}),
2040
+ models: checkedModels,
2041
+ };
2042
+ const existingSelections = pickerSelectionsExcept(next, providerName);
2043
+ applyPickerSelections(next, pickerInput.checked ? [...existingSelections, ...checkedModels.map((model) => ({ ...model, provider: providerName }))] : existingSelections);
2044
+ saveButton.disabled = true;
2045
+ try { await configRequest(next); currentConfig = next; selectedProviderName = providerName; providerDetailTab = "overview"; slotsLoaded = false; clientsLoaded = false; providersLoaded = false; closeModal(); await loadProviders(); toast(t("common.saved")); await offerPickerOn(pickerInput.checked && checkedModels.length > 0); }
2046
+ catch (error) { toast(t("common.saveFailed"), true, error.message); }
2047
+ finally { saveButton.disabled = false; }
2048
+ });
2049
+ form.appendChild(el("div", { class: "actions end" }, [el("button", { class: "btn secondary", type: "button", text: t("common.cancel"), onclick: closeModal }), saveButton]));
2050
+ showModal(form, () => { formActive = false; });
2051
+ void runProbe();
2052
+ }
2053
+
1950
2054
  function openGoogleProviderForm(options) {
1951
2055
  let formActive = true;
1952
2056
  const existing = options.provider;
@@ -2066,6 +2170,7 @@ function openProviderForm(options) {
2066
2170
  const displayName = options.name || (preset && preset.name) || (isChatgpt ? t("providers.chatgpt") : isAnthropic ? t("providers.anthropic") : t("providers.customName"));
2067
2171
  if (isAnthropic) { openAnthropicProviderForm(options); return; }
2068
2172
  if (options.kind === "google" || (existing && existing.type === "google")) { openGoogleProviderForm(options); return; }
2173
+ if (options.kind === "grok" || (existing && existing.type === "grok")) { openGrokProviderForm(options); return; }
2069
2174
  const nameInput = el("input", { value: displayName, maxlength: "60" });
2070
2175
  const keyInput = el("input", { type: "password", autocomplete: "off", placeholder: t("providers.keyPlaceholder") });
2071
2176
  const showKey = el("button", { class: "eye-button", type: "button", text: t("common.show") });