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.
- package/CHANGELOG.md +76 -0
- package/README.ko.md +48 -4
- package/README.md +58 -4
- package/dist/cli/src/claude-auth.js +3 -2
- package/dist/cli/src/codex.js +20 -1
- package/dist/cli/src/hooks/agent-title.js +1 -1
- package/dist/cli/src/index.js +4 -4
- package/dist/cli/src/schtasks.js +43 -1
- package/dist/cli/src/settings.js +73 -6
- package/dist/router/src/admin.js +489 -56
- package/dist/router/src/agents.js +250 -0
- package/dist/router/src/bootstrap.js +24 -8
- package/dist/router/src/capabilities.js +214 -0
- package/dist/router/src/compat.js +5 -1
- package/dist/router/src/config.js +264 -11
- package/dist/router/src/index.js +14 -1
- package/dist/router/src/ingress/server.js +24 -14
- package/dist/router/src/picker.js +14 -6
- package/dist/router/src/pool.js +233 -0
- package/dist/router/src/presets.js +156 -1
- package/dist/router/src/providers/anthropic-account-pool.js +139 -0
- package/dist/router/src/providers/anthropic-accounts.js +281 -0
- package/dist/router/src/providers/chatgpt/catalog.js +97 -0
- package/dist/router/src/providers/chatgpt/index.js +343 -12
- package/dist/router/src/providers/chatgpt/sse.js +4 -0
- package/dist/router/src/providers/chatgpt/translate.js +156 -14
- package/dist/router/src/providers/claude-oauth.js +61 -19
- package/dist/router/src/providers/openai/index.js +55 -11
- package/dist/router/src/providers/openai/translate.js +82 -14
- package/dist/router/src/providers/retry.js +88 -0
- package/dist/router/src/proxy.js +697 -82
- package/dist/router/src/requestlog.js +5 -2
- package/dist/router/src/routing.js +151 -17
- package/dist/router/src/version.js +1 -1
- package/dist/router/src/websearch.js +307 -0
- package/dist/router/src/x509.js +7 -2
- package/dist/ui/app.js +740 -160
- package/dist/ui/i18n.js +14 -6
- package/dist/ui/index.html +18 -5
- package/dist/ui/presets-fallback.js +2 -0
- package/dist/ui/style.css +133 -9
- package/docs/ARCHITECTURE.md +381 -20
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
160
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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`).
|
|
10
|
-
//
|
|
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 {
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
+
// A stream has already sent 200; the record still says the turn failed.
|
|
228
|
+
status: failure ? failedStatus : 200,
|
|
187
229
|
bytes,
|
|
188
|
-
note:
|
|
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
|
};
|