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,193 @@
|
|
|
1
|
+
// OpenAI-compatible adapter: Anthropic Messages in, vendor Chat Completions or
|
|
2
|
+
// Responses SSE out, translated back to Anthropic Messages. It never synthesizes
|
|
3
|
+
// cache figures; the request log reflects cached tokens only when the vendor sends them.
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import { credentialHeaderValues, redactErrorText } from "../../redact.js";
|
|
6
|
+
import { SseParser } from "../chatgpt/sse.js";
|
|
7
|
+
import { estimateTokens, formatSse, OpenAiStreamMapper, toOpenAiRequest } from "./translate.js";
|
|
8
|
+
const PING_MS = 15_000;
|
|
9
|
+
function anthropicError(status, type, message) {
|
|
10
|
+
return { status, body: JSON.stringify({ type: "error", error: { type, message } }) };
|
|
11
|
+
}
|
|
12
|
+
function vendorMessage(text) {
|
|
13
|
+
try {
|
|
14
|
+
const json = JSON.parse(text);
|
|
15
|
+
const candidate = json.error?.message ?? json.message ?? json.detail;
|
|
16
|
+
if (typeof candidate === "string")
|
|
17
|
+
return candidate.slice(0, 500);
|
|
18
|
+
}
|
|
19
|
+
catch { /* retain the response text */ }
|
|
20
|
+
return text.replace(/\s+/g, " ").trim().slice(0, 500) || "upstream request failed";
|
|
21
|
+
}
|
|
22
|
+
export function mapHttpError(status, text) {
|
|
23
|
+
const message = `OpenAI-compatible provider: ${vendorMessage(text)}`;
|
|
24
|
+
if (status === 401 || status === 403)
|
|
25
|
+
return anthropicError(401, "authentication_error", message);
|
|
26
|
+
if (status === 429)
|
|
27
|
+
return anthropicError(429, "rate_limit_error", message);
|
|
28
|
+
if (status >= 500)
|
|
29
|
+
return anthropicError(529, "api_error", message);
|
|
30
|
+
return anthropicError(400, "invalid_request_error", message);
|
|
31
|
+
}
|
|
32
|
+
function endpoint(base, wire) {
|
|
33
|
+
return `${base.replace(/\/+$/, "")}/${wire === "chat" ? "chat/completions" : "responses"}`;
|
|
34
|
+
}
|
|
35
|
+
function write(res, value) {
|
|
36
|
+
if (res.writableEnded || res.destroyed)
|
|
37
|
+
return 0;
|
|
38
|
+
res.write(value);
|
|
39
|
+
return Buffer.byteLength(value);
|
|
40
|
+
}
|
|
41
|
+
export class OpenAiCompatibleAdapter {
|
|
42
|
+
name;
|
|
43
|
+
cfg;
|
|
44
|
+
log;
|
|
45
|
+
lastInputByKey = new Map();
|
|
46
|
+
constructor(name, cfg, log) {
|
|
47
|
+
this.name = name;
|
|
48
|
+
this.cfg = cfg;
|
|
49
|
+
this.log = log;
|
|
50
|
+
}
|
|
51
|
+
rememberInput(key, usage) {
|
|
52
|
+
const total = usage.input_tokens + usage.cache_read_input_tokens;
|
|
53
|
+
if (total <= 0)
|
|
54
|
+
return;
|
|
55
|
+
this.lastInputByKey.set(key, total);
|
|
56
|
+
if (this.lastInputByKey.size > 500)
|
|
57
|
+
this.lastInputByKey.delete(this.lastInputByKey.keys().next().value);
|
|
58
|
+
}
|
|
59
|
+
/** Handle a fully-read Messages request. Model/effort have already been resolved by routing. */
|
|
60
|
+
async handle(req, res, path, json, model, effort) {
|
|
61
|
+
if (path.startsWith("/v1/messages/count_tokens")) {
|
|
62
|
+
const body = JSON.stringify({ input_tokens: estimateTokens(json) });
|
|
63
|
+
res.writeHead(200, { "content-type": "application/json", "content-length": String(Buffer.byteLength(body)) }).end(body);
|
|
64
|
+
return { status: 200, bytes: Buffer.byteLength(body), note: "estimated" };
|
|
65
|
+
}
|
|
66
|
+
const wire = this.cfg.wire ?? "chat";
|
|
67
|
+
const modelEffortLevels = this.cfg.models?.find((entry) => entry.id === model)?.effortLevels;
|
|
68
|
+
const caps = this.cfg.caps && modelEffortLevels === undefined
|
|
69
|
+
? this.cfg.caps
|
|
70
|
+
: {
|
|
71
|
+
...(this.cfg.caps ?? {}),
|
|
72
|
+
reasoning: modelEffortLevels && modelEffortLevels.length > 0 ? "effort" : "none",
|
|
73
|
+
effortLevels: modelEffortLevels ?? this.cfg.caps?.effortLevels ?? [],
|
|
74
|
+
};
|
|
75
|
+
const upstreamRequest = toOpenAiRequest(json, {
|
|
76
|
+
model,
|
|
77
|
+
wire,
|
|
78
|
+
...(effort ? { effort } : {}),
|
|
79
|
+
caps,
|
|
80
|
+
...(this.cfg.identity === undefined ? {} : { identity: this.cfg.identity }),
|
|
81
|
+
...(this.cfg.instructionsAppend ? { instructionsAppend: this.cfg.instructionsAppend } : {}),
|
|
82
|
+
});
|
|
83
|
+
const requestBody = JSON.stringify(upstreamRequest);
|
|
84
|
+
const upstreamHeaders = { "content-type": "application/json", accept: "text/event-stream", ...(this.cfg.headers ?? {}) };
|
|
85
|
+
const upstreamSecrets = credentialHeaderValues(Object.entries(upstreamHeaders));
|
|
86
|
+
// Same input floor behavior as the ChatGPT adapter: the CLI snapshots message_start before usage arrives.
|
|
87
|
+
const key = JSON.stringify({ model, wire, system: json.system ?? "", user: json.messages.find((message) => message.role === "user")?.content ?? "" });
|
|
88
|
+
const startInput = Math.max(estimateTokens(json), this.lastInputByKey.get(key) ?? 0);
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
const onClose = () => controller.abort();
|
|
91
|
+
res.on("close", onClose);
|
|
92
|
+
let upstream;
|
|
93
|
+
try {
|
|
94
|
+
upstream = await fetch(endpoint(this.cfg.url, wire), {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: upstreamHeaders,
|
|
97
|
+
body: requestBody,
|
|
98
|
+
signal: controller.signal,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
res.off("close", onClose);
|
|
103
|
+
if (controller.signal.aborted)
|
|
104
|
+
return { status: 0, bytes: 0, note: "client closed" };
|
|
105
|
+
const out = anthropicError(502, "api_error", `OpenAI-compatible provider unreachable: ${error.message}`);
|
|
106
|
+
if (!res.headersSent)
|
|
107
|
+
res.writeHead(out.status, { "content-type": "application/json" }).end(out.body);
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
if (!upstream.ok || !upstream.body) {
|
|
111
|
+
const text = await upstream.text().catch(() => "");
|
|
112
|
+
const safeText = redactErrorText(text, upstreamSecrets);
|
|
113
|
+
const out = mapHttpError(upstream.status, safeText);
|
|
114
|
+
this.log.warn(`openai ${this.name}: upstream ${upstream.status} for ${model}: ${safeText.slice(0, 400)}`);
|
|
115
|
+
res.off("close", onClose);
|
|
116
|
+
res.writeHead(out.status, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out.body)) }).end(out.body);
|
|
117
|
+
return { status: out.status, bytes: Buffer.byteLength(out.body), note: `upstream ${upstream.status}` };
|
|
118
|
+
}
|
|
119
|
+
const wantStream = json.stream === true;
|
|
120
|
+
const mapper = new OpenAiStreamMapper(model, startInput);
|
|
121
|
+
const parser = new SseParser();
|
|
122
|
+
const reader = upstream.body.getReader();
|
|
123
|
+
const decoder = new TextDecoder();
|
|
124
|
+
let bytes = 0;
|
|
125
|
+
let ping;
|
|
126
|
+
if (wantStream) {
|
|
127
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
128
|
+
for (const event of mapper.start())
|
|
129
|
+
bytes += write(res, formatSse(event));
|
|
130
|
+
ping = setInterval(() => {
|
|
131
|
+
if (!res.writableEnded)
|
|
132
|
+
bytes += write(res, formatSse({ event: "ping", data: { type: "ping" } }));
|
|
133
|
+
}, PING_MS);
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
for (;;) {
|
|
137
|
+
const { done, value } = await reader.read();
|
|
138
|
+
if (done)
|
|
139
|
+
break;
|
|
140
|
+
for (const event of parser.feed(decoder.decode(value, { stream: true }))) {
|
|
141
|
+
const output = mapper.feed(event, wire);
|
|
142
|
+
this.rememberInput(key, mapper.usage);
|
|
143
|
+
if (wantStream)
|
|
144
|
+
for (const anthropic of output)
|
|
145
|
+
bytes += write(res, formatSse(anthropic));
|
|
146
|
+
if (mapper.isFinished)
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
if (mapper.isFinished)
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
if (!mapper.isFinished) {
|
|
153
|
+
const tail = mapper.finish();
|
|
154
|
+
if (wantStream)
|
|
155
|
+
for (const event of tail)
|
|
156
|
+
bytes += write(res, formatSse(event));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (!controller.signal.aborted) {
|
|
161
|
+
const tail = mapper.fail(`stream interrupted: ${error.message}`);
|
|
162
|
+
if (wantStream)
|
|
163
|
+
for (const event of tail)
|
|
164
|
+
bytes += write(res, formatSse(event));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
if (ping)
|
|
169
|
+
clearInterval(ping);
|
|
170
|
+
res.off("close", onClose);
|
|
171
|
+
try {
|
|
172
|
+
await reader.cancel();
|
|
173
|
+
}
|
|
174
|
+
catch { /* already closed */ }
|
|
175
|
+
}
|
|
176
|
+
if (!wantStream) {
|
|
177
|
+
const body = JSON.stringify(mapper.message());
|
|
178
|
+
bytes = Buffer.byteLength(body);
|
|
179
|
+
res.writeHead(200, { "content-type": "application/json", "content-length": String(bytes) }).end(body);
|
|
180
|
+
}
|
|
181
|
+
else if (!res.writableEnded) {
|
|
182
|
+
res.end();
|
|
183
|
+
}
|
|
184
|
+
const usage = mapper.usage;
|
|
185
|
+
return {
|
|
186
|
+
status: 200,
|
|
187
|
+
bytes,
|
|
188
|
+
note: `in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens} stop=${mapper.stopReason}`,
|
|
189
|
+
usage: { input: usage.input_tokens, cached: usage.cache_read_input_tokens, output: usage.output_tokens },
|
|
190
|
+
stopReason: mapper.stopReason,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|