clauderipple 0.7.0 → 0.8.1
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 +77 -0
- package/README.ko.md +18 -2
- package/README.md +27 -5
- package/dist/app/package.json +1 -1
- package/dist/cli/src/claude-auth.js +35 -1
- package/dist/cli/src/codex.js +23 -0
- package/dist/cli/src/hooks/agent-title.js +58 -29
- package/dist/cli/src/index.js +46 -1
- package/dist/cli/src/picker.js +19 -11
- package/dist/cli/src/runtime.js +3 -0
- package/dist/cli/src/settings.js +49 -0
- package/dist/mod/.claude-plugin/plugin.json +1 -0
- package/dist/mod/hooks/hooks.json +1 -0
- package/dist/mod/hooks/register.tsx +309 -0
- package/dist/mod/hooks/worker.ts +49 -0
- package/dist/mod/tsconfig.json +3 -0
- package/dist/mod/types/index.d.ts +30 -0
- package/dist/router/src/admin.js +411 -32
- package/dist/router/src/agents.js +24 -8
- package/dist/router/src/catalog.js +80 -0
- package/dist/router/src/compat.js +27 -1
- package/dist/router/src/config.js +31 -0
- package/dist/router/src/index.js +12 -1
- package/dist/router/src/ingress/server.js +3 -2
- package/dist/router/src/picker.js +33 -2
- package/dist/router/src/presets.js +2 -1
- package/dist/router/src/providers/chatgpt/index.js +77 -18
- package/dist/router/src/providers/chatgpt/translate.js +34 -6
- package/dist/router/src/providers/google/accounts.js +345 -0
- package/dist/router/src/providers/google/antigravity.js +392 -0
- package/dist/router/src/providers/google/index.js +617 -0
- package/dist/router/src/providers/google/login.js +249 -0
- package/dist/router/src/providers/google/thoughtSignatures.js +47 -0
- package/dist/router/src/providers/google/translate.js +539 -0
- package/dist/router/src/providers/google/transport.js +118 -0
- package/dist/router/src/providers/openai/translate.js +3 -3
- package/dist/router/src/proxy.js +116 -9
- package/dist/router/src/requestlog.js +2 -0
- package/dist/router/src/routing.js +22 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +420 -45
- package/dist/ui/i18n.js +76 -4
- package/dist/ui/index.html +5 -0
- package/dist/ui/style.css +4 -0
- package/docs/ARCHITECTURE.md +307 -4
- package/package.json +1 -1
package/dist/router/src/admin.js
CHANGED
|
@@ -8,32 +8,42 @@
|
|
|
8
8
|
// GET /api/chatgpt-login ChatGPT browser login state and credential status
|
|
9
9
|
// GET /api/chatgpt-accounts?provider= signed-in ChatGPT accounts, rotation state, quota (no tokens)
|
|
10
10
|
// PATCH/DELETE /api/chatgpt-accounts/:id rename, pause/resume, clear a cooldown; remove
|
|
11
|
-
//
|
|
11
|
+
// POST /api/google-login begin a Google (Antigravity) browser sign-in; GET polls the state
|
|
12
|
+
// POST /api/google-login/code paste the code when no loopback callback could be used
|
|
13
|
+
// GET /api/google-accounts?provider= signed-in Google accounts, rotation state (no tokens)
|
|
14
|
+
// PATCH/DELETE /api/google-accounts/:id rename, pause/resume, clear a cooldown; remove
|
|
15
|
+
// GET /api/update whether a newer version is published, and how this copy was installed
|
|
16
|
+
// POST /api/update start `clauderipple update` in the background (it restarts this router)
|
|
17
|
+
// GET /* static files from packages/ui (the GUI itself)
|
|
12
18
|
import crypto from "node:crypto";
|
|
13
19
|
import fs from "node:fs";
|
|
14
20
|
import net from "node:net";
|
|
15
21
|
import os from "node:os";
|
|
16
22
|
import path from "node:path";
|
|
17
23
|
import http from "node:http";
|
|
18
|
-
import { execFile } from "node:child_process";
|
|
24
|
+
import { execFile, spawn } from "node:child_process";
|
|
19
25
|
import { fileURLToPath } from "node:url";
|
|
20
26
|
import { homeDir, validate } from "./config.js";
|
|
21
27
|
import { PRESETS } from "./presets.js";
|
|
22
28
|
import { resolveCompatibleCaps } from "./compat.js";
|
|
23
29
|
import { CHATGPT_FALLBACK_MODELS } from "./providers/chatgpt/catalog.js";
|
|
24
30
|
import { measureModel, refusedByPlan } from "./capabilities.js";
|
|
31
|
+
import { catalogEntry } from "./catalog.js";
|
|
25
32
|
import { ClaudeCodeAuthStore, nativeAnthropicHeaders } from "./providers/anthropic.js";
|
|
26
33
|
import { codexEnabled, codexHome } from "../../cli/src/codex.js";
|
|
27
34
|
import { openBrowser } from "../../cli/src/browser.js";
|
|
28
35
|
import { caTrusted, currentAppProxy } from "../../cli/src/picker.js";
|
|
29
36
|
import { certPaths } from "../../cli/src/certs.js";
|
|
30
37
|
import { desktopClaudeCodeDirs } from "../../cli/src/claude-auth.js";
|
|
31
|
-
import { syncModelSlots } from "../../cli/src/settings.js";
|
|
38
|
+
import { statusModEnabled, syncModelSlots } from "../../cli/src/settings.js";
|
|
32
39
|
import { ClaudeOAuthSession } from "./providers/claude-oauth.js";
|
|
33
40
|
import { readClaudeAuthFile } from "./providers/anthropic-token-file.js";
|
|
34
41
|
import { listClaudeAccounts, removeClaudeAccount, renameClaudeAccount } from "./providers/anthropic-accounts.js";
|
|
35
42
|
import { readChatGptAccounts, removeChatGptAccount, summarize as summarizeChatGptAccount, updateChatGptAccount } from "./providers/chatgpt/accounts.js";
|
|
36
43
|
import { IMAGE_ASPECTS, IMAGE_BACKGROUNDS, IMAGE_FORMATS } from "./providers/chatgpt/index.js";
|
|
44
|
+
import { ANTIGRAVITY_WARNING, staticAntigravityModels } from "./providers/google/antigravity.js";
|
|
45
|
+
import { readGoogleAccounts, removeGoogleAccount, summarize as summarizeGoogleAccount, updateGoogleAccount } from "./providers/google/accounts.js";
|
|
46
|
+
import { GoogleOAuthSession } from "./providers/google/login.js";
|
|
37
47
|
const MAX_BODY = 1024 * 1024;
|
|
38
48
|
/** `/api/image` carries reference images inline, so it gets more room than a config save. */
|
|
39
49
|
const MAX_IMAGE_BODY = 32 * 1024 * 1024;
|
|
@@ -82,6 +92,23 @@ const CHATGPT_DEFAULT_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
|
82
92
|
let chatgptLogin = { running: false };
|
|
83
93
|
/** The Claude subscription sign-in in progress (or the last one), for GET /api/claude-oauth. */
|
|
84
94
|
let claudeOAuth = null;
|
|
95
|
+
/** The Google (Antigravity) sign-in in progress (or the last one), for GET /api/google-login. */
|
|
96
|
+
let googleOAuth = null;
|
|
97
|
+
/** When the GUI last started an update. It restarts this router, so a fresh process starts at 0. */
|
|
98
|
+
let updateStartedAt = 0;
|
|
99
|
+
const UPDATE_RUN_MS = 10 * 60 * 1000;
|
|
100
|
+
/** The last line of `clauderipple update --check --json`, or null when it said something else. */
|
|
101
|
+
export function parseUpdateCheck(output) {
|
|
102
|
+
try {
|
|
103
|
+
const check = JSON.parse(output.trim().split("\n").pop() ?? "");
|
|
104
|
+
if (typeof check.current !== "string" || typeof check.newer !== "boolean" || typeof check.kind !== "string")
|
|
105
|
+
return null;
|
|
106
|
+
return { current: check.current, latest: typeof check.latest === "string" ? check.latest : null, newer: check.newer, kind: check.kind };
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
85
112
|
/** Shared catalog for the GUI: model-specific values override provider defaults. */
|
|
86
113
|
export function effortLevels(cfg) {
|
|
87
114
|
const providers = {
|
|
@@ -110,6 +137,13 @@ export function effortLevels(cfg) {
|
|
|
110
137
|
};
|
|
111
138
|
continue;
|
|
112
139
|
}
|
|
140
|
+
if (provider.type === "google") {
|
|
141
|
+
// Gemini exposes its ladder per model (the probe fills `effortLevels`); there is no
|
|
142
|
+
// provider-wide default, so the union of what the models declared is the fallback.
|
|
143
|
+
const levels = [...new Set((provider.models ?? []).flatMap((model) => model.effortLevels ?? []))];
|
|
144
|
+
providers[name] = { default: levels, ...(Object.keys(modelLevels).length ? { models: modelLevels } : {}) };
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
113
147
|
if (provider.type === "anthropic") {
|
|
114
148
|
providers[name] = { default: ANTHROPIC_EFFORT_LEVELS };
|
|
115
149
|
continue;
|
|
@@ -122,6 +156,21 @@ export function effortLevels(cfg) {
|
|
|
122
156
|
}
|
|
123
157
|
return { providers };
|
|
124
158
|
}
|
|
159
|
+
/** One routed model's ladder, from the provider that declares it — what the picker's effort menu
|
|
160
|
+
* should offer for it. Undefined when no provider declares the id: the menu is then left alone. */
|
|
161
|
+
export function effortLevelsForModel(cfg, modelId) {
|
|
162
|
+
const catalog = effortLevels(cfg).providers;
|
|
163
|
+
for (const [name, provider] of Object.entries(cfg.providers)) {
|
|
164
|
+
if (provider.type === "anthropic")
|
|
165
|
+
continue;
|
|
166
|
+
const own = catalog[name];
|
|
167
|
+
if (own?.models?.[modelId])
|
|
168
|
+
return own.models[modelId];
|
|
169
|
+
if (own && provider.models?.some((model) => model.id === modelId))
|
|
170
|
+
return own.default;
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
125
174
|
const CLAUDE_MODEL_FALLBACK = [
|
|
126
175
|
{ id: "claude-fable-5-1", name: "Fable 5.1" },
|
|
127
176
|
{ id: "claude-opus-5-5", name: "Opus 5.5" },
|
|
@@ -228,13 +277,23 @@ export function chatgptSignedIn(mode) {
|
|
|
228
277
|
const borrowed = fs.existsSync(path.join(os.homedir(), ".codex", "auth.json"));
|
|
229
278
|
return mode === "own" ? own : mode === "borrow-codex" ? borrowed : own || borrowed;
|
|
230
279
|
}
|
|
280
|
+
/**
|
|
281
|
+
* An Antigravity provider is only as usable as its accounts: none signed in, or every one waiting on
|
|
282
|
+
* Google's verification page, answers nothing however reachable the host is.
|
|
283
|
+
*/
|
|
284
|
+
export function googleHealth(accounts) {
|
|
285
|
+
if (accounts.every((a) => a.state === "needs-login"))
|
|
286
|
+
return { needsLogin: true };
|
|
287
|
+
const inRotation = accounts.filter((a) => a.state !== "paused" && a.state !== "needs-login");
|
|
288
|
+
return inRotation.length > 0 && inRotation.every((a) => a.state === "needs-verification") ? { needsLogin: false, needsVerification: true } : { needsLogin: false };
|
|
289
|
+
}
|
|
231
290
|
async function buildStatus(deps, opts = {}) {
|
|
232
291
|
const cfg = deps.config();
|
|
233
292
|
const providers = {};
|
|
234
293
|
// Keyed in the order the config lists them. Assigning inside the Promise.all callbacks ordered
|
|
235
294
|
// them by whichever TCP check answered first, so the Health list reshuffled on every poll.
|
|
236
295
|
const checked = await Promise.all(Object.entries(cfg.providers).map(async ([name, p]) => {
|
|
237
|
-
const url = p.type === "anthropic" ? "https://api.anthropic.com" : p.type === "chatgpt" ? (p.url ?? "https://chatgpt.com/backend-api") : p.url;
|
|
296
|
+
const url = p.type === "anthropic" ? "https://api.anthropic.com" : p.type === "chatgpt" ? (p.url ?? "https://chatgpt.com/backend-api") : p.type === "google" ? (p.url ?? "https://generativelanguage.googleapis.com") : p.url;
|
|
238
297
|
let reachable = false;
|
|
239
298
|
try {
|
|
240
299
|
const u = new URL(url);
|
|
@@ -262,6 +321,7 @@ async function buildStatus(deps, opts = {}) {
|
|
|
262
321
|
// Reaching the host says nothing about being able to use it: a chatgpt provider with no
|
|
263
322
|
// credentials is not "connected", and calling it that sends the user off believing it works.
|
|
264
323
|
...(p.type === "chatgpt" ? { needsLogin: !chatgptSignedIn(p.auth) } : {}),
|
|
324
|
+
...(p.type === "google" && p.auth === "antigravity" ? googleHealth(deps.google?.().accounts?.()[name] ?? []) : {}),
|
|
265
325
|
...(p.type === "anthropic"
|
|
266
326
|
? {
|
|
267
327
|
authSource: p.auth === "claude-code" ? claudeAuthStore(deps).describeSource() : null,
|
|
@@ -352,6 +412,7 @@ async function buildStatus(deps, opts = {}) {
|
|
|
352
412
|
credentials: deps.credentials?.() ?? {},
|
|
353
413
|
picker,
|
|
354
414
|
agentTitle: agentTitleHookEnabled(),
|
|
415
|
+
statusMod: statusModEnabled(),
|
|
355
416
|
pickerModels: cfg.cli.extraModels.map((m) => m.name || m.model),
|
|
356
417
|
uiRevision: uiRevision(),
|
|
357
418
|
};
|
|
@@ -372,30 +433,50 @@ function pickerTrust() {
|
|
|
372
433
|
pickerTrustMemo = { at: now, value };
|
|
373
434
|
return value;
|
|
374
435
|
}
|
|
375
|
-
/**
|
|
436
|
+
/** The ClaudeRipple CLI and the Node that installed us (`<home>/paths.json`, written by `install`). */
|
|
437
|
+
function cliInvocation() {
|
|
438
|
+
let node = process.execPath;
|
|
439
|
+
// Same walk either way; the extension says which layout this file was loaded from.
|
|
440
|
+
let cli = path.resolve(here, `../../cli/src/index${path.extname(fileURLToPath(import.meta.url))}`);
|
|
441
|
+
let runtimeEnv = {};
|
|
442
|
+
try {
|
|
443
|
+
const p = JSON.parse(fs.readFileSync(path.join(homeDir(), "paths.json"), "utf8"));
|
|
444
|
+
if (p.node)
|
|
445
|
+
node = p.node;
|
|
446
|
+
if (p.env)
|
|
447
|
+
runtimeEnv = p.env;
|
|
448
|
+
if (p.cli)
|
|
449
|
+
cli = p.cli;
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
/* not installed via the CLI: fall back to our own node + repo layout */
|
|
453
|
+
}
|
|
454
|
+
return { node, cli, env: { ...process.env, ...runtimeEnv, CLAUDERIPPLE_HOME: homeDir() } };
|
|
455
|
+
}
|
|
376
456
|
function runCli(args, timeout = 180_000) {
|
|
377
457
|
return new Promise((resolveP) => {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
let cli = path.resolve(here, `../../cli/src/index${path.extname(fileURLToPath(import.meta.url))}`);
|
|
381
|
-
let runtimeEnv = {};
|
|
382
|
-
try {
|
|
383
|
-
const p = JSON.parse(fs.readFileSync(path.join(homeDir(), "paths.json"), "utf8"));
|
|
384
|
-
if (p.node)
|
|
385
|
-
node = p.node;
|
|
386
|
-
if (p.env)
|
|
387
|
-
runtimeEnv = p.env;
|
|
388
|
-
if (p.cli)
|
|
389
|
-
cli = p.cli;
|
|
390
|
-
}
|
|
391
|
-
catch {
|
|
392
|
-
/* not installed via the CLI: fall back to our own node + repo layout */
|
|
393
|
-
}
|
|
394
|
-
execFile(node, [cli, ...args], { env: { ...process.env, ...runtimeEnv, CLAUDERIPPLE_HOME: homeDir() }, timeout }, (err, stdout, stderr) => {
|
|
458
|
+
const { node, cli, env } = cliInvocation();
|
|
459
|
+
execFile(node, [cli, ...args], { env, timeout }, (err, stdout, stderr) => {
|
|
395
460
|
resolveP({ ok: !err, output: `${stdout}${stderr}${err ? `\n${err.message}` : ""}`.trim() });
|
|
396
461
|
});
|
|
397
462
|
});
|
|
398
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* Start the CLI in its own process group, writing to `log`. An update ends by restarting this
|
|
466
|
+
* router, and launchd stops a job by killing its whole process group — an ordinary child would die
|
|
467
|
+
* with the router halfway through the install that restarted it.
|
|
468
|
+
*/
|
|
469
|
+
function startCliDetached(args, log) {
|
|
470
|
+
const { node, cli, env } = cliInvocation();
|
|
471
|
+
fs.mkdirSync(path.dirname(log), { recursive: true });
|
|
472
|
+
const out = fs.openSync(log, "w");
|
|
473
|
+
try {
|
|
474
|
+
spawn(node, [cli, ...args], { env, detached: true, stdio: ["ignore", out, out], windowsHide: true }).unref();
|
|
475
|
+
}
|
|
476
|
+
finally {
|
|
477
|
+
fs.closeSync(out);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
399
480
|
/**
|
|
400
481
|
* Whether this provider can actually run a web search — measured capability, not an assumption from
|
|
401
482
|
* the vendor name. The rule is the router's own (`Proxy.canRunServerTools`, mirrored here because
|
|
@@ -565,7 +646,7 @@ async function probeAnthropicApiKey(apiKey, probeFetch = fetchWithTimeout) {
|
|
|
565
646
|
if (response.ok)
|
|
566
647
|
return { ok: true, auth: "ok", models };
|
|
567
648
|
const detail = snippet(await response.text());
|
|
568
|
-
if (response.status
|
|
649
|
+
if (refusedForModel(response.status, detail))
|
|
569
650
|
return { ok: true, auth: "ok", models };
|
|
570
651
|
if (response.status === 402 || /insufficient|balance|credit|quota|billing/i.test(detail))
|
|
571
652
|
return { ok: true, auth: "ok", models, error: `no-credits: ${response.status} ${detail}` };
|
|
@@ -584,6 +665,86 @@ function probeClaudeCodeAuth(deps) {
|
|
|
584
665
|
const signedIn = accounts.length > 0 ? "oauth" : readClaudeAuthFile(homeDir())?.source ?? null;
|
|
585
666
|
return { ok: source !== null || hasUsableAccount, auth: source !== null || hasUsableAccount ? "ok" : "missing", source, signedIn, accountCount: accounts.length, models: CLAUDE_MODEL_FALLBACK };
|
|
586
667
|
}
|
|
668
|
+
/** The reasoning levels a `GET /v1beta/models` entry says the model takes, or none to offer. */
|
|
669
|
+
function googleEffortLevels(entry) {
|
|
670
|
+
// `thinking: true` is the listing's own flag that a model reasons. The Generative Language API
|
|
671
|
+
// documents no per-model effort ladder, so a thinking model is offered the Gemini-3 levels this
|
|
672
|
+
// router maps onto (translate.ts): a model that only knows a subset clamps server-side.
|
|
673
|
+
return entry.thinking === true ? ["minimal", "low", "medium", "high"] : [];
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* `GET {base}/v1beta/models` with the key on `x-goog-api-key`, following `nextPageToken`.
|
|
677
|
+
*
|
|
678
|
+
* The auth check is the listing itself: Google answers a bad key with `400 API_KEY_INVALID` (or a
|
|
679
|
+
* 401/403), so there is no separate one-token request to make.
|
|
680
|
+
*/
|
|
681
|
+
async function probeGoogle(body, probeFetch = fetchWithTimeout) {
|
|
682
|
+
const apiKey = typeof body.apiKey === "string" && body.apiKey.length > 0 ? body.apiKey : (process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY);
|
|
683
|
+
if (!apiKey)
|
|
684
|
+
return { ok: false, auth: "missing", models: [], error: "no API key; set one or GEMINI_API_KEY" };
|
|
685
|
+
const base = (typeof body.url === "string" && body.url ? body.url : "https://generativelanguage.googleapis.com").replace(/\/+$/, "");
|
|
686
|
+
const models = [];
|
|
687
|
+
let pageToken;
|
|
688
|
+
try {
|
|
689
|
+
// Bounded: a catalogue that keeps handing back a token must not spin the admin request forever.
|
|
690
|
+
for (let page = 0; page < 20; page++) {
|
|
691
|
+
const url = `${base}/v1beta/models?pageSize=1000${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ""}`;
|
|
692
|
+
const response = await probeFetch(url, { headers: { "x-goog-api-key": apiKey } });
|
|
693
|
+
if (response.status === 401 || response.status === 403)
|
|
694
|
+
return { ok: false, auth: "bad-key", models: [], error: `models endpoint returned ${response.status}` };
|
|
695
|
+
if (!response.ok) {
|
|
696
|
+
const detail = snippet(await response.text());
|
|
697
|
+
// A wrong key comes back 400 with `status: API_KEY_INVALID`; that is a bad key, not a bad base.
|
|
698
|
+
const badKey = /API_KEY_INVALID|API key not valid|invalid api key/i.test(detail);
|
|
699
|
+
return { ok: false, auth: badKey ? "bad-key" : response.status >= 500 ? "unreachable" : "unknown", models: [], error: `models endpoint returned ${response.status}: ${detail}` };
|
|
700
|
+
}
|
|
701
|
+
const parsed = await response.json();
|
|
702
|
+
for (const raw of Array.isArray(parsed.models) ? parsed.models : []) {
|
|
703
|
+
if (!raw || typeof raw !== "object")
|
|
704
|
+
continue;
|
|
705
|
+
const entry = raw;
|
|
706
|
+
if (typeof entry.name !== "string")
|
|
707
|
+
continue;
|
|
708
|
+
const id = entry.name.replace(/^models\//, "");
|
|
709
|
+
// Only models that can actually answer `generateContent` are worth listing; embedding and
|
|
710
|
+
// prediction models share the catalogue but cannot serve a turn.
|
|
711
|
+
const methods = Array.isArray(entry.supportedGenerationMethods) ? entry.supportedGenerationMethods : [];
|
|
712
|
+
if (!methods.some((method) => method === "generateContent"))
|
|
713
|
+
continue;
|
|
714
|
+
const levels = googleEffortLevels(entry);
|
|
715
|
+
models.push({
|
|
716
|
+
id,
|
|
717
|
+
...(typeof entry.displayName === "string" ? { name: entry.displayName } : {}),
|
|
718
|
+
...(typeof entry.inputTokenLimit === "number" && entry.inputTokenLimit > 0 ? { contextWindow: Math.floor(entry.inputTokenLimit) } : {}),
|
|
719
|
+
...(levels.length > 0 ? { effortLevels: levels } : {}),
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
pageToken = typeof parsed.nextPageToken === "string" && parsed.nextPageToken ? parsed.nextPageToken : undefined;
|
|
723
|
+
if (!pageToken)
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
if (models.length === 0)
|
|
727
|
+
return { ok: true, auth: "ok", models, error: "no generateContent models listed" };
|
|
728
|
+
return { ok: true, auth: "ok", models };
|
|
729
|
+
}
|
|
730
|
+
catch (e) {
|
|
731
|
+
return { ok: false, auth: "unreachable", models, error: `models endpoint: ${errorText(e)}` };
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* `{type:"google", auth:"antigravity"}`: whether any Google account is signed in, and the model list
|
|
736
|
+
* Cloud Code Assist returns for it (falling back to the static list). No network call is made here
|
|
737
|
+
* beyond the catalogue the provider already serves; a signed-out state is the common answer.
|
|
738
|
+
*/
|
|
739
|
+
async function probeGoogleAntigravity(deps) {
|
|
740
|
+
const accounts = deps.google?.().accounts?.() ?? {};
|
|
741
|
+
const signedIn = Object.values(accounts).some((list) => list.length > 0) || readGoogleAccounts(homeDir()).length > 0;
|
|
742
|
+
if (!signedIn)
|
|
743
|
+
return { ok: false, auth: "missing", models: staticAntigravityModels(), error: "no Google account; run `clauderipple google-login`" };
|
|
744
|
+
const named = Object.keys(accounts)[0];
|
|
745
|
+
const models = named ? await deps.google?.().models?.(named).catch(() => null) ?? null : null;
|
|
746
|
+
return { ok: true, auth: "ok", models: models ?? staticAntigravityModels() };
|
|
747
|
+
}
|
|
587
748
|
function chatCompletionsUrl(base) {
|
|
588
749
|
return `${base.replace(/\/+$/, "")}/chat/completions`;
|
|
589
750
|
}
|
|
@@ -617,6 +778,15 @@ function parsedModels(value) {
|
|
|
617
778
|
function snippet(text) {
|
|
618
779
|
return text.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
619
780
|
}
|
|
781
|
+
/**
|
|
782
|
+
* Whether a refused test request was refused for its model alone, which still shows the endpoint is
|
|
783
|
+
* right and the key was accepted. Anthropic's own wire says so with 404 not_found_error rather than
|
|
784
|
+
* 400 — abliteration.ai answers "Requested model was not found." (issue #42) — so 404 counts too,
|
|
785
|
+
* but only with a model named in the body: a bare 404 is a wrong URL.
|
|
786
|
+
*/
|
|
787
|
+
function refusedForModel(status, detail) {
|
|
788
|
+
return (status === 400 || status === 404) && /model.{0,80}(not.?found|invalid|unsupported|does not exist)|unknown.{0,20}model/i.test(detail);
|
|
789
|
+
}
|
|
620
790
|
/**
|
|
621
791
|
* Fill each discovered model with the wire, endpoint and auth convention its preset declares for it.
|
|
622
792
|
*
|
|
@@ -709,8 +879,8 @@ async function probeProvider(body, probeFetch = fetchWithTimeout) {
|
|
|
709
879
|
if (response.ok)
|
|
710
880
|
return { ok: true, auth: "ok", models, ...(modelsError ? { error: modelsError } : {}) };
|
|
711
881
|
const detail = snippet(await response.text());
|
|
712
|
-
// A model-validation
|
|
713
|
-
if (response.status
|
|
882
|
+
// A model-validation refusal still demonstrates that the endpoint reached the provider and the key was accepted.
|
|
883
|
+
if (refusedForModel(response.status, detail)) {
|
|
714
884
|
return { ok: true, auth: "ok", models, ...(modelsError ? { error: modelsError } : {}) };
|
|
715
885
|
}
|
|
716
886
|
// Key accepted but the account cannot pay: report auth ok so the user tops up instead of re-checking the key.
|
|
@@ -730,6 +900,8 @@ const MEASURE_JOBS = new Map();
|
|
|
730
900
|
* (and its error strings) in memory for the life of the process. */
|
|
731
901
|
const MEASURE_JOB_TTL_MS = 10 * 60 * 1000;
|
|
732
902
|
const MEASURE_TIMEOUT_MS = 20_000;
|
|
903
|
+
/** Models measured at once. One job is one provider and one key, so this is also its burst. */
|
|
904
|
+
const MEASURE_CONCURRENCY = 4;
|
|
733
905
|
async function measureFetchWithTimeout(url, init) {
|
|
734
906
|
return fetch(url, { ...init, signal: AbortSignal.timeout(MEASURE_TIMEOUT_MS) });
|
|
735
907
|
}
|
|
@@ -749,8 +921,12 @@ function sweepMeasureJobs() {
|
|
|
749
921
|
* trying the list in catalogue order made it measure as `chat` and quietly overrule the `anthropic`
|
|
750
922
|
* the preset recorded for it. A recorded wire is still only a starting point: it has to answer like
|
|
751
923
|
* any other candidate, and the rest of the list is tried when it does not.
|
|
924
|
+
*
|
|
925
|
+
* `catalogWire` is the wire the public catalogue gives this model (catalog.ts). It goes first when
|
|
926
|
+
* the preset's own table says nothing about the model, so a model newer than that table is still
|
|
927
|
+
* settled in one request instead of walking the list.
|
|
752
928
|
*/
|
|
753
|
-
function measureCandidates(p, modelId) {
|
|
929
|
+
function measureCandidates(p, modelId, catalogWire) {
|
|
754
930
|
const defaultWire = p.type === "anthropic-compatible" ? "anthropic"
|
|
755
931
|
: p.type === "openai-compatible" ? (p.wire ?? "chat")
|
|
756
932
|
: "chat";
|
|
@@ -790,6 +966,11 @@ function measureCandidates(p, modelId) {
|
|
|
790
966
|
if (model.wire)
|
|
791
967
|
add({ wire: model.wire, url: model.url ?? p.url, ...(model.authHeader ? { authHeader: model.authHeader } : {}) });
|
|
792
968
|
}
|
|
969
|
+
if (!known?.wire && catalogWire) {
|
|
970
|
+
const at = candidates.findIndex((c) => c.wire === catalogWire);
|
|
971
|
+
if (at > 0)
|
|
972
|
+
candidates.unshift(...candidates.splice(at, 1));
|
|
973
|
+
}
|
|
793
974
|
return candidates;
|
|
794
975
|
}
|
|
795
976
|
/** The effort ladder this provider offers, before any per-model override narrows it. */
|
|
@@ -858,14 +1039,31 @@ async function runMeasureJob(jobId, providerName, models, deps) {
|
|
|
858
1039
|
const fetchImpl = deps.measureFetch ?? measureFetchWithTimeout;
|
|
859
1040
|
const headers = "headers" in provider ? provider.headers : undefined;
|
|
860
1041
|
const sessionHeader = "sessionHeader" in provider ? provider.sessionHeader : undefined;
|
|
861
|
-
|
|
1042
|
+
const preset = "preset" in provider ? provider.preset : undefined;
|
|
1043
|
+
const catalog = deps.modelCatalog ? await deps.modelCatalog().catch(() => undefined) : undefined;
|
|
1044
|
+
const measureOne = async (id) => {
|
|
1045
|
+
const listed = catalogEntry(catalog, preset, id);
|
|
862
1046
|
// Per model, not once per job: the order depends on what the preset recorded for this id.
|
|
863
|
-
const candidates = measureCandidates(provider, id);
|
|
864
|
-
|
|
1047
|
+
const candidates = measureCandidates(provider, id, listed?.wire);
|
|
1048
|
+
// A ladder the catalogue states is taken as stated; the wire is still asked, once, because that
|
|
1049
|
+
// one request is also what finds a model the plan refuses (403 FreeTierError, DataPolicyError).
|
|
1050
|
+
const stated = ladder.length > 0 ? listed?.effortLevels : undefined;
|
|
1051
|
+
const known = stated !== undefined;
|
|
1052
|
+
const measured = await measureModel(id, candidates, known ? [] : ladder, { fetch: fetchImpl, ...(sessionHeader ? { sessionHeader } : {}), ...(headers ? { headers } : {}) });
|
|
1053
|
+
if (stated && measured.wire)
|
|
1054
|
+
measured.effortLevels = stated;
|
|
865
1055
|
job.results.push(measured);
|
|
866
1056
|
job.done++;
|
|
867
|
-
deps.log.info(`admin: measured ${providerName}/${id} -> ${measured.error ? `error ${measured.error}` : `wire ${measured.wire}${measured.effortLevels ? ` effort ${measured.effortLevels.join(",")}` : ""}`}`);
|
|
868
|
-
}
|
|
1057
|
+
deps.log.info(`admin: measured ${providerName}/${id} -> ${measured.error ? `error ${measured.error}` : `wire ${measured.wire}${measured.effortLevels ? ` effort ${measured.effortLevels.join(",")}${known ? " (catalogue)" : ""}` : ""}`}`);
|
|
1058
|
+
};
|
|
1059
|
+
// Models are independent, so a few run at once and a slow upstream answer holds up only its own
|
|
1060
|
+
// model. Levels inside one model stay sequential: a burst there invites 429s, and a level that
|
|
1061
|
+
// meets one is kept rather than measured, which would quietly widen the ladder.
|
|
1062
|
+
const queue = [...models];
|
|
1063
|
+
await Promise.all(Array.from({ length: Math.min(MEASURE_CONCURRENCY, queue.length) }, async () => {
|
|
1064
|
+
for (let id = queue.shift(); id !== undefined; id = queue.shift())
|
|
1065
|
+
await measureOne(id);
|
|
1066
|
+
}));
|
|
869
1067
|
try {
|
|
870
1068
|
const outcome = applyMeasurements(deps.configFile, job.results);
|
|
871
1069
|
if ("error" in outcome) {
|
|
@@ -1092,6 +1290,14 @@ export function startAdmin(deps) {
|
|
|
1092
1290
|
});
|
|
1093
1291
|
return;
|
|
1094
1292
|
}
|
|
1293
|
+
if (probe.type === "google") {
|
|
1294
|
+
if (probe.auth === "antigravity") {
|
|
1295
|
+
sendJson(res, 200, await probeGoogleAntigravity(deps));
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
sendJson(res, 200, await probeGoogle({ apiKey: probe.apiKey, url: probe.url }, deps.probeFetch));
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1095
1301
|
if ((probe.type !== "anthropic-compatible" && probe.type !== "openai-compatible") ||
|
|
1096
1302
|
typeof probe.url !== "string" ||
|
|
1097
1303
|
!/^https?:\/\//.test(probe.url) ||
|
|
@@ -1422,6 +1628,126 @@ export function startAdmin(deps) {
|
|
|
1422
1628
|
sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
|
|
1423
1629
|
return;
|
|
1424
1630
|
}
|
|
1631
|
+
// Google (Antigravity) sign-in of our own (browser, PKCE, loopback callback). The GUI starts it
|
|
1632
|
+
// and polls the state; tokens never leave the router and never appear in any response.
|
|
1633
|
+
if (pathname === "/api/google-login" && method === "GET") {
|
|
1634
|
+
const state = googleOAuth?.snapshot ?? { running: false, url: null, startedAt: null, finishedAt: null, ok: null, error: null, account: null, termsWarning: ANTIGRAVITY_WARNING };
|
|
1635
|
+
sendJson(res, 200, state);
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
if (pathname === "/api/google-login" && method === "POST") {
|
|
1639
|
+
if (googleOAuth?.snapshot.running) {
|
|
1640
|
+
sendJson(res, 200, googleOAuth.snapshot);
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
const session = new GoogleOAuthSession({ home: homeDir(), ...(deps.googleOAuthFetch ? { fetch: deps.googleOAuthFetch } : {}), ...(deps.googleOAuthPort !== undefined ? { port: deps.googleOAuthPort } : {}) }, ANTIGRAVITY_WARNING);
|
|
1644
|
+
googleOAuth = session;
|
|
1645
|
+
let started;
|
|
1646
|
+
try {
|
|
1647
|
+
started = await session.start();
|
|
1648
|
+
}
|
|
1649
|
+
catch (error) {
|
|
1650
|
+
sendJson(res, 500, { ...session.snapshot, error: error.message });
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
const opened = deps.openBrowser ? deps.openBrowser(started.url) : openBrowser(started.url);
|
|
1654
|
+
deps.log.info(`admin: google sign-in via GUI -> started (browser ${opened ? "opened" : "not opened"})`);
|
|
1655
|
+
void session.result.then(() => deps.log.info("admin: google sign-in via GUI -> ok"), (error) => deps.log.info(`admin: google sign-in via GUI -> failed: ${error.message}`));
|
|
1656
|
+
sendJson(res, 200, { ...session.snapshot, opened, termsWarning: ANTIGRAVITY_WARNING });
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
if (pathname === "/api/google-login/code" && method === "POST") {
|
|
1660
|
+
const session = googleOAuth;
|
|
1661
|
+
if (!session?.snapshot.running) {
|
|
1662
|
+
sendJson(res, 409, { error: "no Google sign-in is waiting for a code" });
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
let code = "";
|
|
1666
|
+
try {
|
|
1667
|
+
code = String(JSON.parse((await readBody(req)).toString("utf8")).code ?? "");
|
|
1668
|
+
}
|
|
1669
|
+
catch {
|
|
1670
|
+
sendJson(res, 400, { error: "invalid JSON" });
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
await session.submitCode(code);
|
|
1674
|
+
await session.result.catch(() => { });
|
|
1675
|
+
sendJson(res, session.snapshot.ok ? 200 : 400, session.snapshot);
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
if (pathname === "/api/google-login/cancel" && method === "POST") {
|
|
1679
|
+
googleOAuth?.cancel();
|
|
1680
|
+
sendJson(res, 200, googleOAuth?.snapshot ?? { running: false });
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
if (pathname === "/api/google-logout" && method === "POST") {
|
|
1684
|
+
const r = await (deps.runCli ?? runCli)(["google-logout"]);
|
|
1685
|
+
deps.log.info(`admin: google-logout via GUI -> ${r.ok ? "ok" : "failed"}`);
|
|
1686
|
+
sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
// Safe account metadata only: labels, emails, project ids and rotation state. Tokens stay in
|
|
1690
|
+
// the router and are never serialized.
|
|
1691
|
+
if (pathname === "/api/google-accounts" && method === "GET") {
|
|
1692
|
+
const all = deps.google?.().accounts?.() ?? {};
|
|
1693
|
+
const wanted = new URL(url, "http://127.0.0.1").searchParams.get("provider");
|
|
1694
|
+
const name = wanted && all[wanted] ? wanted : Object.keys(all)[0];
|
|
1695
|
+
const accounts = name ? all[name] : readGoogleAccounts(homeDir()).map(summarizeGoogleAccount);
|
|
1696
|
+
sendJson(res, 200, { provider: name ?? null, accounts });
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
if (pathname.startsWith("/api/google-accounts/") && (method === "PATCH" || method === "DELETE")) {
|
|
1700
|
+
let id;
|
|
1701
|
+
try {
|
|
1702
|
+
id = decodeURIComponent(pathname.slice("/api/google-accounts/".length));
|
|
1703
|
+
}
|
|
1704
|
+
catch {
|
|
1705
|
+
sendJson(res, 400, { error: "invalid account id" });
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) {
|
|
1709
|
+
sendJson(res, 400, { error: "invalid account id" });
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
if (method === "DELETE") {
|
|
1713
|
+
if (!removeGoogleAccount(homeDir(), id)) {
|
|
1714
|
+
sendJson(res, 404, { error: "Google account not found" });
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
deps.log.info(`admin: removed Google account ${id.slice(0, 8)}`);
|
|
1718
|
+
sendJson(res, 200, { ok: true });
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
let change;
|
|
1722
|
+
try {
|
|
1723
|
+
change = JSON.parse((await readBody(req)).toString("utf8"));
|
|
1724
|
+
}
|
|
1725
|
+
catch {
|
|
1726
|
+
sendJson(res, 400, { error: "invalid JSON" });
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
if ((change.label !== undefined && (typeof change.label !== "string" || !change.label.trim())) || (change.paused !== undefined && typeof change.paused !== "boolean")) {
|
|
1730
|
+
sendJson(res, 400, { error: "expected {label?: non-empty string, paused?: boolean, clearCooldown?: true, recheck?: true}" });
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
if (change.recheck === true) {
|
|
1734
|
+
const result = (await deps.google?.().recheckVerification?.(id)) ?? "unknown";
|
|
1735
|
+
deps.log.info(`admin: rechecked Google verification for ${id.slice(0, 8)} -> ${result}`);
|
|
1736
|
+
sendJson(res, 200, { ok: true, verification: result });
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
if (change.clearCooldown === true)
|
|
1740
|
+
deps.google?.().clearCooldown?.(id);
|
|
1741
|
+
if (change.label !== undefined || change.paused !== undefined) {
|
|
1742
|
+
if (!updateGoogleAccount(homeDir(), id, { ...(typeof change.label === "string" ? { label: change.label } : {}), ...(typeof change.paused === "boolean" ? { paused: change.paused } : {}) })) {
|
|
1743
|
+
sendJson(res, 404, { error: "Google account not found" });
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
deps.log.info(`admin: updated Google account ${id.slice(0, 8)}`);
|
|
1748
|
+
sendJson(res, 200, { ok: true });
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1425
1751
|
if (pathname === "/api/picker" && method === "POST") {
|
|
1426
1752
|
// Runs `clauderipple picker on|off` (keychain trust, app Config Library, config flag).
|
|
1427
1753
|
// macOS shows its keychain password dialog in the user's session; we never see the password.
|
|
@@ -1450,6 +1776,39 @@ export function startAdmin(deps) {
|
|
|
1450
1776
|
sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
|
|
1451
1777
|
return;
|
|
1452
1778
|
}
|
|
1779
|
+
if (pathname === "/api/update" && (method === "GET" || method === "POST")) {
|
|
1780
|
+
// The CLI answers both: it knows how this copy was installed, which decides what an update is
|
|
1781
|
+
// (the tray asks it the same way). A POST checks again rather than trusting the page.
|
|
1782
|
+
const r = await (deps.runCli ?? runCli)(["update", "--check", "--json"], 30_000);
|
|
1783
|
+
const check = parseUpdateCheck(r.output);
|
|
1784
|
+
if (!check) {
|
|
1785
|
+
sendJson(res, 502, { error: "could not check for updates", output: r.output.slice(-2000) });
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
const running = updateStartedAt > 0 && Date.now() - updateStartedAt < UPDATE_RUN_MS;
|
|
1789
|
+
if (method === "GET") {
|
|
1790
|
+
sendJson(res, 200, { ...check, running });
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
if (check.kind !== "script" && check.kind !== "npm") {
|
|
1794
|
+
sendJson(res, 409, { error: `this copy (${check.kind}) is not updated from here`, ...check });
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
if (!check.newer) {
|
|
1798
|
+
sendJson(res, 409, { error: "already the latest version", ...check });
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
if (running) {
|
|
1802
|
+
sendJson(res, 409, { error: "an update is already running", ...check, running });
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
updateStartedAt = Date.now();
|
|
1806
|
+
const log = path.join(homeDir(), "logs", "update.log");
|
|
1807
|
+
(deps.startCliDetached ?? startCliDetached)(["update"], log);
|
|
1808
|
+
deps.log.info(`admin: update ${check.current} -> ${check.latest ?? "?"} started via GUI (output in ${log})`);
|
|
1809
|
+
sendJson(res, 202, { started: true, ...check, log });
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1453
1812
|
if (pathname === "/api/image" && method === "POST") {
|
|
1454
1813
|
// The image itself is the response body, so `curl -o out.png` is the whole client. An error
|
|
1455
1814
|
// is JSON with a non-2xx status (`curl --fail-with-body` writes it to the same file).
|
|
@@ -1512,14 +1871,34 @@ export function startAdmin(deps) {
|
|
|
1512
1871
|
sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
|
|
1513
1872
|
return;
|
|
1514
1873
|
}
|
|
1874
|
+
if (pathname === "/api/status-mod" && method === "POST") {
|
|
1875
|
+
// Through the CLI, like agent-title: it knows where this install keeps packages/mod.
|
|
1876
|
+
let enabled;
|
|
1877
|
+
try {
|
|
1878
|
+
enabled = JSON.parse((await readBody(req)).toString("utf8")).enabled;
|
|
1879
|
+
}
|
|
1880
|
+
catch {
|
|
1881
|
+
sendJson(res, 400, { error: "invalid JSON" });
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
if (typeof enabled !== "boolean") {
|
|
1885
|
+
sendJson(res, 400, { error: "expected {enabled: boolean}" });
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
const r = await runCli(["mod", enabled ? "on" : "off"]);
|
|
1889
|
+
deps.log.info(`admin: status mod ${enabled ? "on" : "off"} via GUI -> ${r.ok ? "ok" : "failed"}`);
|
|
1890
|
+
sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1515
1893
|
if (pathname === "/api/requests" && method === "GET") {
|
|
1516
1894
|
const query = new URL(url, "http://x").searchParams;
|
|
1517
1895
|
const requested = Number(query.get("n") ?? 200);
|
|
1518
1896
|
const n = Number.isFinite(requested) ? Math.min(2000, Math.max(1, Math.floor(requested))) : 200;
|
|
1519
1897
|
const provider = query.get("provider") || undefined;
|
|
1898
|
+
const session = query.get("session") || undefined;
|
|
1520
1899
|
const kindValue = query.get("kind");
|
|
1521
1900
|
const kind = kindValue === "messages" || kindValue === "count_tokens" || kindValue === "other" ? kindValue : undefined;
|
|
1522
|
-
sendJson(res, 200, { requests: deps.requests.list(n, { ...(provider ? { provider } : {}), ...(kind ? { kind } : {}) }) });
|
|
1901
|
+
sendJson(res, 200, { requests: deps.requests.list(n, { ...(provider ? { provider } : {}), ...(kind ? { kind } : {}), ...(session ? { session } : {}) }) });
|
|
1523
1902
|
return;
|
|
1524
1903
|
}
|
|
1525
1904
|
if (pathname === "/api/requests/summary" && method === "GET") {
|