@juspay/neurolink 11.13.2 → 11.14.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.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Per-account token and cost totals, read incrementally from the proxy's own
3
+ * request log.
4
+ *
5
+ * `~/.neurolink/logs` runs to hundreds of megabytes, so a polled endpoint
6
+ * cannot re-read it per request. This keeps a per-file cursor and only ever
7
+ * reads bytes appended since the last call.
8
+ *
9
+ * ## Why this is not built on proxyAnalysis.ts
10
+ *
11
+ * That module always re-reads whole files and sweeps the lifecycle, attempts
12
+ * and debug streams too. Attempts carry one row per retry for the same
13
+ * requestId, so folding them in would multiply a retried request's tokens by
14
+ * its retry count.
15
+ *
16
+ * ## Correctness rules this file exists to enforce
17
+ *
18
+ * - **Only `proxy-<date>.jsonl`.** Never attempts/lifecycle/debug.
19
+ * - **Dedupe by requestId.** A request can be logged twice: once when the
20
+ * response headers are known and again when a streamed body finishes and its
21
+ * token counts arrive (the Codex engine does exactly this). Totals are
22
+ * derived from a requestId-keyed map, never by adding up raw lines.
23
+ * - **Only advance the cursor to the last complete newline**, so a line still
24
+ * being appended is re-read whole next time rather than parsed truncated.
25
+ * - **A shrinking file means delete-and-recreate**, not truncation — the writer
26
+ * only appends. Reset the cursor rather than reporting corruption.
27
+ */
28
+ import type { CliAccountUsageTotals } from "../types/index.js";
29
+ /** UTC date stamp of the log file the totals cover. */
30
+ export declare function currentUsageDate(now?: Date): string;
31
+ /**
32
+ * Token and cost totals for one UTC day, keyed by bare account label.
33
+ *
34
+ * Only Anthropic-pool rows are attributed, so a Codex account sharing a label
35
+ * with an Anthropic one cannot contribute its tokens to the wrong row.
36
+ */
37
+ export declare function readAccountUsage(date?: string): Promise<Map<string, CliAccountUsageTotals>>;
38
+ /** Drop all cached cursors. Exported for tests, which vary HOME per case. */
39
+ export declare function resetAccountLedgerCache(): void;
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Per-account token and cost totals, read incrementally from the proxy's own
3
+ * request log.
4
+ *
5
+ * `~/.neurolink/logs` runs to hundreds of megabytes, so a polled endpoint
6
+ * cannot re-read it per request. This keeps a per-file cursor and only ever
7
+ * reads bytes appended since the last call.
8
+ *
9
+ * ## Why this is not built on proxyAnalysis.ts
10
+ *
11
+ * That module always re-reads whole files and sweeps the lifecycle, attempts
12
+ * and debug streams too. Attempts carry one row per retry for the same
13
+ * requestId, so folding them in would multiply a retried request's tokens by
14
+ * its retry count.
15
+ *
16
+ * ## Correctness rules this file exists to enforce
17
+ *
18
+ * - **Only `proxy-<date>.jsonl`.** Never attempts/lifecycle/debug.
19
+ * - **Dedupe by requestId.** A request can be logged twice: once when the
20
+ * response headers are known and again when a streamed body finishes and its
21
+ * token counts arrive (the Codex engine does exactly this). Totals are
22
+ * derived from a requestId-keyed map, never by adding up raw lines.
23
+ * - **Only advance the cursor to the last complete newline**, so a line still
24
+ * being appended is re-read whole next time rather than parsed truncated.
25
+ * - **A shrinking file means delete-and-recreate**, not truncation — the writer
26
+ * only appends. Reset the cursor rather than reporting corruption.
27
+ */
28
+ import { homedir } from "node:os";
29
+ import { join } from "node:path";
30
+ import { calculateCost, hasPricing } from "../utils/pricing.js";
31
+ /** Same pattern proxyAnalysis.ts uses, deliberately duplicated as a constant. */
32
+ const REQUEST_FILE_PATTERN = /^proxy-\d{4}-\d{2}-\d{2}\.jsonl$/;
33
+ /**
34
+ * Account types written by the Anthropic pool. The request log is shared with
35
+ * the Codex engine, and an operator can use the same email for both, so rows
36
+ * must be filtered by engine before being attributed to an Anthropic account.
37
+ */
38
+ const ANTHROPIC_ACCOUNT_TYPES = new Set(["oauth", "api_key"]);
39
+ const cursors = new Map();
40
+ function getLogsDir() {
41
+ return join(homedir(), ".neurolink", "logs");
42
+ }
43
+ function finiteNumber(value) {
44
+ return typeof value === "number" && Number.isFinite(value) && value > 0
45
+ ? value
46
+ : 0;
47
+ }
48
+ /**
49
+ * Provider to price against, given a log row.
50
+ *
51
+ * The Anthropic pool never sets `provider` on its records, so the account type
52
+ * is the only signal for the overwhelming majority of rows.
53
+ */
54
+ function resolveProvider(entry) {
55
+ if (entry.provider) {
56
+ return entry.provider;
57
+ }
58
+ if (ANTHROPIC_ACCOUNT_TYPES.has(entry.accountType)) {
59
+ return "anthropic";
60
+ }
61
+ if (entry.accountType === "codex-oauth") {
62
+ return "openai";
63
+ }
64
+ // Translation and unknown rows can have landed anywhere; the cross-provider
65
+ // lookup finds the model wherever it lives rather than guessing a vendor.
66
+ return "openai-compatible";
67
+ }
68
+ /**
69
+ * Read newly appended lines and fold them into the file's requestId map.
70
+ *
71
+ * `node:fs` is imported dynamically rather than statically: this module is
72
+ * reachable from the bundled SDK, whose browser build stubs `node:fs` and has
73
+ * no `readSync`, so a static import fails the bundle outright.
74
+ */
75
+ async function advanceCursor(fileName, cursor) {
76
+ const { closeSync, openSync, readSync, statSync } = await import("node:fs");
77
+ const path = join(getLogsDir(), fileName);
78
+ let size;
79
+ try {
80
+ size = statSync(path).size;
81
+ }
82
+ catch {
83
+ // Retention deleted the file. Its accumulated totals are the final answer
84
+ // for that day and can never be recomputed, so freeze rather than drop.
85
+ return;
86
+ }
87
+ if (size < cursor.size) {
88
+ // Only reachable via delete-and-recreate; the writer appends in place.
89
+ cursor.offset = 0;
90
+ cursor.entries.clear();
91
+ }
92
+ cursor.size = size;
93
+ if (size <= cursor.offset) {
94
+ return;
95
+ }
96
+ let chunk;
97
+ const fd = openSync(path, "r");
98
+ try {
99
+ const length = size - cursor.offset;
100
+ chunk = Buffer.alloc(length);
101
+ readSync(fd, chunk, 0, length, cursor.offset);
102
+ }
103
+ finally {
104
+ closeSync(fd);
105
+ }
106
+ const text = chunk.toString("utf8");
107
+ const lastBreak = text.lastIndexOf("\n");
108
+ if (lastBreak === -1) {
109
+ // A partial line with no terminator yet; leave the cursor where it is.
110
+ return;
111
+ }
112
+ cursor.offset += Buffer.byteLength(text.slice(0, lastBreak + 1), "utf8");
113
+ for (const line of text.slice(0, lastBreak).split("\n")) {
114
+ if (!line.trim()) {
115
+ continue;
116
+ }
117
+ let record;
118
+ try {
119
+ record = JSON.parse(line);
120
+ }
121
+ catch {
122
+ continue;
123
+ }
124
+ const requestId = record.requestId;
125
+ const account = record.account;
126
+ if (typeof requestId !== "string" || typeof account !== "string") {
127
+ continue;
128
+ }
129
+ if (!account) {
130
+ continue;
131
+ }
132
+ // requestId can come straight from a client-supplied X-Request-ID header,
133
+ // so it is not guaranteed unique across genuinely separate requests. Keying
134
+ // on the triple means two distinct calls that collide on id but differ in
135
+ // account or model are still counted separately; a true re-log of the same
136
+ // request keeps the same triple and merges.
137
+ const entryKey = `${requestId}\u0000${account}\u0000${typeof record.model === "string" ? record.model : ""}`;
138
+ const next = {
139
+ account,
140
+ accountType: typeof record.accountType === "string" ? record.accountType : "",
141
+ model: typeof record.model === "string" ? record.model : "",
142
+ provider: typeof record.provider === "string" ? record.provider : undefined,
143
+ inputTokens: finiteNumber(record.inputTokens),
144
+ outputTokens: finiteNumber(record.outputTokens),
145
+ cacheReadTokens: finiteNumber(record.cacheReadTokens),
146
+ cacheCreationTokens: finiteNumber(record.cacheCreationTokens),
147
+ };
148
+ // A later record for the same request enriches the earlier one — it must
149
+ // replace it, never add to it. But token fields take the MAX rather than
150
+ // the newer value: a request can be re-logged with no token fields at all
151
+ // (a terminal error recorded after a successful response), and letting
152
+ // those zeros win would silently erase real usage.
153
+ //
154
+ // The catch is that the key is not guaranteed unique. requestId can come
155
+ // straight from a client-supplied X-Request-ID, so a fixed correlation
156
+ // header or an idempotency wrapper produces N genuinely distinct requests
157
+ // that agree on id, account and model. Merging those undercounts by a
158
+ // factor of N.
159
+ //
160
+ // What separates the two cases is usage. The re-log this dedup exists for
161
+ // is an *enrichment*: the Codex engine writes once when the response
162
+ // headers are known, carrying no tokens, and again when the stream ends,
163
+ // carrying them all. No writer ever emits two token-bearing lines for one
164
+ // request — the Anthropic engine's terminal log is guarded by a per-request
165
+ // flag, and retries go to a separate attempts file this reader never opens.
166
+ // So a second line that carries its own usage is a second request, and
167
+ // takes its own slot.
168
+ const slotKey = resolveSlotKey(cursor, entryKey, next);
169
+ const previous = cursor.entries.get(slotKey);
170
+ cursor.entries.set(slotKey, previous
171
+ ? {
172
+ ...previous,
173
+ ...next,
174
+ inputTokens: Math.max(previous.inputTokens, next.inputTokens),
175
+ outputTokens: Math.max(previous.outputTokens, next.outputTokens),
176
+ cacheReadTokens: Math.max(previous.cacheReadTokens, next.cacheReadTokens),
177
+ cacheCreationTokens: Math.max(previous.cacheCreationTokens, next.cacheCreationTokens),
178
+ // Likewise keep a real model name over a placeholder.
179
+ model: next.model && next.model !== "-" ? next.model : previous.model,
180
+ }
181
+ : next);
182
+ }
183
+ }
184
+ /** Whether a log record carries any usage at all. */
185
+ function hasUsage(entry) {
186
+ return (entry.inputTokens > 0 ||
187
+ entry.outputTokens > 0 ||
188
+ entry.cacheReadTokens > 0 ||
189
+ entry.cacheCreationTokens > 0);
190
+ }
191
+ /**
192
+ * Where this record belongs: the existing slot for its id, or a fresh one.
193
+ *
194
+ * Returns `entryKey` for the first record with that id, and for any later
195
+ * record that looks like a re-log of it. A later record that reports its own,
196
+ * different usage is a separate request that merely collided on a
197
+ * client-supplied id, so it gets the next free numbered slot instead.
198
+ */
199
+ function resolveSlotKey(cursor, entryKey, next) {
200
+ const base = cursor.entries.get(entryKey);
201
+ if (!base || !hasUsage(next)) {
202
+ return entryKey;
203
+ }
204
+ let slot = entryKey;
205
+ let occupant = base;
206
+ let index = 1;
207
+ while (occupant) {
208
+ if (!hasUsage(occupant)) {
209
+ return slot;
210
+ }
211
+ index += 1;
212
+ slot = `${entryKey}\u0000#${index}`;
213
+ occupant = cursor.entries.get(slot);
214
+ }
215
+ return slot;
216
+ }
217
+ /** UTC date stamp of the log file the totals cover. */
218
+ export function currentUsageDate(now = new Date()) {
219
+ return now.toISOString().slice(0, 10);
220
+ }
221
+ function emptyTotals() {
222
+ return {
223
+ requests: 0,
224
+ inputTokens: 0,
225
+ outputTokens: 0,
226
+ cacheReadTokens: 0,
227
+ cacheCreationTokens: 0,
228
+ costUsd: 0,
229
+ unpricedRequests: 0,
230
+ unpricedModels: [],
231
+ };
232
+ }
233
+ /**
234
+ * Token and cost totals for one UTC day, keyed by bare account label.
235
+ *
236
+ * Only Anthropic-pool rows are attributed, so a Codex account sharing a label
237
+ * with an Anthropic one cannot contribute its tokens to the wrong row.
238
+ */
239
+ export async function readAccountUsage(date = currentUsageDate()) {
240
+ const fileName = `proxy-${date}.jsonl`;
241
+ if (!REQUEST_FILE_PATTERN.test(fileName)) {
242
+ return new Map();
243
+ }
244
+ // Only the current day is ever read, so anything else is a stale cursor
245
+ // holding one object per requestId seen that day. Without this the map grows
246
+ // for the life of the process.
247
+ for (const key of cursors.keys()) {
248
+ if (key !== fileName) {
249
+ cursors.delete(key);
250
+ }
251
+ }
252
+ let cursor = cursors.get(fileName);
253
+ if (!cursor) {
254
+ cursor = { offset: 0, size: 0, entries: new Map() };
255
+ cursors.set(fileName, cursor);
256
+ }
257
+ await advanceCursor(fileName, cursor);
258
+ const totals = new Map();
259
+ const unpriced = new Map();
260
+ for (const entry of cursor.entries.values()) {
261
+ if (!ANTHROPIC_ACCOUNT_TYPES.has(entry.accountType)) {
262
+ continue;
263
+ }
264
+ const row = totals.get(entry.account) ?? emptyTotals();
265
+ row.requests += 1;
266
+ row.inputTokens += entry.inputTokens;
267
+ row.outputTokens += entry.outputTokens;
268
+ row.cacheReadTokens += entry.cacheReadTokens;
269
+ row.cacheCreationTokens += entry.cacheCreationTokens;
270
+ const provider = resolveProvider(entry);
271
+ if (entry.model && entry.model !== "-") {
272
+ if (hasPricing(provider, entry.model)) {
273
+ row.costUsd += calculateCost(provider, entry.model, {
274
+ input: entry.inputTokens,
275
+ output: entry.outputTokens,
276
+ total: entry.inputTokens + entry.outputTokens,
277
+ cacheReadTokens: entry.cacheReadTokens,
278
+ cacheCreationTokens: entry.cacheCreationTokens,
279
+ });
280
+ }
281
+ else {
282
+ row.unpricedRequests += 1;
283
+ const seen = unpriced.get(entry.account) ?? new Set();
284
+ seen.add(entry.model);
285
+ unpriced.set(entry.account, seen);
286
+ }
287
+ }
288
+ totals.set(entry.account, row);
289
+ }
290
+ for (const [account, row] of totals) {
291
+ row.costUsd = Number(row.costUsd.toFixed(6));
292
+ row.unpricedModels = [...(unpriced.get(account) ?? [])].sort();
293
+ }
294
+ return totals;
295
+ }
296
+ /** Drop all cached cursors. Exported for tests, which vary HOME per case. */
297
+ export function resetAccountLedgerCache() {
298
+ cursors.clear();
299
+ }
@@ -251,6 +251,17 @@ export async function handleTranslatedStreamRequest(args) {
251
251
  let translatedModel;
252
252
  /** Provider that actually served the successful attempt, for costing. */
253
253
  let translatedProvider;
254
+ /**
255
+ * Whether the substitution has already been reported to the tracer.
256
+ *
257
+ * The success path records it before the tracer prices anything, and the
258
+ * finally block keeps its own call as a net for any future path that sets
259
+ * translatedModel without reaching that point. Both guards are otherwise
260
+ * identical and translatedModel is assigned in exactly one place, so without
261
+ * this flag every substituted stream reports twice and
262
+ * proxy_model_substitution_total reads exactly double the real count.
263
+ */
264
+ let substitutionRecorded = false;
254
265
  let finalStreamError = "No translation providers succeeded";
255
266
  let upstreamIterator;
256
267
  let lastAttemptLabel = "translation";
@@ -338,10 +349,13 @@ export async function handleTranslatedStreamRequest(args) {
338
349
  // bills the model the client ASKED for — which is exactly what
339
350
  // ProxyTracer.setModelSubstitution() documents as wrong, since a
340
351
  // claude-* alias served by another provider would be charged at
341
- // Claude rates. The finally block below still calls it, harmlessly,
342
- // for paths that never reach here.
352
+ // Claude rates. The finally block below keeps its own call as a
353
+ // net for any future path that sets translatedModel without
354
+ // reaching here; substitutionRecorded stops the two from
355
+ // double-counting proxy_model_substitution_total.
343
356
  if (tracer && translatedModel && translatedModel !== requestModel) {
344
357
  tracer.setModelSubstitution(requestModel, translatedModel, translatedProvider);
358
+ substitutionRecorded = true;
345
359
  }
346
360
  // Track usage and metrics
347
361
  const resolvedUsageForTracer = extractUsageFromStreamResult(streamResult.usage);
@@ -391,8 +405,12 @@ export async function handleTranslatedStreamRequest(args) {
391
405
  if (!cancelled) {
392
406
  controller.close();
393
407
  }
394
- if (tracer && translatedModel && translatedModel !== requestModel) {
408
+ if (!substitutionRecorded &&
409
+ tracer &&
410
+ translatedModel &&
411
+ translatedModel !== requestModel) {
395
412
  tracer.setModelSubstitution(requestModel, translatedModel, translatedProvider);
413
+ substitutionRecorded = true;
396
414
  }
397
415
  const terminalStatus = cancelled
398
416
  ? 499
@@ -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, 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, 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>;
@@ -348,15 +348,13 @@ declare function fetchAnthropicAccountResponse(args: {
348
348
  }): Promise<AnthropicUpstreamFetchResult>;
349
349
  declare function shouldAttemptClaudeFallback(loopState: AnthropicLoopState): boolean;
350
350
  /**
351
- * Create Claude-compatible proxy routes.
351
+ * Normalise one account's quota for a dashboard consumer.
352
352
  *
353
- * Every request flows through ctx.neurolink.generate() or ctx.neurolink.stream().
354
- * No direct fetch() calls to api.anthropic.com.
355
- *
356
- * @param modelRouter - Optional model router for remapping model names.
357
- * @param basePath - Base path prefix (default: "" since Claude API uses /v1/...).
358
- * @returns RouteGroup with Claude-compatible endpoints.
353
+ * `severity` and `isActive` are absent on header-sourced windows — a
354
+ * structural property of how those rows are parsed, not a transient gap — so
355
+ * every consumer would otherwise need the same fallback branch.
359
356
  */
357
+ declare function normalizeQuotaForAccounts(quota: unknown): JsonObject | null;
360
358
  export declare function createClaudeProxyRoutes(modelRouter?: ModelRouterInterface, basePath?: string, accountStrategy?: "round-robin" | "fill-first", passthroughMode?: boolean, primaryAccountKey?: string, accountAllowlistOrRuntimeOptions?: AccountAllowlist | ClaudeProxyRouteRuntimeOptions): RouteGroup;
361
359
  declare function reconcileEligibleAccountRuntimeState(account: ProxyPassthroughAccount): void;
362
360
  export declare function getTransientSameAccountRetryDelayMs(retryNumber: number): number;
@@ -428,6 +426,7 @@ export declare function isTransientHttpFailure(status: number, errBody: string):
428
426
  export declare function isUpstreamOverload(status: number, errBody: string): boolean;
429
427
  export declare function redactProviderErrorMessage(message: string): string;
430
428
  export declare const __testHooks: {
429
+ normalizeQuotaForAccounts: typeof normalizeQuotaForAccounts;
431
430
  resolveHomeIndex: typeof resolveHomeIndex;
432
431
  maybeResetPrimaryToHome: typeof maybeResetPrimaryToHome;
433
432
  planCooldownFor429: typeof planCooldownFor429;