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.
Files changed (71) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/LICENSE +674 -0
  3. package/README.ko.md +328 -0
  4. package/README.md +372 -0
  5. package/bin/clauderipple.js +12 -0
  6. package/dist/app/assets/trayDownTemplate.png +0 -0
  7. package/dist/app/assets/trayDownTemplate@2x.png +0 -0
  8. package/dist/app/assets/trayTemplate.png +0 -0
  9. package/dist/app/assets/trayTemplate@2x.png +0 -0
  10. package/dist/app/assets/trayWarnTemplate.png +0 -0
  11. package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
  12. package/dist/app/assets/trayWin.png +0 -0
  13. package/dist/app/assets/trayWin@2x.png +0 -0
  14. package/dist/app/assets/trayWinDown.png +0 -0
  15. package/dist/app/assets/trayWinDown@2x.png +0 -0
  16. package/dist/app/assets/trayWinWarn.png +0 -0
  17. package/dist/app/assets/trayWinWarn@2x.png +0 -0
  18. package/dist/app/dist/main.js +518 -0
  19. package/dist/cli/src/browser.js +21 -0
  20. package/dist/cli/src/bundle.js +51 -0
  21. package/dist/cli/src/certs.js +33 -0
  22. package/dist/cli/src/claude-auth.js +112 -0
  23. package/dist/cli/src/codex.js +172 -0
  24. package/dist/cli/src/gen-certs.js +7 -0
  25. package/dist/cli/src/hooks/agent-title.js +160 -0
  26. package/dist/cli/src/index.js +489 -0
  27. package/dist/cli/src/launchd.js +183 -0
  28. package/dist/cli/src/picker.js +166 -0
  29. package/dist/cli/src/probe.js +55 -0
  30. package/dist/cli/src/runtime.js +62 -0
  31. package/dist/cli/src/schtasks.js +134 -0
  32. package/dist/cli/src/settings.js +142 -0
  33. package/dist/cli/src/supervisor.js +100 -0
  34. package/dist/cli/src/tray.js +85 -0
  35. package/dist/router/src/admin.js +945 -0
  36. package/dist/router/src/bootstrap.js +80 -0
  37. package/dist/router/src/certs.js +65 -0
  38. package/dist/router/src/compat.js +172 -0
  39. package/dist/router/src/config.js +179 -0
  40. package/dist/router/src/health.js +45 -0
  41. package/dist/router/src/identity.js +51 -0
  42. package/dist/router/src/index.js +144 -0
  43. package/dist/router/src/ingress/models.js +29 -0
  44. package/dist/router/src/ingress/server.js +400 -0
  45. package/dist/router/src/ingress/translate.js +457 -0
  46. package/dist/router/src/log.js +81 -0
  47. package/dist/router/src/picker.js +74 -0
  48. package/dist/router/src/presets.js +267 -0
  49. package/dist/router/src/providers/anthropic-observed.js +88 -0
  50. package/dist/router/src/providers/anthropic-token-file.js +48 -0
  51. package/dist/router/src/providers/anthropic.js +203 -0
  52. package/dist/router/src/providers/chatgpt/auth.js +226 -0
  53. package/dist/router/src/providers/chatgpt/index.js +274 -0
  54. package/dist/router/src/providers/chatgpt/sse.js +28 -0
  55. package/dist/router/src/providers/chatgpt/translate.js +393 -0
  56. package/dist/router/src/providers/claude-oauth.js +252 -0
  57. package/dist/router/src/providers/openai/index.js +193 -0
  58. package/dist/router/src/providers/openai/translate.js +504 -0
  59. package/dist/router/src/proxy.js +724 -0
  60. package/dist/router/src/redact.js +43 -0
  61. package/dist/router/src/requestlog.js +346 -0
  62. package/dist/router/src/routing.js +113 -0
  63. package/dist/router/src/version.js +8 -0
  64. package/dist/router/src/x509.js +203 -0
  65. package/dist/ui/app.js +1228 -0
  66. package/dist/ui/i18n.js +95 -0
  67. package/dist/ui/index.html +104 -0
  68. package/dist/ui/presets-fallback.js +61 -0
  69. package/dist/ui/style.css +347 -0
  70. package/docs/ARCHITECTURE.md +441 -0
  71. package/package.json +66 -0
@@ -0,0 +1,457 @@
1
+ // OpenAI Responses / Chat Completions ⇄ Anthropic Messages. Pure functions, no I/O.
2
+ //
3
+ // The translation deliberately emits deterministic object/property ordering. Prompt caching is
4
+ // controlled at the Anthropic side: system, final tool, and final user content carry the
5
+ // ephemeral breakpoint requested by the ingress contract.
6
+ import crypto from "node:crypto";
7
+ // Anthropic requires max_tokens; OpenAI clients (Codex included) usually omit it. 16k is safe for every current Claude model.
8
+ export const DEFAULT_MAX_TOKENS = 16384;
9
+ const EPHEMERAL = { type: "ephemeral" };
10
+ function object(value) {
11
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
12
+ }
13
+ function string(value) {
14
+ return typeof value === "string" ? value : null;
15
+ }
16
+ function contentText(value) {
17
+ if (typeof value === "string")
18
+ return value;
19
+ if (!Array.isArray(value))
20
+ return "";
21
+ return value
22
+ .flatMap((part) => {
23
+ const p = object(part);
24
+ if (!p)
25
+ return [];
26
+ const type = string(p.type);
27
+ if (type === "input_text" || type === "output_text" || type === "text")
28
+ return [string(p.text) ?? ""];
29
+ if (type === "refusal")
30
+ return [string(p.refusal) ?? ""];
31
+ return [];
32
+ })
33
+ .join("\n");
34
+ }
35
+ function imageFromPart(part) {
36
+ const imageUrl = object(part.image_url);
37
+ const value = string(part.image_url) ?? string(imageUrl?.url) ?? string(part.url);
38
+ if (!value)
39
+ return null;
40
+ const data = /^data:([^;,]+);base64,(.*)$/s.exec(value);
41
+ if (data)
42
+ return { type: "image", source: { type: "base64", media_type: data[1], data: data[2] } };
43
+ return { type: "image", source: { type: "url", url: value } };
44
+ }
45
+ function messageContent(value) {
46
+ if (typeof value === "string")
47
+ return value ? [{ type: "text", text: value }] : [];
48
+ if (!Array.isArray(value))
49
+ return [];
50
+ const out = [];
51
+ for (const raw of value) {
52
+ const part = object(raw);
53
+ if (!part)
54
+ continue;
55
+ const type = string(part.type);
56
+ if (type === "input_text" || type === "output_text" || type === "text") {
57
+ const text = string(part.text);
58
+ if (text)
59
+ out.push({ type: "text", text });
60
+ }
61
+ else if (type === "input_image" || type === "image_url") {
62
+ const image = imageFromPart(part);
63
+ if (image)
64
+ out.push(image);
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+ function parseArguments(value) {
70
+ if (typeof value !== "string" || value === "")
71
+ return {};
72
+ try {
73
+ return JSON.parse(value);
74
+ }
75
+ catch {
76
+ return { _raw_arguments: value };
77
+ }
78
+ }
79
+ function normalizeSchema(value) {
80
+ const source = object(value);
81
+ const out = source ? structuredClone(source) : {};
82
+ if (out.type !== "object")
83
+ out.type = "object";
84
+ if (!object(out.properties))
85
+ out.properties = {};
86
+ return out;
87
+ }
88
+ function mapTools(value) {
89
+ if (!Array.isArray(value))
90
+ return [];
91
+ const tools = value.flatMap((raw) => {
92
+ const tool = object(raw);
93
+ if (!tool || string(tool.type) !== "function")
94
+ return [];
95
+ // Responses uses function fields directly; Chat Completions nests them under `function`.
96
+ const fn = object(tool.function) ?? tool;
97
+ const name = string(fn.name);
98
+ if (!name)
99
+ return [];
100
+ const description = string(fn.description);
101
+ return [{ name, ...(description ? { description } : {}), input_schema: normalizeSchema(fn.parameters) }];
102
+ });
103
+ if (tools.length)
104
+ tools[tools.length - 1].cache_control = EPHEMERAL;
105
+ return tools;
106
+ }
107
+ function mapToolChoice(value) {
108
+ if (value === "auto")
109
+ return { type: "auto" };
110
+ if (value === "required")
111
+ return { type: "any" };
112
+ if (value === "none")
113
+ return { type: "none" };
114
+ const choice = object(value);
115
+ if (choice?.type === "function" && typeof choice.name === "string")
116
+ return { type: "tool", name: choice.name };
117
+ return undefined;
118
+ }
119
+ function applyMessageCache(messages) {
120
+ for (let i = messages.length - 1; i >= 0; i--) {
121
+ const message = messages[i];
122
+ if (message.role !== "user")
123
+ continue;
124
+ const last = message.content.at(-1);
125
+ if (last?.type === "text")
126
+ last.cache_control = EPHEMERAL;
127
+ else if (last)
128
+ last.cache_control = EPHEMERAL;
129
+ else
130
+ message.content.push({ type: "text", text: "", cache_control: EPHEMERAL });
131
+ return;
132
+ }
133
+ }
134
+ function responseItems(value) {
135
+ if (typeof value === "string")
136
+ return value ? [{ role: "user", content: [{ type: "text", text: value }] }] : [];
137
+ if (!Array.isArray(value))
138
+ return [];
139
+ const messages = [];
140
+ for (const raw of value) {
141
+ const item = object(raw);
142
+ if (!item)
143
+ continue;
144
+ const type = string(item.type);
145
+ if (type === "reasoning")
146
+ continue;
147
+ if (type === "function_call") {
148
+ const callId = string(item.call_id) ?? string(item.id) ?? `call_${crypto.randomUUID().replaceAll("-", "")}`;
149
+ messages.push({ role: "assistant", content: [{ type: "tool_use", id: callId, name: string(item.name) ?? "tool", input: parseArguments(item.arguments) }] });
150
+ continue;
151
+ }
152
+ if (type === "function_call_output") {
153
+ const callId = string(item.call_id) ?? "";
154
+ if (callId)
155
+ messages.push({ role: "user", content: [{ type: "tool_result", tool_use_id: callId, content: contentText(item.output) }] });
156
+ continue;
157
+ }
158
+ if (type !== "message")
159
+ continue;
160
+ const role = item.role === "assistant" ? "assistant" : "user";
161
+ const content = messageContent(item.content);
162
+ if (content.length)
163
+ messages.push({ role, content });
164
+ }
165
+ return messages;
166
+ }
167
+ export function effortFromRequest(value) {
168
+ const reasoning = object(value);
169
+ const effort = reasoning && string(reasoning.effort);
170
+ return effort ?? undefined;
171
+ }
172
+ /** Convert an OpenAI Responses request to a cache-friendly Anthropic Messages request. */
173
+ /** Claude models that accept `output_config.effort` (measured 2026-09-13: Haiku 4.5 answers 400 "does not support the effort parameter"). */
174
+ export function claudeSupportsEffort(model) {
175
+ return /^claude-(opus|sonnet|fable)-(4-[6-9]|5)(-|$)/.test(model);
176
+ }
177
+ /**
178
+ * With a borrowed Claude Code login, Anthropic accepts the request only when the system prompt is
179
+ * Claude Code's own (measured 2026-09-13: any other long system text → 429 "rate_limit_error: Error",
180
+ * even though a tiny request passes). So in that mode `system` carries just the identity line and
181
+ * the client's instructions travel as the first user block, tagged so the model reads them as
182
+ * operator instructions. The block is cache-marked, so the prefix still caches.
183
+ */
184
+ function placeInstructions(prefix, instructions, messages) {
185
+ const text = instructions.filter((t) => t.length > 0).join("\n\n");
186
+ if (prefix) {
187
+ if (text) {
188
+ const block = { type: "text", text: `<operator_instructions>\n${text}\n</operator_instructions>`, cache_control: EPHEMERAL };
189
+ const first = messages.find((m) => m.role === "user");
190
+ if (first && Array.isArray(first.content))
191
+ first.content.unshift(block);
192
+ else
193
+ messages.unshift({ role: "user", content: [block] });
194
+ }
195
+ return [{ type: "text", text: prefix, cache_control: EPHEMERAL }];
196
+ }
197
+ return text ? [{ type: "text", text, cache_control: EPHEMERAL }] : undefined;
198
+ }
199
+ export function responsesToAnthropic(body, targetModel, systemPrefix) {
200
+ const instructions = string(body.instructions);
201
+ const messages = responseItems(body.input);
202
+ const system = placeInstructions(systemPrefix, instructions ? [instructions] : [], messages);
203
+ applyMessageCache(messages);
204
+ const tools = mapTools(body.tools);
205
+ const maxTokens = typeof body.max_output_tokens === "number" && Number.isFinite(body.max_output_tokens) ? Math.max(1, Math.floor(body.max_output_tokens)) : undefined;
206
+ const temperature = typeof body.temperature === "number" && Number.isFinite(body.temperature) ? body.temperature : undefined;
207
+ const effort = effortFromRequest(body.reasoning);
208
+ const toolChoice = mapToolChoice(body.tool_choice);
209
+ return {
210
+ model: targetModel,
211
+ ...(system ? { system } : {}),
212
+ messages,
213
+ ...(tools.length ? { tools } : {}),
214
+ ...(toolChoice ? { tool_choice: toolChoice } : {}),
215
+ max_tokens: maxTokens ?? DEFAULT_MAX_TOKENS,
216
+ ...(temperature !== undefined ? { temperature } : {}),
217
+ ...(effort && claudeSupportsEffort(targetModel) ? { output_config: { effort } } : {}),
218
+ stream: body.stream === true,
219
+ };
220
+ }
221
+ function chatMessageToAnthropic(raw) {
222
+ const message = object(raw);
223
+ if (!message)
224
+ return [];
225
+ const role = string(message.role);
226
+ if (role === "system" || role === "developer") {
227
+ const text = contentText(message.content);
228
+ return text ? [{ role: "user", content: [{ type: "text", text: `[${role}]\n${text}` }] }] : [];
229
+ }
230
+ if (role === "tool") {
231
+ const id = string(message.tool_call_id);
232
+ return id ? [{ role: "user", content: [{ type: "tool_result", tool_use_id: id, content: contentText(message.content) }] }] : [];
233
+ }
234
+ const mappedRole = role === "assistant" ? "assistant" : "user";
235
+ const content = messageContent(message.content);
236
+ const calls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
237
+ for (const rawCall of calls) {
238
+ const call = object(rawCall);
239
+ const fn = call && object(call.function);
240
+ const id = call && string(call.id);
241
+ const name = fn && string(fn.name);
242
+ if (id && name)
243
+ content.push({ type: "tool_use", id, name, input: parseArguments(fn.arguments) });
244
+ }
245
+ return content.length ? [{ role: mappedRole, content }] : [];
246
+ }
247
+ /** Convert OpenAI Chat Completions request to an Anthropic Messages request. */
248
+ export function chatToAnthropic(body, targetModel, systemPrefix) {
249
+ const rawMessages = Array.isArray(body.messages) ? body.messages : [];
250
+ const systems = [];
251
+ const messages = [];
252
+ for (const raw of rawMessages) {
253
+ const message = object(raw);
254
+ const role = message && string(message.role);
255
+ if (role === "system" || role === "developer") {
256
+ const text = message ? contentText(message.content) : "";
257
+ if (text)
258
+ systems.push(text);
259
+ continue;
260
+ }
261
+ messages.push(...chatMessageToAnthropic(raw));
262
+ }
263
+ const system = placeInstructions(systemPrefix, systems, messages);
264
+ applyMessageCache(messages);
265
+ const tools = mapTools(body.tools);
266
+ const maxTokensValue = body.max_completion_tokens ?? body.max_tokens;
267
+ const maxTokens = typeof maxTokensValue === "number" && Number.isFinite(maxTokensValue) ? Math.max(1, Math.floor(maxTokensValue)) : undefined;
268
+ const temperature = typeof body.temperature === "number" && Number.isFinite(body.temperature) ? body.temperature : undefined;
269
+ const toolChoice = mapToolChoice(body.tool_choice);
270
+ return {
271
+ model: targetModel,
272
+ ...(system ? { system } : {}),
273
+ messages,
274
+ ...(tools.length ? { tools } : {}),
275
+ ...(toolChoice ? { tool_choice: toolChoice } : {}),
276
+ max_tokens: maxTokens ?? DEFAULT_MAX_TOKENS,
277
+ ...(temperature !== undefined ? { temperature } : {}),
278
+ stream: body.stream === true,
279
+ };
280
+ }
281
+ function usageFromAnthropic(value) {
282
+ const usage = object(value);
283
+ const input = typeof usage?.input_tokens === "number" ? usage.input_tokens : 0;
284
+ const cached = typeof usage?.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : 0;
285
+ const output = typeof usage?.output_tokens === "number" ? usage.output_tokens : 0;
286
+ return { input_tokens: input + cached, input_tokens_details: { cached_tokens: cached }, output_tokens: output, total_tokens: input + cached + output };
287
+ }
288
+ function id(prefix) {
289
+ return `${prefix}_${crypto.randomBytes(12).toString("hex")}`;
290
+ }
291
+ /** Stateful Anthropic Messages SSE → OpenAI Responses event mapper. */
292
+ export class ResponsesEventMapper {
293
+ responseId = id("resp");
294
+ created = Math.floor(Date.now() / 1000);
295
+ model;
296
+ output = [];
297
+ usage = { input_tokens: 0, input_tokens_details: { cached_tokens: 0 }, output_tokens: 0, total_tokens: 0 };
298
+ blocks = new Map();
299
+ emittedCreated = false;
300
+ completed = false;
301
+ outputIndex = 0;
302
+ toolNameFromWire;
303
+ constructor(model, toolNameFromWire = (name) => name) {
304
+ this.model = model;
305
+ this.toolNameFromWire = toolNameFromWire;
306
+ }
307
+ response(status = "in_progress") {
308
+ return { id: this.responseId, object: "response", created_at: this.created, status, model: this.model, output: this.output, usage: this.usage };
309
+ }
310
+ start() {
311
+ if (this.emittedCreated)
312
+ return [];
313
+ this.emittedCreated = true;
314
+ return [{ type: "response.created", response: this.response() }];
315
+ }
316
+ feed(event) {
317
+ const out = this.start();
318
+ const type = string(event.type) ?? "";
319
+ if (type === "message_start") {
320
+ const message = object(event.message);
321
+ this.usage = usageFromAnthropic(message?.usage);
322
+ return out;
323
+ }
324
+ if (type === "content_block_start") {
325
+ const index = typeof event.index === "number" ? event.index : -1;
326
+ const block = object(event.content_block);
327
+ if (index < 0 || !block)
328
+ return out;
329
+ const blockType = string(block.type);
330
+ if (blockType === "text") {
331
+ const item = { id: id("msg"), type: "message", role: "assistant", status: "in_progress", content: [] };
332
+ this.output.push(item);
333
+ this.blocks.set(index, { kind: "text", item, arguments: "" });
334
+ out.push({ type: "response.output_item.added", output_index: this.outputIndex++, item });
335
+ }
336
+ else if (blockType === "tool_use") {
337
+ const callId = string(block.id) ?? id("call");
338
+ const item = { id: id("fc"), type: "function_call", call_id: callId, name: this.toolNameFromWire(string(block.name) ?? "tool"), arguments: "", status: "in_progress" };
339
+ this.output.push(item);
340
+ this.blocks.set(index, { kind: "tool", item, arguments: "" });
341
+ out.push({ type: "response.output_item.added", output_index: this.outputIndex++, item });
342
+ }
343
+ return out;
344
+ }
345
+ if (type === "content_block_delta") {
346
+ const index = typeof event.index === "number" ? event.index : -1;
347
+ const state = this.blocks.get(index);
348
+ const delta = object(event.delta);
349
+ const deltaType = string(delta?.type);
350
+ if (!state || !delta)
351
+ return out;
352
+ if (state.kind === "text" && deltaType === "text_delta") {
353
+ const text = string(delta.text) ?? "";
354
+ const content = state.item.content;
355
+ if (content.length === 0)
356
+ content.push({ type: "output_text", text: "", annotations: [] });
357
+ const piece = content[0];
358
+ piece.text = `${string(piece.text) ?? ""}${text}`;
359
+ out.push({ type: "response.output_text.delta", item_id: state.item.id, output_index: this.output.indexOf(state.item), content_index: 0, delta: text });
360
+ }
361
+ else if (state.kind === "tool" && deltaType === "input_json_delta") {
362
+ const text = string(delta.partial_json) ?? "";
363
+ state.arguments += text;
364
+ state.item.arguments = state.arguments;
365
+ out.push({ type: "response.function_call_arguments.delta", item_id: state.item.id, output_index: this.output.indexOf(state.item), delta: text });
366
+ }
367
+ return out;
368
+ }
369
+ if (type === "content_block_stop") {
370
+ const index = typeof event.index === "number" ? event.index : -1;
371
+ const state = this.blocks.get(index);
372
+ if (!state)
373
+ return out;
374
+ if (state.kind === "tool")
375
+ out.push({ type: "response.function_call_arguments.done", item_id: state.item.id, output_index: this.output.indexOf(state.item), arguments: state.arguments });
376
+ state.item.status = "completed";
377
+ out.push({ type: "response.output_item.done", output_index: this.output.indexOf(state.item), item: state.item });
378
+ this.blocks.delete(index);
379
+ return out;
380
+ }
381
+ if (type === "message_delta") {
382
+ this.usage = usageFromAnthropic(event.usage);
383
+ return out;
384
+ }
385
+ if (type === "message_stop")
386
+ return [...out, ...this.finish()];
387
+ if (type === "error") {
388
+ const error = object(event.error);
389
+ return [...out, { type: "error", error: { message: string(error?.message) ?? "Anthropic upstream error", type: string(error?.type) ?? "api_error", code: null } }];
390
+ }
391
+ return out;
392
+ }
393
+ finish() {
394
+ if (this.completed)
395
+ return [];
396
+ this.completed = true;
397
+ for (const state of this.blocks.values()) {
398
+ state.item.status = "completed";
399
+ }
400
+ this.blocks.clear();
401
+ return [{ type: "response.completed", response: this.response("completed") }];
402
+ }
403
+ }
404
+ function firstText(item) {
405
+ const content = item.content;
406
+ if (!Array.isArray(content))
407
+ return null;
408
+ return content
409
+ .filter((part) => object(part)?.type === "output_text")
410
+ .map((part) => string(object(part)?.text) ?? "")
411
+ .join("") || null;
412
+ }
413
+ export function responsesToChatCompletion(mapper) {
414
+ const toolCalls = mapper.output
415
+ .filter((item) => item.type === "function_call")
416
+ .map((item, index) => ({ id: string(item.call_id) ?? item.id, type: "function", function: { name: string(item.name) ?? "tool", arguments: string(item.arguments) ?? "" }, index }));
417
+ const text = mapper.output.filter((item) => item.type === "message").map(firstText).filter((x) => x !== null).join("");
418
+ return {
419
+ id: `chatcmpl_${mapper.responseId.slice(5)}`,
420
+ object: "chat.completion",
421
+ created: mapper.created,
422
+ model: mapper.model,
423
+ choices: [{ index: 0, message: { role: "assistant", content: text || null, ...(toolCalls.length ? { tool_calls: toolCalls.map(({ index: _index, ...call }) => call) } : {}) }, finish_reason: toolCalls.length ? "tool_calls" : "stop" }],
424
+ usage: { prompt_tokens: mapper.usage.input_tokens, prompt_tokens_details: mapper.usage.input_tokens_details, completion_tokens: mapper.usage.output_tokens, total_tokens: mapper.usage.total_tokens },
425
+ };
426
+ }
427
+ /** Convert Responses mapper events to OpenAI Chat Completions streaming chunks. */
428
+ export function responsesEventsToChatChunks(events, mapper) {
429
+ const chunks = [];
430
+ const chatId = `chatcmpl_${mapper.responseId.slice(5)}`;
431
+ for (const event of events) {
432
+ const type = string(event.type);
433
+ const base = { id: chatId, object: "chat.completion.chunk", created: mapper.created, model: mapper.model };
434
+ if (type === "response.created")
435
+ chunks.push({ ...base, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
436
+ else if (type === "response.output_text.delta")
437
+ chunks.push({ ...base, choices: [{ index: 0, delta: { content: string(event.delta) ?? "" }, finish_reason: null }] });
438
+ else if (type === "response.output_item.added") {
439
+ const item = object(event.item);
440
+ if (item?.type === "function_call")
441
+ chunks.push({ ...base, choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: string(item.call_id) ?? string(item.id), type: "function", function: { name: string(item.name) ?? "tool", arguments: "" } }] }, finish_reason: null }] });
442
+ }
443
+ else if (type === "response.function_call_arguments.delta")
444
+ chunks.push({ ...base, choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: string(event.delta) ?? "" } }] }, finish_reason: null }] });
445
+ else if (type === "response.completed") {
446
+ const hasTools = mapper.output.some((item) => item.type === "function_call");
447
+ chunks.push({ ...base, choices: [{ index: 0, delta: {}, finish_reason: hasTools ? "tool_calls" : "stop" }], usage: { prompt_tokens: mapper.usage.input_tokens, prompt_tokens_details: mapper.usage.input_tokens_details, completion_tokens: mapper.usage.output_tokens, total_tokens: mapper.usage.total_tokens } });
448
+ }
449
+ }
450
+ return chunks;
451
+ }
452
+ export function formatSse(event) {
453
+ return `event: ${string(event.type) ?? "message"}\ndata: ${JSON.stringify(event)}\n\n`;
454
+ }
455
+ export function formatDataSse(event) {
456
+ return `data: ${JSON.stringify(event)}\n\n`;
457
+ }
@@ -0,0 +1,81 @@
1
+ // Append-only log file with size-based rotation. Never logs request bodies or headers.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ export class Logger {
5
+ fd = null;
6
+ bytes = 0;
7
+ writesSinceCheck = 0;
8
+ file;
9
+ maxBytes;
10
+ keep;
11
+ echo;
12
+ constructor(file, maxBytes, keep, echo) {
13
+ this.file = file;
14
+ this.maxBytes = maxBytes;
15
+ this.keep = keep;
16
+ this.echo = echo;
17
+ if (file) {
18
+ fs.mkdirSync(path.dirname(file), { recursive: true });
19
+ this.open();
20
+ }
21
+ }
22
+ open() {
23
+ if (!this.file)
24
+ return;
25
+ this.fd = fs.openSync(this.file, "a");
26
+ try {
27
+ this.bytes = fs.fstatSync(this.fd).size;
28
+ }
29
+ catch {
30
+ this.bytes = 0;
31
+ }
32
+ }
33
+ rotate() {
34
+ if (!this.file || this.fd === null)
35
+ return;
36
+ fs.closeSync(this.fd);
37
+ this.fd = null;
38
+ for (let i = this.keep - 1; i >= 1; i--) {
39
+ const from = `${this.file}.${i}`;
40
+ const to = `${this.file}.${i + 1}`;
41
+ if (fs.existsSync(from))
42
+ fs.renameSync(from, to);
43
+ }
44
+ if (this.keep >= 1)
45
+ fs.renameSync(this.file, `${this.file}.1`);
46
+ else
47
+ fs.unlinkSync(this.file);
48
+ this.open();
49
+ }
50
+ line(level, msg) {
51
+ const d = new Date();
52
+ const p = (n) => String(n).padStart(2, "0");
53
+ const ts = `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; // local time, like the app's own logs
54
+ const out = `${ts} ${level === "info" ? "" : level.toUpperCase() + " "}${msg}\n`;
55
+ if (this.echo || this.fd === null)
56
+ process.stdout.write(out);
57
+ if (this.fd !== null) {
58
+ try {
59
+ fs.writeSync(this.fd, out);
60
+ this.bytes += Buffer.byteLength(out);
61
+ if (++this.writesSinceCheck >= 50) {
62
+ this.writesSinceCheck = 0;
63
+ if (this.bytes >= this.maxBytes)
64
+ this.rotate();
65
+ }
66
+ }
67
+ catch {
68
+ /* logging must never take the router down */
69
+ }
70
+ }
71
+ }
72
+ info(msg) {
73
+ this.line("info", msg);
74
+ }
75
+ warn(msg) {
76
+ this.line("warn", msg);
77
+ }
78
+ error(msg) {
79
+ this.line("error", msg);
80
+ }
81
+ }
@@ -0,0 +1,74 @@
1
+ // Picker mode: put real model names into the Claude Desktop model picker.
2
+ //
3
+ // The picker list comes from claude.ai's bootstrap response (`model_selector_config`, an
4
+ // array of surfaces, each with `models[]`). The renderer forwards the chosen id to the app,
5
+ // which passes it to the CLI unchecked (docs/ARCHITECTURE.md §3). So when the app's own
6
+ // traffic goes through ClaudeRipple, we add our entries to the Code surface here.
7
+ //
8
+ // We do not know every field of a model entry, so we clone an existing enabled Claude
9
+ // entry as a template and override the identifying fields. Surfaces are matched by id
10
+ // heuristically (anything that is not the claude.ai chat surface); the ids we saw are
11
+ // logged once so the heuristic can be tightened.
12
+ export const BOOTSTRAP_PATHS = ["/edge-api/bootstrap", "/api/bootstrap"];
13
+ export function isBootstrapPath(path) {
14
+ return BOOTSTRAP_PATHS.some((p) => path === p || path.startsWith(p + "/") || path.startsWith(p + "?"));
15
+ }
16
+ // Surfaces whose sessions run through the Claude Code CLI (and therefore through the router).
17
+ // Measured 2026-09-13: app_start had chat, code, ccr, ccd, cowork, design, office_agent, chrome, voice,
18
+ // claude_science; the Code tab list matched code/ccd/ccr (9 entries). Chat & co. never reach the CLI.
19
+ const CLI_SURFACES = new Set(["code", "ccd", "ccr", "cowork"]);
20
+ function isClaudeEntry(m) {
21
+ return typeof m.id === "string" && m.id.startsWith("claude-");
22
+ }
23
+ function pickTemplate(models) {
24
+ return models.find((m) => isClaudeEntry(m) && !m.disabled && m.section !== "deprecated" && m.section !== "legacy") ?? models.find(isClaudeEntry) ?? null;
25
+ }
26
+ /** Mutates `json` in place; returns what was done for logging. */
27
+ export function injectPickerModels(json, extra, contextWindow) {
28
+ const result = { injected: 0, surfaces: [] };
29
+ const msc = json.model_selector_config;
30
+ if (!Array.isArray(msc) || extra.length === 0)
31
+ return result;
32
+ for (const surface of msc) {
33
+ if (!surface || typeof surface !== "object" || !Array.isArray(surface.models))
34
+ continue;
35
+ const id = String(surface.id ?? "");
36
+ result.surfaces.push({
37
+ id,
38
+ models: surface.models.map((m) => String(m.id ?? "?")),
39
+ entries: surface.models.map((m) => ({ id: String(m.id ?? "?"), name: typeof m.name === "string" ? m.name : String(m.id ?? "?") })),
40
+ });
41
+ if (!CLI_SURFACES.has(id))
42
+ continue;
43
+ const template = pickTemplate(surface.models);
44
+ if (!template)
45
+ continue;
46
+ const existing = new Set(surface.models.map((m) => m.id));
47
+ for (const e of extra) {
48
+ if (existing.has(e.model))
49
+ continue;
50
+ const entry = { ...structuredClone(template), id: e.model, name: e.name };
51
+ if (e.description)
52
+ entry.description = e.description;
53
+ else
54
+ delete entry.description;
55
+ for (const k of ["disabled", "disabled_reason", "notice", "selection_notice", "badge", "badge_tooltip", "tooltip", "minimum_tier", "is_default"])
56
+ delete entry[k];
57
+ entry.section = "main";
58
+ surface.models.push(entry);
59
+ result.injected++;
60
+ }
61
+ if (contextWindow) {
62
+ for (const key of ["context_window_by_model", "contextWindowByModel"]) {
63
+ const map = surface[key];
64
+ if (map && typeof map === "object")
65
+ for (const e of extra)
66
+ map[e.model] = contextWindow;
67
+ }
68
+ }
69
+ const recorded = result.surfaces[result.surfaces.length - 1];
70
+ recorded.models = surface.models.map((m) => String(m.id ?? "?"));
71
+ recorded.entries = surface.models.map((m) => ({ id: String(m.id ?? "?"), name: typeof m.name === "string" ? m.name : String(m.id ?? "?") }));
72
+ }
73
+ return result;
74
+ }