@juspay/neurolink 9.86.4 → 9.87.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 (57) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/adapters/video/replicateVideoHandler.js +27 -17
  3. package/dist/auth/tokenStore.d.ts +6 -0
  4. package/dist/auth/tokenStore.js +36 -4
  5. package/dist/browser/neurolink.min.js +417 -417
  6. package/dist/cli/commands/auth.js +3 -0
  7. package/dist/cli/commands/proxy.js +310 -107
  8. package/dist/lib/adapters/video/replicateVideoHandler.js +27 -17
  9. package/dist/lib/auth/tokenStore.d.ts +6 -0
  10. package/dist/lib/auth/tokenStore.js +36 -4
  11. package/dist/lib/proxy/accountCooldown.d.ts +5 -0
  12. package/dist/lib/proxy/accountCooldown.js +93 -0
  13. package/dist/lib/proxy/accountQuota.d.ts +3 -4
  14. package/dist/lib/proxy/accountQuota.js +75 -44
  15. package/dist/lib/proxy/accountSelection.d.ts +8 -0
  16. package/dist/lib/proxy/accountSelection.js +26 -0
  17. package/dist/lib/proxy/proxyConfig.js +24 -0
  18. package/dist/lib/proxy/proxyPaths.js +2 -0
  19. package/dist/lib/proxy/requestLogger.js +2 -0
  20. package/dist/lib/proxy/snapshotPersistence.js +1 -1
  21. package/dist/lib/proxy/sseInterceptor.js +16 -0
  22. package/dist/lib/proxy/streamOutcome.d.ts +3 -0
  23. package/dist/lib/proxy/streamOutcome.js +27 -0
  24. package/dist/lib/proxy/tokenRefresh.d.ts +8 -0
  25. package/dist/lib/proxy/tokenRefresh.js +92 -15
  26. package/dist/lib/proxy/updateState.js +17 -4
  27. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +18 -3
  28. package/dist/lib/server/routes/claudeProxyRoutes.js +624 -223
  29. package/dist/lib/types/auth.d.ts +1 -1
  30. package/dist/lib/types/cli.d.ts +2 -0
  31. package/dist/lib/types/multimodal.d.ts +12 -0
  32. package/dist/lib/types/proxy.d.ts +49 -8
  33. package/dist/lib/types/subscription.d.ts +5 -0
  34. package/dist/proxy/accountCooldown.d.ts +5 -0
  35. package/dist/proxy/accountCooldown.js +92 -0
  36. package/dist/proxy/accountQuota.d.ts +3 -4
  37. package/dist/proxy/accountQuota.js +75 -44
  38. package/dist/proxy/accountSelection.d.ts +8 -0
  39. package/dist/proxy/accountSelection.js +25 -0
  40. package/dist/proxy/proxyConfig.js +24 -0
  41. package/dist/proxy/proxyPaths.js +2 -0
  42. package/dist/proxy/requestLogger.js +2 -0
  43. package/dist/proxy/snapshotPersistence.js +1 -1
  44. package/dist/proxy/sseInterceptor.js +16 -0
  45. package/dist/proxy/streamOutcome.d.ts +3 -0
  46. package/dist/proxy/streamOutcome.js +26 -0
  47. package/dist/proxy/tokenRefresh.d.ts +8 -0
  48. package/dist/proxy/tokenRefresh.js +92 -15
  49. package/dist/proxy/updateState.js +17 -4
  50. package/dist/server/routes/claudeProxyRoutes.d.ts +18 -3
  51. package/dist/server/routes/claudeProxyRoutes.js +624 -223
  52. package/dist/types/auth.d.ts +1 -1
  53. package/dist/types/cli.d.ts +2 -0
  54. package/dist/types/multimodal.d.ts +12 -0
  55. package/dist/types/proxy.d.ts +49 -8
  56. package/dist/types/subscription.d.ts +5 -0
  57. package/package.json +1 -1
@@ -51,27 +51,37 @@ export class ReplicateVideoHandler {
51
51
  const startTime = Date.now();
52
52
  const model = options.model ?? DEFAULT_MODEL;
53
53
  const dataUri = `data:image/${this.detectImageType(image)};base64,${image.toString("base64")}`;
54
- // Wan-Alpha + most image-to-video models accept this shape; specific
55
- // models may require provider-specific extras passed through
56
- // VideoOutputOptions.[unknown key].
54
+ // Replicate image-to-video models do not share an input schema. The
55
+ // legacy shape below (`image` + `num_frames`/`fps`/`aspect_ratio`) fits
56
+ // Wan-Alpha-era models; current models (minimax/hailuo-2.3-*,
57
+ // wan-video/wan-2.7-i2v) key the image as `first_frame_image` /
58
+ // `first_frame` and take `duration` instead of frame counts — a missing
59
+ // required image key fails the prediction on submit. `imageInputKey`
60
+ // selects the image key AND the modern `duration`/`resolution` shape;
61
+ // omitting it preserves the legacy payload exactly.
57
62
  //
58
- // `resolution` is forwarded as the `resolution` input parameter.
59
- // Wan-Alpha and several other Replicate image-to-video models accept it
60
- // (e.g. "720p", "1080p"). Models that do not recognise it will silently
61
- // ignore the field — the Replicate API does not reject unknown input keys.
62
63
  // `calculateDimensions` still populates the metadata `dimensions` field
63
64
  // so downstream consumers always receive correct width/height regardless
64
65
  // of whether the model honoured the resolution hint.
65
- const inputPayload = {
66
- image: dataUri,
67
- prompt,
68
- num_frames: (options.length ?? 4) * 24, // Assume 24 fps
69
- fps: 24,
70
- aspect_ratio: options.aspectRatio,
71
- ...(options.resolution !== undefined
72
- ? { resolution: options.resolution }
73
- : {}),
74
- };
66
+ const inputPayload = options.imageInputKey !== undefined
67
+ ? {
68
+ [options.imageInputKey]: dataUri,
69
+ prompt,
70
+ duration: options.length ?? 4,
71
+ ...(options.resolution !== undefined
72
+ ? { resolution: options.resolution }
73
+ : {}),
74
+ }
75
+ : {
76
+ image: dataUri,
77
+ prompt,
78
+ num_frames: (options.length ?? 4) * 24, // Assume 24 fps
79
+ fps: 24,
80
+ aspect_ratio: options.aspectRatio,
81
+ ...(options.resolution !== undefined
82
+ ? { resolution: options.resolution }
83
+ : {}),
84
+ };
75
85
  let prediction;
76
86
  try {
77
87
  prediction = await predict(auth, { model, input: inputPayload }, { abortSignal: options.abortSignal });
@@ -171,6 +171,10 @@ export declare class TokenStore {
171
171
  * round-trips. The state survives proxy restarts because it is stored
172
172
  * alongside the tokens in the JSON file.
173
173
  *
174
+ * This operation and saveTokens() share the instance mutex. A concurrent
175
+ * save cannot erase a disable: a later disable wins, while a later save
176
+ * preserves the existing disabled metadata.
177
+ *
174
178
  * @param provider - The provider key (e.g., "anthropic:user@example.com")
175
179
  * @param reason - Optional human-readable reason (e.g., "refresh_failed")
176
180
  */
@@ -194,6 +198,8 @@ export declare class TokenStore {
194
198
  * @returns true if the provider entry exists and is disabled
195
199
  */
196
200
  isDisabled(provider: string): Promise<boolean>;
201
+ /** Return the persisted disable reason, if the provider is disabled. */
202
+ getDisabledReason(provider: string): Promise<string | undefined>;
197
203
  /**
198
204
  * Lists all provider keys that are currently disabled.
199
205
  *
@@ -146,13 +146,23 @@ export class TokenStore {
146
146
  throw error;
147
147
  }
148
148
  }
149
- // Update provider tokens
149
+ const existingProvider = storageData.providers[provider];
150
+ const now = Date.now();
151
+ // Token rotation must not silently clear an operator-disabled account.
152
+ // Explicit login calls markEnabled() after saving fresh credentials.
150
153
  storageData.providers[provider] = {
151
154
  tokens,
152
- createdAt: Date.now(),
153
- lastAccessed: Date.now(),
155
+ createdAt: existingProvider?.createdAt ?? now,
156
+ lastAccessed: now,
157
+ ...(existingProvider?.disabled === true
158
+ ? {
159
+ disabled: true,
160
+ disabledAt: existingProvider.disabledAt,
161
+ disabledReason: existingProvider.disabledReason,
162
+ }
163
+ : {}),
154
164
  };
155
- storageData.lastModified = Date.now();
165
+ storageData.lastModified = now;
156
166
  try {
157
167
  const content = this.encryptionEnabled
158
168
  ? this.obfuscate(JSON.stringify(storageData))
@@ -489,6 +499,10 @@ export class TokenStore {
489
499
  * round-trips. The state survives proxy restarts because it is stored
490
500
  * alongside the tokens in the JSON file.
491
501
  *
502
+ * This operation and saveTokens() share the instance mutex. A concurrent
503
+ * save cannot erase a disable: a later disable wins, while a later save
504
+ * preserves the existing disabled metadata.
505
+ *
492
506
  * @param provider - The provider key (e.g., "anthropic:user@example.com")
493
507
  * @param reason - Optional human-readable reason (e.g., "refresh_failed")
494
508
  */
@@ -587,6 +601,24 @@ export class TokenStore {
587
601
  }
588
602
  });
589
603
  }
604
+ /** Return the persisted disable reason, if the provider is disabled. */
605
+ async getDisabledReason(provider) {
606
+ return this._mutex.runExclusive(async () => {
607
+ try {
608
+ const storageData = await this.loadStorageData();
609
+ const providerData = storageData.providers[provider];
610
+ return providerData?.disabled === true
611
+ ? providerData.disabledReason
612
+ : undefined;
613
+ }
614
+ catch (error) {
615
+ if (error instanceof TokenStoreError && error.code === "NOT_FOUND") {
616
+ return undefined;
617
+ }
618
+ throw error;
619
+ }
620
+ });
621
+ }
590
622
  /**
591
623
  * Lists all provider keys that are currently disabled.
592
624
  *
@@ -0,0 +1,5 @@
1
+ import type { AccountCoolingReason, PersistedAccountCooldown } from "../types/index.js";
2
+ export declare function initAccountCooldown(cooldownFilePath: string): void;
3
+ export declare function loadAccountCooldowns(): Promise<Record<string, PersistedAccountCooldown>>;
4
+ export declare function saveAccountCooldown(accountKey: string, coolingUntil: number, reason: AccountCoolingReason): Promise<void>;
5
+ export declare function clearAccountCooldown(accountKey: string, expectedCoolingUntil?: number): Promise<void>;
@@ -0,0 +1,93 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { AsyncMutex } from "../utils/asyncMutex.js";
5
+ import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js";
6
+ const COOLDOWN_FILE = "account-cooldowns.json";
7
+ const VALID_REASONS = new Set([
8
+ "weekly",
9
+ "session",
10
+ "unified",
11
+ "transient",
12
+ "auth",
13
+ ]);
14
+ let customCooldownFilePath = null;
15
+ let cacheLoaded = false;
16
+ let cacheLoadPromise = null;
17
+ let memoryCache = {};
18
+ const mutationMutex = new AsyncMutex();
19
+ export function initAccountCooldown(cooldownFilePath) {
20
+ customCooldownFilePath = cooldownFilePath;
21
+ cacheLoaded = false;
22
+ cacheLoadPromise = null;
23
+ memoryCache = {};
24
+ }
25
+ function getCooldownFilePath() {
26
+ return customCooldownFilePath ?? join(homedir(), ".neurolink", COOLDOWN_FILE);
27
+ }
28
+ function isPersistedCooldown(value) {
29
+ if (!value || typeof value !== "object") {
30
+ return false;
31
+ }
32
+ const candidate = value;
33
+ return (typeof candidate.coolingUntil === "number" &&
34
+ Number.isFinite(candidate.coolingUntil) &&
35
+ typeof candidate.updatedAt === "number" &&
36
+ Number.isFinite(candidate.updatedAt) &&
37
+ typeof candidate.reason === "string" &&
38
+ VALID_REASONS.has(candidate.reason));
39
+ }
40
+ async function ensureAccountCooldownsLoaded() {
41
+ if (!cacheLoaded) {
42
+ if (!cacheLoadPromise) {
43
+ cacheLoadPromise = (async () => {
44
+ try {
45
+ const parsed = JSON.parse(await readFile(getCooldownFilePath(), "utf8"));
46
+ memoryCache = Object.fromEntries(Object.entries(parsed).filter((entry) => isPersistedCooldown(entry[1])));
47
+ }
48
+ catch {
49
+ memoryCache = {};
50
+ }
51
+ cacheLoaded = true;
52
+ })().finally(() => {
53
+ cacheLoadPromise = null;
54
+ });
55
+ }
56
+ await cacheLoadPromise;
57
+ }
58
+ }
59
+ export async function loadAccountCooldowns() {
60
+ await ensureAccountCooldownsLoaded();
61
+ return mutationMutex.runExclusive(async () => ({ ...memoryCache }));
62
+ }
63
+ export async function saveAccountCooldown(accountKey, coolingUntil, reason) {
64
+ await mutationMutex.runExclusive(async () => {
65
+ await ensureAccountCooldownsLoaded();
66
+ const current = memoryCache[accountKey];
67
+ if (current && current.coolingUntil > coolingUntil) {
68
+ return;
69
+ }
70
+ memoryCache[accountKey] = {
71
+ coolingUntil,
72
+ reason,
73
+ updatedAt: Date.now(),
74
+ };
75
+ await writeJsonSnapshotAtomically(getCooldownFilePath(), memoryCache);
76
+ });
77
+ }
78
+ export async function clearAccountCooldown(accountKey, expectedCoolingUntil) {
79
+ await mutationMutex.runExclusive(async () => {
80
+ await ensureAccountCooldownsLoaded();
81
+ const current = memoryCache[accountKey];
82
+ if (!current) {
83
+ return;
84
+ }
85
+ if (expectedCoolingUntil !== undefined &&
86
+ current.coolingUntil !== expectedCoolingUntil) {
87
+ return;
88
+ }
89
+ delete memoryCache[accountKey];
90
+ await writeJsonSnapshotAtomically(getCooldownFilePath(), memoryCache);
91
+ });
92
+ }
93
+ //# sourceMappingURL=accountCooldown.js.map
@@ -10,6 +10,8 @@
10
10
  * path is never blocked by file I/O.
11
11
  */
12
12
  import type { AccountQuota } from "../types/index.js";
13
+ /** Read and normalize Anthropic's authoritative top-level unified status. */
14
+ export declare function getUnifiedRateLimitStatus(headers: Headers | Record<string, string>): string | undefined;
13
15
  /**
14
16
  * Parse Anthropic rate-limit / quota headers into an `AccountQuota`.
15
17
  * Returns `null` when key headers are absent.
@@ -22,10 +24,6 @@ export declare function parseQuotaHeaders(headers: Headers | Record<string, stri
22
24
  * ~/.neurolink/account-quotas.json. Call before the first load/save.
23
25
  */
24
26
  export declare function initAccountQuota(quotaFilePath: string): void;
25
- /**
26
- * Load all persisted account quotas.
27
- * First call reads from disk; subsequent calls return the in-memory cache.
28
- */
29
27
  export declare function loadAccountQuotas(): Promise<Record<string, AccountQuota>>;
30
28
  /**
31
29
  * Load quota for a single account.
@@ -42,3 +40,4 @@ export declare function loadAccountQuota(accountKey: string): Promise<AccountQuo
42
40
  * other accounts' snapshots and blinded quota-aware routing to them).
43
41
  */
44
42
  export declare function saveAccountQuota(accountKey: string, quota: AccountQuota): Promise<void>;
43
+ export declare function flushAccountQuotaStateForTests(): Promise<void>;
@@ -9,9 +9,11 @@
9
9
  * updates an in-memory cache and debounces disk writes so the request/response
10
10
  * path is never blocked by file I/O.
11
11
  */
12
- import { dirname, join } from "path";
12
+ import { join } from "path";
13
13
  import { homedir } from "os";
14
14
  import { promises as fs } from "fs";
15
+ import { AsyncMutex } from "../utils/asyncMutex.js";
16
+ import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js";
15
17
  // ---------------------------------------------------------------------------
16
18
  // Header parsing (pure CPU — no I/O, safe for hot path)
17
19
  // ---------------------------------------------------------------------------
@@ -31,6 +33,12 @@ function getHeader(headers, name) {
31
33
  }
32
34
  return undefined;
33
35
  }
36
+ /** Read and normalize Anthropic's authoritative top-level unified status. */
37
+ export function getUnifiedRateLimitStatus(headers) {
38
+ const value = getHeader(headers, "anthropic-ratelimit-unified-status");
39
+ const normalized = value?.trim().toLowerCase();
40
+ return normalized || undefined;
41
+ }
34
42
  /**
35
43
  * Parse Anthropic rate-limit / quota headers into an `AccountQuota`.
36
44
  * Returns `null` when key headers are absent.
@@ -53,6 +61,7 @@ export function parseQuotaHeaders(headers) {
53
61
  const weeklyResetRaw = getHeader(headers, `${P}unified-7d-reset`);
54
62
  const fallbackRaw = getHeader(headers, `${P}unified-fallback-percentage`);
55
63
  return {
64
+ unifiedStatus: getUnifiedRateLimitStatus(headers),
56
65
  sessionUsed,
57
66
  sessionStatus: getHeader(headers, `${P}unified-5h-status`) ?? "unknown",
58
67
  sessionResetAt: sessionResetRaw ? parseInt(sessionResetRaw, 10) || 0 : 0,
@@ -71,8 +80,12 @@ const QUOTA_FILE = "account-quotas.json";
71
80
  const FLUSH_INTERVAL_MS = 5_000; // write to disk at most every 5 seconds
72
81
  let memoryCache = {};
73
82
  let cacheLoaded = false;
83
+ let cacheLoadPromise = null;
74
84
  let dirty = false;
85
+ let cacheVersion = 0;
75
86
  let flushTimer = null;
87
+ const stateMutex = new AsyncMutex();
88
+ const flushMutex = new AsyncMutex();
76
89
  /** Custom quota file path set via initAccountQuota(). */
77
90
  let customQuotaFilePath = null;
78
91
  /**
@@ -91,41 +104,42 @@ export function initAccountQuota(quotaFilePath) {
91
104
  // Reset cache so the new path is picked up on next load
92
105
  memoryCache = {};
93
106
  cacheLoaded = false;
107
+ cacheLoadPromise = null;
94
108
  dirty = false;
109
+ cacheVersion = 0;
95
110
  }
96
111
  function getQuotaFilePath() {
97
112
  return customQuotaFilePath ?? join(homedir(), ".neurolink", QUOTA_FILE);
98
113
  }
99
- async function ensureDir() {
100
- const filePath = getQuotaFilePath();
101
- const dir = dirname(filePath);
102
- await fs.mkdir(dir, { recursive: true, mode: 0o700 }).catch(() => {
103
- // Non-fatal: directory may already exist
104
- });
105
- }
106
114
  /** Flush the in-memory cache to disk (async, non-blocking). */
107
115
  async function flushToDisk() {
108
- if (!dirty) {
109
- return;
110
- }
111
- try {
112
- // Snapshot before async I/O so we only clear dirty if nothing changed
113
- const snapshot = JSON.stringify(memoryCache, null, 2);
114
- await ensureDir();
115
- const filePath = getQuotaFilePath();
116
- const tmpPath = `${filePath}.tmp`;
117
- await fs.writeFile(tmpPath, snapshot, {
118
- mode: 0o600,
116
+ await flushMutex.runExclusive(async () => {
117
+ let snapshot;
118
+ let snapshotVersion = 0;
119
+ let filePath = "";
120
+ await stateMutex.runExclusive(async () => {
121
+ if (!dirty) {
122
+ return;
123
+ }
124
+ snapshot = Object.fromEntries(Object.entries(memoryCache).map(([key, quota]) => [key, { ...quota }]));
125
+ snapshotVersion = cacheVersion;
126
+ filePath = getQuotaFilePath();
119
127
  });
120
- await fs.rename(tmpPath, filePath);
121
- // Only clear dirty if the cache hasn't changed during the write
122
- if (JSON.stringify(memoryCache, null, 2) === snapshot) {
123
- dirty = false;
128
+ if (!snapshot) {
129
+ return;
124
130
  }
125
- }
126
- catch {
127
- // Non-fatal quota is best-effort telemetry
128
- }
131
+ try {
132
+ await writeJsonSnapshotAtomically(filePath, snapshot, 0o600);
133
+ await stateMutex.runExclusive(async () => {
134
+ if (cacheVersion === snapshotVersion) {
135
+ dirty = false;
136
+ }
137
+ });
138
+ }
139
+ catch {
140
+ // Non-fatal — quota is best-effort telemetry
141
+ }
142
+ });
129
143
  }
130
144
  function scheduleFlush() {
131
145
  if (flushTimer) {
@@ -149,19 +163,28 @@ function scheduleFlush() {
149
163
  * Load all persisted account quotas.
150
164
  * First call reads from disk; subsequent calls return the in-memory cache.
151
165
  */
152
- export async function loadAccountQuotas() {
153
- if (cacheLoaded) {
154
- return { ...memoryCache };
155
- }
156
- try {
157
- const raw = await fs.readFile(getQuotaFilePath(), "utf-8");
158
- memoryCache = JSON.parse(raw);
159
- }
160
- catch {
161
- memoryCache = {};
166
+ async function ensureAccountQuotasLoaded() {
167
+ if (!cacheLoaded) {
168
+ if (!cacheLoadPromise) {
169
+ cacheLoadPromise = (async () => {
170
+ try {
171
+ const raw = await fs.readFile(getQuotaFilePath(), "utf-8");
172
+ memoryCache = JSON.parse(raw);
173
+ }
174
+ catch {
175
+ memoryCache = {};
176
+ }
177
+ cacheLoaded = true;
178
+ })().finally(() => {
179
+ cacheLoadPromise = null;
180
+ });
181
+ }
182
+ await cacheLoadPromise;
162
183
  }
163
- cacheLoaded = true;
164
- return { ...memoryCache };
184
+ }
185
+ export async function loadAccountQuotas() {
186
+ await ensureAccountQuotasLoaded();
187
+ return stateMutex.runExclusive(async () => ({ ...memoryCache }));
165
188
  }
166
189
  /**
167
190
  * Load quota for a single account.
@@ -181,11 +204,19 @@ export async function loadAccountQuota(accountKey) {
181
204
  * other accounts' snapshots and blinded quota-aware routing to them).
182
205
  */
183
206
  export async function saveAccountQuota(accountKey, quota) {
184
- if (!cacheLoaded) {
185
- await loadAccountQuotas();
186
- }
187
- memoryCache[accountKey] = quota;
188
- dirty = true;
207
+ await stateMutex.runExclusive(async () => {
208
+ await ensureAccountQuotasLoaded();
209
+ memoryCache[accountKey] = { ...quota };
210
+ dirty = true;
211
+ cacheVersion += 1;
212
+ });
189
213
  scheduleFlush();
190
214
  }
215
+ export async function flushAccountQuotaStateForTests() {
216
+ if (flushTimer) {
217
+ clearTimeout(flushTimer);
218
+ flushTimer = null;
219
+ }
220
+ await flushToDisk();
221
+ }
191
222
  //# sourceMappingURL=accountQuota.js.map
@@ -0,0 +1,8 @@
1
+ import type { AccountAllowlist } from "../types/index.js";
2
+ export declare const LEGACY_ANTHROPIC_ACCOUNT_KEY = "anthropic:legacy-default";
3
+ export declare const ENV_ANTHROPIC_ACCOUNT_KEY = "anthropic:env";
4
+ export declare function normalizeAnthropicAccountKey(value: string): string;
5
+ export declare function anthropicAccountKeysEqual(left: string, right: string): boolean;
6
+ export declare function createAccountAllowlist(values?: readonly string[]): AccountAllowlist | undefined;
7
+ export declare function isAccountAllowed(accountKey: string, allowlist?: AccountAllowlist): boolean;
8
+ export declare function shouldLoadFallbackCredential(storedAnthropicAccountCount: number, fallbackAccountKey: string, allowlist?: AccountAllowlist): boolean;
@@ -0,0 +1,26 @@
1
+ export const LEGACY_ANTHROPIC_ACCOUNT_KEY = "anthropic:legacy-default";
2
+ export const ENV_ANTHROPIC_ACCOUNT_KEY = "anthropic:env";
3
+ export function normalizeAnthropicAccountKey(value) {
4
+ const trimmed = value.trim().toLowerCase();
5
+ return trimmed.startsWith("anthropic:") ? trimmed : `anthropic:${trimmed}`;
6
+ }
7
+ export function anthropicAccountKeysEqual(left, right) {
8
+ return (normalizeAnthropicAccountKey(left) === normalizeAnthropicAccountKey(right));
9
+ }
10
+ export function createAccountAllowlist(values) {
11
+ if (values === undefined) {
12
+ return undefined;
13
+ }
14
+ return new Set(values
15
+ .map(normalizeAnthropicAccountKey)
16
+ .filter((value) => value !== "anthropic:"));
17
+ }
18
+ export function isAccountAllowed(accountKey, allowlist) {
19
+ return (allowlist === undefined ||
20
+ allowlist.has(normalizeAnthropicAccountKey(accountKey)));
21
+ }
22
+ export function shouldLoadFallbackCredential(storedAnthropicAccountCount, fallbackAccountKey, allowlist) {
23
+ return (storedAnthropicAccountCount === 0 &&
24
+ isAccountAllowed(fallbackAccountKey, allowlist));
25
+ }
26
+ //# sourceMappingURL=accountSelection.js.map
@@ -212,6 +212,22 @@ export function validateProxyConfig(config) {
212
212
  errors.push('"routing" must be an object');
213
213
  return errors;
214
214
  }
215
+ if (hasRouting) {
216
+ const routing = cfg.routing;
217
+ const rawAccountAllowlist = routing["account-allowlist"] ?? routing.accountAllowlist;
218
+ if (rawAccountAllowlist !== undefined) {
219
+ if (!Array.isArray(rawAccountAllowlist)) {
220
+ errors.push("routing.account-allowlist must be an array of non-empty strings");
221
+ }
222
+ else {
223
+ rawAccountAllowlist.forEach((entry, index) => {
224
+ if (typeof entry !== "string" || entry.trim() === "") {
225
+ errors.push(`routing.account-allowlist[${index}] must be a non-empty string`);
226
+ }
227
+ });
228
+ }
229
+ }
230
+ }
215
231
  if (!hasAccounts && !hasRouting) {
216
232
  errors.push('Config must contain at least one of "accounts" or "routing"');
217
233
  return errors;
@@ -280,6 +296,7 @@ function warnPlaintextApiKeys(accounts) {
280
296
  * - `model-mappings` / `modelMappings` — array of {from, to, provider}
281
297
  * - `fallback-chain` / `fallbackChain` — array of {provider, model}
282
298
  * - `passthroughModels` / `passthrough-models` — array of model IDs
299
+ * - `account-allowlist` / `accountAllowlist` — allowed Anthropic account IDs
283
300
  *
284
301
  * Accepts both camelCase and kebab-case keys for YAML-friendliness.
285
302
  */
@@ -350,6 +367,13 @@ function parseRoutingConfig(raw) {
350
367
  `string, got ${typeof rawPrimary}`);
351
368
  }
352
369
  }
370
+ const rawAccountAllowlist = (raw["account-allowlist"] ??
371
+ raw.accountAllowlist);
372
+ if (Array.isArray(rawAccountAllowlist)) {
373
+ result.accountAllowlist = [
374
+ ...new Set(rawAccountAllowlist.map((entry) => String(entry).trim())),
375
+ ];
376
+ }
353
377
  return result;
354
378
  }
355
379
  // ---------------------------------------------------------------------------
@@ -21,6 +21,7 @@ export function resolveProxyPaths(dev) {
21
21
  stateDir: base,
22
22
  logsDir: join(base, "logs"),
23
23
  quotaFile: join(base, "account-quotas.json"),
24
+ cooldownFile: join(base, "account-cooldowns.json"),
24
25
  isDev: true,
25
26
  };
26
27
  }
@@ -29,6 +30,7 @@ export function resolveProxyPaths(dev) {
29
30
  stateDir: base,
30
31
  logsDir: join(base, "logs"),
31
32
  quotaFile: join(base, "account-quotas.json"),
33
+ cooldownFile: join(base, "account-cooldowns.json"),
32
34
  isDev: false,
33
35
  };
34
36
  }
@@ -594,6 +594,8 @@ export async function logStreamError(entry) {
594
594
  const logEntry = {
595
595
  ...entry,
596
596
  responseStatus: 200,
597
+ terminalStatus: 502,
598
+ terminalOutcome: "stream_error",
597
599
  errorType: "stream_error",
598
600
  note: "mid-stream failure after initial 200",
599
601
  };
@@ -5,7 +5,7 @@ const writeLocks = new Map();
5
5
  async function writeSnapshotFile(targetPath, payload, mode) {
6
6
  const dir = dirname(targetPath);
7
7
  const baseName = basename(targetPath);
8
- await mkdir(dir, { recursive: true });
8
+ await mkdir(dir, { recursive: true, mode: 0o700 });
9
9
  const tempPath = join(dir, `.${baseName}.${process.pid}.${randomUUID()}.tmp`);
10
10
  try {
11
11
  await writeFile(tempPath, payload, { mode });
@@ -185,6 +185,9 @@ function finalize(acc) {
185
185
  streamDurationMs: Date.now() - acc.startTime,
186
186
  totalBytesReceived: acc.totalBytesReceived,
187
187
  events: acc.events,
188
+ ...(acc.streamErrorMessage
189
+ ? { streamErrorMessage: acc.streamErrorMessage }
190
+ : {}),
188
191
  ...(acc.rawTextChunks ? { rawText: acc.rawTextChunks.join("") } : {}),
189
192
  };
190
193
  }
@@ -323,6 +326,19 @@ function processEvent(acc, event) {
323
326
  // Malformed JSON — skip silently, bytes already forwarded to client
324
327
  return;
325
328
  }
329
+ if (event.event === "error") {
330
+ const payload = parsed && typeof parsed === "object"
331
+ ? parsed
332
+ : {};
333
+ const nestedError = payload.error && typeof payload.error === "object"
334
+ ? payload.error
335
+ : undefined;
336
+ const message = nestedError?.message ?? payload.message;
337
+ acc.streamErrorMessage =
338
+ typeof message === "string" && message.trim()
339
+ ? message.trim()
340
+ : truncateString(event.data, MAX_EVENT_DATA_BYTES);
341
+ }
326
342
  switch (event.event) {
327
343
  case "message_start":
328
344
  processMessageStart(acc, parsed);
@@ -0,0 +1,3 @@
1
+ import type { StreamTerminalOutcome, StreamTerminalOutcomeTracker } from "../types/index.js";
2
+ export declare function createStreamTerminalOutcomeTracker(): StreamTerminalOutcomeTracker;
3
+ export declare function mergeStreamTerminalOutcome(outcome: StreamTerminalOutcome, sseErrorMessage?: string): StreamTerminalOutcome;
@@ -0,0 +1,27 @@
1
+ export function createStreamTerminalOutcomeTracker() {
2
+ let settled = false;
3
+ let resolveOutcome;
4
+ const outcome = new Promise((resolve) => {
5
+ resolveOutcome = resolve;
6
+ });
7
+ const settle = (value) => {
8
+ if (settled) {
9
+ return;
10
+ }
11
+ settled = true;
12
+ resolveOutcome(value);
13
+ };
14
+ return {
15
+ outcome,
16
+ complete: () => settle({ kind: "completed" }),
17
+ fail: (message) => settle({ kind: "upstream_error", message }),
18
+ cancel: () => settle({ kind: "client_cancelled" }),
19
+ };
20
+ }
21
+ export function mergeStreamTerminalOutcome(outcome, sseErrorMessage) {
22
+ if (outcome.kind === "completed" && sseErrorMessage) {
23
+ return { kind: "upstream_error", message: sseErrorMessage };
24
+ }
25
+ return outcome;
26
+ }
27
+ //# sourceMappingURL=streamOutcome.js.map
@@ -1,4 +1,12 @@
1
1
  import type { RefreshableAccount, RefreshResult, TokenPersistTarget } from "../types/index.js";
2
2
  export declare function needsRefresh(account: RefreshableAccount): boolean;
3
+ export declare function isPermanentRefreshFailure(result: RefreshResult): boolean;
4
+ /**
5
+ * Serialize refreshes by refresh-token value. OAuth refresh tokens may rotate,
6
+ * so concurrent refreshes with the same old token can make all but the first
7
+ * request fail with invalid_grant. A short success cache covers callers that
8
+ * loaded either the old token or the newly rotated token around persistence.
9
+ */
3
10
  export declare function refreshToken(account: RefreshableAccount): Promise<RefreshResult>;
11
+ export declare function clearRefreshStateForTests(): void;
4
12
  export declare function persistTokens(target: TokenPersistTarget, account: RefreshableAccount): Promise<void>;