clauderipple 0.9.1 → 0.9.3
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 +42 -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 +42 -12
- package/dist/mod/hooks/ladder.js +5 -5
- package/dist/mod/hooks/ladder.ts +5 -5
- package/dist/mod/hooks/register.tsx +58 -15
- package/dist/mod/hooks/session.ts +37 -3
- package/dist/mod/hooks/settings.js +13 -11
- package/dist/mod/hooks/settings.ts +13 -11
- package/dist/mod/hooks/ui/alerts.tsx +17 -4
- package/dist/mod/hooks/ui/art.ts +2 -2
- package/dist/mod/hooks/ui/band.tsx +1 -1
- package/dist/mod/hooks/ui/kit.tsx +15 -2
- package/dist/mod/hooks/ui/view.ts +4 -1
- package/dist/mod/hooks/workers.ts +8 -8
- package/dist/mod/tests/register.test.tsx +63 -0
- package/dist/mod/tests/ui.test.tsx +10 -6
- package/dist/mod/types/index.d.ts +10 -1
- package/dist/router/src/admin.js +76 -5
- 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 +115 -6
- package/dist/ui/i18n.js +6 -6
- package/docs/ARCHITECTURE.md +78 -0
- package/package.json +1 -1
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.3";
|
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") });
|
|
@@ -2381,7 +2486,7 @@ const MOD_DEFAULTS = {
|
|
|
2381
2486
|
hard: [{ target: { agent: "gpt-6-astra" }, effort: "high" }], plan: [modClaude("opus", "high")],
|
|
2382
2487
|
} },
|
|
2383
2488
|
},
|
|
2384
|
-
allow: [], modes: [], providers: {},
|
|
2489
|
+
allow: [], allowKnown: [], modes: [], providers: {},
|
|
2385
2490
|
fallback: { agent: "general-purpose", model: "sonnet" },
|
|
2386
2491
|
keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
|
|
2387
2492
|
compactWith: "haiku", handoffSkill: "", handoffAfter: "continue", handoffButton: "advised",
|
|
@@ -2405,6 +2510,7 @@ function modNormalize(raw) {
|
|
|
2405
2510
|
...value,
|
|
2406
2511
|
rules: { ...clone(MOD_DEFAULTS.rules), ...(value.rules || {}) },
|
|
2407
2512
|
allow: Array.isArray(value.allow) ? value.allow : [],
|
|
2513
|
+
allowKnown: Array.isArray(value.allowKnown) ? value.allowKnown : [],
|
|
2408
2514
|
modes: Array.isArray(value.modes) ? value.modes : [],
|
|
2409
2515
|
providers: { ...(value.providers || {}) },
|
|
2410
2516
|
hide: Array.isArray(value.hide) ? value.hide : clone(MOD_DEFAULTS.hide),
|
|
@@ -2590,7 +2696,7 @@ function modEffortCard() {
|
|
|
2590
2696
|
*/
|
|
2591
2697
|
function modChoices() {
|
|
2592
2698
|
const options = modState.options || {};
|
|
2593
|
-
const claudeEfforts = Array.isArray(options.claudeEfforts) && options.claudeEfforts.length ? options.claudeEfforts : ["low", "medium", "high", "max"];
|
|
2699
|
+
const claudeEfforts = Array.isArray(options.claudeEfforts) && options.claudeEfforts.length ? options.claudeEfforts : ["low", "medium", "high", "xhigh", "max"];
|
|
2594
2700
|
const workers = modAgents().map((agent) => {
|
|
2595
2701
|
const info = modProviderInfo(agent.provider);
|
|
2596
2702
|
return {
|
|
@@ -2644,17 +2750,20 @@ function modGroupLabel(group) { return group.billing ? `${group.name} · ${t(`mo
|
|
|
2644
2750
|
function modTickKey(choice) { return choice.claude ? choice.value.replace("+", ":") : choice.value; }
|
|
2645
2751
|
/** The models that can be ticked: every worker and Claude's own; the session's model is always there. */
|
|
2646
2752
|
function modTickable() { return modChoices().filter((choice) => choice.value !== "general-purpose"); }
|
|
2647
|
-
/** Whether a model is ticked: an empty list means every model,
|
|
2753
|
+
/** Whether a model is ticked: an empty list means every model, an old provider name ticks that provider's, and a model added after the list was last written (absent from `allowKnown`) ticks on. */
|
|
2648
2754
|
function modTicked(choice) {
|
|
2649
2755
|
const allow = modState.settings.allow || [];
|
|
2650
|
-
|
|
2756
|
+
const known = modState.settings.allowKnown || [];
|
|
2757
|
+
return !allow.length || allow.includes(modTickKey(choice)) || allow.includes(choice.provider) || known.length > 0 && !known.includes(modTickKey(choice));
|
|
2651
2758
|
}
|
|
2652
2759
|
/** Ticks one model; every model on again is stored as [], and the last one on cannot go off. */
|
|
2653
2760
|
function modTick(choice, on) {
|
|
2654
2761
|
const all = modTickable();
|
|
2655
|
-
const
|
|
2762
|
+
const known = all.map(modTickKey);
|
|
2763
|
+
const kept = new Set(all.filter((model) => modTicked(model)).map(modTickKey));
|
|
2656
2764
|
if (on) kept.add(modTickKey(choice)); else kept.delete(modTickKey(choice));
|
|
2657
|
-
|
|
2765
|
+
const allow = kept.size === known.length ? [] : known.filter((key) => kept.has(key));
|
|
2766
|
+
modPatch({ allow, allowKnown: allow.length ? known : [] });
|
|
2658
2767
|
}
|
|
2659
2768
|
function modTickRows() {
|
|
2660
2769
|
const choices = modTickable();
|
package/dist/ui/i18n.js
CHANGED
|
@@ -41,7 +41,7 @@ const I18N = {
|
|
|
41
41
|
"providers.googleWarningShort": "Google 이용약관에서 해당 사용을 금지하고 있으며 계정 정지 사례가 있습니다. 주 계정 대신 보조 계정 사용을 권장합니다.",
|
|
42
42
|
"providers.googleOpenManually": "브라우저가 열리지 않았습니다. 아래 주소를 직접 여세요.",
|
|
43
43
|
"providers.googleAdded": "Google 계정을 추가했습니다.",
|
|
44
|
-
"providers.googleSignInFirst": "로그인된 Google 계정이 없습니다. 먼저 로그인해 주세요.",
|
|
44
|
+
"providers.googleSignInFirst": "로그인된 Google 계정이 없습니다. 먼저 로그인해 주세요.", "providers.grok": "Grok 구독", "providers.grokHelp": "Grok CLI가 포함된 Grok 요금제로 Grok 모델을 씁니다. `grok login`으로 로그인만 하면 되고 API 키는 필요 없습니다.", "providers.grokFormHelp": "ClaudeRipple은 Grok CLI의 로그인 세션(~/.grok/auth.json)을 읽기만 하고, 만료가 가까우면 CLI에 갱신을 맡깁니다. 터미널에서 `grok login`을 한 번 실행해 두세요.", "providers.grokWarningShort": "Grok CLI 전용 채널을 씁니다. xAI가 API로 공개한 경로가 아니라서 CLI 업데이트나 xAI 쪽 변경으로 예고 없이 막힐 수 있습니다.", "providers.grokSignInFirst": "Grok CLI 로그인 세션이 없습니다. 먼저 터미널에서 `grok login`을 실행하세요.",
|
|
45
45
|
"providers.googleRemoveConfirm": "Google 계정 {name}을(를) ClaudeRipple에서 삭제하시겠습니까? 실제 Google 계정은 삭제되지 않습니다.",
|
|
46
46
|
"providers.googleProject": "프로젝트 {id}",
|
|
47
47
|
"providers.googleOwnHelp": "ClaudeRipple이 로컬 사용자 전용 파일에 로그인 정보를 안전하게 저장하고 자동으로 갱신합니다.",
|
|
@@ -247,7 +247,7 @@ const I18N = {
|
|
|
247
247
|
"providers.googleWarningShort": "Google's terms prohibit this use and accounts have been suspended for it. A secondary account is recommended.",
|
|
248
248
|
"providers.googleOpenManually": "The browser did not open. Open this address yourself.",
|
|
249
249
|
"providers.googleAdded": "Google account added.",
|
|
250
|
-
"providers.googleSignInFirst": "No Google account is signed in. Sign in first.",
|
|
250
|
+
"providers.googleSignInFirst": "No Google account is signed in. Sign in first.", "providers.grok": "Grok subscription", "providers.grokHelp": "Use Grok models through a Grok plan that includes the Grok CLI. Sign in with `grok login`, no API key.", "providers.grokFormHelp": "ClaudeRipple only reads the Grok CLI's session (~/.grok/auth.json) and asks the CLI to refresh it when it runs low. Run `grok login` once in a terminal.", "providers.grokWarningShort": "This uses the Grok CLI's own channel, which xAI does not publish as an API; a CLI update or a change on xAI's side can break it without warning.", "providers.grokSignInFirst": "No Grok CLI session. Run `grok login` in a terminal first.",
|
|
251
251
|
"providers.googleRemoveConfirm": "Remove {name} from ClaudeRipple? The Google account itself is untouched.",
|
|
252
252
|
"providers.googleProject": "project {id}",
|
|
253
253
|
"providers.googleOwnHelp": "ClaudeRipple keeps this sign-in in a file only your user can read and refreshes it automatically.",
|
|
@@ -737,7 +737,7 @@ const I18N = {
|
|
|
737
737
|
"providers.googleWarningShort": "Google 的条款禁止这种用法,已有账号因此被停用。建议使用备用账号。",
|
|
738
738
|
"providers.googleOpenManually": "浏览器未能打开,请手动打开此地址。",
|
|
739
739
|
"providers.googleAdded": "已添加 Google 账号。",
|
|
740
|
-
"providers.googleSignInFirst": "尚未登录任何 Google 账号,请先登录。",
|
|
740
|
+
"providers.googleSignInFirst": "尚未登录任何 Google 账号,请先登录。", "providers.grok": "Grok 订阅", "providers.grokHelp": "通过包含 Grok CLI 的 Grok 套餐使用 Grok 模型。用 `grok login` 登录即可,无需 API 密钥。", "providers.grokFormHelp": "ClaudeRipple 只读取 Grok CLI 的登录会话(~/.grok/auth.json),快过期时请 CLI 刷新。请先在终端运行一次 `grok login`。", "providers.grokWarningShort": "这里使用的是 Grok CLI 自己的通道,xAI 并未将其作为 API 公开,CLI 更新或 xAI 一侧的变更都可能使其在没有预告的情况下失效。", "providers.grokSignInFirst": "没有 Grok CLI 登录会话。请先在终端运行 `grok login`。",
|
|
741
741
|
"providers.googleRemoveConfirm": "从 ClaudeRipple 中移除 {name}?Google 账号本身不受影响。",
|
|
742
742
|
"providers.googleProject": "项目 {id}",
|
|
743
743
|
"providers.googleOwnHelp": "ClaudeRipple 将此登录保存在只有你的系统用户账号能读取的文件中,并自动刷新。",
|
|
@@ -1227,7 +1227,7 @@ const I18N = {
|
|
|
1227
1227
|
"providers.googleWarningShort": "Googleの規約はこの使い方を禁止しており、実際にアカウントが停止された例があります。サブアカウントの使用をおすすめします。",
|
|
1228
1228
|
"providers.googleOpenManually": "ブラウザが開きませんでした。このアドレスを手動で開いてください。",
|
|
1229
1229
|
"providers.googleAdded": "Googleアカウントを追加しました",
|
|
1230
|
-
"providers.googleSignInFirst": "ログイン済みのGoogleアカウントがありません。先にログインしてください。",
|
|
1230
|
+
"providers.googleSignInFirst": "ログイン済みのGoogleアカウントがありません。先にログインしてください。", "providers.grok": "Grokサブスクリプション", "providers.grokHelp": "Grok CLIを含むGrokプランでGrokモデルを使います。`grok login`でログインするだけで、APIキーは不要です。", "providers.grokFormHelp": "ClaudeRippleはGrok CLIのログインセッション(~/.grok/auth.json)を読むだけで、期限が近づくと更新をCLIに任せます。ターミナルで一度`grok login`を実行してください。", "providers.grokWarningShort": "Grok CLI専用の経路を使います。xAIがAPIとして公開しているものではないため、CLIの更新やxAI側の変更で予告なく使えなくなる可能性があります。", "providers.grokSignInFirst": "Grok CLIのログインセッションがありません。先にターミナルで`grok login`を実行してください。",
|
|
1231
1231
|
"providers.googleRemoveConfirm": "{name} をClaudeRippleから削除しますか?Googleアカウント自体には影響しません。",
|
|
1232
1232
|
"providers.googleProject": "プロジェクト {id}",
|
|
1233
1233
|
"providers.googleOwnHelp": "ClaudeRippleがこのログインを自分のユーザーだけが読めるファイルに保存し、自動で更新します。",
|
|
@@ -1717,7 +1717,7 @@ const I18N = {
|
|
|
1717
1717
|
"providers.googleWarningShort": "Los términos de Google prohíben este uso y se han suspendido cuentas por ello. Se recomienda una cuenta secundaria.",
|
|
1718
1718
|
"providers.googleOpenManually": "El navegador no se abrió. Abre esta dirección manualmente.",
|
|
1719
1719
|
"providers.googleAdded": "Cuenta de Google añadida.",
|
|
1720
|
-
"providers.googleSignInFirst": "No hay ninguna cuenta de Google con sesión iniciada. Inicia sesión primero.",
|
|
1720
|
+
"providers.googleSignInFirst": "No hay ninguna cuenta de Google con sesión iniciada. Inicia sesión primero.", "providers.grok": "Suscripción de Grok", "providers.grokHelp": "Usa modelos Grok con un plan de Grok que incluya la CLI de Grok. Solo inicia sesión con `grok login`, sin clave de API.", "providers.grokFormHelp": "ClaudeRipple solo lee la sesión de la CLI de Grok (~/.grok/auth.json) y le pide a la CLI que la renueve cuando está por vencer. Ejecuta `grok login` una vez en una terminal.", "providers.grokWarningShort": "Usa el canal propio de la CLI de Grok, que xAI no publica como API; una actualización de la CLI o un cambio por parte de xAI puede romperlo sin aviso.", "providers.grokSignInFirst": "No hay sesión de la CLI de Grok. Ejecuta `grok login` en una terminal primero.",
|
|
1721
1721
|
"providers.googleRemoveConfirm": "¿Quitar {name} de ClaudeRipple? La cuenta de Google en sí no se modifica.",
|
|
1722
1722
|
"providers.googleProject": "proyecto {id}",
|
|
1723
1723
|
"providers.googleOwnHelp": "ClaudeRipple guarda este inicio de sesión en un archivo que solo tu usuario puede leer y lo renueva automáticamente.",
|
|
@@ -2207,7 +2207,7 @@ const I18N = {
|
|
|
2207
2207
|
"providers.googleWarningShort": "Os termos do Google proíbem este uso, e contas já foram suspensas por isso. Recomenda-se usar uma conta secundária.",
|
|
2208
2208
|
"providers.googleOpenManually": "O navegador não abriu. Abra este endereço manualmente.",
|
|
2209
2209
|
"providers.googleAdded": "Conta do Google adicionada.",
|
|
2210
|
-
"providers.googleSignInFirst": "Nenhuma conta do Google conectada. Entre primeiro.",
|
|
2210
|
+
"providers.googleSignInFirst": "Nenhuma conta do Google conectada. Entre primeiro.", "providers.grok": "Assinatura do Grok", "providers.grokHelp": "Use modelos Grok com um plano do Grok que inclua a CLI do Grok. Só entre com `grok login`, sem chave de API.", "providers.grokFormHelp": "O ClaudeRipple só lê a sessão da CLI do Grok (~/.grok/auth.json) e pede à CLI que a renove quando estiver perto de expirar. Rode `grok login` uma vez em um terminal.", "providers.grokWarningShort": "Usa o canal próprio da CLI do Grok, que a xAI não publica como API; uma atualização da CLI ou uma mudança do lado da xAI pode quebrá-lo sem aviso.", "providers.grokSignInFirst": "Nenhuma sessão da CLI do Grok. Rode `grok login` em um terminal primeiro.",
|
|
2211
2211
|
"providers.googleRemoveConfirm": "Remover {name} do ClaudeRipple? A conta do Google em si não é afetada.",
|
|
2212
2212
|
"providers.googleProject": "projeto {id}",
|
|
2213
2213
|
"providers.googleOwnHelp": "O ClaudeRipple guarda este login em um arquivo que só o seu usuário pode ler e o renova automaticamente.",
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1256,6 +1256,84 @@ subscription exists in this environment. Files are under `src/`:
|
|
|
1256
1256
|
`PATCH/DELETE /api/google-accounts/:id`, and `POST /api/providers/probe` with
|
|
1257
1257
|
`{type:"google", auth:"antigravity"}`. CLI: `clauderipple google-login` / `google-logout`.
|
|
1258
1258
|
|
|
1259
|
+
### 4e. `grok` providers (implemented 2026-10-08)
|
|
1260
|
+
|
|
1261
|
+
Grok on a Grok subscription, through the chat proxy the Grok CLI itself talks to, signed in
|
|
1262
|
+
with the CLI's own session. A translated provider like §4c: the adapter
|
|
1263
|
+
(`packages/router/src/providers/grok/`) reuses `OpenAiCompatibleAdapter` on the Chat wire
|
|
1264
|
+
and adds only what an API key would not need — a credential that rotates under the router,
|
|
1265
|
+
and the headers the proxy routes on. Measured 2026-10-08 against CLI 1.0.46 on a SuperGrok
|
|
1266
|
+
plan unless marked otherwise.
|
|
1267
|
+
|
|
1268
|
+
- **Endpoint and headers.** `POST https://cli-chat-proxy.grok.com/v1/chat/completions`,
|
|
1269
|
+
`GET …/v1/models`. The CLI's own README ("Using auth.json for API Access") documents calling
|
|
1270
|
+
it directly with `Authorization: Bearer <auth.json key>`, `X-XAI-Token-Auth: xai-grok-cli`
|
|
1271
|
+
and `x-grok-model-override: <model>` (the proxy routes on the header, not the body). It does
|
|
1272
|
+
not mention that the proxy also refuses a request without `x-grok-client-version`:
|
|
1273
|
+
`426 {"error":"Your Grok CLI version (none) is outdated. Please update to version 1.0.13 or
|
|
1274
|
+
later …"}`. The router sends `clientVersion` from the config when set, else the installed
|
|
1275
|
+
CLI's version (`grok --version`, read again when the binary changes), else 1.0.46. The header names were
|
|
1276
|
+
read from the CLI binary's strings; nothing else from it is used. This channel is the CLI's,
|
|
1277
|
+
not a published API, so the probe returns a facts-only `warning` (like Antigravity's) and the
|
|
1278
|
+
form says so.
|
|
1279
|
+
- **Session.** `<grok home>/auth.json` (`$GROK_HOME`, else `~/.grok`), written by `grok login`:
|
|
1280
|
+
one entry per issuer keyed `https://auth.x.ai::<id>`, each with `key`, `expires_at`,
|
|
1281
|
+
`refresh_token`. The README's own `jq` example reads a different key
|
|
1282
|
+
(`https://accounts.x.ai/sign-in`), so the entry that expires last is taken and no key name
|
|
1283
|
+
is assumed. A session token lives **six hours** (`expires_at` − `create_time`; the README
|
|
1284
|
+
says 7 days); a running CLI refreshes it about five minutes before expiry under its own lock
|
|
1285
|
+
(`auth lock: attempting acquire (timeout=25000ms)`, `oidc try_refresh_pure`). Whether the
|
|
1286
|
+
refresh token rotates was not measured. So, as with `borrow-codex`, the router never
|
|
1287
|
+
refreshes the grant itself — if it does rotate, racing the CLI could sign the CLI out.
|
|
1288
|
+
- **Refresh by asking the CLI.** With no CLI running, the file just goes stale. When the token
|
|
1289
|
+
has under 10 minutes left the router runs `grok models` with
|
|
1290
|
+
`GROK_AUTH_EARLY_INVALIDATION_SECS=600`: the CLI then sees the token as expiring, refreshes
|
|
1291
|
+
it under its lock and rewrites the file (measured: expiry moved six hours, file rewritten).
|
|
1292
|
+
One run serves every turn waiting on it; 30s bound, since the CLI's lock wait is 25s. A token
|
|
1293
|
+
still alive is used even when that run fails, and a run that left it short is not repeated
|
|
1294
|
+
for a minute, so a broken IdP does not add 30s to every turn. Under launchd's environment
|
|
1295
|
+
(`env -i PATH=/usr/bin:/bin:/usr/sbin:/sbin HOME=…`) `grok models` exits 0 and leaves no
|
|
1296
|
+
process behind. The router runs under launchd with
|
|
1297
|
+
`PATH=/usr/bin:/bin:/usr/sbin:/sbin`, so the binary is looked up in `<home>/bin/grok` first,
|
|
1298
|
+
then PATH, then `~/.local/bin/grok`; `HOME` is set there, which the CLI needs to find its
|
|
1299
|
+
session.
|
|
1300
|
+
- **A 401 is asked once more.** The token read a moment ago can be the one the CLI just
|
|
1301
|
+
replaced. On a 401 before any byte is written the adapter re-reads the file; if it still
|
|
1302
|
+
holds the refused token the CLI refresh is forced (`GROK_AUTH_EARLY_INVALIDATION_SECS=86400`,
|
|
1303
|
+
the value measured to make the CLI refresh, at most once a minute — a proxy that refuses a fresh token is refusing the
|
|
1304
|
+
account), and the turn is asked again only when a different token exists. Otherwise the
|
|
1305
|
+
user reads `authentication_error "Grok: … — sign in with grok login, …"`. An unknown token is
|
|
1306
|
+
`401 {"error":"Invalid or expired credentials (auth_kind=bearer, x_xai_token_auth=xai-grok-cli,
|
|
1307
|
+
upstream=PermissionDenied, reason=no auth context)"}` — a bare string under `error`, which
|
|
1308
|
+
`vendorMessage` now reads. `fetchWithRetry` still never retries a 401 on its own.
|
|
1309
|
+
- **Models and effort.** `/v1/models` lists grok-4.7, grok-4.7-build-fast, grok-4.6 and
|
|
1310
|
+
grok-4.5, each with `context_window` 256000, `api_backend: "responses"` and
|
|
1311
|
+
`reasoning_efforts` listed high to low: low..xhigh, except grok-4.5 low..high. The probe
|
|
1312
|
+
saves that ladder per model. On Chat, every model accepted low/medium/high/xhigh (grok-4.5's
|
|
1313
|
+
`xhigh` too — a 200 is only "not refused", so the listing wins) and refused the string
|
|
1314
|
+
`"none"` and `max` with `400 {"code":"invalid-argument"}`; omitting the field is accepted and
|
|
1315
|
+
the model's default (`high`) applies. A model the listing did not describe gets low..xhigh.
|
|
1316
|
+
- **Chat, not Responses.** Both wires answer. On Chat the reasoning arrives as text the mapper
|
|
1317
|
+
turns into thinking blocks; the Responses mapper has no reasoning handling (§4c), so a Grok
|
|
1318
|
+
turn there showed none. Tools stream as ordinary `tool_calls` deltas and `tool` messages are
|
|
1319
|
+
accepted back; the usage chunk carries `prompt_tokens_details.cached_tokens`.
|
|
1320
|
+
- **Cache.** No conversation header: a repeated 25.5k-token prefix was read from cache on the
|
|
1321
|
+
second call without one (1,152 cached on the first, 25,472 of 25,573 on the second), while
|
|
1322
|
+
the same pair with `x-grok-conv-id` on Chat missed both times. One
|
|
1323
|
+
Claude Code session through a dev router (CLI 2.1.291, grok-4.7, four turns with Read, Write
|
|
1324
|
+
and Bash) read 21,888 / 21,995, 21,888 / 22,210 and 22,144 / 22,257 from cache after its
|
|
1325
|
+
cold first turn; a second session's second turn 21,760 / 22,023. A session's first turn is
|
|
1326
|
+
cold. Watch the daily figure in `requests.jsonl` as for the other translated providers.
|
|
1327
|
+
- **Probe.** `POST /api/providers/probe` with `{type:"grok", home?, cli?, url?,
|
|
1328
|
+
clientVersion?}` checks the session through the same `GrokAuth` the adapter uses (so a near-
|
|
1329
|
+
expired one is refreshed here too) and asks `/models` with the headers live traffic sends.
|
|
1330
|
+
`auth: "missing"` is no session or an expired one the CLI could not refresh; `"bad-key"` is
|
|
1331
|
+
the proxy refusing it. The measured list above is offered when the listing cannot be read.
|
|
1332
|
+
- Routing, agent files, picker entries and the refusal of `web_search` side requests are the
|
|
1333
|
+
same as for any translated provider. A grok model may not carry `wire`, `url` or
|
|
1334
|
+
`authHeader` (validation refuses it): `providerFor` would turn it into an openai-compatible
|
|
1335
|
+
provider without the session headers. Not available through the OpenAI ingress.
|
|
1336
|
+
|
|
1259
1337
|
## 5. Failure modes that must not exist in the product (all observed)
|
|
1260
1338
|
|
|
1261
1339
|
| Observed | Product requirement |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clauderipple",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"description": "Use GPT (any ChatGPT plan with Codex), DeepSeek and 400+ other models in the Claude Desktop Code tab and Claude Code while staying signed in to your Claude subscription — no third-party gateway mode. Several ChatGPT accounts that take over when one runs out, in Codex too.",
|
|
5
5
|
"license": "GPL-3.0-only",
|
|
6
6
|
"workspaces": [
|