@juspay/neurolink 12.7.2 → 12.7.4
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 +2 -2
- package/dist/browser/neurolink.min.js +422 -414
- package/dist/cli/commands/auth.js +252 -125
- package/dist/cli/commands/proxy.js +115 -75
- package/dist/cli/factories/authCommandFactory.js +8 -11
- package/dist/proxy/codexAccountUsage.d.ts +5 -0
- package/dist/proxy/codexAccountUsage.js +30 -0
- package/dist/proxy/codexFallback.d.ts +26 -0
- package/dist/proxy/codexFallback.js +371 -0
- package/dist/proxy/proxyActivity.js +12 -1
- package/dist/proxy/usageStats.d.ts +2 -2
- package/dist/proxy/usageStats.js +66 -10
- package/dist/server/routes/claudeProxyRoutes.d.ts +25 -2
- package/dist/server/routes/claudeProxyRoutes.js +413 -108
- package/dist/server/routes/codexProxyRoutes.d.ts +10 -2
- package/dist/server/routes/codexProxyRoutes.js +192 -59
- package/dist/types/claudeProxy.d.ts +16 -0
- package/dist/types/claudeProxy.js +4 -0
- package/dist/types/cli.d.ts +37 -5
- package/dist/types/codex.d.ts +62 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/proxy.d.ts +37 -0
- package/package.json +1 -1
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic Messages API fallback over the pooled Codex Responses transport.
|
|
3
|
+
*
|
|
4
|
+
* This module deliberately contains only wire-format conversion and buffered
|
|
5
|
+
* SSE parsing. Account selection, OAuth, cooldowns, and quota persistence stay
|
|
6
|
+
* in the native Codex proxy handler so fallback traffic follows the same pool
|
|
7
|
+
* rules as a native Codex request.
|
|
8
|
+
*/
|
|
9
|
+
import { extractCodexUsage } from "./codexUsage.js";
|
|
10
|
+
export class CodexFallbackResponseError extends Error {
|
|
11
|
+
status;
|
|
12
|
+
responseBody;
|
|
13
|
+
constructor(status, responseBody) {
|
|
14
|
+
super(`Codex fallback request returned HTTP ${status}`);
|
|
15
|
+
this.name = "CodexFallbackResponseError";
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.responseBody = responseBody;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function isRecord(value) {
|
|
21
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
function asNonEmptyString(value) {
|
|
24
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
25
|
+
}
|
|
26
|
+
function buildSystemInstructions(body) {
|
|
27
|
+
if (typeof body.system === "string") {
|
|
28
|
+
return body.system || undefined;
|
|
29
|
+
}
|
|
30
|
+
if (Array.isArray(body.system)) {
|
|
31
|
+
const text = body.system
|
|
32
|
+
.map((block) => (typeof block.text === "string" ? block.text : ""))
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.join("\n\n");
|
|
35
|
+
return text || undefined;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
function imageUrlForBlock(block) {
|
|
40
|
+
if (block.source.type === "url" && block.source.url) {
|
|
41
|
+
return block.source.url;
|
|
42
|
+
}
|
|
43
|
+
if (block.source.type === "base64" && block.source.data) {
|
|
44
|
+
return `data:${block.source.media_type ?? "image/png"};base64,${block.source.data}`;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
function flattenClaudeContent(content) {
|
|
49
|
+
if (typeof content === "string") {
|
|
50
|
+
return content;
|
|
51
|
+
}
|
|
52
|
+
return content
|
|
53
|
+
.map((block) => {
|
|
54
|
+
switch (block.type) {
|
|
55
|
+
case "text":
|
|
56
|
+
return block.text;
|
|
57
|
+
case "thinking":
|
|
58
|
+
return block.thinking;
|
|
59
|
+
case "image":
|
|
60
|
+
return "[image attachment]";
|
|
61
|
+
case "tool_use":
|
|
62
|
+
return `[tool call ${block.name}] ${JSON.stringify(block.input ?? {})}`;
|
|
63
|
+
case "tool_result":
|
|
64
|
+
return flattenClaudeContent(block.content);
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
.join("\n");
|
|
68
|
+
}
|
|
69
|
+
function toCodexContentPart(block, role) {
|
|
70
|
+
const textType = role === "assistant" ? "output_text" : "input_text";
|
|
71
|
+
switch (block.type) {
|
|
72
|
+
case "text":
|
|
73
|
+
return { type: textType, text: block.text };
|
|
74
|
+
case "thinking":
|
|
75
|
+
return { type: textType, text: block.thinking };
|
|
76
|
+
case "image": {
|
|
77
|
+
const imageUrl = imageUrlForBlock(block);
|
|
78
|
+
if (role === "user" && imageUrl) {
|
|
79
|
+
return { type: "input_image", image_url: imageUrl };
|
|
80
|
+
}
|
|
81
|
+
return { type: textType, text: "[image attachment]" };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function convertClaudeMessage(role, content) {
|
|
86
|
+
if (typeof content === "string") {
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
role,
|
|
90
|
+
content: [
|
|
91
|
+
{
|
|
92
|
+
type: role === "assistant" ? "output_text" : "input_text",
|
|
93
|
+
text: content,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
const input = [];
|
|
100
|
+
let messageContent = [];
|
|
101
|
+
const flushMessage = () => {
|
|
102
|
+
if (messageContent.length === 0) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
input.push({ role, content: messageContent });
|
|
106
|
+
messageContent = [];
|
|
107
|
+
};
|
|
108
|
+
for (const block of content) {
|
|
109
|
+
if (block.type === "tool_use") {
|
|
110
|
+
flushMessage();
|
|
111
|
+
input.push({
|
|
112
|
+
type: "function_call",
|
|
113
|
+
call_id: block.id,
|
|
114
|
+
name: block.name,
|
|
115
|
+
arguments: JSON.stringify(block.input ?? {}),
|
|
116
|
+
});
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (block.type === "tool_result") {
|
|
120
|
+
flushMessage();
|
|
121
|
+
input.push({
|
|
122
|
+
type: "function_call_output",
|
|
123
|
+
call_id: block.tool_use_id,
|
|
124
|
+
output: flattenClaudeContent(block.content),
|
|
125
|
+
});
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
messageContent.push(toCodexContentPart(block, role));
|
|
129
|
+
}
|
|
130
|
+
flushMessage();
|
|
131
|
+
return input;
|
|
132
|
+
}
|
|
133
|
+
/** Convert a Claude Messages request into the ChatGPT Codex Responses shape. */
|
|
134
|
+
export function convertClaudeRequestToCodex(body, model) {
|
|
135
|
+
const input = body.messages.flatMap((message) => convertClaudeMessage(message.role, message.content));
|
|
136
|
+
const request = {
|
|
137
|
+
model,
|
|
138
|
+
input,
|
|
139
|
+
stream: true,
|
|
140
|
+
// ChatGPT's backend rejects requests unless this is explicitly false.
|
|
141
|
+
store: false,
|
|
142
|
+
};
|
|
143
|
+
const instructions = buildSystemInstructions(body);
|
|
144
|
+
if (instructions) {
|
|
145
|
+
request.instructions = instructions;
|
|
146
|
+
}
|
|
147
|
+
if (body.tools && body.tools.length > 0) {
|
|
148
|
+
request.tools = body.tools.map((tool) => ({
|
|
149
|
+
type: "function",
|
|
150
|
+
name: tool.name,
|
|
151
|
+
...(tool.description ? { description: tool.description } : {}),
|
|
152
|
+
parameters: tool.input_schema,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
if (body.tool_choice) {
|
|
156
|
+
switch (body.tool_choice.type) {
|
|
157
|
+
case "any":
|
|
158
|
+
request.tool_choice = "required";
|
|
159
|
+
break;
|
|
160
|
+
case "tool":
|
|
161
|
+
request.tool_choice = { type: "function", name: body.tool_choice.name };
|
|
162
|
+
break;
|
|
163
|
+
default:
|
|
164
|
+
request.tool_choice = body.tool_choice.type;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// The ChatGPT Codex backend is not the public Responses API. In particular,
|
|
169
|
+
// it rejects `max_output_tokens`, and its model-owned sampling controls do
|
|
170
|
+
// not map safely from Anthropic's `temperature` or `top_p`. Omit all three
|
|
171
|
+
// so the backend uses its supported defaults instead of rejecting fallback
|
|
172
|
+
// traffic before it can be served.
|
|
173
|
+
return request;
|
|
174
|
+
}
|
|
175
|
+
function parseFunctionArguments(value) {
|
|
176
|
+
if (isRecord(value)) {
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
if (typeof value !== "string") {
|
|
180
|
+
throw new Error("Codex fallback function call is missing JSON arguments");
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const parsed = JSON.parse(value || "{}");
|
|
184
|
+
if (!isRecord(parsed)) {
|
|
185
|
+
throw new Error("not an object");
|
|
186
|
+
}
|
|
187
|
+
return parsed;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
throw new Error("Codex fallback function call returned invalid JSON arguments");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function outputTextFromItem(value) {
|
|
194
|
+
if (!isRecord(value) ||
|
|
195
|
+
value.type !== "message" ||
|
|
196
|
+
!Array.isArray(value.content)) {
|
|
197
|
+
return "";
|
|
198
|
+
}
|
|
199
|
+
return value.content
|
|
200
|
+
.filter(isRecord)
|
|
201
|
+
.filter((part) => part.type === "output_text")
|
|
202
|
+
.map((part) => (typeof part.text === "string" ? part.text : ""))
|
|
203
|
+
.join("");
|
|
204
|
+
}
|
|
205
|
+
function addFunctionCall(item, toolCalls) {
|
|
206
|
+
if (item.type !== "function_call") {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const callId = asNonEmptyString(item.call_id);
|
|
210
|
+
const name = asNonEmptyString(item.name);
|
|
211
|
+
if (!callId || !name) {
|
|
212
|
+
throw new Error("Codex fallback function call is missing an id or name");
|
|
213
|
+
}
|
|
214
|
+
toolCalls.set(callId, {
|
|
215
|
+
toolCallId: callId,
|
|
216
|
+
toolName: name,
|
|
217
|
+
args: parseFunctionArguments(item.arguments),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
function parseSSEPayloads(sse) {
|
|
221
|
+
const frames = sse.replace(/\r\n?/g, "\n").split("\n\n");
|
|
222
|
+
const parsed = [];
|
|
223
|
+
for (const frame of frames) {
|
|
224
|
+
if (!frame.trim()) {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
let event;
|
|
228
|
+
const data = [];
|
|
229
|
+
for (const line of frame.split("\n")) {
|
|
230
|
+
if (!line || line.startsWith(":")) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (line.startsWith("event:")) {
|
|
234
|
+
event = line.slice("event:".length).trim();
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (line.startsWith("data:")) {
|
|
238
|
+
data.push(line.slice("data:".length).trimStart());
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (line.startsWith("id:") || line.startsWith("retry:")) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// SSE permits extension fields that this parser does not consume.
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (data.length === 0) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const raw = data.join("\n").trim();
|
|
251
|
+
if (raw === "[DONE]") {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
const payload = JSON.parse(raw);
|
|
256
|
+
if (!isRecord(payload)) {
|
|
257
|
+
throw new Error("not an object");
|
|
258
|
+
}
|
|
259
|
+
parsed.push({ event, payload });
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
throw new Error("Codex fallback stream contains malformed JSON");
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return parsed;
|
|
266
|
+
}
|
|
267
|
+
function responseStatus(payload) {
|
|
268
|
+
const response = payload.response;
|
|
269
|
+
return isRecord(response) ? asNonEmptyString(response.status) : undefined;
|
|
270
|
+
}
|
|
271
|
+
function outputTextFromResponse(payload) {
|
|
272
|
+
const response = payload.response;
|
|
273
|
+
if (!isRecord(response) || !Array.isArray(response.output)) {
|
|
274
|
+
return "";
|
|
275
|
+
}
|
|
276
|
+
return response.output.map(outputTextFromItem).join("");
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Parse a complete Codex Responses SSE stream before emitting Claude output.
|
|
280
|
+
*
|
|
281
|
+
* A missing terminal event, malformed JSON, terminal error, or empty response
|
|
282
|
+
* is rejected. That makes it safe for the caller to try the next configured
|
|
283
|
+
* fallback without ever replaying output already sent to a client.
|
|
284
|
+
*/
|
|
285
|
+
export function parseCodexFallbackSSE(sse) {
|
|
286
|
+
const payloads = parseSSEPayloads(sse);
|
|
287
|
+
const toolCalls = new Map();
|
|
288
|
+
let textFromDeltas = "";
|
|
289
|
+
let textFromCompletedItems = "";
|
|
290
|
+
let textFromResponse = "";
|
|
291
|
+
let usage;
|
|
292
|
+
let sawCompleted = false;
|
|
293
|
+
for (const { event, payload } of payloads) {
|
|
294
|
+
const type = asNonEmptyString(payload.type) ?? event;
|
|
295
|
+
if (!type) {
|
|
296
|
+
throw new Error("Codex fallback stream event is missing a type");
|
|
297
|
+
}
|
|
298
|
+
if (type === "error" ||
|
|
299
|
+
type === "response.failed" ||
|
|
300
|
+
type === "response.incomplete") {
|
|
301
|
+
throw new Error(`Codex fallback stream terminated with ${type}`);
|
|
302
|
+
}
|
|
303
|
+
if (type === "response.output_text.delta") {
|
|
304
|
+
if (typeof payload.delta !== "string") {
|
|
305
|
+
throw new Error("Codex fallback text delta is malformed");
|
|
306
|
+
}
|
|
307
|
+
textFromDeltas += payload.delta;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (type === "response.output_item.done") {
|
|
311
|
+
if (!isRecord(payload.item)) {
|
|
312
|
+
throw new Error("Codex fallback output item is malformed");
|
|
313
|
+
}
|
|
314
|
+
addFunctionCall(payload.item, toolCalls);
|
|
315
|
+
textFromCompletedItems += outputTextFromItem(payload.item);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (type !== "response.completed") {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (sawCompleted) {
|
|
322
|
+
throw new Error("Codex fallback stream emitted more than one completion event");
|
|
323
|
+
}
|
|
324
|
+
sawCompleted = true;
|
|
325
|
+
const status = responseStatus(payload);
|
|
326
|
+
if (status !== "completed") {
|
|
327
|
+
throw new Error(`Codex fallback stream completed with unexpected status ${status ?? "unknown"}`);
|
|
328
|
+
}
|
|
329
|
+
const parsedUsage = extractCodexUsage(payload);
|
|
330
|
+
if (parsedUsage) {
|
|
331
|
+
usage = {
|
|
332
|
+
input: parsedUsage.inputTokens,
|
|
333
|
+
output: parsedUsage.outputTokens,
|
|
334
|
+
total: parsedUsage.inputTokens + parsedUsage.outputTokens,
|
|
335
|
+
cacheReadTokens: parsedUsage.cacheReadTokens,
|
|
336
|
+
cacheCreationTokens: parsedUsage.cacheCreationTokens,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
textFromResponse = outputTextFromResponse(payload);
|
|
340
|
+
}
|
|
341
|
+
if (!sawCompleted) {
|
|
342
|
+
throw new Error("Codex fallback stream ended before response.completed");
|
|
343
|
+
}
|
|
344
|
+
const text = textFromDeltas || textFromCompletedItems || textFromResponse;
|
|
345
|
+
const resolvedToolCalls = [...toolCalls.values()];
|
|
346
|
+
if (!text && resolvedToolCalls.length === 0) {
|
|
347
|
+
throw new Error("Codex fallback returned no content or tool calls");
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
text,
|
|
351
|
+
toolCalls: resolvedToolCalls,
|
|
352
|
+
...(usage ? { usage } : {}),
|
|
353
|
+
finishReason: resolvedToolCalls.length > 0 ? "tool_use" : "end_turn",
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/** Consume and validate a native Codex response before producing Claude output. */
|
|
357
|
+
export async function consumeCodexFallbackResponse(response) {
|
|
358
|
+
if (!response.ok) {
|
|
359
|
+
throw new CodexFallbackResponseError(response.status, await response.text().catch(() => ""));
|
|
360
|
+
}
|
|
361
|
+
if (!response.body) {
|
|
362
|
+
throw new Error("Codex fallback returned an empty stream");
|
|
363
|
+
}
|
|
364
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
365
|
+
if (!contentType.toLowerCase().includes("text/event-stream")) {
|
|
366
|
+
// Consume it before failing so the underlying connection can be reused.
|
|
367
|
+
await response.text().catch(() => "");
|
|
368
|
+
throw new Error("Codex fallback returned a non-SSE response");
|
|
369
|
+
}
|
|
370
|
+
return parseCodexFallbackSSE(await response.text());
|
|
371
|
+
}
|
|
@@ -63,6 +63,13 @@ export function trackProxyResponse(response, finishRequest, observer) {
|
|
|
63
63
|
let observedBodyBytes = 0;
|
|
64
64
|
let responseChunks = 0;
|
|
65
65
|
let settled = false;
|
|
66
|
+
let sourceClosed = false;
|
|
67
|
+
// A framework can cancel its adapter after the upstream stream has already
|
|
68
|
+
// closed. Snapshot reader.closed before cancel() so that normal cleanup is
|
|
69
|
+
// not recorded as a client-aborted request.
|
|
70
|
+
void reader.closed.then(() => {
|
|
71
|
+
sourceClosed = true;
|
|
72
|
+
}, () => undefined);
|
|
66
73
|
const settle = (outcome) => {
|
|
67
74
|
if (settled) {
|
|
68
75
|
return;
|
|
@@ -100,7 +107,11 @@ export function trackProxyResponse(response, finishRequest, observer) {
|
|
|
100
107
|
}
|
|
101
108
|
},
|
|
102
109
|
async cancel(reason) {
|
|
103
|
-
|
|
110
|
+
// Read the state before cancelling: reader.cancel() itself rejects the
|
|
111
|
+
// closed promise for a genuinely active source, which is too late to
|
|
112
|
+
// distinguish it from a source that had already ended normally.
|
|
113
|
+
await Promise.resolve();
|
|
114
|
+
settle(sourceClosed ? "completed" : "client_cancelled");
|
|
104
115
|
await withTimeout(reader.cancel(reason), PROXY_RESPONSE_CANCEL_TIMEOUT_MS, "Timed out cancelling the upstream proxy response");
|
|
105
116
|
},
|
|
106
117
|
});
|
|
@@ -40,7 +40,7 @@ export declare class ProxyUsageStatsStore {
|
|
|
40
40
|
recordAttemptError(accountLabel: string, accountType: string, status: number, rateLimitKind?: "transient" | "quota"): void;
|
|
41
41
|
recordFinalError(status: number, accountLabel?: string, accountType?: string, details?: ProxyTerminalErrorDetails): void;
|
|
42
42
|
getStats(): ProxyStats;
|
|
43
|
-
getAccountStats(label: string): AccountStats | undefined;
|
|
43
|
+
getAccountStats(label: string, type?: string): AccountStats | undefined;
|
|
44
44
|
getTerminalErrors(): ProxyTerminalErrorJournal;
|
|
45
45
|
getUsageSnapshot(): ProxyUsageStatsSnapshot;
|
|
46
46
|
getPersistenceStatus(): ProxyStatsPersistenceStatus;
|
|
@@ -81,7 +81,7 @@ export declare function getStats(): ProxyStats;
|
|
|
81
81
|
export declare function getUsageSnapshot(): ProxyUsageStatsSnapshot;
|
|
82
82
|
export declare function getReconciledStats(): Promise<ProxyStats>;
|
|
83
83
|
export declare function getReconciledUsageSnapshot(): Promise<ProxyUsageStatsSnapshot>;
|
|
84
|
-
export declare function getAccountStats(label: string): AccountStats | undefined;
|
|
84
|
+
export declare function getAccountStats(label: string, type?: string): AccountStats | undefined;
|
|
85
85
|
export declare function getTerminalErrors(): ProxyTerminalErrorJournal;
|
|
86
86
|
export declare function getUsageStatsPersistenceStatus(): ProxyStatsPersistenceStatus;
|
|
87
87
|
export declare function flushUsageStats(): Promise<void>;
|
package/dist/proxy/usageStats.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
9
|
import { mkdir, open, readFile, readdir, rename, rm, stat, } from "node:fs/promises";
|
|
10
10
|
import { basename, dirname, join } from "node:path";
|
|
11
|
+
import { normalizeAnthropicAccountKey } from "./accountSelection.js";
|
|
11
12
|
import { AsyncMutex } from "../utils/asyncMutex.js";
|
|
12
13
|
import { redactUrlsInText, sanitizeForLog } from "../utils/logSanitize.js";
|
|
13
14
|
import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js";
|
|
@@ -63,6 +64,34 @@ function emptyTerminalErrorJournal(startedAt) {
|
|
|
63
64
|
recent: [],
|
|
64
65
|
};
|
|
65
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Statistics must never use an email/label as their primary map key. One
|
|
69
|
+
* person can authenticate both provider pools with the same email, and a
|
|
70
|
+
* bare key silently merged their attempts, limits, and failures into one row.
|
|
71
|
+
*
|
|
72
|
+
* Keep non-account pseudo rows (passthrough, peer, internal) untouched. The
|
|
73
|
+
* display label remains separate so the CLI stays readable while persisted
|
|
74
|
+
* counters retain a collision-proof identity.
|
|
75
|
+
*/
|
|
76
|
+
function resolveStatsAccountIdentity(label, type) {
|
|
77
|
+
if (type === "codex-oauth") {
|
|
78
|
+
const key = label.startsWith("codex:") ? label : `codex:${label}`;
|
|
79
|
+
return {
|
|
80
|
+
key,
|
|
81
|
+
label: label.startsWith("codex:") ? label.slice("codex:".length) : label,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (type === "oauth" || type === "api_key") {
|
|
85
|
+
const key = normalizeAnthropicAccountKey(label);
|
|
86
|
+
return {
|
|
87
|
+
key,
|
|
88
|
+
label: label.startsWith("anthropic:")
|
|
89
|
+
? label.slice("anthropic:".length)
|
|
90
|
+
: label,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { key: label, label };
|
|
94
|
+
}
|
|
66
95
|
function cloneTerminalErrorSummary(summary) {
|
|
67
96
|
return { ...summary };
|
|
68
97
|
}
|
|
@@ -152,6 +181,9 @@ function terminalErrorCategory(status, errorType) {
|
|
|
152
181
|
}
|
|
153
182
|
function createTerminalErrorSummary(args) {
|
|
154
183
|
const { now, status, accountLabel, accountType, details } = args;
|
|
184
|
+
const accountIdentity = accountLabel && accountType
|
|
185
|
+
? resolveStatsAccountIdentity(accountLabel, accountType)
|
|
186
|
+
: undefined;
|
|
155
187
|
const errorType = clipTerminalErrorField(details?.errorType);
|
|
156
188
|
const message = details?.message
|
|
157
189
|
? stripTerminalErrorControlCharacters(sanitizeForLog(redactUrlsInText(details.message), MAX_TERMINAL_ERROR_MESSAGE_LENGTH + MAX_TERMINAL_ERROR_FIELD_LENGTH))
|
|
@@ -170,6 +202,11 @@ function createTerminalErrorSummary(args) {
|
|
|
170
202
|
...(clipTerminalErrorField(accountLabel)
|
|
171
203
|
? { account: clipTerminalErrorField(accountLabel) }
|
|
172
204
|
: {}),
|
|
205
|
+
...(clipTerminalErrorField(details?.accountKey ?? accountIdentity?.key)
|
|
206
|
+
? {
|
|
207
|
+
accountKey: clipTerminalErrorField(details?.accountKey ?? accountIdentity?.key),
|
|
208
|
+
}
|
|
209
|
+
: {}),
|
|
173
210
|
...(clipTerminalErrorField(accountType)
|
|
174
211
|
? { accountType: clipTerminalErrorField(accountType) }
|
|
175
212
|
: {}),
|
|
@@ -201,7 +238,9 @@ function mergeAccountStats(left, right) {
|
|
|
201
238
|
if (!left) {
|
|
202
239
|
return cloneAccount(right);
|
|
203
240
|
}
|
|
241
|
+
const key = right.key ?? left.key;
|
|
204
242
|
return {
|
|
243
|
+
...(key ? { key } : {}),
|
|
205
244
|
label: right.label || left.label,
|
|
206
245
|
type: right.type || left.type,
|
|
207
246
|
attemptCount: left.attemptCount + right.attemptCount,
|
|
@@ -248,7 +287,9 @@ function validAccountStats(value) {
|
|
|
248
287
|
return false;
|
|
249
288
|
}
|
|
250
289
|
const candidate = value;
|
|
251
|
-
return (
|
|
290
|
+
return ((candidate.key === undefined ||
|
|
291
|
+
(typeof candidate.key === "string" && candidate.key.length > 0)) &&
|
|
292
|
+
typeof candidate.label === "string" &&
|
|
252
293
|
typeof candidate.type === "string" &&
|
|
253
294
|
finiteNonNegativeInteger(candidate.attemptCount) &&
|
|
254
295
|
finiteNonNegativeInteger(candidate.attemptErrorCount) &&
|
|
@@ -282,7 +323,7 @@ function validStats(value) {
|
|
|
282
323
|
typeof candidate.accounts !== "object") {
|
|
283
324
|
return false;
|
|
284
325
|
}
|
|
285
|
-
return Object.entries(candidate.accounts).every(([
|
|
326
|
+
return Object.entries(candidate.accounts).every(([key, account]) => validAccountStats(account) && key === (account.key ?? account.label));
|
|
286
327
|
}
|
|
287
328
|
function validOptionalString(value, maxLength) {
|
|
288
329
|
return (value === undefined ||
|
|
@@ -302,6 +343,7 @@ function validTerminalErrorSummary(value) {
|
|
|
302
343
|
TERMINAL_ERROR_CATEGORIES.includes(candidate.category) &&
|
|
303
344
|
validOptionalString(candidate.requestId, MAX_TERMINAL_ERROR_FIELD_LENGTH) &&
|
|
304
345
|
validOptionalString(candidate.account, MAX_TERMINAL_ERROR_FIELD_LENGTH) &&
|
|
346
|
+
validOptionalString(candidate.accountKey, MAX_TERMINAL_ERROR_FIELD_LENGTH) &&
|
|
305
347
|
validOptionalString(candidate.accountType, MAX_TERMINAL_ERROR_FIELD_LENGTH) &&
|
|
306
348
|
validOptionalString(candidate.errorType, MAX_TERMINAL_ERROR_FIELD_LENGTH) &&
|
|
307
349
|
validOptionalString(candidate.errorCode, MAX_TERMINAL_ERROR_FIELD_LENGTH) &&
|
|
@@ -615,8 +657,20 @@ export class ProxyUsageStatsStore {
|
|
|
615
657
|
getStats() {
|
|
616
658
|
return cloneStats(this.stats);
|
|
617
659
|
}
|
|
618
|
-
getAccountStats(label) {
|
|
619
|
-
const
|
|
660
|
+
getAccountStats(label, type) {
|
|
661
|
+
const direct = this.stats.accounts[label];
|
|
662
|
+
if (direct) {
|
|
663
|
+
return cloneAccount(direct);
|
|
664
|
+
}
|
|
665
|
+
const identity = type
|
|
666
|
+
? resolveStatsAccountIdentity(label, type)
|
|
667
|
+
: undefined;
|
|
668
|
+
const account = identity
|
|
669
|
+
? this.stats.accounts[identity.key]
|
|
670
|
+
: (() => {
|
|
671
|
+
const matches = Object.values(this.stats.accounts).filter((candidate) => candidate.label === label);
|
|
672
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
673
|
+
})();
|
|
620
674
|
return account ? cloneAccount(account) : undefined;
|
|
621
675
|
}
|
|
622
676
|
getTerminalErrors() {
|
|
@@ -920,9 +974,11 @@ export class ProxyUsageStatsStore {
|
|
|
920
974
|
}
|
|
921
975
|
}
|
|
922
976
|
ensureAccount(target, label, type) {
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
977
|
+
const identity = resolveStatsAccountIdentity(label, type);
|
|
978
|
+
if (!target.accounts[identity.key]) {
|
|
979
|
+
target.accounts[identity.key] = {
|
|
980
|
+
key: identity.key,
|
|
981
|
+
label: identity.label,
|
|
926
982
|
type,
|
|
927
983
|
attemptCount: 0,
|
|
928
984
|
attemptErrorCount: 0,
|
|
@@ -934,7 +990,7 @@ export class ProxyUsageStatsStore {
|
|
|
934
990
|
lastAttemptAt: 0,
|
|
935
991
|
};
|
|
936
992
|
}
|
|
937
|
-
return target.accounts[
|
|
993
|
+
return target.accounts[identity.key];
|
|
938
994
|
}
|
|
939
995
|
scheduleFlush() {
|
|
940
996
|
if (!this.filePath || this.flushTimer) {
|
|
@@ -1131,8 +1187,8 @@ export async function getReconciledStats() {
|
|
|
1131
1187
|
export async function getReconciledUsageSnapshot() {
|
|
1132
1188
|
return defaultStore.reconcileUsageSnapshot();
|
|
1133
1189
|
}
|
|
1134
|
-
export function getAccountStats(label) {
|
|
1135
|
-
return defaultStore.getAccountStats(label);
|
|
1190
|
+
export function getAccountStats(label, type) {
|
|
1191
|
+
return defaultStore.getAccountStats(label, type);
|
|
1136
1192
|
}
|
|
1137
1193
|
export function getTerminalErrors() {
|
|
1138
1194
|
return defaultStore.getTerminalErrors();
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { buildTranslationOptions } from "../../proxy/proxyTranslationEngine.js";
|
|
13
13
|
import { ProxyTracer } from "../../proxy/proxyTracer.js";
|
|
14
14
|
import { isPermanentRefreshFailure } from "../../proxy/tokenRefresh.js";
|
|
15
|
-
import type { AccountAllowlist, AccountAdmissionLease, JsonObject, AccountCooldownPlan, AccountQuota, AccountQuotaWindow, AccountUsageFetchResult, AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicEntitlementFailure, AnthropicLoopState, AnthropicScopedExhaustion, AnthropicNonOkResult, AnthropicSuccessResult, AnthropicUpstreamFetchResult, ClaudeFinalRequestLogger, ClaudeLoggedErrorBuilder, ClaudeRequest, ClaudeProxyRouteRuntimeOptions, ModelRouterInterface, ParsedClaudeError, ProxyAccountRoutingDecision, ProxyAccountSortMetrics, ProxyBodyCaptureLogger, ProxyLimitsRefreshResponse, ProxyQuotaCooldownUpdate, ProxyOveragePolicy, ProxyPassthroughAccount, QueuedAccountAdmission, RouteGroup, RuntimeAccountState, ServerContext, StreamTerminalOutcome } from "../../types/index.js";
|
|
15
|
+
import type { AccountAllowlist, AccountAdmissionLease, JsonObject, AccountCooldownPlan, AccountQuota, AccountQuotaWindow, AccountUsageFetchResult, AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicEntitlementFailure, AnthropicInvalidRequestFailure, AnthropicLoopState, AnthropicScopedExhaustion, AnthropicNonOkResult, AnthropicSuccessResult, AnthropicUpstreamFetchResult, ClaudeFinalRequestLogger, ClaudeLoggedErrorBuilder, ClaudeRequest, ClaudeProxyRouteRuntimeOptions, ModelRouterInterface, ParsedClaudeError, ProxyAccountRoutingDecision, ProxyAccountSortMetrics, ProxyBodyCaptureLogger, ProxyLimitsRefreshResponse, ProxyQuotaCooldownUpdate, ProxyOveragePolicy, ProxyPassthroughAccount, QueuedAccountAdmission, RouteGroup, RuntimeAccountState, ServerContext, StreamTerminalOutcome } from "../../types/index.js";
|
|
16
16
|
declare function tryAcquireAccountAdmission(accountKey: string, capacity: number | undefined): AccountAdmissionLease | undefined;
|
|
17
17
|
declare function enqueueAccountAdmission(accountKey: string, capacity: number): QueuedAccountAdmission;
|
|
18
18
|
declare function acquireAccountAdmission(accountKey: string, capacity: number, abortSignal?: AbortSignal, timeoutMs?: number): Promise<AccountAdmissionLease | undefined>;
|
|
@@ -65,7 +65,8 @@ declare function planCooldownFor429(quota: AccountQuota | null, retryAfterMs: nu
|
|
|
65
65
|
declare function reconcileCooldownFromQuota(state: RuntimeAccountState, quota: AccountQuota, now: number, policy?: ProxyOveragePolicy): ProxyQuotaCooldownUpdate;
|
|
66
66
|
/**
|
|
67
67
|
* Seed each account's runtime quota from the persisted snapshots in
|
|
68
|
-
* ~/.neurolink/account-quotas.json (keyed by
|
|
68
|
+
* ~/.neurolink/account-quotas.json (keyed by provider-qualified account key).
|
|
69
|
+
* Runtime state is
|
|
69
70
|
* in-memory only, so without this the quota-aware ordering is blind after a
|
|
70
71
|
* proxy restart: all accounts tie, selection falls back to token-store
|
|
71
72
|
* enumeration order, and the first account served becomes self-reinforcing
|
|
@@ -173,6 +174,18 @@ declare function trackUpstreamReadableStream(source: ReadableStream<Uint8Array>)
|
|
|
173
174
|
stream: ReadableStream<Uint8Array>;
|
|
174
175
|
outcome: Promise<StreamTerminalOutcome>;
|
|
175
176
|
};
|
|
177
|
+
/**
|
|
178
|
+
* Remove what a borrower has no business seeing.
|
|
179
|
+
*
|
|
180
|
+
* `x-neurolink-account` carries the lender's account label, which for an OAuth
|
|
181
|
+
* account is their email address; the pool counters describe the shape of a
|
|
182
|
+
* pool that is not the borrower's. The borrower's own routing needs the quota
|
|
183
|
+
* and grant headers, and nothing else here.
|
|
184
|
+
*
|
|
185
|
+
* A no-op for the node's own traffic, where these headers are exactly the
|
|
186
|
+
* diagnostics the operator wants.
|
|
187
|
+
*/
|
|
188
|
+
declare function redactHeadersForBorrower(headers: Record<string, string>): Record<string, string>;
|
|
176
189
|
declare function executeClaudeFallbackTranslation(args: {
|
|
177
190
|
ctx: ServerContext;
|
|
178
191
|
body: ClaudeRequest;
|
|
@@ -203,6 +216,7 @@ declare function executeClaudeFallbackTranslation(args: {
|
|
|
203
216
|
idleTimeoutMs?: number;
|
|
204
217
|
}): Promise<unknown>;
|
|
205
218
|
declare function executeClaudeFallbackWithRetry(args: Parameters<typeof executeClaudeFallbackTranslation>[0]): Promise<unknown>;
|
|
219
|
+
declare function getCodexFallbackInvalidRequestFailure(error: unknown): AnthropicInvalidRequestFailure | null;
|
|
206
220
|
declare function buildClaudeAnthropicFailureResponse(args: {
|
|
207
221
|
tracer?: ProxyTracer;
|
|
208
222
|
requestStartTime: number;
|
|
@@ -329,6 +343,7 @@ declare function handleAnthropicNonOkResponse(args: {
|
|
|
329
343
|
contentType?: string;
|
|
330
344
|
} | null;
|
|
331
345
|
entitlementFailure: AnthropicEntitlementFailure | null;
|
|
346
|
+
allowConfiguredModelFallback?: boolean;
|
|
332
347
|
}): Promise<AnthropicNonOkResult>;
|
|
333
348
|
/**
|
|
334
349
|
* Detect Anthropic's anti-abuse / request-construction 429.
|
|
@@ -388,6 +403,11 @@ export declare function parseClaudeErrorBody(errBody: string): ParsedClaudeError
|
|
|
388
403
|
* Detect malformed request errors that should not trigger account/provider failover.
|
|
389
404
|
*/
|
|
390
405
|
export declare function isInvalidRequestError(status: number, errBody: string): boolean;
|
|
406
|
+
/**
|
|
407
|
+
* A 404 for a retired model can be served by an explicitly configured fallback;
|
|
408
|
+
* other 404s remain terminal so a bad endpoint or resource is never disguised.
|
|
409
|
+
*/
|
|
410
|
+
declare function isAnthropicModelNotFound(status: number, errBody: string): boolean;
|
|
391
411
|
/**
|
|
392
412
|
* A subscription-specific beta rejection. Anthropic returns
|
|
393
413
|
* `400 invalid_request_error` with a message like
|
|
@@ -492,7 +512,10 @@ export declare const __testHooks: {
|
|
|
492
512
|
redactProviderErrorMessage: typeof redactProviderErrorMessage;
|
|
493
513
|
isUpstreamOverload: typeof isUpstreamOverload;
|
|
494
514
|
getOverloadRotationDelayMs: typeof getOverloadRotationDelayMs;
|
|
515
|
+
redactHeadersForBorrower: typeof redactHeadersForBorrower;
|
|
495
516
|
shouldAttemptClaudeFallback: typeof shouldAttemptClaudeFallback;
|
|
517
|
+
isAnthropicModelNotFound: typeof isAnthropicModelNotFound;
|
|
518
|
+
getCodexFallbackInvalidRequestFailure: typeof getCodexFallbackInvalidRequestFailure;
|
|
496
519
|
executeClaudeFallbackWithRetry: typeof executeClaudeFallbackWithRetry;
|
|
497
520
|
buildClaudeAnthropicFailureResponse: typeof buildClaudeAnthropicFailureResponse;
|
|
498
521
|
isAccountEntitlementError: typeof isAccountEntitlementError;
|