gsd-pi 2.10.2 → 2.10.5

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 (136) hide show
  1. package/README.md +2 -0
  2. package/dist/cli.js +7 -0
  3. package/dist/loader.js +1 -0
  4. package/dist/onboarding.js +104 -59
  5. package/dist/update-cmd.d.ts +1 -0
  6. package/dist/update-cmd.js +40 -0
  7. package/node_modules/@gsd/native/dist/hasher/index.d.ts +32 -0
  8. package/node_modules/@gsd/native/dist/hasher/index.js +37 -0
  9. package/node_modules/@gsd/native/dist/native.d.ts +4 -1
  10. package/node_modules/@gsd/native/dist/native.js +39 -9
  11. package/node_modules/@gsd/native/dist/xxhash/index.d.ts +14 -0
  12. package/node_modules/@gsd/native/dist/xxhash/index.js +17 -0
  13. package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.d.ts +6 -0
  14. package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  15. package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.js +58 -9
  16. package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  17. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.d.ts +72 -12
  18. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.d.ts.map +1 -1
  19. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.js +254 -43
  20. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.js.map +1 -1
  21. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.d.ts +2 -0
  22. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.d.ts.map +1 -0
  23. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.js +159 -0
  24. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.js.map +1 -0
  25. package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.d.ts +4 -2
  26. package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
  27. package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.js +6 -4
  28. package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.js.map +1 -1
  29. package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.d.ts +15 -0
  30. package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  31. package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.js +12 -0
  32. package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  33. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts +40 -0
  34. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts.map +1 -0
  35. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.js +92 -0
  36. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.js.map +1 -0
  37. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts +2 -0
  38. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts.map +1 -0
  39. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.js +156 -0
  40. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.js.map +1 -0
  41. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.d.ts +8 -0
  42. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
  43. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.js +18 -0
  44. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  45. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.d.ts +1 -0
  46. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
  47. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.js +1 -0
  48. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.js.map +1 -1
  49. package/node_modules/@gsd/pi-coding-agent/dist/index.d.ts +2 -2
  50. package/node_modules/@gsd/pi-coding-agent/dist/index.d.ts.map +1 -1
  51. package/node_modules/@gsd/pi-coding-agent/dist/index.js +1 -1
  52. package/node_modules/@gsd/pi-coding-agent/dist/index.js.map +1 -1
  53. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  54. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  55. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  56. package/node_modules/@gsd/pi-coding-agent/src/core/agent-session.ts +65 -9
  57. package/node_modules/@gsd/pi-coding-agent/src/core/auth-storage.test.ts +194 -0
  58. package/node_modules/@gsd/pi-coding-agent/src/core/auth-storage.ts +283 -53
  59. package/node_modules/@gsd/pi-coding-agent/src/core/model-registry.ts +6 -4
  60. package/node_modules/@gsd/pi-coding-agent/src/core/settings-manager.ts +29 -0
  61. package/node_modules/@gsd/pi-coding-agent/src/core/tools/bash-interceptor.test.ts +198 -0
  62. package/node_modules/@gsd/pi-coding-agent/src/core/tools/bash-interceptor.ts +115 -0
  63. package/node_modules/@gsd/pi-coding-agent/src/core/tools/bash.ts +29 -0
  64. package/node_modules/@gsd/pi-coding-agent/src/core/tools/index.ts +8 -0
  65. package/node_modules/@gsd/pi-coding-agent/src/index.ts +6 -0
  66. package/node_modules/@gsd/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  67. package/package.json +8 -2
  68. package/packages/native/dist/hasher/index.d.ts +32 -0
  69. package/packages/native/dist/hasher/index.js +37 -0
  70. package/packages/native/dist/native.d.ts +4 -1
  71. package/packages/native/dist/native.js +39 -9
  72. package/packages/native/dist/xxhash/index.d.ts +14 -0
  73. package/packages/native/dist/xxhash/index.js +17 -0
  74. package/packages/native/src/native.ts +39 -9
  75. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +6 -0
  76. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  77. package/packages/pi-coding-agent/dist/core/agent-session.js +58 -9
  78. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  79. package/packages/pi-coding-agent/dist/core/auth-storage.d.ts +72 -12
  80. package/packages/pi-coding-agent/dist/core/auth-storage.d.ts.map +1 -1
  81. package/packages/pi-coding-agent/dist/core/auth-storage.js +254 -43
  82. package/packages/pi-coding-agent/dist/core/auth-storage.js.map +1 -1
  83. package/packages/pi-coding-agent/dist/core/auth-storage.test.d.ts +2 -0
  84. package/packages/pi-coding-agent/dist/core/auth-storage.test.d.ts.map +1 -0
  85. package/packages/pi-coding-agent/dist/core/auth-storage.test.js +159 -0
  86. package/packages/pi-coding-agent/dist/core/auth-storage.test.js.map +1 -0
  87. package/packages/pi-coding-agent/dist/core/model-registry.d.ts +4 -2
  88. package/packages/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
  89. package/packages/pi-coding-agent/dist/core/model-registry.js +6 -4
  90. package/packages/pi-coding-agent/dist/core/model-registry.js.map +1 -1
  91. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +15 -0
  92. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  93. package/packages/pi-coding-agent/dist/core/settings-manager.js +12 -0
  94. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  95. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts +40 -0
  96. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts.map +1 -0
  97. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.js +92 -0
  98. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.js.map +1 -0
  99. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts +2 -0
  100. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts.map +1 -0
  101. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.js +156 -0
  102. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.js.map +1 -0
  103. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts +8 -0
  104. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
  105. package/packages/pi-coding-agent/dist/core/tools/bash.js +18 -0
  106. package/packages/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  107. package/packages/pi-coding-agent/dist/core/tools/index.d.ts +1 -0
  108. package/packages/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
  109. package/packages/pi-coding-agent/dist/core/tools/index.js +1 -0
  110. package/packages/pi-coding-agent/dist/core/tools/index.js.map +1 -1
  111. package/packages/pi-coding-agent/dist/index.d.ts +2 -2
  112. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  113. package/packages/pi-coding-agent/dist/index.js +1 -1
  114. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  115. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  116. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  117. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  118. package/packages/pi-coding-agent/src/core/agent-session.ts +65 -9
  119. package/packages/pi-coding-agent/src/core/auth-storage.test.ts +194 -0
  120. package/packages/pi-coding-agent/src/core/auth-storage.ts +283 -53
  121. package/packages/pi-coding-agent/src/core/model-registry.ts +6 -4
  122. package/packages/pi-coding-agent/src/core/settings-manager.ts +29 -0
  123. package/packages/pi-coding-agent/src/core/tools/bash-interceptor.test.ts +198 -0
  124. package/packages/pi-coding-agent/src/core/tools/bash-interceptor.ts +115 -0
  125. package/packages/pi-coding-agent/src/core/tools/bash.ts +29 -0
  126. package/packages/pi-coding-agent/src/core/tools/index.ts +8 -0
  127. package/packages/pi-coding-agent/src/index.ts +6 -0
  128. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  129. package/src/resources/extensions/async-jobs/async-bash-tool.ts +211 -0
  130. package/src/resources/extensions/async-jobs/await-tool.ts +101 -0
  131. package/src/resources/extensions/async-jobs/cancel-job-tool.ts +34 -0
  132. package/src/resources/extensions/async-jobs/index.ts +133 -0
  133. package/src/resources/extensions/async-jobs/job-manager.ts +250 -0
  134. package/src/resources/extensions/gsd/git-service.ts +13 -3
  135. package/src/resources/extensions/gsd/prompts/system.md +5 -2
  136. package/src/resources/extensions/gsd/tests/git-service.test.ts +36 -0
@@ -869,7 +869,7 @@ export class AgentSession {
869
869
  }
870
870
 
871
871
  // Validate API key
872
- const apiKey = await this._modelRegistry.getApiKey(this.model);
872
+ const apiKey = await this._modelRegistry.getApiKey(this.model, this.sessionId);
873
873
  if (!apiKey) {
874
874
  const isOAuth = this._modelRegistry.isUsingOAuth(this.model);
875
875
  if (isOAuth) {
@@ -1309,7 +1309,7 @@ export class AgentSession {
1309
1309
  * @throws Error if no API key available for the model
1310
1310
  */
1311
1311
  async setModel(model: Model<any>, options?: { persist?: boolean }): Promise<void> {
1312
- const apiKey = await this._modelRegistry.getApiKey(model);
1312
+ const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId);
1313
1313
  if (!apiKey) {
1314
1314
  throw new Error(`No API key for ${model.provider}/${model.id}`);
1315
1315
  }
@@ -1351,7 +1351,7 @@ export class AgentSession {
1351
1351
  if (apiKeysByProvider.has(provider)) {
1352
1352
  apiKey = apiKeysByProvider.get(provider);
1353
1353
  } else {
1354
- apiKey = await this._modelRegistry.getApiKeyForProvider(provider);
1354
+ apiKey = await this._modelRegistry.getApiKeyForProvider(provider, this.sessionId);
1355
1355
  apiKeysByProvider.set(provider, apiKey);
1356
1356
  }
1357
1357
 
@@ -1406,7 +1406,7 @@ export class AgentSession {
1406
1406
  const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
1407
1407
  const nextModel = availableModels[nextIndex];
1408
1408
 
1409
- const apiKey = await this._modelRegistry.getApiKey(nextModel);
1409
+ const apiKey = await this._modelRegistry.getApiKey(nextModel, this.sessionId);
1410
1410
  if (!apiKey) {
1411
1411
  throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`);
1412
1412
  }
@@ -1560,7 +1560,7 @@ export class AgentSession {
1560
1560
  throw new Error("No model selected");
1561
1561
  }
1562
1562
 
1563
- const apiKey = await this._modelRegistry.getApiKey(this.model);
1563
+ const apiKey = await this._modelRegistry.getApiKey(this.model, this.sessionId);
1564
1564
  if (!apiKey) {
1565
1565
  throw new Error(`No API key for ${this.model.provider}`);
1566
1566
  }
@@ -1780,7 +1780,7 @@ export class AgentSession {
1780
1780
  return;
1781
1781
  }
1782
1782
 
1783
- const apiKey = await this._modelRegistry.getApiKey(this.model);
1783
+ const apiKey = await this._modelRegistry.getApiKey(this.model, this.sessionId);
1784
1784
  if (!apiKey) {
1785
1785
  this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
1786
1786
  return;
@@ -2082,7 +2082,7 @@ export class AgentSession {
2082
2082
  refreshTools: () => this._refreshToolRegistry(),
2083
2083
  getCommands,
2084
2084
  setModel: async (model, options) => {
2085
- const key = await this.modelRegistry.getApiKey(model);
2085
+ const key = await this.modelRegistry.getApiKey(model, this.sessionId);
2086
2086
  if (!key) return false;
2087
2087
  await this.setModel(model, options);
2088
2088
  return true;
@@ -2188,7 +2188,14 @@ export class AgentSession {
2188
2188
  ? this._baseToolsOverride
2189
2189
  : createAllTools(this._cwd, {
2190
2190
  read: { autoResizeImages },
2191
- bash: { commandPrefix: shellCommandPrefix },
2191
+ bash: {
2192
+ commandPrefix: shellCommandPrefix,
2193
+ interceptor: {
2194
+ enabled: this.settingsManager.getBashInterceptorEnabled(),
2195
+ rules: this.settingsManager.getBashInterceptorRules(),
2196
+ },
2197
+ availableToolNames: () => this.getActiveToolNames(),
2198
+ },
2192
2199
  });
2193
2200
 
2194
2201
  this._baseToolRegistry = new Map(Object.entries(baseTools).map(([name, tool]) => [name, tool as AgentTool]));
@@ -2275,8 +2282,21 @@ export class AgentSession {
2275
2282
  );
2276
2283
  }
2277
2284
 
2285
+ /**
2286
+ * Classify an error message into a usage-limit error type for credential backoff.
2287
+ */
2288
+ private _classifyErrorType(errorMessage: string): import("./auth-storage.js").UsageLimitErrorType {
2289
+ const err = errorMessage.toLowerCase();
2290
+ if (/quota|billing|exceeded.*limit|usage.*limit/i.test(err)) return "quota_exhausted";
2291
+ if (/rate.?limit|too many requests|429/i.test(err)) return "rate_limit";
2292
+ if (/500|502|503|504|server.?error|internal.?error|service.?unavailable/i.test(err)) return "server_error";
2293
+ return "unknown";
2294
+ }
2295
+
2278
2296
  /**
2279
2297
  * Handle retryable errors with exponential backoff.
2298
+ * When multiple credentials are available, marks the failing credential
2299
+ * as backed off and retries immediately with the next one.
2280
2300
  * @returns true if retry was initiated, false if max retries exceeded or disabled
2281
2301
  */
2282
2302
  private async _handleRetryableError(message: AssistantMessage): Promise<boolean> {
@@ -2294,6 +2314,42 @@ export class AgentSession {
2294
2314
  });
2295
2315
  }
2296
2316
 
2317
+ // Try credential fallback before counting against retry budget.
2318
+ // If another credential is available, switch to it and retry immediately.
2319
+ if (this.model && message.errorMessage) {
2320
+ const errorType = this._classifyErrorType(message.errorMessage);
2321
+ const hasAlternate = this._modelRegistry.authStorage.markUsageLimitReached(
2322
+ this.model.provider,
2323
+ this.sessionId,
2324
+ { errorType },
2325
+ );
2326
+
2327
+ if (hasAlternate) {
2328
+ // Remove error message from agent state
2329
+ const messages = this.agent.state.messages;
2330
+ if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
2331
+ this.agent.replaceMessages(messages.slice(0, -1));
2332
+ }
2333
+
2334
+ this._emit({
2335
+ type: "auto_retry_start",
2336
+ attempt: this._retryAttempt + 1,
2337
+ maxAttempts: settings.maxRetries,
2338
+ delayMs: 0,
2339
+ errorMessage: `${message.errorMessage} (switching credential)`,
2340
+ });
2341
+
2342
+ // Retry immediately with the next credential - don't increment _retryAttempt
2343
+ setTimeout(() => {
2344
+ this.agent.continue().catch(() => {
2345
+ // Retry failed - will be caught by next agent_end
2346
+ });
2347
+ }, 0);
2348
+
2349
+ return true;
2350
+ }
2351
+ }
2352
+
2297
2353
  this._retryAttempt++;
2298
2354
 
2299
2355
  if (this._retryAttempt > settings.maxRetries) {
@@ -2750,7 +2806,7 @@ export class AgentSession {
2750
2806
  let summaryDetails: unknown;
2751
2807
  if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) {
2752
2808
  const model = this.model!;
2753
- const apiKey = await this._modelRegistry.getApiKey(model);
2809
+ const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId);
2754
2810
  if (!apiKey) {
2755
2811
  throw new Error(`No API key for ${model.provider}`);
2756
2812
  }
@@ -0,0 +1,194 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { AuthStorage } from "./auth-storage.js";
4
+
5
+ // ─── helpers ──────────────────────────────────────────────────────────────────
6
+
7
+ function makeKey(key: string) {
8
+ return { type: "api_key" as const, key };
9
+ }
10
+
11
+ function inMemory(data: Record<string, unknown> = {}) {
12
+ return AuthStorage.inMemory(data as any);
13
+ }
14
+
15
+ // ─── single credential (backward compat) ─────────────────────────────────────
16
+
17
+ describe("AuthStorage — single credential (backward compat)", () => {
18
+ it("returns the api key for a provider with one key", async () => {
19
+ const storage = inMemory({ anthropic: makeKey("sk-abc") });
20
+ const key = await storage.getApiKey("anthropic");
21
+ assert.equal(key, "sk-abc");
22
+ });
23
+
24
+ it("returns undefined for unknown provider", async () => {
25
+ const storage = inMemory({});
26
+ const key = await storage.getApiKey("unknown");
27
+ assert.equal(key, undefined);
28
+ });
29
+
30
+ it("runtime override takes precedence over stored key", async () => {
31
+ const storage = inMemory({ anthropic: makeKey("sk-stored") });
32
+ storage.setRuntimeApiKey("anthropic", "sk-runtime");
33
+ const key = await storage.getApiKey("anthropic");
34
+ assert.equal(key, "sk-runtime");
35
+ });
36
+ });
37
+
38
+ // ─── multiple credentials ─────────────────────────────────────────────────────
39
+
40
+ describe("AuthStorage — multiple credentials", () => {
41
+ it("round-robins across multiple api keys without sessionId", async () => {
42
+ const storage = inMemory({
43
+ anthropic: [makeKey("sk-1"), makeKey("sk-2"), makeKey("sk-3")],
44
+ });
45
+
46
+ const keys = new Set<string>();
47
+ for (let i = 0; i < 6; i++) {
48
+ const k = await storage.getApiKey("anthropic");
49
+ assert.ok(k, `call ${i} should return a key`);
50
+ keys.add(k);
51
+ }
52
+ // All three keys should have been selected across 6 calls
53
+ assert.deepEqual(keys, new Set(["sk-1", "sk-2", "sk-3"]));
54
+ });
55
+
56
+ it("session-sticky: same sessionId always picks the same key", async () => {
57
+ const storage = inMemory({
58
+ anthropic: [makeKey("sk-1"), makeKey("sk-2"), makeKey("sk-3")],
59
+ });
60
+
61
+ const sessionId = "sess-abc";
62
+ const first = await storage.getApiKey("anthropic", sessionId);
63
+ for (let i = 0; i < 5; i++) {
64
+ const k = await storage.getApiKey("anthropic", sessionId);
65
+ assert.equal(k, first, `call ${i} should be sticky to first selection`);
66
+ }
67
+ });
68
+
69
+ it("different sessionIds may select different keys", async () => {
70
+ const storage = inMemory({
71
+ anthropic: [makeKey("sk-1"), makeKey("sk-2"), makeKey("sk-3")],
72
+ });
73
+
74
+ const results = new Set<string>();
75
+ for (let i = 0; i < 20; i++) {
76
+ const k = await storage.getApiKey("anthropic", `sess-${i}`);
77
+ if (k) results.add(k);
78
+ }
79
+ // With 20 different sessions and 3 keys, we should see more than one key
80
+ assert.ok(results.size > 1, "multiple sessions should hash to different keys");
81
+ });
82
+ });
83
+
84
+ // ─── login accumulation ───────────────────────────────────────────────────────
85
+
86
+ describe("AuthStorage — login accumulation", () => {
87
+ it("accumulates api keys on repeated set()", () => {
88
+ const storage = inMemory({});
89
+ storage.set("anthropic", makeKey("sk-1"));
90
+ storage.set("anthropic", makeKey("sk-2"));
91
+ const creds = storage.getCredentialsForProvider("anthropic");
92
+ assert.equal(creds.length, 2);
93
+ assert.deepEqual(
94
+ creds.map((c) => (c.type === "api_key" ? c.key : null)),
95
+ ["sk-1", "sk-2"],
96
+ );
97
+ });
98
+
99
+ it("deduplicates identical api keys", () => {
100
+ const storage = inMemory({});
101
+ storage.set("anthropic", makeKey("sk-1"));
102
+ storage.set("anthropic", makeKey("sk-1"));
103
+ const creds = storage.getCredentialsForProvider("anthropic");
104
+ assert.equal(creds.length, 1);
105
+ });
106
+ });
107
+
108
+ // ─── backoff / markUsageLimitReached ─────────────────────────────────────────
109
+
110
+ describe("AuthStorage — rate-limit backoff", () => {
111
+ it("returns true when a backed-off credential has an alternate", async () => {
112
+ const storage = inMemory({
113
+ anthropic: [makeKey("sk-1"), makeKey("sk-2")],
114
+ });
115
+
116
+ // Use sk-1 via round-robin (first call, index 0)
117
+ await storage.getApiKey("anthropic");
118
+
119
+ // Mark it as rate-limited; sk-2 should still be available
120
+ const hasAlternate = storage.markUsageLimitReached("anthropic");
121
+ assert.equal(hasAlternate, true);
122
+ });
123
+
124
+ it("returns false when all credentials are backed off", async () => {
125
+ const storage = inMemory({
126
+ anthropic: [makeKey("sk-1"), makeKey("sk-2")],
127
+ });
128
+
129
+ // Back off both keys
130
+ await storage.getApiKey("anthropic"); // uses index 0
131
+ storage.markUsageLimitReached("anthropic"); // backs off index 0
132
+ await storage.getApiKey("anthropic"); // uses index 1
133
+ const hasAlternate = storage.markUsageLimitReached("anthropic"); // backs off index 1
134
+ assert.equal(hasAlternate, false);
135
+ });
136
+
137
+ it("backed-off credential is skipped; next available key is returned", async () => {
138
+ const storage = inMemory({
139
+ anthropic: [makeKey("sk-1"), makeKey("sk-2")],
140
+ });
141
+
142
+ // First call → sk-1 (round-robin index 0)
143
+ const first = await storage.getApiKey("anthropic");
144
+ assert.equal(first, "sk-1");
145
+
146
+ // Back off sk-1
147
+ storage.markUsageLimitReached("anthropic");
148
+
149
+ // Next call should skip backed-off sk-1 and return sk-2
150
+ const second = await storage.getApiKey("anthropic");
151
+ assert.equal(second, "sk-2");
152
+ });
153
+
154
+ it("single credential: markUsageLimitReached returns false", async () => {
155
+ const storage = inMemory({ anthropic: makeKey("sk-only") });
156
+ await storage.getApiKey("anthropic");
157
+ const hasAlternate = storage.markUsageLimitReached("anthropic");
158
+ assert.equal(hasAlternate, false);
159
+ });
160
+
161
+ it("session-sticky: marks the correct credential as backed off", async () => {
162
+ const storage = inMemory({
163
+ anthropic: [makeKey("sk-1"), makeKey("sk-2")],
164
+ });
165
+
166
+ const sessionId = "sess-xyz";
167
+ const chosen = await storage.getApiKey("anthropic", sessionId);
168
+ assert.ok(chosen);
169
+
170
+ // Back off the chosen credential for this session
171
+ const hasAlternate = storage.markUsageLimitReached("anthropic", sessionId);
172
+ assert.equal(hasAlternate, true);
173
+
174
+ // Next call with same session should return the other key
175
+ const next = await storage.getApiKey("anthropic", sessionId);
176
+ assert.ok(next);
177
+ assert.notEqual(next, chosen);
178
+ });
179
+ });
180
+
181
+ // ─── getAll truncation ────────────────────────────────────────────────────────
182
+
183
+ describe("AuthStorage — getAll()", () => {
184
+ it("returns first credential only for providers with multiple keys", () => {
185
+ const storage = inMemory({
186
+ anthropic: [makeKey("sk-1"), makeKey("sk-2")],
187
+ openai: makeKey("sk-openai"),
188
+ });
189
+ const all = storage.getAll();
190
+ assert.ok(all["anthropic"]?.type === "api_key");
191
+ assert.equal((all["anthropic"] as any).key, "sk-1");
192
+ assert.equal((all["openai"] as any).key, "sk-openai");
193
+ });
194
+ });