clauderipple 0.2.0
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 +229 -0
- package/LICENSE +674 -0
- package/README.ko.md +328 -0
- package/README.md +372 -0
- package/bin/clauderipple.js +12 -0
- package/dist/app/assets/trayDownTemplate.png +0 -0
- package/dist/app/assets/trayDownTemplate@2x.png +0 -0
- package/dist/app/assets/trayTemplate.png +0 -0
- package/dist/app/assets/trayTemplate@2x.png +0 -0
- package/dist/app/assets/trayWarnTemplate.png +0 -0
- package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
- package/dist/app/assets/trayWin.png +0 -0
- package/dist/app/assets/trayWin@2x.png +0 -0
- package/dist/app/assets/trayWinDown.png +0 -0
- package/dist/app/assets/trayWinDown@2x.png +0 -0
- package/dist/app/assets/trayWinWarn.png +0 -0
- package/dist/app/assets/trayWinWarn@2x.png +0 -0
- package/dist/app/dist/main.js +518 -0
- package/dist/cli/src/browser.js +21 -0
- package/dist/cli/src/bundle.js +51 -0
- package/dist/cli/src/certs.js +33 -0
- package/dist/cli/src/claude-auth.js +112 -0
- package/dist/cli/src/codex.js +172 -0
- package/dist/cli/src/gen-certs.js +7 -0
- package/dist/cli/src/hooks/agent-title.js +160 -0
- package/dist/cli/src/index.js +489 -0
- package/dist/cli/src/launchd.js +183 -0
- package/dist/cli/src/picker.js +166 -0
- package/dist/cli/src/probe.js +55 -0
- package/dist/cli/src/runtime.js +62 -0
- package/dist/cli/src/schtasks.js +134 -0
- package/dist/cli/src/settings.js +142 -0
- package/dist/cli/src/supervisor.js +100 -0
- package/dist/cli/src/tray.js +85 -0
- package/dist/router/src/admin.js +945 -0
- package/dist/router/src/bootstrap.js +80 -0
- package/dist/router/src/certs.js +65 -0
- package/dist/router/src/compat.js +172 -0
- package/dist/router/src/config.js +179 -0
- package/dist/router/src/health.js +45 -0
- package/dist/router/src/identity.js +51 -0
- package/dist/router/src/index.js +144 -0
- package/dist/router/src/ingress/models.js +29 -0
- package/dist/router/src/ingress/server.js +400 -0
- package/dist/router/src/ingress/translate.js +457 -0
- package/dist/router/src/log.js +81 -0
- package/dist/router/src/picker.js +74 -0
- package/dist/router/src/presets.js +267 -0
- package/dist/router/src/providers/anthropic-observed.js +88 -0
- package/dist/router/src/providers/anthropic-token-file.js +48 -0
- package/dist/router/src/providers/anthropic.js +203 -0
- package/dist/router/src/providers/chatgpt/auth.js +226 -0
- package/dist/router/src/providers/chatgpt/index.js +274 -0
- package/dist/router/src/providers/chatgpt/sse.js +28 -0
- package/dist/router/src/providers/chatgpt/translate.js +393 -0
- package/dist/router/src/providers/claude-oauth.js +252 -0
- package/dist/router/src/providers/openai/index.js +193 -0
- package/dist/router/src/providers/openai/translate.js +504 -0
- package/dist/router/src/proxy.js +724 -0
- package/dist/router/src/redact.js +43 -0
- package/dist/router/src/requestlog.js +346 -0
- package/dist/router/src/routing.js +113 -0
- package/dist/router/src/version.js +8 -0
- package/dist/router/src/x509.js +203 -0
- package/dist/ui/app.js +1228 -0
- package/dist/ui/i18n.js +95 -0
- package/dist/ui/index.html +104 -0
- package/dist/ui/presets-fallback.js +61 -0
- package/dist/ui/style.css +347 -0
- package/docs/ARCHITECTURE.md +441 -0
- package/package.json +66 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Safe excerpts of upstream errors for logs and client-safe translated errors.
|
|
2
|
+
//
|
|
3
|
+
// An upstream error may reflect the credential it was given. Keys are opaque vendor strings, so
|
|
4
|
+
// callers supply their actual outbound credential values; token-shaped fallbacks cover errors from
|
|
5
|
+
// a layer whose credentials are not present in config (for example a refreshed OAuth token).
|
|
6
|
+
const REDACTED = "[REDACTED]";
|
|
7
|
+
/** Values sent under a credential-bearing outbound header name. */
|
|
8
|
+
function credentialHeaderName(name) {
|
|
9
|
+
return /^(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[-_]?key|.*(?:token|secret|password).*)$/i.test(name);
|
|
10
|
+
}
|
|
11
|
+
export function credentialHeaderValues(headers) {
|
|
12
|
+
const secrets = new Set();
|
|
13
|
+
for (const [rawName, value] of headers) {
|
|
14
|
+
if (credentialHeaderName(rawName) && value)
|
|
15
|
+
secrets.add(value);
|
|
16
|
+
}
|
|
17
|
+
return [...secrets].sort((a, b) => b.length - a.length);
|
|
18
|
+
}
|
|
19
|
+
/** Header names are diagnostic; their sensitive values must never reach a log line. */
|
|
20
|
+
export function redactHeaders(headers) {
|
|
21
|
+
return Object.fromEntries(Object.entries(headers).flatMap(([name, value]) => {
|
|
22
|
+
if (value === undefined)
|
|
23
|
+
return [];
|
|
24
|
+
return [[name, credentialHeaderName(name) ? REDACTED : value]];
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
/** Return a bounded, one-line diagnostic without credentials. */
|
|
28
|
+
export function redactErrorText(value, secrets = [], max = 500) {
|
|
29
|
+
let text = value.replace(/\s+/g, " ").trim();
|
|
30
|
+
// Longest first avoids leaving a suffix exposed when one configured value contains another.
|
|
31
|
+
for (const secret of [...new Set(secrets.filter(Boolean))].sort((a, b) => b.length - a.length)) {
|
|
32
|
+
text = text.replaceAll(secret, REDACTED);
|
|
33
|
+
}
|
|
34
|
+
return text
|
|
35
|
+
// Authorization values in prose or header-shaped JSON.
|
|
36
|
+
.replace(/\b(Bearer)\s+[A-Za-z0-9._~+/=-]{8,}\b/gi, `$1 ${REDACTED}`)
|
|
37
|
+
.replace(/\b(sk-[A-Za-z0-9][A-Za-z0-9._-]{6,})\b/g, REDACTED)
|
|
38
|
+
// OAuth access/refresh tokens are JWTs, regardless of the header/body formatting.
|
|
39
|
+
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*\b/g, REDACTED)
|
|
40
|
+
// Key/value echoes that are not sent as a standard Authorization header.
|
|
41
|
+
.replace(/\b((?:api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|client[-_]?secret|token|secret|password)\s*[:=]\s*)[^\s,"'};]+/gi, `$1${REDACTED}`)
|
|
42
|
+
.slice(0, max);
|
|
43
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import zlib from "node:zlib";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
const DEFAULT_MAX_BYTES = 20 * 1024 * 1024;
|
|
6
|
+
const MAX_CARRY = 64 * 1024;
|
|
7
|
+
function emptyBucket() {
|
|
8
|
+
return { count: 0, ok: 0, failed: 0, input: 0, cached: 0, output: 0, cacheHitPercent: 0, avgMs: 0 };
|
|
9
|
+
}
|
|
10
|
+
function isRecord(value) {
|
|
11
|
+
if (!value || typeof value !== "object")
|
|
12
|
+
return false;
|
|
13
|
+
const r = value;
|
|
14
|
+
return typeof r.id === "string" && typeof r.at === "string" && typeof r.ms === "number" && typeof r.ok === "boolean" &&
|
|
15
|
+
(r.kind === "messages" || r.kind === "count_tokens" || r.kind === "other") && typeof r.source === "string" &&
|
|
16
|
+
typeof r.target === "string" && typeof r.provider === "string" && typeof r.stream === "boolean";
|
|
17
|
+
}
|
|
18
|
+
/** Bounded request history, persisted as JSONL so a router restart preserves recent entries. */
|
|
19
|
+
export class RequestLog {
|
|
20
|
+
file;
|
|
21
|
+
max;
|
|
22
|
+
maxBytes;
|
|
23
|
+
records;
|
|
24
|
+
constructor(file, max = 2000, maxBytes = DEFAULT_MAX_BYTES) {
|
|
25
|
+
this.file = file;
|
|
26
|
+
this.max = max;
|
|
27
|
+
this.maxBytes = maxBytes;
|
|
28
|
+
this.records = this.load();
|
|
29
|
+
}
|
|
30
|
+
add(record) {
|
|
31
|
+
this.records.push(record);
|
|
32
|
+
if (this.records.length > this.max)
|
|
33
|
+
this.records.splice(0, this.records.length - this.max);
|
|
34
|
+
const line = JSON.stringify(record) + "\n";
|
|
35
|
+
try {
|
|
36
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
37
|
+
const bytes = Buffer.byteLength(line);
|
|
38
|
+
const size = fs.existsSync(this.file) ? fs.statSync(this.file).size : 0;
|
|
39
|
+
if (size > 0 && size + bytes > this.maxBytes) {
|
|
40
|
+
const previous = `${this.file}.1`;
|
|
41
|
+
fs.rmSync(previous, { force: true });
|
|
42
|
+
fs.renameSync(this.file, previous);
|
|
43
|
+
}
|
|
44
|
+
fs.appendFileSync(this.file, line, "utf8");
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Request logging must never alter the proxy response path.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
list(n, filter = {}) {
|
|
51
|
+
const limit = Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0;
|
|
52
|
+
const out = [];
|
|
53
|
+
for (let i = this.records.length - 1; i >= 0 && out.length < limit; i--) {
|
|
54
|
+
const record = this.records[i];
|
|
55
|
+
if (filter.provider && record.provider !== filter.provider)
|
|
56
|
+
continue;
|
|
57
|
+
if (filter.kind && record.kind !== filter.kind)
|
|
58
|
+
continue;
|
|
59
|
+
out.push(record);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/** Summarise records at or after an epoch timestamp in milliseconds. */
|
|
64
|
+
summary(sinceMs) {
|
|
65
|
+
const total = emptyBucket();
|
|
66
|
+
const providers = {};
|
|
67
|
+
const cacheBases = new Map();
|
|
68
|
+
const add = (bucket, record) => {
|
|
69
|
+
bucket.count++;
|
|
70
|
+
if (record.ok)
|
|
71
|
+
bucket.ok++;
|
|
72
|
+
else
|
|
73
|
+
bucket.failed++;
|
|
74
|
+
bucket.avgMs += record.ms;
|
|
75
|
+
if (record.usage) {
|
|
76
|
+
bucket.input += record.usage.input;
|
|
77
|
+
bucket.cached += record.usage.cached;
|
|
78
|
+
bucket.output += record.usage.output;
|
|
79
|
+
cacheBases.set(bucket, (cacheBases.get(bucket) ?? 0) + record.usage.input + record.usage.cached);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
for (const record of this.records) {
|
|
83
|
+
const at = Date.parse(record.at);
|
|
84
|
+
if (!Number.isFinite(at) || at < sinceMs)
|
|
85
|
+
continue;
|
|
86
|
+
const provider = providers[record.provider] ?? (providers[record.provider] = emptyBucket());
|
|
87
|
+
add(total, record);
|
|
88
|
+
add(provider, record);
|
|
89
|
+
}
|
|
90
|
+
for (const bucket of [total, ...Object.values(providers)]) {
|
|
91
|
+
if (bucket.count > 0)
|
|
92
|
+
bucket.avgMs = Math.round(bucket.avgMs / bucket.count);
|
|
93
|
+
const base = cacheBases.get(bucket) ?? 0;
|
|
94
|
+
bucket.cacheHitPercent = base > 0 ? Math.round((bucket.cached / base) * 1000) / 10 : 0;
|
|
95
|
+
}
|
|
96
|
+
return { total, providers };
|
|
97
|
+
}
|
|
98
|
+
load() {
|
|
99
|
+
try {
|
|
100
|
+
if (!fs.existsSync(this.file))
|
|
101
|
+
return [];
|
|
102
|
+
const records = fs.readFileSync(this.file, "utf8")
|
|
103
|
+
.split("\n")
|
|
104
|
+
.flatMap((line) => {
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(line);
|
|
107
|
+
return isRecord(parsed) ? [parsed] : [];
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
return records.slice(-this.max);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Observes an Anthropic-compatible response without retaining or modifying its bytes.
|
|
122
|
+
* Call feed() after forwarding each response chunk, then finish() on response end.
|
|
123
|
+
*/
|
|
124
|
+
export class ResponseUsageTap {
|
|
125
|
+
isSse;
|
|
126
|
+
decoder = new TextDecoder();
|
|
127
|
+
carry = "";
|
|
128
|
+
event = "";
|
|
129
|
+
data = [];
|
|
130
|
+
dropUntilNewline = false;
|
|
131
|
+
jsonUsageStarted = false;
|
|
132
|
+
jsonUsageDepth = 0;
|
|
133
|
+
jsonUsageText = "";
|
|
134
|
+
stopCarry = "";
|
|
135
|
+
stopReason;
|
|
136
|
+
usage;
|
|
137
|
+
// Compressed responses (Anthropic answers the CLI with gzip/br) are collected side-band up to a cap
|
|
138
|
+
// and inflated once at the end; the forwarded bytes are never touched.
|
|
139
|
+
encoding = null;
|
|
140
|
+
encodedChunks = [];
|
|
141
|
+
encodedBytes = 0;
|
|
142
|
+
static MAX_ENCODED = 16 * 1024 * 1024;
|
|
143
|
+
constructor(contentType, contentEncoding) {
|
|
144
|
+
this.isSse = /^text\/event-stream\b/i.test(contentType ?? "");
|
|
145
|
+
const enc = (contentEncoding ?? "").toLowerCase().trim();
|
|
146
|
+
if (enc === "" || enc === "identity")
|
|
147
|
+
this.encoding = null;
|
|
148
|
+
else if (enc === "gzip" || enc === "x-gzip")
|
|
149
|
+
this.encoding = "gzip";
|
|
150
|
+
else if (enc === "deflate")
|
|
151
|
+
this.encoding = "deflate";
|
|
152
|
+
else if (enc === "br")
|
|
153
|
+
this.encoding = "br";
|
|
154
|
+
else if (enc === "zstd" && typeof zlib.zstdDecompressSync === "function")
|
|
155
|
+
this.encoding = "zstd";
|
|
156
|
+
else
|
|
157
|
+
this.dropUntilNewline = true; // unknown encoding: observe nothing
|
|
158
|
+
}
|
|
159
|
+
get enabled() {
|
|
160
|
+
return !this.dropUntilNewline;
|
|
161
|
+
}
|
|
162
|
+
feed(chunk) {
|
|
163
|
+
if (!this.enabled)
|
|
164
|
+
return;
|
|
165
|
+
if (this.encoding) {
|
|
166
|
+
if (this.encodedBytes + chunk.length > ResponseUsageTap.MAX_ENCODED) {
|
|
167
|
+
this.dropUntilNewline = true;
|
|
168
|
+
this.encodedChunks = [];
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
this.encodedChunks.push(chunk);
|
|
172
|
+
this.encodedBytes += chunk.length;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const text = this.decoder.decode(chunk, { stream: true });
|
|
176
|
+
if (this.isSse)
|
|
177
|
+
this.feedSse(text);
|
|
178
|
+
else
|
|
179
|
+
this.feedJson(text);
|
|
180
|
+
}
|
|
181
|
+
finish() {
|
|
182
|
+
if (!this.enabled)
|
|
183
|
+
return {};
|
|
184
|
+
if (this.encoding) {
|
|
185
|
+
let plain;
|
|
186
|
+
try {
|
|
187
|
+
const all = Buffer.concat(this.encodedChunks);
|
|
188
|
+
plain =
|
|
189
|
+
this.encoding === "gzip" ? zlib.gunzipSync(all)
|
|
190
|
+
: this.encoding === "deflate" ? zlib.inflateSync(all)
|
|
191
|
+
: this.encoding === "br" ? zlib.brotliDecompressSync(all)
|
|
192
|
+
: zlib.zstdDecompressSync(all);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return {};
|
|
196
|
+
}
|
|
197
|
+
this.encodedChunks = [];
|
|
198
|
+
const text = plain.toString("utf8");
|
|
199
|
+
if (this.isSse)
|
|
200
|
+
this.feedSse(text);
|
|
201
|
+
else
|
|
202
|
+
this.feedJson(text);
|
|
203
|
+
}
|
|
204
|
+
const tail = this.decoder.decode();
|
|
205
|
+
if (tail) {
|
|
206
|
+
if (this.isSse)
|
|
207
|
+
this.feedSse(tail);
|
|
208
|
+
else
|
|
209
|
+
this.feedJson(tail);
|
|
210
|
+
}
|
|
211
|
+
if (this.isSse && this.carry)
|
|
212
|
+
this.feedSse("\n");
|
|
213
|
+
return { ...(this.usage ? { usage: this.usage } : {}), ...(this.stopReason ? { stopReason: this.stopReason } : {}) };
|
|
214
|
+
}
|
|
215
|
+
feedSse(text) {
|
|
216
|
+
this.carry += text;
|
|
217
|
+
if (this.carry.length > MAX_CARRY) {
|
|
218
|
+
const newline = this.carry.lastIndexOf("\n");
|
|
219
|
+
if (newline < 0) {
|
|
220
|
+
this.carry = "";
|
|
221
|
+
this.event = "";
|
|
222
|
+
this.data = [];
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
this.carry = this.carry.slice(newline + 1);
|
|
226
|
+
this.event = "";
|
|
227
|
+
this.data = [];
|
|
228
|
+
}
|
|
229
|
+
for (;;) {
|
|
230
|
+
const newline = this.carry.indexOf("\n");
|
|
231
|
+
if (newline < 0)
|
|
232
|
+
return;
|
|
233
|
+
const raw = this.carry.slice(0, newline);
|
|
234
|
+
this.carry = this.carry.slice(newline + 1);
|
|
235
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
236
|
+
if (line === "") {
|
|
237
|
+
this.consumeSseEvent();
|
|
238
|
+
this.event = "";
|
|
239
|
+
this.data = [];
|
|
240
|
+
}
|
|
241
|
+
else if (line.startsWith("event:")) {
|
|
242
|
+
this.event = line.slice(6).trim();
|
|
243
|
+
}
|
|
244
|
+
else if (line.startsWith("data:")) {
|
|
245
|
+
this.data.push(line.slice(5).trimStart());
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
consumeSseEvent() {
|
|
250
|
+
if (!this.data.length)
|
|
251
|
+
return;
|
|
252
|
+
try {
|
|
253
|
+
const body = JSON.parse(this.data.join("\n"));
|
|
254
|
+
if (this.event === "message_start" || body.type === "message_start") {
|
|
255
|
+
const message = body.message;
|
|
256
|
+
this.setUsage(message?.usage);
|
|
257
|
+
}
|
|
258
|
+
else if (this.event === "message_delta" || body.type === "message_delta") {
|
|
259
|
+
this.setUsage(body.usage);
|
|
260
|
+
const delta = body.delta;
|
|
261
|
+
if (typeof delta?.stop_reason === "string")
|
|
262
|
+
this.stopReason = delta.stop_reason;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// A malformed upstream event is not a proxy error and must not affect delivery.
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
feedJson(text) {
|
|
270
|
+
this.carry += text;
|
|
271
|
+
this.stopCarry = (this.stopCarry + text).slice(-MAX_CARRY);
|
|
272
|
+
const stop = /"stop_reason"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/.exec(this.stopCarry);
|
|
273
|
+
if (stop) {
|
|
274
|
+
try {
|
|
275
|
+
this.stopReason = JSON.parse(`"${stop[1]}"`);
|
|
276
|
+
}
|
|
277
|
+
catch { /* ignore */ }
|
|
278
|
+
}
|
|
279
|
+
if (!this.jsonUsageStarted) {
|
|
280
|
+
const match = /"usage"\s*:\s*\{/.exec(this.carry);
|
|
281
|
+
if (!match) {
|
|
282
|
+
if (this.carry.length > MAX_CARRY)
|
|
283
|
+
this.carry = this.carry.slice(-MAX_CARRY);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
this.jsonUsageStarted = true;
|
|
287
|
+
const start = (match.index ?? 0) + match[0].lastIndexOf("{");
|
|
288
|
+
this.jsonUsageText = this.carry.slice(start);
|
|
289
|
+
this.carry = "";
|
|
290
|
+
this.jsonUsageDepth = 0;
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
this.jsonUsageText += this.carry;
|
|
294
|
+
this.carry = "";
|
|
295
|
+
}
|
|
296
|
+
if (this.jsonUsageText.length > MAX_CARRY) {
|
|
297
|
+
this.jsonUsageStarted = false;
|
|
298
|
+
this.jsonUsageText = "";
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
let quoted = false;
|
|
302
|
+
let escaped = false;
|
|
303
|
+
for (let i = 0; i < this.jsonUsageText.length; i++) {
|
|
304
|
+
const char = this.jsonUsageText[i];
|
|
305
|
+
if (quoted) {
|
|
306
|
+
if (escaped)
|
|
307
|
+
escaped = false;
|
|
308
|
+
else if (char === "\\")
|
|
309
|
+
escaped = true;
|
|
310
|
+
else if (char === '"')
|
|
311
|
+
quoted = false;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (char === '"')
|
|
315
|
+
quoted = true;
|
|
316
|
+
else if (char === "{")
|
|
317
|
+
this.jsonUsageDepth++;
|
|
318
|
+
else if (char === "}" && --this.jsonUsageDepth === 0) {
|
|
319
|
+
try {
|
|
320
|
+
this.setUsage(JSON.parse(this.jsonUsageText.slice(0, i + 1)));
|
|
321
|
+
}
|
|
322
|
+
catch { /* ignore */ }
|
|
323
|
+
this.jsonUsageStarted = false;
|
|
324
|
+
this.jsonUsageText = "";
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
setUsage(value) {
|
|
330
|
+
if (!value || typeof value !== "object")
|
|
331
|
+
return;
|
|
332
|
+
const usage = value;
|
|
333
|
+
const number = (key) => typeof usage[key] === "number" && Number.isFinite(usage[key]) ? Math.max(0, usage[key]) : 0;
|
|
334
|
+
if (!("input_tokens" in usage || "output_tokens" in usage || "cache_read_input_tokens" in usage || "cache_creation_input_tokens" in usage))
|
|
335
|
+
return;
|
|
336
|
+
const current = this.usage ?? { input: 0, cached: 0, output: 0 };
|
|
337
|
+
const input = "input_tokens" in usage ? number("input_tokens") : current.input;
|
|
338
|
+
const cached = "cache_read_input_tokens" in usage ? number("cache_read_input_tokens") : current.cached;
|
|
339
|
+
const cacheWrite = "cache_creation_input_tokens" in usage ? number("cache_creation_input_tokens") : current.cacheWrite;
|
|
340
|
+
const output = "output_tokens" in usage ? number("output_tokens") : current.output;
|
|
341
|
+
this.usage = { input, cached, ...(cacheWrite && cacheWrite > 0 ? { cacheWrite } : {}), output };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
export function requestId() {
|
|
345
|
+
return crypto.randomUUID();
|
|
346
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Decide, per request, whether the model goes to a provider or straight to Anthropic.
|
|
2
|
+
//
|
|
3
|
+
// "claude-opus-4-8" → routes["claude-opus-4-8"] (picker-slot alias)
|
|
4
|
+
// "gpt-5.6-sol" → direct rule by prefix, model unchanged
|
|
5
|
+
// "gpt-5.6-sol@medium" → same, effort forced to medium
|
|
6
|
+
// "[[ripple: sol@xhigh]]" or "[[gpt: sol@xhigh]]" at the top of the first user
|
|
7
|
+
// message overrides model/effort for direct-rule models only (subagent prompts).
|
|
8
|
+
const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
|
|
9
|
+
const MARKER = /\[\[\s*(?:ripple|gpt)\s*:\s*([A-Za-z0-9.\-]+)\s*(?:@\s*([A-Za-z]+))?\s*\]\]/;
|
|
10
|
+
export function markerOverride(body, aliases) {
|
|
11
|
+
const messages = body?.messages;
|
|
12
|
+
if (!Array.isArray(messages))
|
|
13
|
+
return null;
|
|
14
|
+
let seen = 0;
|
|
15
|
+
for (const m of messages) {
|
|
16
|
+
if (m?.role !== "user")
|
|
17
|
+
continue;
|
|
18
|
+
if (++seen > 5)
|
|
19
|
+
break; // the task prompt is always near the top
|
|
20
|
+
const c = m.content;
|
|
21
|
+
const text = typeof c === "string"
|
|
22
|
+
? c
|
|
23
|
+
: Array.isArray(c)
|
|
24
|
+
? c.filter((b) => b && b.type === "text").map((b) => b.text ?? "").join(" ")
|
|
25
|
+
: "";
|
|
26
|
+
const hit = MARKER.exec(text.replace(REMINDER, ""));
|
|
27
|
+
if (hit) {
|
|
28
|
+
const name = hit[1].toLowerCase();
|
|
29
|
+
const effort = hit[2]?.toLowerCase();
|
|
30
|
+
return effort ? { model: aliases[name] ?? name, effort } : { model: aliases[name] ?? name };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
export function resolve(model, body, cfg) {
|
|
36
|
+
if (typeof model !== "string")
|
|
37
|
+
return null;
|
|
38
|
+
let base = model;
|
|
39
|
+
let effort;
|
|
40
|
+
const at = model.indexOf("@");
|
|
41
|
+
if (at > 0) {
|
|
42
|
+
base = model.slice(0, at);
|
|
43
|
+
effort = model.slice(at + 1) || undefined;
|
|
44
|
+
}
|
|
45
|
+
// A native `anthropic` provider serves the OpenAI ingress only; it speaks the Messages API with
|
|
46
|
+
// its own credentials and has nothing to translate for a caller that already speaks it. A rule
|
|
47
|
+
// naming one is ignored here so the request passes through to Anthropic instead of failing.
|
|
48
|
+
const ingressOnly = (name) => cfg.providers[name]?.type === "anthropic";
|
|
49
|
+
const direct = cfg.direct.find((d) => base.startsWith(d.prefix));
|
|
50
|
+
if (direct && ingressOnly(direct.provider))
|
|
51
|
+
return null;
|
|
52
|
+
if (direct) {
|
|
53
|
+
const ov = markerOverride(body, cfg.aliases);
|
|
54
|
+
const finalModel = ov?.model ?? base;
|
|
55
|
+
const finalEffort = ov?.effort ?? effort;
|
|
56
|
+
return { provider: direct.provider, model: finalModel, effort: finalEffort, tag: `${model}->${finalModel}` };
|
|
57
|
+
}
|
|
58
|
+
// The app sends some slots with a dated id (`claude-haiku-4-5-20251001`) and others without
|
|
59
|
+
// (`claude-opus-5`), while the GUI only ever offers the undated form. Match either.
|
|
60
|
+
const route = cfg.routes[base] ?? cfg.routes[base.replace(/-\d{8}$/, "")];
|
|
61
|
+
if (!route || ingressOnly(route.provider))
|
|
62
|
+
return null;
|
|
63
|
+
return {
|
|
64
|
+
provider: route.provider,
|
|
65
|
+
model: route.model,
|
|
66
|
+
effort: effort ?? route.effort,
|
|
67
|
+
tag: `${model}->${route.model}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Apply a resolution to a parsed Messages request body (mutates and returns it). */
|
|
71
|
+
export function rewriteBody(json, r, effortClamp) {
|
|
72
|
+
json.model = r.model;
|
|
73
|
+
const oc = { ...(json.output_config ?? {}) };
|
|
74
|
+
if (r.effort)
|
|
75
|
+
oc.effort = r.effort;
|
|
76
|
+
if (typeof oc.effort === "string" && effortClamp[oc.effort])
|
|
77
|
+
oc.effort = effortClamp[oc.effort];
|
|
78
|
+
if (Object.keys(oc).length > 0)
|
|
79
|
+
json.output_config = oc;
|
|
80
|
+
return json;
|
|
81
|
+
}
|
|
82
|
+
export function effortOf(json) {
|
|
83
|
+
const oc = json.output_config;
|
|
84
|
+
return typeof oc?.effort === "string" ? oc.effort : undefined;
|
|
85
|
+
}
|
|
86
|
+
// ---- Server-side threads ("tether") -------------------------------------------------
|
|
87
|
+
//
|
|
88
|
+
// Claude Code 2.1.266 sends `thread: {type:"create"}` on a session's first request and then
|
|
89
|
+
// `thread: {type:"continue", previous_message_id}` with ONLY the new messages, expecting the API to
|
|
90
|
+
// hold the history (measured 2026-09-13; see docs/ARCHITECTURE.md). Translated providers have no
|
|
91
|
+
// such state, so a continue request must be refused with the error code the CLI recognises: it then
|
|
92
|
+
// resends the turn stateless and keeps the session stateless on this model ("retry:tether-stateless").
|
|
93
|
+
// Accepting the continue instead makes the model see an orphan tool_result and forget the task, and
|
|
94
|
+
// kills prompt caching (each turn is a tiny delta with a cold prefix).
|
|
95
|
+
export const THREAD_UNSUPPORTED = {
|
|
96
|
+
type: "error",
|
|
97
|
+
error: {
|
|
98
|
+
type: "invalid_request_error",
|
|
99
|
+
message: "thread: unsupported request — ClaudeRipple routes this model to a provider without server-side threads; resend stateless",
|
|
100
|
+
details: { error_code: "thread_unsupported_request" },
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
/** "refuse" → answer 400 with THREAD_UNSUPPORTED; "strip" → drop thread/diagnostics and proceed. */
|
|
104
|
+
export function threadDecision(json) {
|
|
105
|
+
const thread = json.thread;
|
|
106
|
+
if (!thread || typeof thread !== "object")
|
|
107
|
+
return "none";
|
|
108
|
+
return thread.type === "continue" ? "refuse" : "strip";
|
|
109
|
+
}
|
|
110
|
+
export function stripThreadFields(json) {
|
|
111
|
+
delete json.thread;
|
|
112
|
+
delete json.diagnostics;
|
|
113
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The one version string the router, CLI and app compare against each other.
|
|
2
|
+
//
|
|
3
|
+
// Packaged apps ship only `packages/*/src`, so nothing at runtime can read a package.json. The
|
|
4
|
+
// constant lives here and packages/router/test/version.test.ts fails the build when it drifts
|
|
5
|
+
// from the manifests. Until 0.1.1 the router and the CLI each carried their own literal, and both
|
|
6
|
+
// still said "0.1.0" in the 0.1.1 release: after an update nobody could tell which router was
|
|
7
|
+
// running, and the tray app had nothing to compare (2026-09-15, reported from a Windows install).
|
|
8
|
+
export const VERSION = "0.2.0";
|