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,393 @@
|
|
|
1
|
+
// Anthropic Messages ⇄ OpenAI Responses (Codex backend). Pure functions, no I/O.
|
|
2
|
+
//
|
|
3
|
+
// Prompt-cache rule: the Responses `input` we build must be a byte-stable prefix of the
|
|
4
|
+
// next turn's `input`. Claude Code resends the whole history every turn, so the mapping
|
|
5
|
+
// has to be deterministic and must not inject anything that varies (timestamps, salts,
|
|
6
|
+
// re-signed reasoning). Thinking blocks from earlier assistant turns are dropped for the
|
|
7
|
+
// same reason. `prompt_cache_key` is derived from the conversation's first user message.
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import { identityLine } from "../../identity.js";
|
|
10
|
+
function blockText(c) {
|
|
11
|
+
if (typeof c === "string")
|
|
12
|
+
return c;
|
|
13
|
+
if (!Array.isArray(c))
|
|
14
|
+
return "";
|
|
15
|
+
return c
|
|
16
|
+
.map((b) => (b.type === "text" ? b.text : b.type === "image" ? "[image omitted]" : ""))
|
|
17
|
+
.filter((s) => s.length > 0)
|
|
18
|
+
.join("\n");
|
|
19
|
+
}
|
|
20
|
+
// Claude Code's first system block is Anthropic billing telemetry ("x-anthropic-billing-header: …
|
|
21
|
+
// cch=<hash> …") whose hash changes on every turn. Left in, it sits at the top of `instructions`
|
|
22
|
+
// and invalidates the prompt cache for everything after it (measured 2026-09-13: cached_tokens
|
|
23
|
+
// stuck at the tools prefix while input grew 39k→43k). It means nothing to another provider.
|
|
24
|
+
const BILLING_BLOCK = /^x-anthropic-billing-header:/;
|
|
25
|
+
export function systemText(system) {
|
|
26
|
+
if (typeof system === "string")
|
|
27
|
+
return system.replace(/^x-anthropic-billing-header:[^\n]*\n*/, "");
|
|
28
|
+
if (!Array.isArray(system))
|
|
29
|
+
return "";
|
|
30
|
+
return system
|
|
31
|
+
.map((b) => b.text ?? "")
|
|
32
|
+
.filter((s) => s.length > 0 && !BILLING_BLOCK.test(s))
|
|
33
|
+
.join("\n\n");
|
|
34
|
+
}
|
|
35
|
+
export function conversationKey(req) {
|
|
36
|
+
const first = req.messages.find((m) => m.role === "user");
|
|
37
|
+
const seed = `${req.metadata?.user_id ?? ""}\n${first ? blockText(first.content).slice(0, 4000) : ""}`;
|
|
38
|
+
return crypto.createHash("sha256").update(seed).digest("hex").slice(0, 32);
|
|
39
|
+
}
|
|
40
|
+
// The Codex backend validates every `pattern` in a tool schema with a regex engine that has no
|
|
41
|
+
// lookaround or backreferences; one such pattern anywhere fails the whole request with
|
|
42
|
+
// "Invalid schema for function 'X': '...' is not a 'regex'" (measured 2026-09-13 with the
|
|
43
|
+
// Claude Code Artifact tool). Those patterns are dropped; the client validates inputs itself.
|
|
44
|
+
const UNSUPPORTED_REGEX = /\(\?[=!<]|\\[1-9]/;
|
|
45
|
+
export function unsupportedPattern(p) {
|
|
46
|
+
return UNSUPPORTED_REGEX.test(p);
|
|
47
|
+
}
|
|
48
|
+
function scrubSchema(node) {
|
|
49
|
+
if (Array.isArray(node))
|
|
50
|
+
return node.map(scrubSchema);
|
|
51
|
+
if (typeof node !== "object" || node === null)
|
|
52
|
+
return node;
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const [k, v] of Object.entries(node)) {
|
|
55
|
+
if (k === "pattern" && typeof v === "string" && unsupportedPattern(v))
|
|
56
|
+
continue;
|
|
57
|
+
out[k] = scrubSchema(v);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
export function normalizeSchema(s) {
|
|
62
|
+
const out = scrubSchema(s ?? {});
|
|
63
|
+
if (out.type !== "object")
|
|
64
|
+
out.type = "object";
|
|
65
|
+
if (typeof out.properties !== "object" || out.properties === null)
|
|
66
|
+
out.properties = {};
|
|
67
|
+
if (out.required !== undefined && !Array.isArray(out.required))
|
|
68
|
+
delete out.required;
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
export function toResponsesRequest(req, opts) {
|
|
72
|
+
const parts = [];
|
|
73
|
+
// Effort is named here because the model cannot see its own reasoning setting and will otherwise guess.
|
|
74
|
+
// Constant per (model, effort): changing effort mid-session costs one cache miss, which is acceptable.
|
|
75
|
+
if (opts.identity)
|
|
76
|
+
parts.push(identityLine(opts.model, opts.effort));
|
|
77
|
+
const sys = systemText(req.system);
|
|
78
|
+
if (sys)
|
|
79
|
+
parts.push(sys);
|
|
80
|
+
if (opts.instructionsAppend)
|
|
81
|
+
parts.push(opts.instructionsAppend);
|
|
82
|
+
const input = [];
|
|
83
|
+
// Claude Code sends side queries whose history starts with a bare tool_result (e.g. summarising a
|
|
84
|
+
// large tool output). Anthropic tolerates the orphan; the Responses API rejects a function_call_output
|
|
85
|
+
// whose call_id has no function_call in the same input ("No tool call found…", measured 2026-09-13).
|
|
86
|
+
// Such results are sent as plain user text instead.
|
|
87
|
+
const knownCalls = new Set();
|
|
88
|
+
for (const m of req.messages) {
|
|
89
|
+
const role = m.role === "assistant" ? "assistant" : "user";
|
|
90
|
+
if (typeof m.content === "string") {
|
|
91
|
+
if (m.content.length > 0)
|
|
92
|
+
input.push({ type: "message", role, content: [{ type: role === "user" ? "input_text" : "output_text", text: m.content }] });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!Array.isArray(m.content))
|
|
96
|
+
continue;
|
|
97
|
+
let pending = [];
|
|
98
|
+
const flush = () => {
|
|
99
|
+
if (pending.length > 0)
|
|
100
|
+
input.push({ type: "message", role, content: pending });
|
|
101
|
+
pending = [];
|
|
102
|
+
};
|
|
103
|
+
for (const b of m.content) {
|
|
104
|
+
switch (b.type) {
|
|
105
|
+
case "text": {
|
|
106
|
+
const t = b.text;
|
|
107
|
+
if (t.length > 0)
|
|
108
|
+
pending.push({ type: role === "user" ? "input_text" : "output_text", text: t });
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case "image": {
|
|
112
|
+
const src = b.source;
|
|
113
|
+
if (role === "user") {
|
|
114
|
+
if (src.type === "base64" && src.data)
|
|
115
|
+
pending.push({ type: "input_image", image_url: `data:${src.media_type ?? "image/png"};base64,${src.data}` });
|
|
116
|
+
else if (src.type === "url" && src.url)
|
|
117
|
+
pending.push({ type: "input_image", image_url: src.url });
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case "tool_use": {
|
|
122
|
+
flush();
|
|
123
|
+
const tu = b;
|
|
124
|
+
knownCalls.add(tu.id);
|
|
125
|
+
input.push({ type: "function_call", call_id: tu.id, name: tu.name, arguments: typeof tu.input === "string" ? tu.input : JSON.stringify(tu.input ?? {}) });
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case "tool_result": {
|
|
129
|
+
flush();
|
|
130
|
+
const tr = b;
|
|
131
|
+
let out = blockText(tr.content);
|
|
132
|
+
if (tr.is_error && !out)
|
|
133
|
+
out = "Tool execution failed";
|
|
134
|
+
if (knownCalls.has(tr.tool_use_id))
|
|
135
|
+
input.push({ type: "function_call_output", call_id: tr.tool_use_id, output: out });
|
|
136
|
+
else
|
|
137
|
+
pending.push({ type: "input_text", text: `[Tool result]\n${out}` });
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
default:
|
|
141
|
+
// thinking / redacted_thinking / unknown: dropped on purpose (see header comment)
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
flush();
|
|
146
|
+
}
|
|
147
|
+
const tools = (req.tools ?? [])
|
|
148
|
+
.filter((t) => typeof t.name === "string")
|
|
149
|
+
.map((t) => ({ type: "function", name: t.name, description: t.description ?? "", parameters: normalizeSchema(t.input_schema), strict: false }));
|
|
150
|
+
let tool_choice;
|
|
151
|
+
const tc = req.tool_choice;
|
|
152
|
+
if (tools.length > 0) {
|
|
153
|
+
if (!tc || tc.type === "auto")
|
|
154
|
+
tool_choice = "auto";
|
|
155
|
+
else if (tc.type === "any")
|
|
156
|
+
tool_choice = "required";
|
|
157
|
+
else if (tc.type === "none")
|
|
158
|
+
tool_choice = "none";
|
|
159
|
+
else if (tc.type === "tool" && tc.name)
|
|
160
|
+
tool_choice = { type: "function", name: tc.name };
|
|
161
|
+
}
|
|
162
|
+
const out = {
|
|
163
|
+
model: opts.model,
|
|
164
|
+
instructions: parts.join("\n\n"),
|
|
165
|
+
input,
|
|
166
|
+
reasoning: { effort: opts.effort, summary: "auto" },
|
|
167
|
+
text: { verbosity: "medium" },
|
|
168
|
+
store: false,
|
|
169
|
+
stream: true,
|
|
170
|
+
prompt_cache_key: conversationKey(req),
|
|
171
|
+
};
|
|
172
|
+
if (tools.length > 0) {
|
|
173
|
+
out.tools = tools;
|
|
174
|
+
out.parallel_tool_calls = !(tc?.disable_parallel_tool_use ?? false);
|
|
175
|
+
}
|
|
176
|
+
if (tool_choice)
|
|
177
|
+
out.tool_choice = tool_choice;
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
/** Rough token estimate for /v1/messages/count_tokens when the model is not Anthropic's. */
|
|
181
|
+
export function estimateTokens(req) {
|
|
182
|
+
const text = JSON.stringify({ s: req.system ?? "", m: req.messages, t: req.tools ?? [] });
|
|
183
|
+
return Math.ceil(text.length / 4);
|
|
184
|
+
}
|
|
185
|
+
/** Stateful mapper: feed Responses SSE events, get Anthropic SSE events. */
|
|
186
|
+
export class StreamMapper {
|
|
187
|
+
started = false;
|
|
188
|
+
blockIndex = -1;
|
|
189
|
+
open = null;
|
|
190
|
+
sawToolCall = false;
|
|
191
|
+
finished = false;
|
|
192
|
+
messageId;
|
|
193
|
+
model;
|
|
194
|
+
usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
|
|
195
|
+
/** Latest `codex.rate_limits` payload, if the backend sent one. */
|
|
196
|
+
rateLimits = null;
|
|
197
|
+
/** Accumulated content for non-streaming responses. */
|
|
198
|
+
content = [];
|
|
199
|
+
stopReason = "end_turn";
|
|
200
|
+
/** Input-token figure announced in message_start (the real one only arrives with response.completed). */
|
|
201
|
+
startInput;
|
|
202
|
+
constructor(model, startInput = 0) {
|
|
203
|
+
this.model = model;
|
|
204
|
+
this.startInput = startInput;
|
|
205
|
+
this.messageId = `msg_${crypto.randomBytes(12).toString("hex")}`;
|
|
206
|
+
}
|
|
207
|
+
get isFinished() {
|
|
208
|
+
return this.finished;
|
|
209
|
+
}
|
|
210
|
+
start() {
|
|
211
|
+
if (this.started)
|
|
212
|
+
return [];
|
|
213
|
+
this.started = true;
|
|
214
|
+
return [
|
|
215
|
+
{
|
|
216
|
+
event: "message_start",
|
|
217
|
+
data: {
|
|
218
|
+
type: "message_start",
|
|
219
|
+
// Claude Code snapshots `message.usage` per streamed content block, before message_delta
|
|
220
|
+
// (measured 2026-09-13: with zeros here the app showed "1 token" for a 118k-token subagent and
|
|
221
|
+
// the CLI's context accounting saw an empty context). Announce an estimate; the true figures
|
|
222
|
+
// follow in message_delta and the SDK merges them into the final message.
|
|
223
|
+
message: { id: this.messageId, type: "message", role: "assistant", model: this.model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: this.startInput, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } },
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
];
|
|
227
|
+
}
|
|
228
|
+
closeBlock() {
|
|
229
|
+
if (!this.open)
|
|
230
|
+
return [];
|
|
231
|
+
const evs = [];
|
|
232
|
+
if (this.open.kind === "thinking")
|
|
233
|
+
evs.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "signature_delta", signature: "" } } });
|
|
234
|
+
evs.push({ event: "content_block_stop", data: { type: "content_block_stop", index: this.blockIndex } });
|
|
235
|
+
this.open = null;
|
|
236
|
+
return evs;
|
|
237
|
+
}
|
|
238
|
+
openBlock(kind, block, itemId) {
|
|
239
|
+
const evs = this.closeBlock();
|
|
240
|
+
this.blockIndex++;
|
|
241
|
+
this.open = itemId ? { kind, itemId } : { kind };
|
|
242
|
+
evs.push({ event: "content_block_start", data: { type: "content_block_start", index: this.blockIndex, content_block: block } });
|
|
243
|
+
return evs;
|
|
244
|
+
}
|
|
245
|
+
/** Map one upstream event. Returns Anthropic events to emit (possibly none). */
|
|
246
|
+
feed(ev) {
|
|
247
|
+
const type = ev.type;
|
|
248
|
+
const out = [...this.start()];
|
|
249
|
+
switch (type) {
|
|
250
|
+
case "codex.rate_limits":
|
|
251
|
+
this.rateLimits = ev;
|
|
252
|
+
break;
|
|
253
|
+
case "response.output_item.added": {
|
|
254
|
+
const item = ev.item;
|
|
255
|
+
if (item.type === "function_call") {
|
|
256
|
+
this.sawToolCall = true;
|
|
257
|
+
const id = item.call_id ?? item.id ?? `call_${crypto.randomBytes(8).toString("hex")}`;
|
|
258
|
+
this.content.push({ type: "tool_use", id, name: item.name ?? "tool", input: {}, _args: "" });
|
|
259
|
+
out.push(...this.openBlock("tool", { type: "tool_use", id, name: item.name ?? "tool", input: {} }, item.id));
|
|
260
|
+
}
|
|
261
|
+
else if (item.type === "reasoning") {
|
|
262
|
+
this.content.push({ type: "thinking", thinking: "", signature: "" });
|
|
263
|
+
out.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }, item.id));
|
|
264
|
+
}
|
|
265
|
+
else if (item.type === "message") {
|
|
266
|
+
this.content.push({ type: "text", text: "" });
|
|
267
|
+
out.push(...this.openBlock("text", { type: "text", text: "" }, item.id));
|
|
268
|
+
}
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
case "response.output_text.delta": {
|
|
272
|
+
const delta = String(ev.delta ?? "");
|
|
273
|
+
if (!this.open || this.open.kind !== "text") {
|
|
274
|
+
this.content.push({ type: "text", text: "" });
|
|
275
|
+
out.push(...this.openBlock("text", { type: "text", text: "" }));
|
|
276
|
+
}
|
|
277
|
+
const last = this.content[this.content.length - 1];
|
|
278
|
+
if (last?.type === "text")
|
|
279
|
+
last.text += delta;
|
|
280
|
+
out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "text_delta", text: delta } } });
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
case "response.reasoning_summary_text.delta":
|
|
284
|
+
case "response.reasoning_text.delta": {
|
|
285
|
+
const delta = String(ev.delta ?? "");
|
|
286
|
+
if (!this.open || this.open.kind !== "thinking") {
|
|
287
|
+
this.content.push({ type: "thinking", thinking: "", signature: "" });
|
|
288
|
+
out.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }));
|
|
289
|
+
}
|
|
290
|
+
const last = this.content[this.content.length - 1];
|
|
291
|
+
if (last?.type === "thinking")
|
|
292
|
+
last.thinking += delta;
|
|
293
|
+
out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "thinking_delta", thinking: delta } } });
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
case "response.function_call_arguments.delta": {
|
|
297
|
+
const delta = String(ev.delta ?? "");
|
|
298
|
+
const last = this.content[this.content.length - 1];
|
|
299
|
+
if (last?.type === "tool_use")
|
|
300
|
+
last._args = (last._args ?? "") + delta;
|
|
301
|
+
if (this.open?.kind === "tool")
|
|
302
|
+
out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "input_json_delta", partial_json: delta } } });
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
case "response.function_call_arguments.done": {
|
|
306
|
+
const last = this.content[this.content.length - 1];
|
|
307
|
+
if (last?.type === "tool_use") {
|
|
308
|
+
const args = typeof ev.arguments === "string" ? ev.arguments : last._args ?? "";
|
|
309
|
+
try {
|
|
310
|
+
last.input = args ? JSON.parse(args) : {};
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
last.input = {};
|
|
314
|
+
}
|
|
315
|
+
delete last._args;
|
|
316
|
+
}
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
case "response.output_item.done": {
|
|
320
|
+
out.push(...this.closeBlock());
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
case "response.completed":
|
|
324
|
+
case "response.incomplete": {
|
|
325
|
+
const r = ev.response;
|
|
326
|
+
const u = r?.usage;
|
|
327
|
+
if (u) {
|
|
328
|
+
const cached = u.input_tokens_details?.cached_tokens ?? 0;
|
|
329
|
+
this.usage = {
|
|
330
|
+
input_tokens: Math.max(0, (u.input_tokens ?? 0) - cached),
|
|
331
|
+
output_tokens: u.output_tokens ?? 0,
|
|
332
|
+
cache_read_input_tokens: cached,
|
|
333
|
+
cache_creation_input_tokens: 0,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
this.stopReason = this.sawToolCall ? "tool_use" : r?.incomplete_details?.reason === "max_output_tokens" ? "max_tokens" : "end_turn";
|
|
337
|
+
out.push(...this.finish());
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
case "response.failed": {
|
|
341
|
+
const r = ev.response;
|
|
342
|
+
out.push(...this.fail(r?.error?.message ?? "upstream response failed", r?.error?.code));
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
case "error": {
|
|
346
|
+
const e = ev.error;
|
|
347
|
+
out.push(...this.fail(e?.message ?? "upstream error", e?.code));
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
default:
|
|
351
|
+
break; // response.created / in_progress / content_part.* / reasoning_summary_part.* etc. carry nothing we need
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
finish() {
|
|
356
|
+
if (this.finished)
|
|
357
|
+
return [];
|
|
358
|
+
this.finished = true;
|
|
359
|
+
const out = [...this.start(), ...this.closeBlock()];
|
|
360
|
+
out.push({ event: "message_delta", data: { type: "message_delta", delta: { stop_reason: this.stopReason, stop_sequence: null }, usage: this.usage } });
|
|
361
|
+
out.push({ event: "message_stop", data: { type: "message_stop" } });
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
fail(message, code) {
|
|
365
|
+
if (this.finished)
|
|
366
|
+
return [];
|
|
367
|
+
this.finished = true;
|
|
368
|
+
const type = code === "server_is_overloaded" ? "overloaded_error" : code === "rate_limit_exceeded" || code === "usage_limit_reached" ? "rate_limit_error" : "api_error";
|
|
369
|
+
return [...this.start(), ...this.closeBlock(), { event: "error", data: { type: "error", error: { type, message } } }];
|
|
370
|
+
}
|
|
371
|
+
/** Non-streaming body once finished. */
|
|
372
|
+
message() {
|
|
373
|
+
const content = this.content.map((b) => {
|
|
374
|
+
if (b.type === "tool_use") {
|
|
375
|
+
const { _args, ...rest } = b;
|
|
376
|
+
if (_args !== undefined) {
|
|
377
|
+
try {
|
|
378
|
+
rest.input = _args ? JSON.parse(_args) : {};
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
rest.input = {};
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return rest;
|
|
385
|
+
}
|
|
386
|
+
return b;
|
|
387
|
+
});
|
|
388
|
+
return { id: this.messageId, type: "message", role: "assistant", model: this.model, content, stop_reason: this.stopReason, stop_sequence: null, usage: this.usage };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
export function formatSse(ev) {
|
|
392
|
+
return `event: ${ev.event}\ndata: ${JSON.stringify(ev.data)}\n\n`;
|
|
393
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Claude subscription sign-in of our own: the OAuth authorization-code flow with PKCE that Claude
|
|
2
|
+
// Code itself uses, driven from ClaudeRipple so that no terminal is needed. Until 0.1.2 the only
|
|
3
|
+
// way to connect a subscription was `claude setup-token`, an interactive terminal flow that the
|
|
4
|
+
// tray app and the GUI cannot host (2026-09-15, Windows report).
|
|
5
|
+
//
|
|
6
|
+
// Wire facts are behaviorally measured against Claude Code's public client and are not an
|
|
7
|
+
// Anthropic guarantee (docs/ARCHITECTURE.md §4b). The browser is sent to claude.ai; the code comes
|
|
8
|
+
// back either to a loopback listener on the registered port or, when that port is taken, through
|
|
9
|
+
// the manual "paste the code" page (`code#state`). Tokens are stored only in <home>/claude-auth.json
|
|
10
|
+
// (mode 0600) and never logged or returned by the admin API.
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
import net from "node:net";
|
|
14
|
+
import { saveClaudeOAuthFile } from "./anthropic-token-file.js";
|
|
15
|
+
// Endpoints, scopes and body shapes are what Claude Code 2.1.271 sends (read from its binary on
|
|
16
|
+
// 2026-09-16): the claude.ai login is `claude.com/cai/oauth/authorize`, tokens come from
|
|
17
|
+
// `platform.claude.com`. The previous `claude.ai/oauth/authorize` answers "Invalid request format".
|
|
18
|
+
export const CLAUDE_OAUTH = {
|
|
19
|
+
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
|
20
|
+
authorizeUrl: "https://claude.com/cai/oauth/authorize",
|
|
21
|
+
tokenUrl: "https://platform.claude.com/v1/oauth/token",
|
|
22
|
+
scope: "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload",
|
|
23
|
+
/** The refresh grant names the subscription scopes only (as Claude Code does). */
|
|
24
|
+
refreshScope: "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload",
|
|
25
|
+
/** Preferred loopback port; any free port is accepted by the authorize server, so a taken port falls back to a random one. */
|
|
26
|
+
port: 54545,
|
|
27
|
+
callbackPath: "/callback",
|
|
28
|
+
/** Anthropic's own page that shows the code for pasting when no loopback listener can be reached. */
|
|
29
|
+
manualRedirectUri: "https://platform.claude.com/oauth/code/callback",
|
|
30
|
+
/** How long a sign-in may stay open before it is abandoned. */
|
|
31
|
+
timeoutMs: 5 * 60 * 1000,
|
|
32
|
+
/** Refresh this long before the access token expires. */
|
|
33
|
+
refreshLeadMs: 5 * 60 * 1000,
|
|
34
|
+
};
|
|
35
|
+
function base64url(buffer) {
|
|
36
|
+
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
37
|
+
}
|
|
38
|
+
async function postToken(fetchImpl, body) {
|
|
39
|
+
const response = await fetchImpl(CLAUDE_OAUTH.tokenUrl, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
42
|
+
body: JSON.stringify(body),
|
|
43
|
+
signal: AbortSignal.timeout(30_000),
|
|
44
|
+
});
|
|
45
|
+
const text = await response.text();
|
|
46
|
+
let parsed = {};
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(text);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// A non-JSON body is reported through the status below.
|
|
52
|
+
}
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
const detail = typeof parsed.error_description === "string" ? parsed.error_description : typeof parsed.error === "string" ? parsed.error : `HTTP ${response.status}`;
|
|
55
|
+
throw new Error(`Claude sign-in was refused by the token endpoint: ${detail}`);
|
|
56
|
+
}
|
|
57
|
+
return parsed;
|
|
58
|
+
}
|
|
59
|
+
function grantFrom(reply, now, previousRefresh) {
|
|
60
|
+
if (typeof reply.access_token !== "string" || reply.access_token.length === 0)
|
|
61
|
+
throw new Error("Claude sign-in returned no access token");
|
|
62
|
+
const refreshToken = typeof reply.refresh_token === "string" && reply.refresh_token.length > 0 ? reply.refresh_token : previousRefresh;
|
|
63
|
+
if (!refreshToken)
|
|
64
|
+
throw new Error("Claude sign-in returned no refresh token");
|
|
65
|
+
// A missing or absurd expires_in is treated as one hour: better an early refresh than a token
|
|
66
|
+
// believed valid forever.
|
|
67
|
+
const expiresIn = typeof reply.expires_in === "number" && Number.isFinite(reply.expires_in) && reply.expires_in > 0 ? reply.expires_in : 3600;
|
|
68
|
+
return { accessToken: reply.access_token, refreshToken, expiresAt: now + expiresIn * 1000 };
|
|
69
|
+
}
|
|
70
|
+
/** Exchanges a refresh token; the caller persists the result. */
|
|
71
|
+
export async function refreshClaudeOAuth(refreshToken, options = {}) {
|
|
72
|
+
const reply = await postToken(options.fetch ?? fetch, { grant_type: "refresh_token", client_id: CLAUDE_OAUTH.clientId, refresh_token: refreshToken, scope: CLAUDE_OAUTH.refreshScope });
|
|
73
|
+
return grantFrom(reply, (options.now ?? Date.now)(), refreshToken);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* One sign-in attempt. `start()` returns the authorize URL; the grant arrives through the loopback
|
|
77
|
+
* callback or `submitCode()`, whichever comes first; `result` resolves when the credential file is
|
|
78
|
+
* written or the attempt failed. A session is single-use.
|
|
79
|
+
*/
|
|
80
|
+
export class ClaudeOAuthSession {
|
|
81
|
+
result;
|
|
82
|
+
options;
|
|
83
|
+
verifier = base64url(crypto.randomBytes(32));
|
|
84
|
+
// 32 bytes, the size Claude Code 2.1.272 uses for both the verifier and the state.
|
|
85
|
+
state = base64url(crypto.randomBytes(32));
|
|
86
|
+
redirectUri = "";
|
|
87
|
+
server = null;
|
|
88
|
+
server6 = null;
|
|
89
|
+
settle;
|
|
90
|
+
grant;
|
|
91
|
+
timer = null;
|
|
92
|
+
finished = false;
|
|
93
|
+
stateSnapshot = { running: false, url: null, manual: false, startedAt: null, finishedAt: null, ok: null, error: null };
|
|
94
|
+
constructor(options) {
|
|
95
|
+
this.options = options;
|
|
96
|
+
this.grant = new Promise((resolve, reject) => {
|
|
97
|
+
this.settle = { resolve, reject };
|
|
98
|
+
});
|
|
99
|
+
this.result = this.grant.then((grant) => {
|
|
100
|
+
saveClaudeOAuthFile(options.home, grant);
|
|
101
|
+
this.stateSnapshot = { ...this.stateSnapshot, running: false, finishedAt: new Date().toISOString(), ok: true };
|
|
102
|
+
}, (error) => {
|
|
103
|
+
this.stateSnapshot = { ...this.stateSnapshot, running: false, finishedAt: new Date().toISOString(), ok: false, error: error.message };
|
|
104
|
+
throw error;
|
|
105
|
+
});
|
|
106
|
+
// Nobody may be awaiting `result` (the GUI polls state instead); do not surface it as unhandled.
|
|
107
|
+
this.result.catch(() => { });
|
|
108
|
+
}
|
|
109
|
+
get snapshot() {
|
|
110
|
+
return this.stateSnapshot;
|
|
111
|
+
}
|
|
112
|
+
/** The loopback port in use, once started. */
|
|
113
|
+
port = null;
|
|
114
|
+
/** Starts the loopback listener when possible and returns the URL to open. */
|
|
115
|
+
async start() {
|
|
116
|
+
const manual = this.options.manual ? true : !(await this.listen(this.options.port ?? CLAUDE_OAUTH.port)) && !(await this.listen(0));
|
|
117
|
+
this.redirectUri = manual ? CLAUDE_OAUTH.manualRedirectUri : `http://localhost:${this.port}${CLAUDE_OAUTH.callbackPath}`;
|
|
118
|
+
const url = new URL(CLAUDE_OAUTH.authorizeUrl);
|
|
119
|
+
url.search = new URLSearchParams({
|
|
120
|
+
code: "true",
|
|
121
|
+
client_id: CLAUDE_OAUTH.clientId,
|
|
122
|
+
response_type: "code",
|
|
123
|
+
redirect_uri: this.redirectUri,
|
|
124
|
+
scope: CLAUDE_OAUTH.scope,
|
|
125
|
+
code_challenge: base64url(crypto.createHash("sha256").update(this.verifier).digest()),
|
|
126
|
+
code_challenge_method: "S256",
|
|
127
|
+
state: this.state,
|
|
128
|
+
}).toString();
|
|
129
|
+
this.timer = setTimeout(() => this.fail(new Error("Claude sign-in timed out; start it again")), CLAUDE_OAUTH.timeoutMs);
|
|
130
|
+
this.timer.unref();
|
|
131
|
+
this.stateSnapshot = { running: true, url: url.toString(), manual, startedAt: new Date().toISOString(), finishedAt: null, ok: null, error: null };
|
|
132
|
+
return { url: url.toString(), manual };
|
|
133
|
+
}
|
|
134
|
+
/** Accepts `code`, `code#state`, or the full redirect URL the browser landed on. */
|
|
135
|
+
async submitCode(input) {
|
|
136
|
+
let code = input.trim();
|
|
137
|
+
let state = null;
|
|
138
|
+
try {
|
|
139
|
+
const u = new URL(code);
|
|
140
|
+
code = u.searchParams.get("code") ?? "";
|
|
141
|
+
state = u.searchParams.get("state");
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
const hash = code.indexOf("#");
|
|
145
|
+
if (hash >= 0) {
|
|
146
|
+
state = code.slice(hash + 1) || null;
|
|
147
|
+
code = code.slice(0, hash);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!code) {
|
|
151
|
+
this.fail(new Error("No authorization code in what was pasted"));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (state !== null && state !== this.state) {
|
|
155
|
+
this.fail(new Error("The pasted code belongs to a different sign-in attempt"));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
await this.exchange(code);
|
|
159
|
+
}
|
|
160
|
+
/** Abandons the attempt (a new one may start). */
|
|
161
|
+
cancel() {
|
|
162
|
+
this.fail(new Error("Claude sign-in cancelled"));
|
|
163
|
+
}
|
|
164
|
+
listen(port) {
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
const handler = (req, res) => {
|
|
167
|
+
const u = new URL(req.url ?? "/", "http://localhost");
|
|
168
|
+
if (u.pathname !== CLAUDE_OAUTH.callbackPath) {
|
|
169
|
+
res.writeHead(404).end();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const error = u.searchParams.get("error");
|
|
173
|
+
const code = u.searchParams.get("code");
|
|
174
|
+
// A request that is not for this attempt (wrong or missing state) is refused but does not
|
|
175
|
+
// end the attempt: anything on the machine can hit a loopback port, and the real redirect
|
|
176
|
+
// may still be on its way.
|
|
177
|
+
if (u.searchParams.get("state") !== this.state || (!error && !code)) {
|
|
178
|
+
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Claude sign-in: this is not the sign-in ClaudeRipple is waiting for. You can close this tab.");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (error || !code) {
|
|
182
|
+
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end(`Claude sign-in failed: ${error}. You can close this tab.`);
|
|
183
|
+
this.fail(new Error(`Claude refused the sign-in: ${error}`));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("ClaudeRipple: Claude subscription connected. You can close this tab.");
|
|
187
|
+
void this.exchange(code);
|
|
188
|
+
};
|
|
189
|
+
const server = http.createServer(handler);
|
|
190
|
+
server.once("error", () => resolve(false));
|
|
191
|
+
server.listen(port, "127.0.0.1", () => {
|
|
192
|
+
this.server = server;
|
|
193
|
+
this.port = server.address().port;
|
|
194
|
+
// The browser resolves "localhost" to ::1 or 127.0.0.1 as it likes; answer on both when
|
|
195
|
+
// IPv6 loopback is available. Best effort: the IPv4 listener alone is enough on most systems.
|
|
196
|
+
const six = http.createServer(handler);
|
|
197
|
+
six.once("error", () => { });
|
|
198
|
+
six.listen(this.port, "::1", () => {
|
|
199
|
+
this.server6 = six;
|
|
200
|
+
});
|
|
201
|
+
resolve(true);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
async exchange(code) {
|
|
206
|
+
if (this.finished)
|
|
207
|
+
return;
|
|
208
|
+
try {
|
|
209
|
+
const reply = await postToken(this.options.fetch ?? fetch, {
|
|
210
|
+
grant_type: "authorization_code",
|
|
211
|
+
client_id: CLAUDE_OAUTH.clientId,
|
|
212
|
+
code,
|
|
213
|
+
state: this.state,
|
|
214
|
+
redirect_uri: this.redirectUri,
|
|
215
|
+
code_verifier: this.verifier,
|
|
216
|
+
});
|
|
217
|
+
this.finish();
|
|
218
|
+
this.settle.resolve(grantFrom(reply, (this.options.now ?? Date.now)()));
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
this.fail(error);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
fail(error) {
|
|
225
|
+
if (this.finished)
|
|
226
|
+
return;
|
|
227
|
+
this.finish();
|
|
228
|
+
this.settle.reject(error);
|
|
229
|
+
}
|
|
230
|
+
finish() {
|
|
231
|
+
this.finished = true;
|
|
232
|
+
if (this.timer)
|
|
233
|
+
clearTimeout(this.timer);
|
|
234
|
+
for (const server of [this.server, this.server6]) {
|
|
235
|
+
if (!server)
|
|
236
|
+
continue;
|
|
237
|
+
server.close();
|
|
238
|
+
// Keep-alive connections would otherwise hold the port for their idle timeout.
|
|
239
|
+
server.closeAllConnections?.();
|
|
240
|
+
}
|
|
241
|
+
this.server = null;
|
|
242
|
+
this.server6 = null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/** True when nothing is listening on the registered callback port (a hint for the GUI, not a guarantee). */
|
|
246
|
+
export function callbackPortFree(port = CLAUDE_OAUTH.port) {
|
|
247
|
+
return new Promise((resolve) => {
|
|
248
|
+
const probe = net.createServer();
|
|
249
|
+
probe.once("error", () => resolve(false));
|
|
250
|
+
probe.listen(port, "127.0.0.1", () => probe.close(() => resolve(true)));
|
|
251
|
+
});
|
|
252
|
+
}
|