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.
- package/CHANGELOG.md +25 -0
- package/README.es.md +1 -0
- package/README.ja.md +1 -0
- package/README.ko.md +1 -0
- package/README.md +1 -0
- package/README.pt-BR.md +1 -0
- package/README.zh-CN.md +1 -0
- 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/register.tsx +39 -9
- package/dist/mod/hooks/session.ts +11 -3
- package/dist/mod/hooks/ui/alerts.tsx +17 -4
- 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/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 +69 -17
- package/dist/router/src/providers/openai/translate.js +6 -1
- package/dist/router/src/proxy.js +43 -0
- package/dist/router/src/redact.js +7 -2
- 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 +78 -0
- package/package.json +1 -1
|
@@ -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();
|
|
@@ -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(
|
|
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";
|
package/dist/router/src/proxy.js
CHANGED
|
@@ -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)
|
|
15
|
-
|
|
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.
|
|
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") });
|