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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// stable prompt prefix can retain their native prompt-cache behavior.
|
|
4
4
|
import crypto from "node:crypto";
|
|
5
5
|
import { clampEffort } from "../../compat.js";
|
|
6
|
-
import { conversationKey, estimateTokens, normalizeSchema, serverToolNames,
|
|
6
|
+
import { conversationKey, estimateTokens, normalizeSchema, serverToolNames, systemWithDroppedToolNote, toolNameForResponses } from "../chatgpt/translate.js";
|
|
7
7
|
import { identityPrefix, instructionsSuffix } from "../../identity.js";
|
|
8
8
|
function textOf(content) {
|
|
9
9
|
if (typeof content === "string")
|
|
@@ -87,7 +87,7 @@ function mappedEffort(opts) {
|
|
|
87
87
|
/** Convert every Anthropic message into OpenAI Chat Completion messages without inventing unstable text. */
|
|
88
88
|
export function toChatMessages(req, opts) {
|
|
89
89
|
const messages = [];
|
|
90
|
-
const sys =
|
|
90
|
+
const sys = systemWithDroppedToolNote(req);
|
|
91
91
|
const content = opts ? systemWithIdentity(sys, opts) : sys;
|
|
92
92
|
if (content)
|
|
93
93
|
messages.push({ role: "system", content });
|
|
@@ -248,7 +248,7 @@ export function toOpenAiRequest(req, opts) {
|
|
|
248
248
|
input: toResponsesInput(req),
|
|
249
249
|
stream: true,
|
|
250
250
|
};
|
|
251
|
-
const instructions = systemWithIdentity(
|
|
251
|
+
const instructions = systemWithIdentity(systemWithDroppedToolNote(req), opts);
|
|
252
252
|
if (instructions)
|
|
253
253
|
out.instructions = instructions;
|
|
254
254
|
if (responseTools.length) {
|
package/dist/router/src/proxy.js
CHANGED
|
@@ -29,9 +29,11 @@ import { classify, CredentialPool, retryAfterMs } from "./pool.js";
|
|
|
29
29
|
import { PRESETS } from "./presets.js";
|
|
30
30
|
import { ChatGptAdapter } from "./providers/chatgpt/index.js";
|
|
31
31
|
import { OpenAiCompatibleAdapter } from "./providers/openai/index.js";
|
|
32
|
+
import { GoogleAdapter } from "./providers/google/index.js";
|
|
32
33
|
import { conversationKey } from "./providers/chatgpt/translate.js";
|
|
33
34
|
import { providerFor, terminateHosts } from "./config.js";
|
|
34
35
|
import { injectPickerModels, isBootstrapPath } from "./picker.js";
|
|
36
|
+
import { effortLevelsForModel } from "./admin.js";
|
|
35
37
|
import { ResponseUsageTap } from "./requestlog.js";
|
|
36
38
|
import { credentialHeaderValues, redactErrorText, redactHeaders } from "./redact.js";
|
|
37
39
|
import { ClaudeAccountAuthPool } from "./providers/anthropic-account-pool.js";
|
|
@@ -45,11 +47,15 @@ const HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-connection", "tra
|
|
|
45
47
|
const CLIENT_AUTH = new Set(["authorization", "x-api-key"]);
|
|
46
48
|
/** How much of an upstream error body is kept for the log. */
|
|
47
49
|
const ERROR_HEAD_MAX = 4096;
|
|
50
|
+
/** Methods safe to send twice (RFC 9110 §9.2.2), and so the only ones resent after a stale socket. */
|
|
51
|
+
const IDEMPOTENT = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
48
52
|
/**
|
|
49
53
|
* Claude Code's Remote Control registration uses HTTPS absolute-form proxy requests instead of
|
|
50
54
|
* CONNECT (`POST https://api.anthropic.com/v1/environments/bridge HTTP/1.1`). Convert that legal
|
|
51
55
|
* forward-proxy form to the origin form expected inside a TLS connection. Credentials and the
|
|
52
56
|
* request body remain byte-for-byte client data; proxy-only headers never reach the destination.
|
|
57
|
+
* Plain `http:` takes the same form and is relayed over TCP: Claude Code 2.1.288 sends its http
|
|
58
|
+
* requests through HTTPS_PROXY too, loopback included (a mod reading the admin API got our 405).
|
|
53
59
|
*/
|
|
54
60
|
export function absoluteProxyRequest(header) {
|
|
55
61
|
const lines = header.toString("latin1").split("\r\n");
|
|
@@ -64,9 +70,10 @@ export function absoluteProxyRequest(header) {
|
|
|
64
70
|
catch {
|
|
65
71
|
return null;
|
|
66
72
|
}
|
|
67
|
-
|
|
73
|
+
const secure = url.protocol === "https:";
|
|
74
|
+
if ((!secure && url.protocol !== "http:") || url.username || url.password || url.hash || !url.hostname)
|
|
68
75
|
return null;
|
|
69
|
-
const port = url.port ? Number(url.port) : 443;
|
|
76
|
+
const port = url.port ? Number(url.port) : secure ? 443 : 80;
|
|
70
77
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
71
78
|
return null;
|
|
72
79
|
// URL.hostname keeps brackets around IPv6 literals; net/tls expect the bare address while the
|
|
@@ -77,10 +84,10 @@ export function absoluteProxyRequest(header) {
|
|
|
77
84
|
!/^connection\s*:/i.test(line));
|
|
78
85
|
const authority = url.port ? `${url.hostname}:${url.port}` : url.hostname;
|
|
79
86
|
const path = `${url.pathname || "/"}${url.search}`;
|
|
80
|
-
// One
|
|
87
|
+
// One upstream connection serves one absolute-form request. Reusing it would send the next proxy-form
|
|
81
88
|
// request target directly to the origin, so explicitly ask both peers to close after the response.
|
|
82
89
|
const rewritten = [`${match[1]} ${path} ${match[3]}`, `Host: ${authority}`, "Connection: close", ...headers].join("\r\n") + "\r\n\r\n";
|
|
83
|
-
return { host, port, path, head: Buffer.from(rewritten, "latin1") };
|
|
90
|
+
return { host, port, path, head: Buffer.from(rewritten, "latin1"), tls: secure };
|
|
84
91
|
}
|
|
85
92
|
/**
|
|
86
93
|
* The same header list with one credential swapped for another. Names carried by either credential
|
|
@@ -177,6 +184,7 @@ export class Proxy {
|
|
|
177
184
|
providerAgents = new Map();
|
|
178
185
|
chatgptAdapters = new Map();
|
|
179
186
|
openaiAdapters = new Map();
|
|
187
|
+
googleAdapters = new Map();
|
|
180
188
|
claudeAccounts;
|
|
181
189
|
/** Each Claude subscription account's usage, for the admin status (never a token). */
|
|
182
190
|
claudeUsage;
|
|
@@ -292,6 +300,52 @@ export class Proxy {
|
|
|
292
300
|
this.openaiAdapters.set(name, { key, adapter });
|
|
293
301
|
return adapter;
|
|
294
302
|
}
|
|
303
|
+
google(name, cfg) {
|
|
304
|
+
const key = JSON.stringify(cfg);
|
|
305
|
+
const cur = this.googleAdapters.get(name);
|
|
306
|
+
if (cur && cur.key === key)
|
|
307
|
+
return cur.adapter;
|
|
308
|
+
const adapter = new GoogleAdapter(name, cfg, this.deps.home, this.deps.log, this.pool);
|
|
309
|
+
this.googleAdapters.set(name, { key, adapter });
|
|
310
|
+
return adapter;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Every configured google provider's Antigravity accounts: rotation state only, never a token.
|
|
314
|
+
* Empty for api-key providers and for an adapter that has not been created yet.
|
|
315
|
+
*/
|
|
316
|
+
googleAccounts() {
|
|
317
|
+
const out = {};
|
|
318
|
+
for (const [name, p] of Object.entries(this.deps.config().providers)) {
|
|
319
|
+
if (p.type === "google" && p.auth === "antigravity")
|
|
320
|
+
out[name] = this.google(name, p).accountStatus();
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
/** The models a google provider can reach now, from Cloud Code Assist's own catalogue. */
|
|
325
|
+
googleFetchModels(name) {
|
|
326
|
+
const cfg = this.deps.config().providers[name];
|
|
327
|
+
if (!cfg || cfg.type !== "google" || cfg.auth !== "antigravity")
|
|
328
|
+
return Promise.resolve(null);
|
|
329
|
+
return this.google(name, cfg).fetchModels();
|
|
330
|
+
}
|
|
331
|
+
/** Dashboard action: put one resting Google account back into rotation now. */
|
|
332
|
+
googleClearCooldown(ownerId) {
|
|
333
|
+
for (const [name, p] of Object.entries(this.deps.config().providers)) {
|
|
334
|
+
if (p.type === "google" && p.auth === "antigravity")
|
|
335
|
+
this.google(name, p).clearCooldown(ownerId);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/** Ask again whether an account still needs Google's verification page; the first provider that knows answers. */
|
|
339
|
+
async googleRecheckVerification(ownerId) {
|
|
340
|
+
for (const [name, p] of Object.entries(this.deps.config().providers)) {
|
|
341
|
+
if (p.type !== "google" || p.auth !== "antigravity")
|
|
342
|
+
continue;
|
|
343
|
+
const result = await this.google(name, p).recheckVerification(ownerId);
|
|
344
|
+
if (result !== "unknown")
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
return "unknown";
|
|
348
|
+
}
|
|
295
349
|
constructor(deps) {
|
|
296
350
|
this.deps = deps;
|
|
297
351
|
this.claudeAccounts = new ClaudeAccountAuthPool({
|
|
@@ -376,7 +430,7 @@ export class Proxy {
|
|
|
376
430
|
if (method !== "CONNECT" || !target) {
|
|
377
431
|
const absolute = absoluteProxyRequest(head.subarray(0, end));
|
|
378
432
|
if (absolute) {
|
|
379
|
-
log.info(`ABSOLUTE ${method ?? "?"} https://${absolute.host}:${absolute.port}${absolute.path}`);
|
|
433
|
+
log.info(`ABSOLUTE ${method ?? "?"} ${absolute.tls ? "https" : "http"}://${absolute.host}:${absolute.port}${absolute.path}`);
|
|
380
434
|
this.forwardAbsolute(sock, absolute, rest);
|
|
381
435
|
return;
|
|
382
436
|
}
|
|
@@ -401,7 +455,9 @@ export class Proxy {
|
|
|
401
455
|
/** Forward an HTTPS absolute-form request without terminating it through the routing layer. */
|
|
402
456
|
forwardAbsolute(sock, request, rest) {
|
|
403
457
|
const servername = net.isIP(request.host) ? undefined : request.host;
|
|
404
|
-
const up =
|
|
458
|
+
const up = request.tls
|
|
459
|
+
? (this.deps.tlsConnect ?? tls.connect)({ host: request.host, port: request.port, ...(servername ? { servername } : {}), ALPNProtocols: ["http/1.1"] })
|
|
460
|
+
: net.connect({ host: request.host, port: request.port });
|
|
405
461
|
const kill = () => {
|
|
406
462
|
sock.destroy();
|
|
407
463
|
up.destroy();
|
|
@@ -413,7 +469,7 @@ export class Proxy {
|
|
|
413
469
|
up.destroy();
|
|
414
470
|
});
|
|
415
471
|
sock.on("error", kill);
|
|
416
|
-
up.once("secureConnect", () => {
|
|
472
|
+
up.once(request.tls ? "secureConnect" : "connect", () => {
|
|
417
473
|
up.write(request.head);
|
|
418
474
|
if (rest.length)
|
|
419
475
|
up.write(rest);
|
|
@@ -525,6 +581,9 @@ export class Proxy {
|
|
|
525
581
|
provider: "anthropic",
|
|
526
582
|
stream: false,
|
|
527
583
|
};
|
|
584
|
+
const session = req.headers["x-claude-code-session-id"];
|
|
585
|
+
if (typeof session === "string" && /^[\w-]{1,128}$/.test(session))
|
|
586
|
+
record.session = session;
|
|
528
587
|
let observedUsage;
|
|
529
588
|
let observedStopReason;
|
|
530
589
|
// `failed` counts requests that did not get a proper response (vanished, upstream error,
|
|
@@ -747,6 +806,38 @@ export class Proxy {
|
|
|
747
806
|
}
|
|
748
807
|
return;
|
|
749
808
|
}
|
|
809
|
+
if (provider.type === "google") {
|
|
810
|
+
// Google holds no server-side thread either, so a `continue` is refused as it is for the
|
|
811
|
+
// other translated providers; the CLI resends the turn with the full history.
|
|
812
|
+
const td = threadDecision(json);
|
|
813
|
+
if (td === "refuse") {
|
|
814
|
+
const out = JSON.stringify(THREAD_UNSUPPORTED);
|
|
815
|
+
res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
|
|
816
|
+
finish("400", out.length, "thread continue refused → CLI resends stateless", false);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (td === "strip")
|
|
820
|
+
stripThreadFields(json);
|
|
821
|
+
record = { ...record, target: route.model, provider: route.provider };
|
|
822
|
+
const routeEffort = effortOf(json);
|
|
823
|
+
if (routeEffort)
|
|
824
|
+
record.effort = routeEffort;
|
|
825
|
+
tag = `GOOGLE ${route.tag} effort=${routeEffort ?? "-"}`;
|
|
826
|
+
try {
|
|
827
|
+
const o = await this.google(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
|
|
828
|
+
this.recordOutcome(route.provider, o.status);
|
|
829
|
+
finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
|
|
830
|
+
}
|
|
831
|
+
catch (e) {
|
|
832
|
+
this.recordOutcome(route.provider, 0);
|
|
833
|
+
finish("-", 0, `google error ${e.code ?? ""} ${e.message}`);
|
|
834
|
+
if (!res.headersSent)
|
|
835
|
+
res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
|
|
836
|
+
else
|
|
837
|
+
res.destroy();
|
|
838
|
+
}
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
750
841
|
if (provider.type === "anthropic") {
|
|
751
842
|
if (!provider.accountPool || provider.auth !== "claude-code") {
|
|
752
843
|
finish("400", 0, "native Anthropic provider is available through OpenAI ingress only", false);
|
|
@@ -852,7 +943,7 @@ export class Proxy {
|
|
|
852
943
|
}
|
|
853
944
|
}
|
|
854
945
|
else if (isApiHost) {
|
|
855
|
-
target = { protocol: "https:", host: cfg.upstream, port: 443, agent: this.upstreamAgent, extraHeaders: {} };
|
|
946
|
+
target = { protocol: "https:", host: cfg.upstream, port: this.deps.upstreamPort ?? 443, agent: this.deps.upstreamAgent ?? this.upstreamAgent, extraHeaders: {} };
|
|
856
947
|
tag = `PASS ${typeof model === "string" ? model : "-"}`;
|
|
857
948
|
// Passthrough stays byte-exact unless another model's thinking would get the turn refused.
|
|
858
949
|
if (json && dropForeignThinking(json) > 0)
|
|
@@ -921,7 +1012,11 @@ export class Proxy {
|
|
|
921
1012
|
let upReq;
|
|
922
1013
|
/** Credentials already spent on this turn, so a retry cannot pick one of them again. */
|
|
923
1014
|
const tried = new Set(penalised ? [penalised.id] : []);
|
|
1015
|
+
/** The headers of the attempt in flight, so a stale-socket resend repeats exactly that attempt. */
|
|
1016
|
+
let lastAttemptHeaders = headers;
|
|
1017
|
+
let staleRetries = 0;
|
|
924
1018
|
const send = (attemptHeaders) => {
|
|
1019
|
+
lastAttemptHeaders = attemptHeaders;
|
|
925
1020
|
upReq = lib.request({
|
|
926
1021
|
protocol: target.protocol,
|
|
927
1022
|
host: target.host,
|
|
@@ -989,6 +1084,18 @@ export class Proxy {
|
|
|
989
1084
|
}
|
|
990
1085
|
});
|
|
991
1086
|
const onUpstreamError = (e) => {
|
|
1087
|
+
// A kept-alive socket the upstream had already closed: the reset arrives before any response,
|
|
1088
|
+
// so the request never reached it. claude.ai did this 259 times in four days of picker mode
|
|
1089
|
+
// (2026-10-03..06), each one a 502 on an app request such as `current_user_access`. Send an
|
|
1090
|
+
// idempotent request again on a fresh attempt instead of failing it; a POST is never resent,
|
|
1091
|
+
// since a reset cannot prove the upstream did not act on it.
|
|
1092
|
+
const code = e.code;
|
|
1093
|
+
if (upReq.reusedSocket && (code === "ECONNRESET" || code === "EPIPE") && IDEMPOTENT.has(method) && !clientAborted && !res.headersSent && staleRetries < 2) {
|
|
1094
|
+
staleRetries++;
|
|
1095
|
+
log.info(`STALE ${reqHost}: kept-alive socket was closed (${code}); resending ${method} ${path.slice(0, 60)}`);
|
|
1096
|
+
send(lastAttemptHeaders);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
992
1099
|
if (target.host === cfg.upstream)
|
|
993
1100
|
this.deps.health.failure(e);
|
|
994
1101
|
// Nothing reached the provider, so this says nothing about the credential — but it does say
|
|
@@ -1148,7 +1255,7 @@ export class Proxy {
|
|
|
1148
1255
|
else if (status === 200 && isPickerBootstrap) {
|
|
1149
1256
|
try {
|
|
1150
1257
|
const j = JSON.parse(out.toString("utf8"));
|
|
1151
|
-
const r = injectPickerModels(j, cfg.cli.extraModels, cfg.cli.autoCompactWindow);
|
|
1258
|
+
const r = injectPickerModels(j, cfg.cli.extraModels, cfg.cli.autoCompactWindow, (model) => effortLevelsForModel(cfg, model));
|
|
1152
1259
|
if (r.surfaces.length > 0) {
|
|
1153
1260
|
this.lastPickerInjection = { at: new Date().toISOString(), ...r };
|
|
1154
1261
|
out = Buffer.from(JSON.stringify(j));
|
|
@@ -50,6 +50,18 @@ export function markerOverride(body, aliases) {
|
|
|
50
50
|
export function resolve(model, body, cfg) {
|
|
51
51
|
if (typeof model !== "string")
|
|
52
52
|
return null;
|
|
53
|
+
// Measured on Desktop's engine 2.1.284: the registered AUTO_MODE_MODEL env var is
|
|
54
|
+
// unused. Match the classifier's system prompt, never quoted conversation text.
|
|
55
|
+
// Its decision and wire format remain the engine's; only the inference target changes.
|
|
56
|
+
if (cfg.autoModeClassifier && isAutoModeClassifier(body)) {
|
|
57
|
+
const target = cfg.autoModeClassifier;
|
|
58
|
+
return {
|
|
59
|
+
provider: target.provider,
|
|
60
|
+
model: target.model,
|
|
61
|
+
effort: target.effort,
|
|
62
|
+
tag: `${model}->${target.model} (auto-mode classifier)`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
53
65
|
let base = model;
|
|
54
66
|
let effort;
|
|
55
67
|
const at = model.indexOf("@");
|
|
@@ -128,6 +140,16 @@ export function resolve(model, body, cfg) {
|
|
|
128
140
|
return null;
|
|
129
141
|
return { provider: owner, model: finalModel, effort: ov?.effort ?? effort, tag: `${model}->${finalModel}${ov ? " (marker)" : ""}` };
|
|
130
142
|
}
|
|
143
|
+
export function isAutoModeClassifier(body) {
|
|
144
|
+
const request = body;
|
|
145
|
+
if (!request || (Array.isArray(request.tools) && request.tools.length > 0))
|
|
146
|
+
return false;
|
|
147
|
+
const system = request.system;
|
|
148
|
+
const texts = typeof system === "string" ? [system] : Array.isArray(system)
|
|
149
|
+
? system.filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text)
|
|
150
|
+
: [];
|
|
151
|
+
return texts.some((text) => text.trimStart().startsWith("You are a security monitor for autonomous AI coding agents."));
|
|
152
|
+
}
|
|
131
153
|
/**
|
|
132
154
|
* Providers whose own `models` list carries this id. Dated and undated forms match each other, the
|
|
133
155
|
* same way a slot lookup does. Ingress-only providers are included on purpose — see the call site.
|
|
@@ -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.
|
|
8
|
+
export const VERSION = "0.8.1";
|