clauderipple 0.2.0 → 0.3.1

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 (44) hide show
  1. package/CHANGELOG.md +96 -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/cli/src/tray.js +17 -2
  11. package/dist/router/src/admin.js +489 -56
  12. package/dist/router/src/agents.js +250 -0
  13. package/dist/router/src/bootstrap.js +24 -8
  14. package/dist/router/src/capabilities.js +214 -0
  15. package/dist/router/src/compat.js +5 -1
  16. package/dist/router/src/config.js +264 -11
  17. package/dist/router/src/index.js +14 -1
  18. package/dist/router/src/ingress/server.js +24 -14
  19. package/dist/router/src/picker.js +14 -6
  20. package/dist/router/src/pool.js +233 -0
  21. package/dist/router/src/presets.js +163 -2
  22. package/dist/router/src/providers/anthropic-account-pool.js +139 -0
  23. package/dist/router/src/providers/anthropic-accounts.js +281 -0
  24. package/dist/router/src/providers/chatgpt/catalog.js +97 -0
  25. package/dist/router/src/providers/chatgpt/index.js +343 -12
  26. package/dist/router/src/providers/chatgpt/sse.js +4 -0
  27. package/dist/router/src/providers/chatgpt/translate.js +156 -14
  28. package/dist/router/src/providers/claude-oauth.js +61 -19
  29. package/dist/router/src/providers/openai/index.js +55 -11
  30. package/dist/router/src/providers/openai/translate.js +82 -14
  31. package/dist/router/src/providers/retry.js +88 -0
  32. package/dist/router/src/proxy.js +713 -82
  33. package/dist/router/src/requestlog.js +5 -2
  34. package/dist/router/src/routing.js +151 -17
  35. package/dist/router/src/version.js +1 -1
  36. package/dist/router/src/websearch.js +307 -0
  37. package/dist/router/src/x509.js +7 -2
  38. package/dist/ui/app.js +740 -160
  39. package/dist/ui/i18n.js +14 -6
  40. package/dist/ui/index.html +18 -5
  41. package/dist/ui/presets-fallback.js +2 -0
  42. package/dist/ui/style.css +133 -9
  43. package/docs/ARCHITECTURE.md +381 -20
  44. package/package.json +5 -1
@@ -4,7 +4,8 @@
4
4
  // next turn's `input`. Claude Code resends the whole history every turn, so the mapping
5
5
  // has to be deterministic and must not inject anything that varies (timestamps, salts,
6
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.
7
+ // same reason. `prompt_cache_key` is derived from the conversation's first user message — or,
8
+ // for a request that is not a conversation at all, from its system prompt (see `conversationKey`).
8
9
  import crypto from "node:crypto";
9
10
  import { identityLine } from "../../identity.js";
10
11
  function blockText(c) {
@@ -13,10 +14,39 @@ function blockText(c) {
13
14
  if (!Array.isArray(c))
14
15
  return "";
15
16
  return c
16
- .map((b) => (b.type === "text" ? b.text : b.type === "image" ? "[image omitted]" : ""))
17
+ .map((b) => (b.type === "text" ? b.text : b.type === "image" ? "[image]" : ""))
17
18
  .filter((s) => s.length > 0)
18
19
  .join("\n");
19
20
  }
21
+ function toolResultText(content) {
22
+ if (typeof content === "string")
23
+ return content;
24
+ if (!Array.isArray(content))
25
+ return "";
26
+ return content
27
+ .filter((block) => block.type === "text")
28
+ .map((block) => String(block.text ?? ""))
29
+ .filter(Boolean)
30
+ .join("\n");
31
+ }
32
+ function imageUrl(block) {
33
+ const source = block.source;
34
+ if (!source || typeof source !== "object")
35
+ return null;
36
+ if (source.type === "base64" && typeof source.data === "string")
37
+ return `data:${typeof source.media_type === "string" ? source.media_type : "image/png"};base64,${source.data}`;
38
+ if (source.type === "url" && typeof source.url === "string")
39
+ return source.url;
40
+ return null;
41
+ }
42
+ function toolResultImages(content) {
43
+ if (!Array.isArray(content))
44
+ return [];
45
+ return content
46
+ .filter((block) => block.type === "image")
47
+ .map(imageUrl)
48
+ .filter((url) => url !== null);
49
+ }
20
50
  // Claude Code's first system block is Anthropic billing telemetry ("x-anthropic-billing-header: …
21
51
  // cch=<hash> …") whose hash changes on every turn. Left in, it sits at the top of `instructions`
22
52
  // and invalidates the prompt cache for everything after it (measured 2026-09-13: cached_tokens
@@ -32,16 +62,55 @@ export function systemText(system) {
32
62
  .filter((s) => s.length > 0 && !BILLING_BLOCK.test(s))
33
63
  .join("\n\n");
34
64
  }
65
+ // A request with no `metadata.user_id` and one lone user turn is not a conversation: it is one of
66
+ // the things the CLI sends beside one — the web-search side request, a title, a summary. Its single
67
+ // message differs every time, so seeding on it minted a fresh key per request and the prefix all of
68
+ // them share (system prompt, tool definitions) was never cached: measured `cached_tokens: 0` on all
69
+ // 68 smallFast calls in a day's log, where the same wire asked twice under one key returns 94%.
70
+ // The system prompt is the part of such a request that does not vary, so it is what names the class.
71
+ // A real conversation that simply was not given metadata — the OpenAI ingress builds none — keeps
72
+ // the old seed from its second turn on; only its opening turn shares the class key, and what that
73
+ // turn reads there is the same fixed prefix it would have paid for anyway.
35
74
  export function conversationKey(req) {
75
+ const userId = req.metadata?.user_id;
36
76
  const first = req.messages.find((m) => m.role === "user");
37
- const seed = `${req.metadata?.user_id ?? ""}\n${first ? blockText(first.content).slice(0, 4000) : ""}`;
77
+ // `side` keeps the two seeds in separate spaces: without it a conversation whose opening message
78
+ // happened to equal a system prompt would land on that class's key.
79
+ const seed = !userId && req.messages.length <= 1
80
+ ? `side\n${systemText(req.system).slice(0, 4000)}`
81
+ : `${userId ?? ""}\n${first ? blockText(first.content).slice(0, 4000) : ""}`;
38
82
  return crypto.createHash("sha256").update(seed).digest("hex").slice(0, 32);
39
83
  }
84
+ /**
85
+ * The conversation key as a UUID, which is what the backend wants a conversation to be called.
86
+ *
87
+ * `prompt_cache_key` alone stopped earning a prompt cache between 2026-09-15 and 2026-09-19: five
88
+ * turns with byte-identical instructions, tools and input prefix, 3–6s apart under one key, all
89
+ * came back `cached_tokens: 0` and `cache_write_tokens: 0` (2026-09-20, GPT-6 Astra; the same
90
+ * adapter read 93% on 2026-09-13). The Codex CLI got 99.8% on the same day. Bisecting its request
91
+ * against ours: a stable per-conversation id in `session-id`/`thread-id`, `x-client-request-id`
92
+ * or body `client_metadata` turns the cache on (any one of them; `x-codex-turn-metadata` alone
93
+ * does not, nor does echoing `x-codex-turn-state` alone). The backend now keys the cache on the
94
+ * conversation's identity, not on the cache key. We send the same set the CLI sends, derived
95
+ * from the same seed the cache key is, so a conversation is one thing everywhere.
96
+ */
97
+ export function conversationId(req) {
98
+ return conversationKey(req).replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, "$1-$2-$3-$4-$5");
99
+ }
40
100
  // The Codex backend validates every `pattern` in a tool schema with a regex engine that has no
41
101
  // lookaround or backreferences; one such pattern anywhere fails the whole request with
42
102
  // "Invalid schema for function 'X': '...' is not a 'regex'" (measured 2026-09-13 with the
43
103
  // Claude Code Artifact tool). Those patterns are dropped; the client validates inputs itself.
44
- const UNSUPPORTED_REGEX = /\(\?[=!<]|\\[1-9]/;
104
+ //
105
+ // `\0` belongs in the same class and was missed, because the rule was written as the
106
+ // backreferences 1-9 rather than as the escapes a strict engine will not take. The Artifact tool
107
+ // declares `file_paths` items as `^[^\0]*$` — any string without a NUL — and OpenCode Go refuses
108
+ // the whole request over it: `Invalid JSON schema: {…"pattern":"^[^\\0]*$"…} is not valid under
109
+ // any of the schemas listed in the 'anyOf' keyword` (measured 2026-09-19 against
110
+ // muse-spark-1.3-contributor; the same schema with the pattern removed is accepted, and
111
+ // `\d`, `propertyNames`, nested `anyOf`, `const`, `format` and `$schema` all pass, so this escape
112
+ // is the whole of it). Two backends now, which is why the rule is the escape class, not a list.
113
+ const UNSUPPORTED_REGEX = /\(\?[=!<]|\\[0-9]/;
45
114
  export function unsupportedPattern(p) {
46
115
  return UNSUPPORTED_REGEX.test(p);
47
116
  }
@@ -68,6 +137,62 @@ export function normalizeSchema(s) {
68
137
  delete out.required;
69
138
  return out;
70
139
  }
140
+ // The Responses API constrains a function name to `^[a-zA-Z0-9_-]{1,64}$`, and one name that
141
+ // breaks it fails the whole request, not just that tool. Claude Code names MCP tools
142
+ // `mcp__<server>__<tool>`, and a claude.ai connector's server name is a UUID, so the prefix alone
143
+ // eats 43 characters: names past 64 are routine, not exotic.
144
+ //
145
+ // The mangling has to be deterministic, because the same tool list is resent every turn and a name
146
+ // that moved would break the cache prefix (see the header rule). Hash of the original, not a
147
+ // counter or a salt. Names already inside the constraint are returned untouched, so a session with
148
+ // no MCP tools produces byte-identical output to before this existed.
149
+ const TOOL_NAME_OK = /^[A-Za-z0-9_-]{1,64}$/;
150
+ export function toolNameForResponses(name) {
151
+ if (TOOL_NAME_OK.test(name))
152
+ return name;
153
+ // Sanitising alone would let `a.b` and `a-b` collapse onto the same name, so every mangled name
154
+ // carries the hash: 55 + "_" + 8 = 64 exactly.
155
+ const hash = crypto.createHash("sha256").update(name).digest("hex").slice(0, 8);
156
+ return `${name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 55)}_${hash}`;
157
+ }
158
+ // Anthropic's server-side tools (`web_search` and friends) are run by Anthropic, not by the model
159
+ // holding them. Declaring one to a translated provider offers a tool that cannot possibly execute:
160
+ // the model calls it, nothing answers, and the turn comes back empty with no error anywhere — the
161
+ // worst failure shape there is. `compat.ts` has dropped them on the anthropic-compatible path from
162
+ // the start; the rule belongs here too, and is the same rule, not a second one.
163
+ //
164
+ // These do not arrive today. Claude Code runs its web search as a separate side request on a fixed
165
+ // small model — measured 2026-09-17: an Opus session and a DeepSeek-routed session both sent it to
166
+ // `claude-haiku-4-5`, which passes through to Anthropic and never reaches an adapter. Which model
167
+ // that is, is a server-side flag we do not own, so this guards the day it changes.
168
+ export function isServerTool(tool) {
169
+ const type = tool.type;
170
+ return type !== undefined && type !== "custom";
171
+ }
172
+ /** Names of the tools this request declares that no translated provider can run. */
173
+ export function serverToolNames(tools) {
174
+ const names = new Set();
175
+ for (const t of tools ?? [])
176
+ if (typeof t.name === "string" && isServerTool(t))
177
+ names.add(t.name);
178
+ return names;
179
+ }
180
+ /**
181
+ * Mangled name → original, for the tools declared in this request. The model echoes the name it was
182
+ * given and Claude Code matches `tool_use.name` against its own tool list, so the response path has
183
+ * to undo the mangling. Empty when nothing needed mangling.
184
+ */
185
+ export function toolNameRestoreMap(req) {
186
+ const map = new Map();
187
+ for (const t of req.tools ?? []) {
188
+ if (typeof t.name !== "string" || isServerTool(t))
189
+ continue;
190
+ const mangled = toolNameForResponses(t.name);
191
+ if (mangled !== t.name)
192
+ map.set(mangled, t.name);
193
+ }
194
+ return map;
195
+ }
71
196
  export function toResponsesRequest(req, opts) {
72
197
  const parts = [];
73
198
  // Effort is named here because the model cannot see its own reasoning setting and will otherwise guess.
@@ -122,19 +247,24 @@ export function toResponsesRequest(req, opts) {
122
247
  flush();
123
248
  const tu = b;
124
249
  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 ?? {}) });
250
+ input.push({ type: "function_call", call_id: tu.id, name: toolNameForResponses(tu.name), arguments: typeof tu.input === "string" ? tu.input : JSON.stringify(tu.input ?? {}) });
126
251
  break;
127
252
  }
128
253
  case "tool_result": {
129
254
  flush();
130
255
  const tr = b;
131
- let out = blockText(tr.content);
256
+ let out = toolResultText(tr.content);
257
+ const images = toolResultImages(tr.content);
132
258
  if (tr.is_error && !out)
133
259
  out = "Tool execution failed";
260
+ if (!out && images.length > 0)
261
+ out = "Tool returned image content.";
134
262
  if (knownCalls.has(tr.tool_use_id))
135
263
  input.push({ type: "function_call_output", call_id: tr.tool_use_id, output: out });
136
264
  else
137
265
  pending.push({ type: "input_text", text: `[Tool result]\n${out}` });
266
+ for (const image_url of images)
267
+ pending.push({ type: "input_image", image_url });
138
268
  break;
139
269
  }
140
270
  default:
@@ -144,9 +274,10 @@ export function toResponsesRequest(req, opts) {
144
274
  }
145
275
  flush();
146
276
  }
277
+ const dropped = serverToolNames(req.tools);
147
278
  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 }));
279
+ .filter((t) => typeof t.name === "string" && !dropped.has(t.name))
280
+ .map((t) => ({ type: "function", name: toolNameForResponses(t.name), description: t.description ?? "", parameters: normalizeSchema(t.input_schema), strict: false }));
150
281
  let tool_choice;
151
282
  const tc = req.tool_choice;
152
283
  if (tools.length > 0) {
@@ -156,8 +287,9 @@ export function toResponsesRequest(req, opts) {
156
287
  tool_choice = "required";
157
288
  else if (tc.type === "none")
158
289
  tool_choice = "none";
159
- else if (tc.type === "tool" && tc.name)
160
- tool_choice = { type: "function", name: tc.name };
290
+ // A choice that named a dropped tool would force the model onto something no longer declared.
291
+ else if (tc.type === "tool" && tc.name && !dropped.has(tc.name))
292
+ tool_choice = { type: "function", name: toolNameForResponses(tc.name) };
161
293
  }
162
294
  const out = {
163
295
  model: opts.model,
@@ -167,7 +299,8 @@ export function toResponsesRequest(req, opts) {
167
299
  text: { verbosity: "medium" },
168
300
  store: false,
169
301
  stream: true,
170
- prompt_cache_key: conversationKey(req),
302
+ prompt_cache_key: conversationId(req),
303
+ client_metadata: { session_id: conversationId(req), thread_id: conversationId(req), turn_id: crypto.randomUUID(), "x-codex-window-id": `${conversationId(req)}:0` },
171
304
  };
172
305
  if (tools.length > 0) {
173
306
  out.tools = tools;
@@ -199,14 +332,19 @@ export class StreamMapper {
199
332
  stopReason = "end_turn";
200
333
  /** Input-token figure announced in message_start (the real one only arrives with response.completed). */
201
334
  startInput;
202
- constructor(model, startInput = 0) {
335
+ /** Mangled tool name → the name Claude Code knows, from `toolNameRestoreMap`. */
336
+ toolNames;
337
+ constructor(model, startInput = 0, toolNames = new Map()) {
203
338
  this.model = model;
204
339
  this.startInput = startInput;
340
+ this.toolNames = toolNames;
205
341
  this.messageId = `msg_${crypto.randomBytes(12).toString("hex")}`;
206
342
  }
207
343
  get isFinished() {
208
344
  return this.finished;
209
345
  }
346
+ /** The error `fail` reported, so a non-streaming caller can answer with it instead of a 200. */
347
+ failure;
210
348
  start() {
211
349
  if (this.started)
212
350
  return [];
@@ -255,8 +393,11 @@ export class StreamMapper {
255
393
  if (item.type === "function_call") {
256
394
  this.sawToolCall = true;
257
395
  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));
396
+ // The model echoes the mangled name; Claude Code only recognises the original.
397
+ const called = item.name ?? "tool";
398
+ const name = this.toolNames.get(called) ?? called;
399
+ this.content.push({ type: "tool_use", id, name, input: {}, _args: "" });
400
+ out.push(...this.openBlock("tool", { type: "tool_use", id, name, input: {} }, item.id));
260
401
  }
261
402
  else if (item.type === "reasoning") {
262
403
  this.content.push({ type: "thinking", thinking: "", signature: "" });
@@ -366,6 +507,7 @@ export class StreamMapper {
366
507
  return [];
367
508
  this.finished = true;
368
509
  const type = code === "server_is_overloaded" ? "overloaded_error" : code === "rate_limit_exceeded" || code === "usage_limit_reached" ? "rate_limit_error" : "api_error";
510
+ this.failure = { type, message };
369
511
  return [...this.start(), ...this.closeBlock(), { event: "error", data: { type: "error", error: { type, message } } }];
370
512
  }
371
513
  /** Non-streaming body once finished. */
@@ -6,12 +6,13 @@
6
6
  // Wire facts are behaviorally measured against Claude Code's public client and are not an
7
7
  // Anthropic guarantee (docs/ARCHITECTURE.md §4b). The browser is sent to claude.ai; the code comes
8
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.
9
+ // the manual "paste the code" page (`code#state`). Grants are stored only in the 0600
10
+ // <home>/claude-accounts.json pool and never logged or returned by the admin API.
11
11
  import crypto from "node:crypto";
12
12
  import http from "node:http";
13
13
  import net from "node:net";
14
- import { saveClaudeOAuthFile } from "./anthropic-token-file.js";
14
+ import { redactErrorText } from "../redact.js";
15
+ import { saveClaudeOAuthAccount } from "./anthropic-accounts.js";
15
16
  // Endpoints, scopes and body shapes are what Claude Code 2.1.271 sends (read from its binary on
16
17
  // 2026-09-16): the claude.ai login is `claude.com/cai/oauth/authorize`, tokens come from
17
18
  // `platform.claude.com`. The previous `claude.ai/oauth/authorize` answers "Invalid request format".
@@ -35,13 +36,32 @@ export const CLAUDE_OAUTH = {
35
36
  function base64url(buffer) {
36
37
  return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
37
38
  }
39
+ export class ClaudeOAuthTokenError extends Error {
40
+ status;
41
+ code;
42
+ needsReauth;
43
+ constructor(message, status, code) {
44
+ super(message);
45
+ this.name = "ClaudeOAuthTokenError";
46
+ this.status = status;
47
+ this.code = code;
48
+ this.needsReauth = status === 400 && code !== null && /^(invalid_grant|invalid_token|access_denied|expired_token)$/.test(code);
49
+ }
50
+ }
38
51
  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
- });
52
+ let response;
53
+ try {
54
+ response = await fetchImpl(CLAUDE_OAUTH.tokenUrl, {
55
+ method: "POST",
56
+ headers: { "content-type": "application/json", accept: "application/json" },
57
+ body: JSON.stringify(body),
58
+ signal: AbortSignal.timeout(30_000),
59
+ });
60
+ }
61
+ catch (error) {
62
+ const raw = error instanceof Error ? error.message : String(error);
63
+ throw new Error(`Claude token endpoint request failed: ${redactErrorText(raw, Object.values(body), 300)}`);
64
+ }
45
65
  const text = await response.text();
46
66
  let parsed = {};
47
67
  try {
@@ -51,8 +71,10 @@ async function postToken(fetchImpl, body) {
51
71
  // A non-JSON body is reported through the status below.
52
72
  }
53
73
  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}`);
74
+ const code = typeof parsed.error === "string" ? parsed.error : null;
75
+ const rawDetail = typeof parsed.error_description === "string" ? parsed.error_description : code ?? `HTTP ${response.status}`;
76
+ const detail = redactErrorText(rawDetail, Object.values(body), 300);
77
+ throw new ClaudeOAuthTokenError(`Claude sign-in was refused by the token endpoint: ${detail}`, response.status, code);
56
78
  }
57
79
  return parsed;
58
80
  }
@@ -65,7 +87,18 @@ function grantFrom(reply, now, previousRefresh) {
65
87
  // A missing or absurd expires_in is treated as one hour: better an early refresh than a token
66
88
  // believed valid forever.
67
89
  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 };
90
+ const accountId = typeof reply.account?.uuid === "string" && reply.account.uuid.length > 0 ? reply.account.uuid : undefined;
91
+ const emailValue = typeof reply.account?.email_address === "string"
92
+ ? reply.account.email_address.replace(/[\x00-\x1f\x7f]/g, "").trim().slice(0, 320)
93
+ : "";
94
+ const email = emailValue || undefined;
95
+ return {
96
+ accessToken: reply.access_token,
97
+ refreshToken,
98
+ expiresAt: now + expiresIn * 1000,
99
+ ...(accountId ? { accountId } : {}),
100
+ ...(email ? { email } : {}),
101
+ };
69
102
  }
70
103
  /** Exchanges a refresh token; the caller persists the result. */
71
104
  export async function refreshClaudeOAuth(refreshToken, options = {}) {
@@ -89,16 +122,17 @@ export class ClaudeOAuthSession {
89
122
  settle;
90
123
  grant;
91
124
  timer = null;
125
+ exchanging = false;
92
126
  finished = false;
93
- stateSnapshot = { running: false, url: null, manual: false, startedAt: null, finishedAt: null, ok: null, error: null };
127
+ stateSnapshot = { running: false, url: null, manual: false, startedAt: null, finishedAt: null, ok: null, error: null, account: null };
94
128
  constructor(options) {
95
129
  this.options = options;
96
130
  this.grant = new Promise((resolve, reject) => {
97
131
  this.settle = { resolve, reject };
98
132
  });
99
133
  this.result = this.grant.then((grant) => {
100
- saveClaudeOAuthFile(options.home, grant);
101
- this.stateSnapshot = { ...this.stateSnapshot, running: false, finishedAt: new Date().toISOString(), ok: true };
134
+ const account = saveClaudeOAuthAccount(options.home, grant);
135
+ this.stateSnapshot = { ...this.stateSnapshot, running: false, finishedAt: new Date().toISOString(), ok: true, account };
102
136
  }, (error) => {
103
137
  this.stateSnapshot = { ...this.stateSnapshot, running: false, finishedAt: new Date().toISOString(), ok: false, error: error.message };
104
138
  throw error;
@@ -128,11 +162,13 @@ export class ClaudeOAuthSession {
128
162
  }).toString();
129
163
  this.timer = setTimeout(() => this.fail(new Error("Claude sign-in timed out; start it again")), CLAUDE_OAUTH.timeoutMs);
130
164
  this.timer.unref();
131
- this.stateSnapshot = { running: true, url: url.toString(), manual, startedAt: new Date().toISOString(), finishedAt: null, ok: null, error: null };
165
+ this.stateSnapshot = { running: true, url: url.toString(), manual, startedAt: new Date().toISOString(), finishedAt: null, ok: null, error: null, account: null };
132
166
  return { url: url.toString(), manual };
133
167
  }
134
168
  /** Accepts `code`, `code#state`, or the full redirect URL the browser landed on. */
135
169
  async submitCode(input) {
170
+ if (this.finished || this.exchanging)
171
+ return;
136
172
  let code = input.trim();
137
173
  let state = null;
138
174
  try {
@@ -179,8 +215,9 @@ export class ClaudeOAuthSession {
179
215
  return;
180
216
  }
181
217
  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}`));
218
+ const detail = redactErrorText(error ?? "missing authorization code", [], 200);
219
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end(`Claude sign-in failed: ${detail}. You can close this tab.`);
220
+ this.fail(new Error(`Claude refused the sign-in: ${detail}`));
184
221
  return;
185
222
  }
186
223
  res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("ClaudeRipple: Claude subscription connected. You can close this tab.");
@@ -196,6 +233,10 @@ export class ClaudeOAuthSession {
196
233
  const six = http.createServer(handler);
197
234
  six.once("error", () => { });
198
235
  six.listen(this.port, "::1", () => {
236
+ if (this.finished) {
237
+ six.close();
238
+ return;
239
+ }
199
240
  this.server6 = six;
200
241
  });
201
242
  resolve(true);
@@ -203,8 +244,9 @@ export class ClaudeOAuthSession {
203
244
  });
204
245
  }
205
246
  async exchange(code) {
206
- if (this.finished)
247
+ if (this.finished || this.exchanging)
207
248
  return;
249
+ this.exchanging = true;
208
250
  try {
209
251
  const reply = await postToken(this.options.fetch ?? fetch, {
210
252
  grant_type: "authorization_code",
@@ -4,11 +4,17 @@
4
4
  import http from "node:http";
5
5
  import { credentialHeaderValues, redactErrorText } from "../../redact.js";
6
6
  import { SseParser } from "../chatgpt/sse.js";
7
+ import { fetchWithRetry } from "../retry.js";
7
8
  import { estimateTokens, formatSse, OpenAiStreamMapper, toOpenAiRequest } from "./translate.js";
9
+ import { conversationKey, serverToolNames, toolNameRestoreMap } from "../chatgpt/translate.js";
8
10
  const PING_MS = 15_000;
9
11
  function anthropicError(status, type, message) {
10
12
  return { status, body: JSON.stringify({ type: "error", error: { type, message } }) };
11
13
  }
14
+ /** The HTTP status Anthropic uses for each error type a mapper can report. */
15
+ function failureStatus(failure) {
16
+ return failure?.type === "overloaded_error" ? 529 : failure?.type === "rate_limit_error" ? 429 : 502;
17
+ }
12
18
  function vendorMessage(text) {
13
19
  try {
14
20
  const json = JSON.parse(text);
@@ -19,10 +25,22 @@ function vendorMessage(text) {
19
25
  catch { /* retain the response text */ }
20
26
  return text.replace(/\s+/g, " ").trim().slice(0, 500) || "upstream request failed";
21
27
  }
28
+ /** Whether a 403 body is about the credential rather than about what the account may do. */
29
+ export function looksLikeAuth(text) {
30
+ return /\b(api[_ -]?key|token|credential|unauthori[sz]ed|authentication|invalid[_ -]?key|expired)\b/i.test(text);
31
+ }
22
32
  export function mapHttpError(status, text) {
23
33
  const message = `OpenAI-compatible provider: ${vendorMessage(text)}`;
24
- if (status === 401 || status === 403)
34
+ if (status === 401)
25
35
  return anthropicError(401, "authentication_error", message);
36
+ // A 403 is often not the credential at all: a data-sharing policy that needs opting into, a
37
+ // region, a model the account may not use. Calling it an authentication error sends the user
38
+ // back to check a key that was never the problem — which is most of an afternoon.
39
+ if (status === 403) {
40
+ return looksLikeAuth(text)
41
+ ? anthropicError(401, "authentication_error", message)
42
+ : anthropicError(403, "permission_error", message);
43
+ }
26
44
  if (status === 429)
27
45
  return anthropicError(429, "rate_limit_error", message);
28
46
  if (status >= 500)
@@ -72,6 +90,11 @@ export class OpenAiCompatibleAdapter {
72
90
  reasoning: modelEffortLevels && modelEffortLevels.length > 0 ? "effort" : "none",
73
91
  effortLevels: modelEffortLevels ?? this.cfg.caps?.effortLevels ?? [],
74
92
  };
93
+ // Dropping a tool the model was meant to have is worth a line: the alternative to this drop is
94
+ // an empty answer with nothing logged anywhere.
95
+ const serverTools = serverToolNames(json.tools);
96
+ if (serverTools.size > 0)
97
+ this.log.warn(`openai ${this.name}: dropped server tools for ${model}: ${[...serverTools].join(", ")} (Anthropic runs these; this provider cannot)`);
75
98
  const upstreamRequest = toOpenAiRequest(json, {
76
99
  model,
77
100
  wire,
@@ -81,7 +104,13 @@ export class OpenAiCompatibleAdapter {
81
104
  ...(this.cfg.instructionsAppend ? { instructionsAppend: this.cfg.instructionsAppend } : {}),
82
105
  });
83
106
  const requestBody = JSON.stringify(upstreamRequest);
84
- const upstreamHeaders = { "content-type": "application/json", accept: "text/event-stream", ...(this.cfg.headers ?? {}) };
107
+ // Some vendors key their prompt cache on a session header rather than on the request's own
108
+ // shape, and hand a cold cache to anyone who does not send one. `conversationKey` is the value
109
+ // this codebase already trusts to be stable for one conversation and different between two.
110
+ const sessionHeader = this.cfg.sessionHeader
111
+ ? { [this.cfg.sessionHeader]: conversationKey(json) }
112
+ : {};
113
+ const upstreamHeaders = { "content-type": "application/json", accept: "text/event-stream", ...sessionHeader, ...(this.cfg.headers ?? {}) };
85
114
  const upstreamSecrets = credentialHeaderValues(Object.entries(upstreamHeaders));
86
115
  // Same input floor behavior as the ChatGPT adapter: the CLI snapshots message_start before usage arrives.
87
116
  const key = JSON.stringify({ model, wire, system: json.system ?? "", user: json.messages.find((message) => message.role === "user")?.content ?? "" });
@@ -91,12 +120,15 @@ export class OpenAiCompatibleAdapter {
91
120
  res.on("close", onClose);
92
121
  let upstream;
93
122
  try {
94
- upstream = await fetch(endpoint(this.cfg.url, wire), {
123
+ // Nothing has been written to the client yet, so a failure another attempt could answer is
124
+ // asked again here rather than handed to the user as an error they would have to retry by
125
+ // hand. Once this returns, the response is written straight through (see `fetchWithRetry`).
126
+ upstream = await fetchWithRetry(endpoint(this.cfg.url, wire), {
95
127
  method: "POST",
96
128
  headers: upstreamHeaders,
97
129
  body: requestBody,
98
130
  signal: controller.signal,
99
- });
131
+ }, { log: (line) => this.log.info(`openai ${this.name}: ${line}`) });
100
132
  }
101
133
  catch (error) {
102
134
  res.off("close", onClose);
@@ -117,7 +149,7 @@ export class OpenAiCompatibleAdapter {
117
149
  return { status: out.status, bytes: Buffer.byteLength(out.body), note: `upstream ${upstream.status}` };
118
150
  }
119
151
  const wantStream = json.stream === true;
120
- const mapper = new OpenAiStreamMapper(model, startInput);
152
+ const mapper = new OpenAiStreamMapper(model, startInput, toolNameRestoreMap(json));
121
153
  const parser = new SseParser();
122
154
  const reader = upstream.body.getReader();
123
155
  const decoder = new TextDecoder();
@@ -150,7 +182,12 @@ export class OpenAiCompatibleAdapter {
150
182
  break;
151
183
  }
152
184
  if (!mapper.isFinished) {
153
- const tail = mapper.finish();
185
+ // Only a vendor that said it was done is finished. A stream that just stopped — muse went
186
+ // quiet for up to 300s and then closed, 2026-09-19 — is reported as overloaded so the
187
+ // client asks again, instead of taking an empty turn as the model's final answer.
188
+ const tail = mapper.completed || parser.sawDone
189
+ ? mapper.finish()
190
+ : mapper.fail(`${model}: upstream stream ended before the response completed`, "server_is_overloaded");
154
191
  if (wantStream)
155
192
  for (const event of tail)
156
193
  bytes += write(res, formatSse(event));
@@ -158,7 +195,7 @@ export class OpenAiCompatibleAdapter {
158
195
  }
159
196
  catch (error) {
160
197
  if (!controller.signal.aborted) {
161
- const tail = mapper.fail(`stream interrupted: ${error.message}`);
198
+ const tail = mapper.fail(`stream interrupted: ${error.message}`, "server_is_overloaded");
162
199
  if (wantStream)
163
200
  for (const event of tail)
164
201
  bytes += write(res, formatSse(event));
@@ -173,19 +210,26 @@ export class OpenAiCompatibleAdapter {
173
210
  }
174
211
  catch { /* already closed */ }
175
212
  }
213
+ const failure = mapper.failure;
214
+ const failedStatus = failureStatus(failure);
176
215
  if (!wantStream) {
177
- const body = JSON.stringify(mapper.message());
216
+ // A failed turn is an error here too: answering it as a 200 with whatever content had
217
+ // arrived is the same silent truncation the stream path used to commit.
218
+ const body = failure ? anthropicError(failedStatus, failure.type, failure.message).body : JSON.stringify(mapper.message());
178
219
  bytes = Buffer.byteLength(body);
179
- res.writeHead(200, { "content-type": "application/json", "content-length": String(bytes) }).end(body);
220
+ res.writeHead(failure ? failedStatus : 200, { "content-type": "application/json", "content-length": String(bytes) }).end(body);
180
221
  }
181
222
  else if (!res.writableEnded) {
182
223
  res.end();
183
224
  }
184
225
  const usage = mapper.usage;
185
226
  return {
186
- status: 200,
227
+ // A stream has already sent 200; the record still says the turn failed.
228
+ status: failure ? failedStatus : 200,
187
229
  bytes,
188
- note: `in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens} stop=${mapper.stopReason}`,
230
+ note: failure
231
+ ? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens})`
232
+ : `in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens} stop=${mapper.stopReason}`,
189
233
  usage: { input: usage.input_tokens, cached: usage.cache_read_input_tokens, output: usage.output_tokens },
190
234
  stopReason: mapper.stopReason,
191
235
  };