@juspay/neurolink 9.87.3 → 9.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/browser/neurolink.min.js +381 -381
  3. package/dist/cli/commands/proxy.d.ts +16 -1
  4. package/dist/cli/commands/proxy.js +275 -238
  5. package/dist/cli/factories/commandFactory.d.ts +39 -0
  6. package/dist/cli/factories/commandFactory.js +484 -3
  7. package/dist/core/conversationMemoryManager.d.ts +11 -1
  8. package/dist/core/conversationMemoryManager.js +44 -0
  9. package/dist/core/redisConversationMemoryManager.d.ts +12 -1
  10. package/dist/core/redisConversationMemoryManager.js +80 -0
  11. package/dist/lib/core/conversationMemoryManager.d.ts +11 -1
  12. package/dist/lib/core/conversationMemoryManager.js +44 -0
  13. package/dist/lib/core/redisConversationMemoryManager.d.ts +12 -1
  14. package/dist/lib/core/redisConversationMemoryManager.js +80 -0
  15. package/dist/lib/neurolink.d.ts +27 -0
  16. package/dist/lib/neurolink.js +133 -0
  17. package/dist/lib/proxy/globalInstaller.d.ts +5 -0
  18. package/dist/lib/proxy/globalInstaller.js +156 -0
  19. package/dist/lib/proxy/openaiFormat.d.ts +3 -1
  20. package/dist/lib/proxy/openaiFormat.js +19 -4
  21. package/dist/lib/proxy/proxyActivity.d.ts +8 -0
  22. package/dist/lib/proxy/proxyActivity.js +77 -0
  23. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +56 -6
  24. package/dist/lib/server/routes/claudeProxyRoutes.js +208 -50
  25. package/dist/lib/server/routes/openaiProxyRoutes.js +47 -6
  26. package/dist/lib/types/cli.d.ts +4 -0
  27. package/dist/lib/types/conversation.d.ts +36 -0
  28. package/dist/lib/types/conversationMemoryInterface.d.ts +5 -1
  29. package/dist/lib/types/proxy.d.ts +49 -0
  30. package/dist/lib/utils/fileDetector.js +34 -10
  31. package/dist/neurolink.d.ts +27 -0
  32. package/dist/neurolink.js +133 -0
  33. package/dist/proxy/globalInstaller.d.ts +5 -0
  34. package/dist/proxy/globalInstaller.js +155 -0
  35. package/dist/proxy/openaiFormat.d.ts +3 -1
  36. package/dist/proxy/openaiFormat.js +19 -4
  37. package/dist/proxy/proxyActivity.d.ts +8 -0
  38. package/dist/proxy/proxyActivity.js +76 -0
  39. package/dist/server/routes/claudeProxyRoutes.d.ts +56 -6
  40. package/dist/server/routes/claudeProxyRoutes.js +208 -50
  41. package/dist/server/routes/openaiProxyRoutes.js +47 -6
  42. package/dist/types/cli.d.ts +4 -0
  43. package/dist/types/conversation.d.ts +36 -0
  44. package/dist/types/conversationMemoryInterface.d.ts +5 -1
  45. package/dist/types/proxy.d.ts +49 -0
  46. package/dist/utils/fileDetector.js +34 -10
  47. package/package.json +3 -2
@@ -1823,23 +1823,47 @@ class ExtensionStrategy {
1823
1823
  };
1824
1824
  }
1825
1825
  getExtension(input) {
1826
- if (this.isURL(input)) {
1827
- const url = new URL(input);
1828
- const match = url.pathname.match(/\.([^.]+)$/);
1829
- return match ? match[1] : null;
1826
+ const normalizedInput = input.trim();
1827
+ let extensionSource = normalizedInput;
1828
+ if (this.isURL(normalizedInput)) {
1829
+ try {
1830
+ const url = new URL(normalizedInput);
1831
+ extensionSource = url.pathname;
1832
+ try {
1833
+ extensionSource = decodeURIComponent(extensionSource);
1834
+ }
1835
+ catch {
1836
+ // Keep the original pathname if the URL contains malformed escapes.
1837
+ }
1838
+ }
1839
+ catch {
1840
+ extensionSource = normalizedInput;
1841
+ }
1830
1842
  }
1831
- const match = input.match(/\.([^.]+)$/);
1832
- return match ? match[1] : null;
1843
+ const match = extensionSource.trim().match(/\.([^.]+)$/);
1844
+ if (!match) {
1845
+ return null;
1846
+ }
1847
+ const ext = match[1].split(/[?#]/)[0].toLowerCase();
1848
+ return /^[a-z0-9]+$/.test(ext) ? ext : null;
1833
1849
  }
1834
1850
  isURL(str) {
1835
- return str.startsWith("http://") || str.startsWith("https://");
1851
+ const normalized = str.trim();
1852
+ return (normalized.startsWith("http://") || normalized.startsWith("https://"));
1836
1853
  }
1837
1854
  detectSource(input) {
1838
- if (input.startsWith("data:")) {
1855
+ const normalized = input.trim();
1856
+ if (normalized.startsWith("data:")) {
1839
1857
  return "datauri";
1840
1858
  }
1841
- if (this.isURL(input)) {
1842
- return "url";
1859
+ if (this.isURL(normalized)) {
1860
+ try {
1861
+ new URL(normalized);
1862
+ return "url";
1863
+ }
1864
+ catch {
1865
+ return "path";
1866
+ }
1843
1867
  }
1844
1868
  return "path";
1845
1869
  }
@@ -14,6 +14,7 @@ import { MCPToolRegistry } from "./mcp/toolRegistry.js";
14
14
  import type { DynamicOptions } from "./types/index.js";
15
15
  import { SkillsManager } from "./skills/skillsManager.js";
16
16
  import { TaskManager } from "./tasks/taskManager.js";
17
+ import type { SessionExport, SessionListItem } from "./types/index.js";
17
18
  /**
18
19
  * Curator P2-4 dedup (concurrency-safe): native providers emit
19
20
  * `generation:end` on the shared SDK emitter. We attach a fresh
@@ -1631,6 +1632,32 @@ export declare class NeuroLink {
1631
1632
  * Clear all conversation history (public API)
1632
1633
  */
1633
1634
  clearAllConversations(): Promise<void>;
1635
+ /**
1636
+ * List all conversation sessions with metadata (public API)
1637
+ * @param userId - Optional user ID to filter sessions (required for Redis storage)
1638
+ * @returns Array of session list items with metadata
1639
+ */
1640
+ listSessions(userId?: string): Promise<SessionListItem[]>;
1641
+ /**
1642
+ * Export a single session with full history and metadata (public API)
1643
+ * @param sessionId - The session ID to export
1644
+ * @param options - Export options
1645
+ * @returns Session export object with full history
1646
+ */
1647
+ exportSession(sessionId: string, options?: {
1648
+ includeMetadata?: boolean;
1649
+ format?: "json" | "csv";
1650
+ }): Promise<SessionExport | null>;
1651
+ /**
1652
+ * Export all sessions for a user (public API)
1653
+ * @param userId - Optional user ID (required for Redis storage)
1654
+ * @param options - Export options
1655
+ * @returns Array of session exports
1656
+ */
1657
+ exportAllSessions(userId?: string, options?: {
1658
+ includeMetadata?: boolean;
1659
+ format?: "json" | "csv";
1660
+ }): Promise<SessionExport[]>;
1634
1661
  /**
1635
1662
  * Store tool executions in conversation memory if enabled and Redis is configured
1636
1663
  * @param sessionId - Session identifier
package/dist/neurolink.js CHANGED
@@ -10504,6 +10504,139 @@ Current user's request: ${currentInput}`;
10504
10504
  this.lastCompactionMessageCount.clear();
10505
10505
  await this.conversationMemory.clearAllSessions();
10506
10506
  }
10507
+ /**
10508
+ * List all conversation sessions with metadata (public API)
10509
+ * @param userId - Optional user ID to filter sessions (required for Redis storage)
10510
+ * @returns Array of session list items with metadata
10511
+ */
10512
+ async listSessions(userId) {
10513
+ // First ensure memory is initialized
10514
+ const initId = `list-sessions-init-${Date.now()}`;
10515
+ await this.initializeConversationMemoryForGeneration(initId, Date.now(), process.hrtime.bigint());
10516
+ if (!this.conversationMemory) {
10517
+ throw new Error("Conversation memory is not enabled");
10518
+ }
10519
+ // Check if listSessions is available on the memory manager
10520
+ if (!this.conversationMemory.listSessions) {
10521
+ logger.warn("listSessions not available on current memory manager");
10522
+ return [];
10523
+ }
10524
+ const MEMORY_OPERATION_TIMEOUT = 30000; // 30 seconds
10525
+ try {
10526
+ const sessions = await withTimeout(this.conversationMemory.listSessions(userId), MEMORY_OPERATION_TIMEOUT, new Error("listSessions operation timed out after 30s"));
10527
+ logger.debug("Listed conversation sessions", {
10528
+ userId,
10529
+ sessionCount: sessions.length,
10530
+ });
10531
+ return sessions;
10532
+ }
10533
+ catch (error) {
10534
+ logger.error("Failed to list conversation sessions", {
10535
+ userId,
10536
+ error: error instanceof Error ? error.message : String(error),
10537
+ });
10538
+ return [];
10539
+ }
10540
+ }
10541
+ /**
10542
+ * Export a single session with full history and metadata (public API)
10543
+ * @param sessionId - The session ID to export
10544
+ * @param options - Export options
10545
+ * @returns Session export object with full history
10546
+ */
10547
+ async exportSession(sessionId, options = {}) {
10548
+ // First ensure memory is initialized
10549
+ const initId = `export-session-init-${Date.now()}`;
10550
+ await this.initializeConversationMemoryForGeneration(initId, Date.now(), process.hrtime.bigint());
10551
+ if (!this.conversationMemory) {
10552
+ throw new Error("Conversation memory is not enabled");
10553
+ }
10554
+ if (!sessionId || typeof sessionId !== "string") {
10555
+ throw new Error("Session ID must be a non-empty string");
10556
+ }
10557
+ const MEMORY_OPERATION_TIMEOUT = 30000; // 30 seconds
10558
+ try {
10559
+ const messages = await withTimeout(this.conversationMemory.buildContextMessages(sessionId), MEMORY_OPERATION_TIMEOUT, new Error("buildContextMessages operation timed out after 30s"));
10560
+ if (messages.length === 0) {
10561
+ logger.debug("No messages found for session export", { sessionId });
10562
+ return null;
10563
+ }
10564
+ const sessionResult = this.conversationMemory.getSession(sessionId);
10565
+ const session = await withTimeout(sessionResult instanceof Promise
10566
+ ? sessionResult
10567
+ : Promise.resolve(sessionResult), MEMORY_OPERATION_TIMEOUT, new Error("getSession operation timed out after 30s"));
10568
+ const now = new Date().toISOString();
10569
+ const exportData = {
10570
+ sessionId,
10571
+ title: sessionId, // Use sessionId as title if not available
10572
+ userId: session?.userId,
10573
+ createdAt: session?.createdAt
10574
+ ? new Date(session.createdAt).toISOString()
10575
+ : now,
10576
+ updatedAt: session?.lastActivity
10577
+ ? new Date(session.lastActivity).toISOString()
10578
+ : now,
10579
+ messages,
10580
+ };
10581
+ if (options.includeMetadata) {
10582
+ exportData.exportMetadata = {
10583
+ exportedAt: now,
10584
+ exportFormat: options.format || "json",
10585
+ };
10586
+ }
10587
+ logger.debug("Exported conversation session", {
10588
+ sessionId,
10589
+ messageCount: messages.length,
10590
+ });
10591
+ return exportData;
10592
+ }
10593
+ catch (error) {
10594
+ logger.error("Failed to export conversation session", {
10595
+ sessionId,
10596
+ error: error instanceof Error ? error.message : String(error),
10597
+ });
10598
+ return null;
10599
+ }
10600
+ }
10601
+ /**
10602
+ * Export all sessions for a user (public API)
10603
+ * @param userId - Optional user ID (required for Redis storage)
10604
+ * @param options - Export options
10605
+ * @returns Array of session exports
10606
+ */
10607
+ async exportAllSessions(userId, options = {}) {
10608
+ // First ensure memory is initialized
10609
+ const initId = `export-all-init-${Date.now()}`;
10610
+ await this.initializeConversationMemoryForGeneration(initId, Date.now(), process.hrtime.bigint());
10611
+ if (!this.conversationMemory) {
10612
+ throw new Error("Conversation memory is not enabled");
10613
+ }
10614
+ const MEMORY_OPERATION_TIMEOUT = 30000; // 30 seconds
10615
+ const EXPORT_SESSION_TIMEOUT = 60000; // 60 seconds for full export
10616
+ try {
10617
+ // Get all session IDs
10618
+ const sessions = await withTimeout(this.listSessions(userId), MEMORY_OPERATION_TIMEOUT, new Error("listSessions operation timed out after 30s"));
10619
+ const exports = [];
10620
+ for (const session of sessions) {
10621
+ const exportData = await withTimeout(this.exportSession(session.id, options), EXPORT_SESSION_TIMEOUT, new Error(`exportSession operation timed out after 60s for session ${session.id}`));
10622
+ if (exportData) {
10623
+ exports.push(exportData);
10624
+ }
10625
+ }
10626
+ logger.debug("Exported all conversation sessions", {
10627
+ userId,
10628
+ sessionCount: exports.length,
10629
+ });
10630
+ return exports;
10631
+ }
10632
+ catch (error) {
10633
+ logger.error("Failed to export all conversation sessions", {
10634
+ userId,
10635
+ error: error instanceof Error ? error.message : String(error),
10636
+ });
10637
+ return [];
10638
+ }
10639
+ }
10507
10640
  /**
10508
10641
  * Store tool executions in conversation memory if enabled and Redis is configured
10509
10642
  * @param sessionId - Session identifier
@@ -0,0 +1,5 @@
1
+ import type { GlobalInstallerKind, GlobalInstallerResolution, ResolveGlobalInstallerOptions } from "../types/index.js";
2
+ /** Resolve a package manager that can update the installation currently running. */
3
+ export declare function resolveGlobalInstaller(options?: ResolveGlobalInstallerOptions): GlobalInstallerResolution;
4
+ export declare function getGlobalInstallArgs(kind: GlobalInstallerKind, packageSpec: string): string[];
5
+ export declare function describeInstallFailure(error: unknown): string;
@@ -0,0 +1,155 @@
1
+ import { execFileSync as nodeExecFileSync } from "node:child_process";
2
+ import { accessSync, constants, existsSync, realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ function runText(execFileSync, bin, args) {
6
+ return String(execFileSync(bin, args, {
7
+ encoding: "utf8",
8
+ timeout: 10_000,
9
+ stdio: ["ignore", "pipe", "pipe"],
10
+ })).trim();
11
+ }
12
+ function writableDirectory(path) {
13
+ try {
14
+ if (!existsSync(path)) {
15
+ return false;
16
+ }
17
+ accessSync(path, constants.W_OK);
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ function isPathInside(path, parent) {
25
+ if (!path) {
26
+ return false;
27
+ }
28
+ try {
29
+ const candidate = realpathSync(path);
30
+ const root = realpathSync(parent);
31
+ return candidate === root || candidate.startsWith(`${root}/`);
32
+ }
33
+ catch {
34
+ const candidate = resolve(path);
35
+ const root = resolve(parent);
36
+ return candidate === root || candidate.startsWith(`${root}/`);
37
+ }
38
+ }
39
+ function probeInstaller(kind, bin, entryScript, execFileSync) {
40
+ const base = {
41
+ kind,
42
+ bin,
43
+ working: false,
44
+ installable: false,
45
+ matchesCurrentInstall: false,
46
+ };
47
+ try {
48
+ base.version = runText(execFileSync, bin, ["--version"]);
49
+ base.working = base.version.length > 0;
50
+ base.globalRoot = runText(execFileSync, bin, ["root", "-g"]);
51
+ base.globalBinDir =
52
+ kind === "pnpm"
53
+ ? runText(execFileSync, bin, ["bin", "-g"])
54
+ : join(runText(execFileSync, bin, ["prefix", "-g"]), "bin");
55
+ if (!base.globalRoot || !writableDirectory(base.globalRoot)) {
56
+ base.reason = "global package root is missing or not writable";
57
+ return base;
58
+ }
59
+ if (!base.globalBinDir || !writableDirectory(base.globalBinDir)) {
60
+ base.reason = "global executable directory is missing or not writable";
61
+ return base;
62
+ }
63
+ base.installable = true;
64
+ base.matchesCurrentInstall = isPathInside(entryScript, base.globalRoot);
65
+ return base;
66
+ }
67
+ catch (error) {
68
+ base.reason = error instanceof Error ? error.message : String(error);
69
+ return base;
70
+ }
71
+ }
72
+ function resolveFromPath(command, execFileSync) {
73
+ try {
74
+ const result = runText(execFileSync, "which", [command]);
75
+ return result || undefined;
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ }
81
+ /** Resolve a package manager that can update the installation currently running. */
82
+ export function resolveGlobalInstaller(options = {}) {
83
+ const env = options.env ?? process.env;
84
+ const homeDir = options.homeDir ?? homedir();
85
+ const entryScript = options.entryScript ?? process.argv[1];
86
+ const execFileSync = options.execFileSync ?? nodeExecFileSync;
87
+ const candidates = [];
88
+ if (env.NEUROLINK_PACKAGE_MANAGER_PATH) {
89
+ const configuredKind = env.NEUROLINK_PACKAGE_MANAGER?.toLowerCase();
90
+ const inferredName = basename(env.NEUROLINK_PACKAGE_MANAGER_PATH);
91
+ const kind = configuredKind === "npm" || configuredKind === "pnpm"
92
+ ? configuredKind
93
+ : inferredName.startsWith("npm")
94
+ ? "npm"
95
+ : "pnpm";
96
+ candidates.push({ kind, bin: env.NEUROLINK_PACKAGE_MANAGER_PATH });
97
+ }
98
+ if (env.NEUROLINK_PNPM_PATH) {
99
+ candidates.push({ kind: "pnpm", bin: env.NEUROLINK_PNPM_PATH });
100
+ }
101
+ if (env.PNPM_HOME) {
102
+ candidates.push({ kind: "pnpm", bin: join(env.PNPM_HOME, "pnpm") });
103
+ }
104
+ const nodeBinDir = dirname(process.execPath);
105
+ candidates.push({ kind: "npm", bin: join(nodeBinDir, "npm") });
106
+ const pathPnpm = resolveFromPath("pnpm", execFileSync);
107
+ const pathNpm = resolveFromPath("npm", execFileSync);
108
+ if (pathPnpm) {
109
+ candidates.push({ kind: "pnpm", bin: pathPnpm });
110
+ }
111
+ if (pathNpm) {
112
+ candidates.push({ kind: "npm", bin: pathNpm });
113
+ }
114
+ candidates.push({ kind: "pnpm", bin: join(homeDir, ".local", "share", "pnpm", "pnpm") }, { kind: "pnpm", bin: join(homeDir, "Library", "pnpm", "pnpm") }, { kind: "npm", bin: "/opt/homebrew/bin/npm" }, { kind: "npm", bin: "/usr/local/bin/npm" });
115
+ const seen = new Set();
116
+ const tried = candidates
117
+ .filter(({ kind, bin }) => {
118
+ const key = `${kind}:${bin}`;
119
+ if (!bin || seen.has(key)) {
120
+ return false;
121
+ }
122
+ seen.add(key);
123
+ return true;
124
+ })
125
+ .map(({ kind, bin }) => probeInstaller(kind, bin, entryScript, execFileSync));
126
+ const matchingInstaller = tried.find((probe) => probe.installable && probe.matchesCurrentInstall);
127
+ // When the running entry script is known, installing into a different
128
+ // global root cannot update that process and may shadow another install.
129
+ const installer = entryScript
130
+ ? matchingInstaller
131
+ : (matchingInstaller ?? tried.find((probe) => probe.installable));
132
+ return { installer, tried };
133
+ }
134
+ export function getGlobalInstallArgs(kind, packageSpec) {
135
+ return kind === "pnpm"
136
+ ? ["add", "-g", packageSpec]
137
+ : ["install", "--global", "--no-audit", "--no-fund", packageSpec];
138
+ }
139
+ function capturedOutput(error, key) {
140
+ if (!error || typeof error !== "object" || !(key in error)) {
141
+ return "";
142
+ }
143
+ const raw = error[key];
144
+ return String(raw ?? "")
145
+ .trim()
146
+ .slice(0, 1_000);
147
+ }
148
+ export function describeInstallFailure(error) {
149
+ const message = error instanceof Error ? error.message : String(error);
150
+ const stdout = capturedOutput(error, "stdout");
151
+ const stderr = capturedOutput(error, "stderr");
152
+ return [message, stdout && `stdout: ${stdout}`, stderr && `stderr: ${stderr}`]
153
+ .filter(Boolean)
154
+ .join("\n");
155
+ }
@@ -134,4 +134,6 @@ export declare function convertClaudeToOpenAIResponse(claude: ClaudeResponse, re
134
134
  * - message_delta -> captures stop_reason and output token usage
135
135
  * - message_stop -> emits the final `finish_reason` chunk + `[DONE]`
136
136
  */
137
- export declare function createClaudeToOpenAIStreamTransform(requestModel: string): TransformStream<Uint8Array, Uint8Array>;
137
+ export declare function createClaudeToOpenAIStreamTransform(requestModel: string, options?: {
138
+ onError?: (message: string) => void;
139
+ }): TransformStream<Uint8Array, Uint8Array>;
@@ -651,7 +651,7 @@ export function convertClaudeToOpenAIResponse(claude, requestModel) {
651
651
  * - message_delta -> captures stop_reason and output token usage
652
652
  * - message_stop -> emits the final `finish_reason` chunk + `[DONE]`
653
653
  */
654
- export function createClaudeToOpenAIStreamTransform(requestModel) {
654
+ export function createClaudeToOpenAIStreamTransform(requestModel, options = {}) {
655
655
  const serializer = new OpenAIStreamSerializer(requestModel);
656
656
  const encoder = new TextEncoder();
657
657
  const decoder = new TextDecoder();
@@ -677,6 +677,9 @@ export function createClaudeToOpenAIStreamTransform(requestModel) {
677
677
  catch {
678
678
  return;
679
679
  }
680
+ if (finished) {
681
+ return;
682
+ }
680
683
  switch (eventName) {
681
684
  case "message_start": {
682
685
  const message = (data.message ?? {});
@@ -749,8 +752,18 @@ export function createClaudeToOpenAIStreamTransform(requestModel) {
749
752
  }
750
753
  return;
751
754
  }
755
+ case "error": {
756
+ const error = (data.error ?? {});
757
+ const message = typeof error.message === "string"
758
+ ? error.message
759
+ : "Anthropic stream failed";
760
+ finished = true;
761
+ options.onError?.(message);
762
+ emit(controller, serializer.emitError(message));
763
+ return;
764
+ }
752
765
  default:
753
- // ping, error, and unknown events are ignored.
766
+ // ping and unknown events are ignored.
754
767
  return;
755
768
  }
756
769
  };
@@ -790,10 +803,12 @@ export function createClaudeToOpenAIStreamTransform(requestModel) {
790
803
  // closing `\n\n` is not silently lost.
791
804
  buffer += decoder.decode();
792
805
  drainBufferedEvents(controller);
793
- // If the upstream closed without a message_stop, still finalize.
806
+ // Closing without message_stop is an interrupted stream, not success.
794
807
  if (!finished) {
795
808
  finished = true;
796
- emit(controller, serializer.finish(stopReason, usage));
809
+ const message = "Anthropic stream ended before message_stop";
810
+ options.onError?.(message);
811
+ emit(controller, serializer.emitError(message));
797
812
  }
798
813
  },
799
814
  });
@@ -0,0 +1,8 @@
1
+ import type { ProxyActivitySnapshot } from "../types/index.js";
2
+ /** Track one client-facing proxy request until its response body settles. */
3
+ export declare function beginProxyRequest(): () => void;
4
+ export declare function getProxyActivitySnapshot(): ProxyActivitySnapshot;
5
+ export declare function isProxyActivityQuiet(snapshot: ProxyActivitySnapshot, quietThresholdMs: number, nowMs?: number): boolean;
6
+ /** Keep activity open until the response body completes, errors, or is cancelled. */
7
+ export declare function trackProxyResponse(response: Response, finishRequest: () => void): Response;
8
+ export declare function resetProxyActivityForTests(): void;
@@ -0,0 +1,76 @@
1
+ let activeRequests = 0;
2
+ let lastActivityAtMs = null;
3
+ function touchActivity() {
4
+ lastActivityAtMs = Date.now();
5
+ }
6
+ /** Track one client-facing proxy request until its response body settles. */
7
+ export function beginProxyRequest() {
8
+ activeRequests += 1;
9
+ touchActivity();
10
+ let finished = false;
11
+ return () => {
12
+ if (finished) {
13
+ return;
14
+ }
15
+ finished = true;
16
+ activeRequests = Math.max(0, activeRequests - 1);
17
+ touchActivity();
18
+ };
19
+ }
20
+ export function getProxyActivitySnapshot() {
21
+ return {
22
+ activeRequests,
23
+ lastActivityAt: lastActivityAtMs === null ? null : new Date(lastActivityAtMs),
24
+ };
25
+ }
26
+ export function isProxyActivityQuiet(snapshot, quietThresholdMs, nowMs = Date.now()) {
27
+ if (snapshot.activeRequests > 0) {
28
+ return false;
29
+ }
30
+ if (snapshot.lastActivityAt === null) {
31
+ return true;
32
+ }
33
+ return nowMs - snapshot.lastActivityAt.getTime() >= quietThresholdMs;
34
+ }
35
+ /** Keep activity open until the response body completes, errors, or is cancelled. */
36
+ export function trackProxyResponse(response, finishRequest) {
37
+ if (!response.body) {
38
+ finishRequest();
39
+ return response;
40
+ }
41
+ const reader = response.body.getReader();
42
+ const trackedBody = new ReadableStream({
43
+ async pull(controller) {
44
+ try {
45
+ const { value, done } = await reader.read();
46
+ if (done) {
47
+ finishRequest();
48
+ controller.close();
49
+ return;
50
+ }
51
+ controller.enqueue(value);
52
+ }
53
+ catch (error) {
54
+ finishRequest();
55
+ controller.error(error);
56
+ }
57
+ },
58
+ async cancel(reason) {
59
+ try {
60
+ await reader.cancel(reason);
61
+ }
62
+ finally {
63
+ finishRequest();
64
+ }
65
+ },
66
+ });
67
+ return new Response(trackedBody, {
68
+ status: response.status,
69
+ statusText: response.statusText,
70
+ headers: response.headers,
71
+ });
72
+ }
73
+ export function resetProxyActivityForTests() {
74
+ activeRequests = 0;
75
+ lastActivityAtMs = null;
76
+ }
@@ -13,7 +13,7 @@ import { buildTranslationOptions } from "../../proxy/proxyTranslationEngine.js";
13
13
  import type { ModelRouter } from "../../proxy/modelRouter.js";
14
14
  import { ProxyTracer } from "../../proxy/proxyTracer.js";
15
15
  import { isPermanentRefreshFailure } from "../../proxy/tokenRefresh.js";
16
- import type { AccountAllowlist, AccountCooldownPlan, AccountQuota, AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicLoopState, AnthropicSuccessResult, AnthropicUpstreamFetchResult, ClaudeFinalRequestLogger, ClaudeRequest, ParsedClaudeError, ProxyBodyCaptureLogger, ProxyPassthroughAccount, RouteGroup, RuntimeAccountState, ServerContext, StreamTerminalOutcome } from "../../types/index.js";
16
+ import type { AccountAllowlist, AccountCooldownPlan, AccountQuota, AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicLoopState, AnthropicSuccessResult, AnthropicUpstreamFetchResult, ClaudeFinalRequestLogger, ClaudeLoggedErrorBuilder, ClaudeRequest, ParsedClaudeError, ProxyBodyCaptureLogger, ProxyPassthroughAccount, RouteGroup, RuntimeAccountState, ServerContext, StreamTerminalOutcome } from "../../types/index.js";
17
17
  /** Resolve the configured primary's stable key to its current index in the
18
18
  * request's enabledAccounts list. Returns 0 (insertion-order fallback) when
19
19
  * no key is configured or the key cannot be matched (account disabled/
@@ -62,17 +62,24 @@ declare function planCooldownFor429(quota: AccountQuota | null, retryAfterMs: nu
62
62
  declare function seedRuntimeQuotasFromDisk(accounts: ProxyPassthroughAccount[]): Promise<void>;
63
63
  /**
64
64
  * Order accounts to MAXIMIZE quota utilization (fill-first, smart order):
65
- * spend the account whose window refreshes SOONEST first, so its about-to-reset
65
+ * spend the window that expires SOONEST first, so its about-to-reset
66
66
  * allowance isn't wasted, then move to accounts with longer-dated resets.
67
67
  *
68
68
  * Priority among usable accounts:
69
69
  * 1. no quota data yet — probe first: one request reveals its windows and
70
70
  * self-corrects the ordering. (Ranking unknowns last would starve them
71
71
  * forever: never picked → never observed → never comparable.)
72
- * 2. soonest WEEKLY (7d) reset — the scarce, use-it-or-lose-it ceiling
73
- * 3. soonest SESSION (5h) reset
74
- * 4. highest weekly utilization finish off the one closest to done
75
- * 5. configured primary account, then insertion order
72
+ * 2. session headroom before session-saturated (>= soft limit or
73
+ * "throttled") saturated accounts then follow the same bucketed
74
+ * session ordering below: soonest back in service first, weekly
75
+ * deciding same-bucket ties.
76
+ * 3. soonest SESSION (5h) reset — the capacity expiring soonest; compared
77
+ * in tolerance buckets so near-simultaneous resets count as equal.
78
+ * 4. soonest WEEKLY (7d) reset — decides between same-bucket sessions.
79
+ * 5. highest weekly utilization — finish off the one closest to done
80
+ * 6. configured primary account, then insertion order
81
+ * An account with NO ticking session window (fresh, not yet started) has
82
+ * nothing expiring and sorts after ticking windows in step 3.
76
83
  * Cooling/rejected accounts sort last, soonest-back-to-service first, as
77
84
  * last resort.
78
85
  */
@@ -81,6 +88,47 @@ declare function trackUpstreamReadableStream(source: ReadableStream<Uint8Array>)
81
88
  stream: ReadableStream<Uint8Array>;
82
89
  outcome: Promise<StreamTerminalOutcome>;
83
90
  };
91
+ declare function executeClaudeFallbackTranslation(args: {
92
+ ctx: ServerContext;
93
+ body: ClaudeRequest;
94
+ tracer?: ProxyTracer;
95
+ requestStartTime: number;
96
+ logProxyBody: ProxyBodyCaptureLogger;
97
+ logFinalRequest: (status: number, accountLabel: string, accountType: string, errorType?: string, errorMessage?: string, extra?: {
98
+ inputTokens?: number;
99
+ outputTokens?: number;
100
+ cacheCreationTokens?: number;
101
+ cacheReadTokens?: number;
102
+ }) => void;
103
+ options: Parameters<ServerContext["neurolink"]["stream"]>[0];
104
+ providerLabel: string;
105
+ }): Promise<unknown>;
106
+ declare function executeClaudeFallbackWithRetry(args: Parameters<typeof executeClaudeFallbackTranslation>[0]): Promise<unknown>;
107
+ declare function buildClaudeAnthropicFailureResponse(args: {
108
+ tracer?: ProxyTracer;
109
+ requestStartTime: number;
110
+ authFailureMessage: string | null;
111
+ authCooldownMessage: string | null;
112
+ invalidRequestFailure: {
113
+ status: number;
114
+ body: string;
115
+ contentType?: string;
116
+ } | null;
117
+ sawNetworkError: boolean;
118
+ sawTransientFailure: boolean;
119
+ sawRateLimit: boolean;
120
+ lastError: unknown;
121
+ fallbackFailureMessage?: string;
122
+ orderedAccounts: ProxyPassthroughAccount[];
123
+ buildLoggedClaudeError: ClaudeLoggedErrorBuilder;
124
+ logProxyBody: ProxyBodyCaptureLogger;
125
+ logFinalRequest: (status: number, accountLabel: string, accountType: string, errorType?: string, errorMessage?: string, extra?: {
126
+ inputTokens?: number;
127
+ outputTokens?: number;
128
+ cacheCreationTokens?: number;
129
+ cacheReadTokens?: number;
130
+ }) => void;
131
+ }): unknown;
84
132
  declare function handleAnthropicStreamingSuccessResponse(args: {
85
133
  ctx: ServerContext;
86
134
  body: ClaudeRequest;
@@ -249,5 +297,7 @@ export declare const __testHooks: {
249
297
  waitForTransientAccountAvailability: typeof waitForTransientAccountAvailability;
250
298
  describeTransportError: typeof describeTransportError;
251
299
  shouldAttemptClaudeFallback: typeof shouldAttemptClaudeFallback;
300
+ executeClaudeFallbackWithRetry: typeof executeClaudeFallbackWithRetry;
301
+ buildClaudeAnthropicFailureResponse: typeof buildClaudeAnthropicFailureResponse;
252
302
  };
253
303
  export {};