opencode-anthropic-fix 0.0.16 → 0.0.18

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,50 @@
1
+ /**
2
+ * @typedef {import('./storage.mjs').AccountMetadata} AccountMetadata
3
+ * @typedef {import('./storage.mjs').AccountStorage} AccountStorage
4
+ */
5
+
6
+ /**
7
+ * Reset transient account tracking fields.
8
+ * @param {AccountMetadata} account
9
+ */
10
+ export function resetAccountTracking(account) {
11
+ account.rateLimitResetTimes = {};
12
+ account.consecutiveFailures = 0;
13
+ account.lastFailureTime = null;
14
+ }
15
+
16
+ /**
17
+ * Normalize active index after removing one account.
18
+ * @param {AccountStorage} storage
19
+ * @param {number} removedIndex
20
+ */
21
+ export function adjustActiveIndexAfterRemoval(storage, removedIndex) {
22
+ if (storage.accounts.length === 0) {
23
+ storage.activeIndex = 0;
24
+ return;
25
+ }
26
+
27
+ if (storage.activeIndex >= storage.accounts.length) {
28
+ storage.activeIndex = storage.accounts.length - 1;
29
+ return;
30
+ }
31
+
32
+ if (storage.activeIndex > removedIndex) {
33
+ storage.activeIndex -= 1;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Apply OAuth credentials to an existing account record.
39
+ * @param {AccountMetadata} account
40
+ * @param {{refresh: string, access: string, expires: number, email?: string}} credentials
41
+ */
42
+ export function applyOAuthCredentials(account, credentials) {
43
+ account.refreshToken = credentials.refresh;
44
+ account.access = credentials.access;
45
+ account.expires = credentials.expires;
46
+ account.token_updated_at = Date.now();
47
+ if (credentials.email) {
48
+ account.email = credentials.email;
49
+ }
50
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { applyOAuthCredentials, resetAccountTracking, adjustActiveIndexAfterRemoval } from "./account-state.mjs";
3
+
4
+ describe("resetAccountTracking", () => {
5
+ it("resets rate-limit and failure fields", () => {
6
+ const account = {
7
+ rateLimitResetTimes: { anthropic: Date.now() + 60_000 },
8
+ consecutiveFailures: 7,
9
+ lastFailureTime: Date.now(),
10
+ };
11
+
12
+ resetAccountTracking(account);
13
+
14
+ expect(account.rateLimitResetTimes).toEqual({});
15
+ expect(account.consecutiveFailures).toBe(0);
16
+ expect(account.lastFailureTime).toBeNull();
17
+ });
18
+ });
19
+
20
+ describe("applyOAuthCredentials", () => {
21
+ it("applies refresh/access/expiry and optional email", () => {
22
+ const account = {
23
+ refreshToken: "old-refresh",
24
+ access: "old-access",
25
+ expires: 1,
26
+ email: "old@example.com",
27
+ };
28
+
29
+ applyOAuthCredentials(account, {
30
+ refresh: "new-refresh",
31
+ access: "new-access",
32
+ expires: 123,
33
+ email: "new@example.com",
34
+ });
35
+
36
+ expect(account).toEqual({
37
+ refreshToken: "new-refresh",
38
+ access: "new-access",
39
+ expires: 123,
40
+ token_updated_at: expect.any(Number),
41
+ email: "new@example.com",
42
+ });
43
+ });
44
+
45
+ it("preserves existing email when credentials omit email", () => {
46
+ const account = {
47
+ refreshToken: "old-refresh",
48
+ access: "old-access",
49
+ expires: 1,
50
+ email: "old@example.com",
51
+ };
52
+
53
+ applyOAuthCredentials(account, {
54
+ refresh: "new-refresh",
55
+ access: "new-access",
56
+ expires: 456,
57
+ });
58
+
59
+ expect(account.email).toBe("old@example.com");
60
+ expect(account.refreshToken).toBe("new-refresh");
61
+ expect(account.access).toBe("new-access");
62
+ expect(account.expires).toBe(456);
63
+ expect(account.token_updated_at).toEqual(expect.any(Number));
64
+ });
65
+ });
66
+
67
+ describe("adjustActiveIndexAfterRemoval", () => {
68
+ it("resets activeIndex to 0 when no accounts remain", () => {
69
+ const storage = { accounts: [], activeIndex: 3 };
70
+ adjustActiveIndexAfterRemoval(storage, 0);
71
+ expect(storage.activeIndex).toBe(0);
72
+ });
73
+
74
+ it("clamps activeIndex when it falls out of range", () => {
75
+ const storage = {
76
+ accounts: [{ id: "a" }, { id: "b" }],
77
+ activeIndex: 2,
78
+ };
79
+ adjustActiveIndexAfterRemoval(storage, 0);
80
+ expect(storage.activeIndex).toBe(1);
81
+ });
82
+
83
+ it("decrements activeIndex when removed index is before active", () => {
84
+ const storage = {
85
+ accounts: [{ id: "a" }, { id: "b" }, { id: "c" }],
86
+ activeIndex: 2,
87
+ };
88
+ adjustActiveIndexAfterRemoval(storage, 0);
89
+ expect(storage.activeIndex).toBe(1);
90
+ });
91
+
92
+ it("keeps activeIndex when removed index is after active", () => {
93
+ const storage = {
94
+ accounts: [{ id: "a" }, { id: "b" }, { id: "c" }],
95
+ activeIndex: 0,
96
+ };
97
+ adjustActiveIndexAfterRemoval(storage, 2);
98
+ expect(storage.activeIndex).toBe(0);
99
+ });
100
+
101
+ it("keeps activeIndex when removed index was the active slot", () => {
102
+ const storage = {
103
+ accounts: [{ id: "a" }, { id: "b" }, { id: "c" }],
104
+ activeIndex: 1,
105
+ };
106
+ adjustActiveIndexAfterRemoval(storage, 1);
107
+ expect(storage.activeIndex).toBe(1);
108
+ });
109
+ });
package/lib/accounts.mjs CHANGED
@@ -22,6 +22,7 @@ import { calculateBackoffMs } from "./backoff.mjs";
22
22
  * @property {string} refreshToken
23
23
  * @property {string} [access]
24
24
  * @property {number} [expires]
25
+ * @property {number} tokenUpdatedAt
25
26
  * @property {number} addedAt
26
27
  * @property {number} lastUsed
27
28
  * @property {boolean} enabled
@@ -97,6 +98,7 @@ export class AccountManager {
97
98
  refreshToken: acc.refreshToken,
98
99
  access: acc.access,
99
100
  expires: acc.expires,
101
+ tokenUpdatedAt: acc.token_updated_at,
100
102
  addedAt: acc.addedAt,
101
103
  lastUsed: acc.lastUsed,
102
104
  enabled: acc.enabled,
@@ -114,8 +116,17 @@ export class AccountManager {
114
116
  if (authFallback && manager.#accounts.length > 0) {
115
117
  const match = manager.#accounts.find((acc) => acc.refreshToken === authFallback.refresh);
116
118
  if (match) {
117
- match.access = authFallback.access;
118
- match.expires = authFallback.expires;
119
+ const fallbackHasAccess = typeof authFallback.access === "string" && authFallback.access.length > 0;
120
+ const fallbackExpires = typeof authFallback.expires === "number" ? authFallback.expires : 0;
121
+ const matchExpires = typeof match.expires === "number" ? match.expires : 0;
122
+ const fallbackLooksFresh = fallbackHasAccess && fallbackExpires > Date.now();
123
+ const shouldAdoptFallback =
124
+ fallbackLooksFresh && (!match.access || !match.expires || fallbackExpires > matchExpires);
125
+ if (shouldAdoptFallback) {
126
+ match.access = authFallback.access;
127
+ match.expires = authFallback.expires;
128
+ match.tokenUpdatedAt = Math.max(match.tokenUpdatedAt || 0, fallbackExpires);
129
+ }
119
130
  }
120
131
  }
121
132
 
@@ -133,6 +144,7 @@ export class AccountManager {
133
144
  refreshToken: authFallback.refresh,
134
145
  access: authFallback.access,
135
146
  expires: authFallback.expires,
147
+ tokenUpdatedAt: now,
136
148
  addedAt: now,
137
149
  lastUsed: 0,
138
150
  enabled: true,
@@ -181,6 +193,16 @@ export class AccountManager {
181
193
  return this.#currentIndex;
182
194
  }
183
195
 
196
+ /**
197
+ * Get enabled account references for internal plugin operations.
198
+ * Returned objects are mutable managed accounts.
199
+ * @param {Set<number>} [excludedIndices]
200
+ * @returns {ManagedAccount[]}
201
+ */
202
+ getEnabledAccounts(excludedIndices) {
203
+ return this.#accounts.filter((acc) => acc.enabled && !excludedIndices?.has(acc.index));
204
+ }
205
+
184
206
  /**
185
207
  * Clear expired rate limits for an account.
186
208
  * @param {ManagedAccount} account
@@ -314,6 +336,7 @@ export class AccountManager {
314
336
  if (existing) {
315
337
  existing.access = accessToken;
316
338
  existing.expires = expires;
339
+ existing.tokenUpdatedAt = Date.now();
317
340
  if (email) existing.email = email;
318
341
  existing.enabled = true;
319
342
  return existing;
@@ -328,6 +351,7 @@ export class AccountManager {
328
351
  refreshToken,
329
352
  access: accessToken,
330
353
  expires,
354
+ tokenUpdatedAt: now,
331
355
  addedAt: now,
332
356
  lastUsed: 0,
333
357
  enabled: true,
@@ -430,25 +454,57 @@ export class AccountManager {
430
454
  */
431
455
  async saveToDisk() {
432
456
  // Read current disk state to merge stats
433
- let diskAccounts = null;
457
+ /** @type {Map<string, import('./storage.mjs').AccountMetadata> | null} */
458
+ let diskAccountsById = null;
459
+ /** @type {Map<number, import('./storage.mjs').AccountMetadata[]> | null} */
460
+ let diskAccountsByAddedAt = null;
461
+ /** @type {Map<string, import('./storage.mjs').AccountMetadata> | null} */
462
+ let diskAccountsByRefreshToken = null;
434
463
  try {
435
464
  const diskData = await loadAccounts();
436
465
  if (diskData) {
437
- diskAccounts = new Map(diskData.accounts.map((a) => [a.id, a]));
466
+ diskAccountsById = new Map(diskData.accounts.map((a) => [a.id, a]));
467
+ diskAccountsByAddedAt = new Map();
468
+ diskAccountsByRefreshToken = new Map();
469
+ for (const diskAcc of diskData.accounts) {
470
+ const bucket = diskAccountsByAddedAt.get(diskAcc.addedAt) || [];
471
+ bucket.push(diskAcc);
472
+ diskAccountsByAddedAt.set(diskAcc.addedAt, bucket);
473
+ diskAccountsByRefreshToken.set(diskAcc.refreshToken, diskAcc);
474
+ }
438
475
  }
439
476
  } catch {
440
477
  // If we can't read, fall through to writing absolute values
441
478
  }
442
479
 
480
+ /**
481
+ * Resolve a corresponding disk account for auth merge.
482
+ * Prefer stable id, then unique addedAt, then refresh token.
483
+ * @param {ManagedAccount} account
484
+ */
485
+ const findDiskAccount = (account) => {
486
+ const byId = diskAccountsById?.get(account.id);
487
+ if (byId) return byId;
488
+
489
+ const byAddedAt = diskAccountsByAddedAt?.get(account.addedAt);
490
+ if (byAddedAt?.length === 1) return byAddedAt[0];
491
+
492
+ const byToken = diskAccountsByRefreshToken?.get(account.refreshToken);
493
+ if (byToken) return byToken;
494
+
495
+ if (byAddedAt && byAddedAt.length > 0) return byAddedAt[0];
496
+ return null;
497
+ };
498
+
443
499
  /** @type {AccountStorage} */
444
500
  const storage = {
445
501
  version: 1,
446
502
  accounts: this.#accounts.map((acc) => {
447
503
  const delta = this.#statsDeltas.get(acc.id);
448
504
  let mergedStats = acc.stats;
505
+ const diskAcc = findDiskAccount(acc);
449
506
 
450
507
  if (delta) {
451
- const diskAcc = diskAccounts?.get(acc.id);
452
508
  const diskStats = diskAcc?.stats;
453
509
 
454
510
  if (delta.isReset) {
@@ -475,12 +531,35 @@ export class AccountManager {
475
531
  // If no diskStats and no reset, we write acc.stats as-is (first save)
476
532
  }
477
533
 
534
+ const memTokenUpdatedAt = acc.tokenUpdatedAt || 0;
535
+ const diskTokenUpdatedAt = diskAcc?.token_updated_at || 0;
536
+ const freshestAuth =
537
+ diskAcc && diskTokenUpdatedAt > memTokenUpdatedAt
538
+ ? {
539
+ refreshToken: diskAcc.refreshToken,
540
+ access: diskAcc.access,
541
+ expires: diskAcc.expires,
542
+ tokenUpdatedAt: diskTokenUpdatedAt,
543
+ }
544
+ : {
545
+ refreshToken: acc.refreshToken,
546
+ access: acc.access,
547
+ expires: acc.expires,
548
+ tokenUpdatedAt: memTokenUpdatedAt,
549
+ };
550
+
551
+ acc.refreshToken = freshestAuth.refreshToken;
552
+ acc.access = freshestAuth.access;
553
+ acc.expires = freshestAuth.expires;
554
+ acc.tokenUpdatedAt = freshestAuth.tokenUpdatedAt;
555
+
478
556
  return {
479
557
  id: acc.id,
480
558
  email: acc.email,
481
- refreshToken: acc.refreshToken,
482
- access: acc.access,
483
- expires: acc.expires,
559
+ refreshToken: freshestAuth.refreshToken,
560
+ access: freshestAuth.access,
561
+ expires: freshestAuth.expires,
562
+ token_updated_at: freshestAuth.tokenUpdatedAt,
484
563
  addedAt: acc.addedAt,
485
564
  lastUsed: acc.lastUsed,
486
565
  enabled: acc.enabled,
@@ -519,14 +598,23 @@ export class AccountManager {
519
598
  if (!stored) return;
520
599
 
521
600
  // Reconcile account list/enabled states with disk (CLI may add/remove/enable/disable)
522
- const diskSnapshot = stored.accounts.map((acc) => `${acc.refreshToken}:${acc.enabled ? 1 : 0}`).join("|");
523
- const memSnapshot = this.#accounts.map((acc) => `${acc.refreshToken}:${acc.enabled ? 1 : 0}`).join("|");
601
+ const existingByTokenForSnapshot = new Map(this.#accounts.map((acc) => [acc.refreshToken, acc]));
602
+ const memSnapshot = this.#accounts.map((acc) => `${acc.id}:${acc.refreshToken}:${acc.enabled ? 1 : 0}`).join("|");
603
+
604
+ const diskSnapshot = stored.accounts
605
+ .map((acc) => {
606
+ const resolvedId = acc.id || existingByTokenForSnapshot.get(acc.refreshToken)?.id || acc.refreshToken;
607
+ return `${resolvedId}:${acc.refreshToken}:${acc.enabled ? 1 : 0}`;
608
+ })
609
+ .join("|");
524
610
 
525
611
  if (diskSnapshot !== memSnapshot) {
612
+ const existingById = new Map(this.#accounts.map((acc) => [acc.id, acc]));
526
613
  const existingByToken = new Map(this.#accounts.map((acc) => [acc.refreshToken, acc]));
527
614
 
528
615
  this.#accounts = stored.accounts.map((acc, index) => {
529
- const existing = existingByToken.get(acc.refreshToken);
616
+ const existing =
617
+ (acc.id && existingById.get(acc.id)) || (!acc.id ? existingByToken.get(acc.refreshToken) : null);
530
618
  return {
531
619
  id: acc.id || existing?.id || `${acc.addedAt}:${acc.refreshToken.slice(0, 12)}`,
532
620
  index,
@@ -534,6 +622,7 @@ export class AccountManager {
534
622
  refreshToken: acc.refreshToken,
535
623
  access: acc.access ?? existing?.access,
536
624
  expires: acc.expires ?? existing?.expires,
625
+ tokenUpdatedAt: acc.token_updated_at ?? existing?.tokenUpdatedAt ?? acc.addedAt,
537
626
  addedAt: acc.addedAt,
538
627
  lastUsed: acc.lastUsed,
539
628
  enabled: acc.enabled,
@@ -100,6 +100,29 @@ describe("AccountManager.load", () => {
100
100
  expect(snapshot[0].access).toBe("fresh-access");
101
101
  });
102
102
 
103
+ it("does not let stale/partial fallback override fresher stored auth", async () => {
104
+ loadAccounts.mockResolvedValue(
105
+ makeAccountsData([
106
+ {
107
+ lastUsed: 2000,
108
+ access: "disk-access",
109
+ expires: Date.now() + 6 * 3600_000,
110
+ token_updated_at: Date.now() + 6 * 3600_000,
111
+ },
112
+ ]),
113
+ );
114
+
115
+ const manager = await AccountManager.load(DEFAULT_CONFIG, {
116
+ refresh: "token1",
117
+ access: "stale-fallback-access",
118
+ expires: Date.now() - 60_000,
119
+ });
120
+
121
+ const snapshot = manager.getAccountsSnapshot();
122
+ expect(snapshot[0].access).toBe("disk-access");
123
+ expect(snapshot[0].expires).toBeGreaterThan(Date.now());
124
+ });
125
+
103
126
  it("clamps activeIndex to valid range", async () => {
104
127
  loadAccounts.mockResolvedValue(makeAccountsData([{ lastUsed: 2000 }], { activeIndex: 99 }));
105
128
  const manager = await AccountManager.load(DEFAULT_CONFIG, null);
@@ -449,6 +472,29 @@ describe("AccountManager persistence", () => {
449
472
  await manager.syncActiveIndexFromDisk();
450
473
  expect(manager.getCurrentIndex()).toBe(1);
451
474
  });
475
+
476
+ it("syncActiveIndexFromDisk reconciles by stable id when refresh token rotates", async () => {
477
+ const current = manager.getAccountsSnapshot()[0];
478
+
479
+ loadAccounts.mockResolvedValue(
480
+ makeAccountsData([
481
+ {
482
+ id: current.id,
483
+ refreshToken: "token1-rotated",
484
+ access: "rotated-access",
485
+ expires: Date.now() + 7200_000,
486
+ token_updated_at: Date.now() + 5_000,
487
+ },
488
+ ]),
489
+ );
490
+
491
+ await manager.syncActiveIndexFromDisk();
492
+ const snapshot = manager.getAccountsSnapshot();
493
+
494
+ expect(snapshot[0].id).toBe(current.id);
495
+ expect(snapshot[0].refreshToken).toBe("token1-rotated");
496
+ expect(snapshot[0].access).toBe("rotated-access");
497
+ });
452
498
  });
453
499
 
454
500
  // ---------------------------------------------------------------------------
@@ -779,4 +825,53 @@ describe("AccountManager merge-on-save", () => {
779
825
  expect(stats.requests).toBe(1);
780
826
  expect(stats.inputTokens).toBe(100);
781
827
  });
828
+
829
+ it("does not let stale in-memory auth overwrite fresher disk auth", async () => {
830
+ const manager = await createManagerWithAccounts(1);
831
+ const account = manager.getAccountsSnapshot()[0];
832
+
833
+ loadAccounts.mockResolvedValue(
834
+ makeAccountsData([
835
+ {
836
+ id: account.id,
837
+ refreshToken: "disk-rotated-refresh",
838
+ access: "disk-fresh-access",
839
+ expires: Date.now() + 9_000_000,
840
+ token_updated_at: Date.now() + 10_000,
841
+ },
842
+ ]),
843
+ );
844
+
845
+ await manager.saveToDisk();
846
+
847
+ const saved = saveAccounts.mock.calls[0][0];
848
+ expect(saved.accounts[0].refreshToken).toBe("disk-rotated-refresh");
849
+ expect(saved.accounts[0].access).toBe("disk-fresh-access");
850
+ expect(saved.accounts[0].token_updated_at).toBeGreaterThan(account.tokenUpdatedAt);
851
+ });
852
+
853
+ it("matches id-less disk records by addedAt when merging auth freshness", async () => {
854
+ const manager = await createManagerWithAccounts(1);
855
+ const account = manager.getAccountsSnapshot()[0];
856
+
857
+ // Simulate legacy/id-less disk record after token rotation.
858
+ loadAccounts.mockResolvedValue(
859
+ makeAccountsData([
860
+ {
861
+ id: undefined,
862
+ addedAt: account.addedAt,
863
+ refreshToken: "disk-rotated-no-id",
864
+ access: "disk-fresh-no-id",
865
+ expires: Date.now() + 9_000_000,
866
+ token_updated_at: Date.now() + 10_000,
867
+ },
868
+ ]),
869
+ );
870
+
871
+ await manager.saveToDisk();
872
+
873
+ const saved = saveAccounts.mock.calls[0][0];
874
+ expect(saved.accounts[0].refreshToken).toBe("disk-rotated-no-id");
875
+ expect(saved.accounts[0].access).toBe("disk-fresh-no-id");
876
+ });
782
877
  });
package/lib/config.mjs CHANGED
@@ -37,6 +37,13 @@ import { homedir } from "node:os";
37
37
  * @property {number} output - Max output tokens to inject. 0 = leave model default unchanged.
38
38
  */
39
39
 
40
+ /**
41
+ * @typedef {object} IdleRefreshConfig
42
+ * @property {boolean} enabled - Opportunistically refresh near-expiry idle accounts
43
+ * @property {number} window_minutes - Refresh idle accounts within this many minutes of expiry
44
+ * @property {number} min_interval_minutes - Minimum minutes between idle refresh attempts per account
45
+ */
46
+
40
47
  /**
41
48
  * @typedef {object} AnthropicAuthConfig
42
49
  * @property {AccountSelectionStrategy} account_selection_strategy
@@ -47,6 +54,8 @@ import { homedir } from "node:os";
47
54
  * @property {HealthScoreConfig} health_score
48
55
  * @property {TokenBucketConfig} token_bucket
49
56
  * @property {ToastConfig} toasts
57
+ * @property {HeaderConfig} headers
58
+ * @property {IdleRefreshConfig} idle_refresh
50
59
  */
51
60
 
52
61
  /** @type {AnthropicAuthConfig} */
@@ -82,6 +91,11 @@ export const DEFAULT_CONFIG = {
82
91
  quiet: false,
83
92
  debounce_seconds: 30,
84
93
  },
94
+ idle_refresh: {
95
+ enabled: true,
96
+ window_minutes: 60,
97
+ min_interval_minutes: 30,
98
+ },
85
99
  };
86
100
 
87
101
  export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid"];
@@ -101,6 +115,7 @@ function createDefaultConfig() {
101
115
  health_score: { ...DEFAULT_CONFIG.health_score },
102
116
  token_bucket: { ...DEFAULT_CONFIG.token_bucket },
103
117
  toasts: { ...DEFAULT_CONFIG.toasts },
118
+ idle_refresh: { ...DEFAULT_CONFIG.idle_refresh },
104
119
  };
105
120
  }
106
121
 
@@ -225,6 +240,51 @@ function validateConfig(raw) {
225
240
  };
226
241
  }
227
242
 
243
+ if (raw.headers && typeof raw.headers === "object") {
244
+ const h = /** @type {Record<string, unknown>} */ (raw.headers);
245
+
246
+ if (typeof h.emulation_profile === "string" && h.emulation_profile.trim()) {
247
+ config.headers.emulation_profile = h.emulation_profile.trim();
248
+ }
249
+
250
+ if (h.overrides && typeof h.overrides === "object" && !Array.isArray(h.overrides)) {
251
+ /** @type {Record<string, string>} */
252
+ const overrides = {};
253
+ for (const [key, value] of Object.entries(/** @type {Record<string, unknown>} */ (h.overrides))) {
254
+ if (!key) continue;
255
+ if (typeof value === "string") {
256
+ overrides[key] = value;
257
+ }
258
+ }
259
+ config.headers.overrides = overrides;
260
+ }
261
+
262
+ if (Array.isArray(h.disable)) {
263
+ config.headers.disable = h.disable
264
+ .filter((v) => typeof v === "string")
265
+ .map((v) => v.trim().toLowerCase())
266
+ .filter(Boolean);
267
+ }
268
+
269
+ if (typeof h.billing_header === "boolean") {
270
+ config.headers.billing_header = h.billing_header;
271
+ }
272
+ }
273
+
274
+ if (raw.idle_refresh && typeof raw.idle_refresh === "object") {
275
+ const ir = /** @type {Record<string, unknown>} */ (raw.idle_refresh);
276
+ config.idle_refresh = {
277
+ enabled: typeof ir.enabled === "boolean" ? ir.enabled : DEFAULT_CONFIG.idle_refresh.enabled,
278
+ window_minutes: clampNumber(ir.window_minutes, 1, 24 * 60, DEFAULT_CONFIG.idle_refresh.window_minutes),
279
+ min_interval_minutes: clampNumber(
280
+ ir.min_interval_minutes,
281
+ 1,
282
+ 24 * 60,
283
+ DEFAULT_CONFIG.idle_refresh.min_interval_minutes,
284
+ ),
285
+ };
286
+ }
287
+
228
288
  return config;
229
289
  }
230
290
 
package/lib/oauth.mjs CHANGED
@@ -101,3 +101,38 @@ export async function revoke(refreshToken) {
101
101
  return false;
102
102
  }
103
103
  }
104
+
105
+ /**
106
+ * Refresh an OAuth access token.
107
+ * @param {string} refreshTokenValue - The refresh token to use
108
+ * @param {{ signal?: AbortSignal }} [options]
109
+ * @returns {Promise<{access_token: string, refresh_token: string, expires_in: number}>}
110
+ * @throws {Error} On HTTP errors or network failures
111
+ */
112
+ export async function refreshToken(refreshTokenValue, options = {}) {
113
+ const resp = await fetch("https://console.anthropic.com/v1/oauth/token", {
114
+ method: "POST",
115
+ headers: { "Content-Type": "application/json" },
116
+ body: JSON.stringify({
117
+ grant_type: "refresh_token",
118
+ client_id: CLIENT_ID,
119
+ refresh_token: refreshTokenValue,
120
+ }),
121
+ ...(options.signal ? { signal: options.signal } : {}),
122
+ });
123
+
124
+ if (!resp.ok) {
125
+ const text = await resp.text().catch(() => "");
126
+ const error = new Error(`Token refresh failed (HTTP ${resp.status}): ${text}`);
127
+ error.status = resp.status;
128
+ try {
129
+ const parsed = JSON.parse(text);
130
+ if (parsed.error) error.code = parsed.error;
131
+ } catch {
132
+ // Body may not be valid JSON
133
+ }
134
+ throw error;
135
+ }
136
+
137
+ return resp.json();
138
+ }