clauderipple 0.2.0 → 0.3.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 (43) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.ko.md +48 -4
  3. package/README.md +58 -4
  4. package/dist/cli/src/claude-auth.js +3 -2
  5. package/dist/cli/src/codex.js +20 -1
  6. package/dist/cli/src/hooks/agent-title.js +1 -1
  7. package/dist/cli/src/index.js +4 -4
  8. package/dist/cli/src/schtasks.js +43 -1
  9. package/dist/cli/src/settings.js +73 -6
  10. package/dist/router/src/admin.js +489 -56
  11. package/dist/router/src/agents.js +250 -0
  12. package/dist/router/src/bootstrap.js +24 -8
  13. package/dist/router/src/capabilities.js +214 -0
  14. package/dist/router/src/compat.js +5 -1
  15. package/dist/router/src/config.js +264 -11
  16. package/dist/router/src/index.js +14 -1
  17. package/dist/router/src/ingress/server.js +24 -14
  18. package/dist/router/src/picker.js +14 -6
  19. package/dist/router/src/pool.js +233 -0
  20. package/dist/router/src/presets.js +156 -1
  21. package/dist/router/src/providers/anthropic-account-pool.js +139 -0
  22. package/dist/router/src/providers/anthropic-accounts.js +281 -0
  23. package/dist/router/src/providers/chatgpt/catalog.js +97 -0
  24. package/dist/router/src/providers/chatgpt/index.js +343 -12
  25. package/dist/router/src/providers/chatgpt/sse.js +4 -0
  26. package/dist/router/src/providers/chatgpt/translate.js +156 -14
  27. package/dist/router/src/providers/claude-oauth.js +61 -19
  28. package/dist/router/src/providers/openai/index.js +55 -11
  29. package/dist/router/src/providers/openai/translate.js +82 -14
  30. package/dist/router/src/providers/retry.js +88 -0
  31. package/dist/router/src/proxy.js +697 -82
  32. package/dist/router/src/requestlog.js +5 -2
  33. package/dist/router/src/routing.js +151 -17
  34. package/dist/router/src/version.js +1 -1
  35. package/dist/router/src/websearch.js +307 -0
  36. package/dist/router/src/x509.js +7 -2
  37. package/dist/ui/app.js +740 -160
  38. package/dist/ui/i18n.js +14 -6
  39. package/dist/ui/index.html +18 -5
  40. package/dist/ui/presets-fallback.js +2 -0
  41. package/dist/ui/style.css +133 -9
  42. package/docs/ARCHITECTURE.md +381 -20
  43. package/package.json +5 -1
@@ -3,7 +3,7 @@
3
3
  // stable prompt prefix can retain their native prompt-cache behavior.
4
4
  import crypto from "node:crypto";
5
5
  import { clampEffort } from "../../compat.js";
6
- import { conversationKey, estimateTokens, normalizeSchema, systemText } from "../chatgpt/translate.js";
6
+ import { conversationKey, estimateTokens, normalizeSchema, serverToolNames, systemText, toolNameForResponses } from "../chatgpt/translate.js";
7
7
  import { identityPrefix, instructionsSuffix } from "../../identity.js";
8
8
  function textOf(content) {
9
9
  if (typeof content === "string")
@@ -11,10 +11,19 @@ function textOf(content) {
11
11
  if (!Array.isArray(content))
12
12
  return "";
13
13
  return content
14
- .flatMap((block) => block.type === "text" ? [String(block.text ?? "")] : block.type === "image" ? ["[image omitted]"] : [])
14
+ .filter((block) => block.type === "text")
15
+ .map((block) => String(block.text ?? ""))
15
16
  .filter((text) => text.length > 0)
16
17
  .join("\n");
17
18
  }
19
+ function imagesOf(content) {
20
+ if (!Array.isArray(content))
21
+ return [];
22
+ return content
23
+ .filter((block) => block.type === "image")
24
+ .map(imageUrl)
25
+ .filter((url) => url !== null);
26
+ }
18
27
  function imageUrl(block) {
19
28
  const source = block.source;
20
29
  if (!source || typeof source !== "object")
@@ -25,10 +34,17 @@ function imageUrl(block) {
25
34
  return source.url;
26
35
  return null;
27
36
  }
37
+ // Anthropic's server-side tools cannot run here; see the rule in the ChatGPT translator.
28
38
  function functionTools(tools) {
39
+ const dropped = serverToolNames(tools);
29
40
  return (tools ?? [])
30
- .filter((tool) => typeof tool.name === "string")
31
- .map((tool) => ({ type: "function", function: { name: tool.name, description: tool.description ?? "", parameters: normalizeSchema(tool.input_schema) } }));
41
+ .filter((tool) => typeof tool.name === "string" && !dropped.has(tool.name))
42
+ .map((tool) => ({ type: "function", function: { name: toolNameForResponses(tool.name), description: tool.description ?? "", parameters: normalizeSchema(tool.input_schema) } }));
43
+ }
44
+ /** A choice that named a dropped tool would force the model onto something no longer declared. */
45
+ function choiceSurvives(req) {
46
+ const choice = req.tool_choice;
47
+ return choice?.type === "tool" && !!choice.name && !serverToolNames(req.tools).has(choice.name);
32
48
  }
33
49
  function mapToolChoice(req, tools) {
34
50
  if (!tools.length)
@@ -40,7 +56,7 @@ function mapToolChoice(req, tools) {
40
56
  return "required";
41
57
  if (choice.type === "none")
42
58
  return "none";
43
- return choice.type === "tool" && choice.name ? { type: "function", function: { name: choice.name } } : undefined;
59
+ return choiceSurvives(req) ? { type: "function", function: { name: toolNameForResponses(choice.name) } } : undefined;
44
60
  }
45
61
  function mapResponsesToolChoice(req, tools) {
46
62
  if (!tools.length)
@@ -52,7 +68,7 @@ function mapResponsesToolChoice(req, tools) {
52
68
  return "required";
53
69
  if (choice.type === "none")
54
70
  return "none";
55
- return choice.type === "tool" && choice.name ? { type: "function", name: choice.name } : undefined;
71
+ return choiceSurvives(req) ? { type: "function", name: toolNameForResponses(choice.name) } : undefined;
56
72
  }
57
73
  /** The system text this provider should see: what it is, the caller's prompt, the configured addendum. */
58
74
  function systemWithIdentity(sys, opts) {
@@ -94,7 +110,7 @@ export function toChatMessages(req, opts) {
94
110
  if (block.type === "tool_use") {
95
111
  const call = block;
96
112
  knownCalls.add(call.id);
97
- calls.push({ id: call.id, type: "function", function: { name: call.name, arguments: typeof call.input === "string" ? call.input : JSON.stringify(call.input ?? {}) } });
113
+ calls.push({ id: call.id, type: "function", function: { name: toolNameForResponses(call.name), arguments: typeof call.input === "string" ? call.input : JSON.stringify(call.input ?? {}) } });
98
114
  }
99
115
  }
100
116
  if (text.some(Boolean) || calls.length)
@@ -124,12 +140,17 @@ export function toChatMessages(req, opts) {
124
140
  flush();
125
141
  const result = block;
126
142
  let output = textOf(result.content);
143
+ const images = imagesOf(result.content);
127
144
  if (result.is_error && !output)
128
145
  output = "Tool execution failed";
146
+ if (!output && images.length > 0)
147
+ output = "Tool returned image content.";
129
148
  if (knownCalls.has(result.tool_use_id))
130
149
  messages.push({ role: "tool", tool_call_id: result.tool_use_id, content: output });
131
150
  else
132
151
  parts.push({ type: "text", text: `[Tool result]\n${output}` });
152
+ for (const url of images)
153
+ parts.push({ type: "image_url", image_url: { url } });
133
154
  }
134
155
  }
135
156
  flush();
@@ -170,18 +191,23 @@ export function toResponsesInput(req) {
170
191
  flush();
171
192
  const call = block;
172
193
  knownCalls.add(call.id);
173
- input.push({ type: "function_call", call_id: call.id, name: call.name, arguments: typeof call.input === "string" ? call.input : JSON.stringify(call.input ?? {}) });
194
+ input.push({ type: "function_call", call_id: call.id, name: toolNameForResponses(call.name), arguments: typeof call.input === "string" ? call.input : JSON.stringify(call.input ?? {}) });
174
195
  }
175
196
  else if (block.type === "tool_result") {
176
197
  flush();
177
198
  const result = block;
178
199
  let output = textOf(result.content);
200
+ const images = imagesOf(result.content);
179
201
  if (result.is_error && !output)
180
202
  output = "Tool execution failed";
203
+ if (!output && images.length > 0)
204
+ output = "Tool returned image content.";
181
205
  if (knownCalls.has(result.tool_use_id))
182
206
  input.push({ type: "function_call_output", call_id: result.tool_use_id, output });
183
207
  else
184
208
  parts.push({ type: "input_text", text: `[Tool result]\n${output}` });
209
+ for (const image_url of images)
210
+ parts.push({ type: "input_image", image_url });
185
211
  }
186
212
  }
187
213
  flush();
@@ -232,8 +258,13 @@ export function toOpenAiRequest(req, opts) {
232
258
  if (choice)
233
259
  out.tool_choice = choice;
234
260
  }
261
+ // Responses will not accept a cap below sixteen, and answers a smaller one with a 400 naming the
262
+ // parameter. Anthropic Messages has no such floor, so a client that asks for a single token is
263
+ // asking something legal that this wire cannot express: Claude Code checks a model by requesting
264
+ // one token, and every switch to a Responses model failed on it (measured 2026-09-22). Raise the
265
+ // floor rather than pass the refusal on — a cap is a limit, and a larger one still obeys it.
235
266
  if (typeof req.max_tokens === "number")
236
- out.max_output_tokens = req.max_tokens;
267
+ out.max_output_tokens = Math.max(16, req.max_tokens);
237
268
  if (typeof req.temperature === "number")
238
269
  out.temperature = req.temperature;
239
270
  if (effort)
@@ -257,11 +288,24 @@ export class OpenAiStreamMapper {
257
288
  stopReason = "end_turn";
258
289
  model;
259
290
  startInput;
260
- constructor(model, startInput = 0) {
291
+ /** Mangled tool name → the name Claude Code knows, from `toolNameRestoreMap`. */
292
+ toolNames;
293
+ constructor(model, startInput = 0, toolNames = new Map()) {
261
294
  this.model = model;
262
295
  this.startInput = startInput;
296
+ this.toolNames = toolNames;
263
297
  }
264
298
  get isFinished() { return this.finished; }
299
+ /**
300
+ * Whether the vendor said the answer was over: `response.completed`/`response.incomplete` on
301
+ * Responses, a `finish_reason` on Chat. A stream that closes without either was cut off, and
302
+ * finishing it as `end_turn` hands the client an empty or half answer it accepts as final — the
303
+ * worker that "stalls and dies" (muse, 84 of 1,815 turns, 2026-09-18..22).
304
+ */
305
+ get completed() { return this.sawCompletion; }
306
+ sawCompletion = false;
307
+ /** The error `fail` reported, so a non-streaming caller can answer with it instead of a 200. */
308
+ failure;
265
309
  start() {
266
310
  if (this.started)
267
311
  return [];
@@ -294,11 +338,15 @@ export class OpenAiStreamMapper {
294
338
  const cached = typeof cachedValue === "number" && Number.isFinite(cachedValue) ? Math.max(0, cachedValue) : 0;
295
339
  this.usage = { input_tokens: Math.max(0, (input ?? this.usage.input_tokens + cached) - cached), output_tokens: Math.max(0, output ?? this.usage.output_tokens), cache_read_input_tokens: cached, cache_creation_input_tokens: 0 };
296
340
  }
341
+ /** The vendor echoes the name it was given; Claude Code only recognises the original. */
342
+ restore(name) {
343
+ return this.toolNames.get(name) ?? name;
344
+ }
297
345
  toolFor(index, delta) {
298
346
  let tool = this.content.find((block) => block.type === "tool_use" && block.index === index);
299
347
  if (!tool) {
300
348
  const id = typeof delta.id === "string" ? delta.id : `call_${crypto.randomBytes(8).toString("hex")}`;
301
- const name = typeof delta.function?.name === "string" ? delta.function.name : "tool";
349
+ const name = typeof delta.function?.name === "string" ? this.restore(delta.function.name) : "tool";
302
350
  tool = { type: "tool_use", id, name, input: {}, args: "", index };
303
351
  this.content.push(tool);
304
352
  this.sawTool = true;
@@ -306,7 +354,7 @@ export class OpenAiStreamMapper {
306
354
  if (typeof delta.id === "string")
307
355
  tool.id = delta.id;
308
356
  if (typeof delta.function?.name === "string")
309
- tool.name = delta.function.name;
357
+ tool.name = this.restore(delta.function.name);
310
358
  return tool;
311
359
  }
312
360
  feedChat(ev) {
@@ -317,6 +365,20 @@ export class OpenAiStreamMapper {
317
365
  const choices = Array.isArray(ev.choices) ? ev.choices : [];
318
366
  for (const choice of choices) {
319
367
  const delta = choice.delta ?? {};
368
+ // Thinking arrives before the answer, so it opens the first block of the turn. `reasoning` is
369
+ // accepted alongside `reasoning_content` because vendors on this wire disagree on the name.
370
+ const reasoning = typeof delta.reasoning_content === "string" && delta.reasoning_content ? delta.reasoning_content
371
+ : typeof delta.reasoning === "string" && delta.reasoning ? delta.reasoning : "";
372
+ if (reasoning) {
373
+ if (!this.open || this.open.kind !== "thinking") {
374
+ this.content.push({ type: "thinking", thinking: "" });
375
+ out.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }));
376
+ }
377
+ const last = this.content[this.content.length - 1];
378
+ if (last?.type === "thinking")
379
+ last.thinking += reasoning;
380
+ out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.open.index, delta: { type: "thinking_delta", thinking: reasoning } } });
381
+ }
320
382
  if (typeof delta.content === "string" && delta.content) {
321
383
  if (!this.open || this.open.kind !== "text") {
322
384
  this.content.push({ type: "text", text: "" });
@@ -343,8 +405,10 @@ export class OpenAiStreamMapper {
343
405
  }
344
406
  }
345
407
  }
346
- if (typeof choice.finish_reason === "string" && choice.finish_reason)
408
+ if (typeof choice.finish_reason === "string" && choice.finish_reason) {
347
409
  this.finishReason = choice.finish_reason;
410
+ this.sawCompletion = true;
411
+ }
348
412
  }
349
413
  return out;
350
414
  }
@@ -452,6 +516,7 @@ export class OpenAiStreamMapper {
452
516
  this.setUsage(response?.usage);
453
517
  if (response?.incomplete_details?.reason === "max_output_tokens")
454
518
  this.finishReason = "length";
519
+ this.sawCompletion = true;
455
520
  }
456
521
  else if (type === "response.failed" || type === "error") {
457
522
  const error = (type === "error" ? ev.error : ev.response?.error);
@@ -482,7 +547,10 @@ export class OpenAiStreamMapper {
482
547
  if (this.finished)
483
548
  return [];
484
549
  this.finished = true;
485
- const type = code === "rate_limit_exceeded" ? "rate_limit_error" : "api_error";
550
+ // `overloaded_error` is the one mid-stream error Claude Code retries on its own (CLI 2.1.278
551
+ // matches `"type":"overloaded_error"` in the message); `api_error` ends the turn for good.
552
+ const type = code === "rate_limit_exceeded" ? "rate_limit_error" : code === "server_is_overloaded" ? "overloaded_error" : "api_error";
553
+ this.failure = { type, message };
486
554
  return [...this.start(), ...this.closeBlock(), { event: "error", data: { type: "error", error: { type, message } } }];
487
555
  }
488
556
  message() {
@@ -0,0 +1,88 @@
1
+ // Retrying a translated provider before its turn is committed.
2
+ //
3
+ // A provider behind a relay fails for reasons that have nothing to do with the request: the relay's
4
+ // own upstream breaks, an edge hiccups, a connection is refused. The user then sees an error, asks
5
+ // again, and the identical request succeeds — which is the whole evidence that the request was
6
+ // never the problem (2026-09-21, OpenCode Go relaying DeepSeek: every failing turn answered 403
7
+ // `Upstream request failed`, and every retry by hand worked).
8
+ //
9
+ // Only a failure another attempt could answer is retried. `classify` already owns that judgement
10
+ // for what failed — a 400 is our request being wrong and is refused everywhere, a 403 wrapping a
11
+ // relay's broken upstream is not about us at all. Reusing it keeps one vocabulary rather than two.
12
+ //
13
+ // It is not quite the same question, though, and the difference is the whole of `isWorthRetrying`.
14
+ // `classify` answers it for a *pool*: a 401 is retryable because another credential could answer.
15
+ // Here there is only the one credential the adapter already holds, so a 401 would be re-sent — nine
16
+ // times — to the key that just refused it, and a 429 to the limit that just refused it. Those fail
17
+ // identically on every attempt and only delay the error the user eventually sees. What is left is
18
+ // `transient`: a connect failure, a 502/503/504, a relay with a broken upstream. Those are the ones
19
+ // where the identical request genuinely succeeds a moment later.
20
+ //
21
+ // **Nothing may have been written to the client.** This is called around the `fetch` alone, before
22
+ // any status or byte is sent, so a retry cannot splice two answers together. Once the first byte is
23
+ // out the turn is committed and this must not be used.
24
+ import { setTimeout as sleep } from "node:timers/promises";
25
+ import { classify } from "../pool.js";
26
+ /** Attempts in total — one ask plus nine retries, matching the CLI's own ceiling (2026-09-21). */
27
+ export const DEFAULT_ATTEMPTS = 10;
28
+ const RETRY_BASE_MS = 250;
29
+ const RETRY_MAX_MS = 2_000;
30
+ /**
31
+ * 250ms, 500ms, 1s, then 2s each. A hiccup is absorbed in a blink, which is the observed case; a
32
+ * sustained outage is not hammered, and the ceiling is deliberately low so a dead relay fails to the
33
+ * user in seconds rather than tens of them.
34
+ */
35
+ function backoffMs(attempt) {
36
+ return Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** (attempt - 1));
37
+ }
38
+ /** Whether asking the same provider the identical request again could plausibly work. */
39
+ function isWorthRetrying(status, text) {
40
+ const verdict = classify(status, undefined, text);
41
+ return verdict.retryable && verdict.kind === "transient";
42
+ }
43
+ /** Wait between attempts, but stop immediately when the client abandons the turn. */
44
+ async function wait(ms, signal) {
45
+ await sleep(ms, undefined, signal ? { signal } : undefined);
46
+ }
47
+ /**
48
+ * Ask until an answer arrives, a status says asking again is pointless, or the attempt ceiling is
49
+ * reached. The last `Response` is returned either way, so the caller still reports the vendor's own
50
+ * refusal when retrying was not going to help.
51
+ *
52
+ * `init.body` must be reusable (a string or buffer). A stream would already have been consumed.
53
+ */
54
+ export async function fetchWithRetry(url, init, opts) {
55
+ const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
56
+ for (let n = 1; n <= attempts; n++) {
57
+ let res;
58
+ try {
59
+ res = await fetch(url, init);
60
+ }
61
+ catch (e) {
62
+ // An abort is the client going away, not the provider: retrying it would keep working on a
63
+ // turn nobody is waiting for.
64
+ if (init.signal?.aborted)
65
+ throw e;
66
+ if (n === attempts)
67
+ throw e;
68
+ opts.log(`attempt ${n}/${attempts} could not connect (${e.message}); retrying`);
69
+ await wait(backoffMs(n), init.signal);
70
+ continue;
71
+ }
72
+ if (res.ok)
73
+ return res;
74
+ // A 403's meaning is in its body and a relay reports its own broken upstream there; every other
75
+ // status is judged from the status alone. Cloned rather than read, so the caller still gets the
76
+ // body to show when this attempt turns out to be the last one.
77
+ const text = res.status === 403 ? await res.clone().text().catch(() => "") : "";
78
+ if (!isWorthRetrying(res.status, text) || n === attempts)
79
+ return res;
80
+ // This response will never reach the caller. Cancel it before opening another request so a relay
81
+ // with a slow or unbounded error body cannot hold one connection per discarded attempt.
82
+ await res.body?.cancel().catch(() => { });
83
+ opts.log(`attempt ${n}/${attempts}: upstream ${res.status} (transient); retrying`);
84
+ await wait(backoffMs(n), init.signal);
85
+ }
86
+ // Unreachable: the loop returns or throws on its last iteration.
87
+ throw new Error("retry loop fell through");
88
+ }