@modelprofile.com/authswitch 3.3.0 → 5.0.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 (87) hide show
  1. package/dist_ts/00_commitinfo_data.js +3 -3
  2. package/dist_ts/accounts.d.ts +52 -15
  3. package/dist_ts/accounts.js +110 -27
  4. package/dist_ts/classes.accountlist.d.ts +0 -25
  5. package/dist_ts/classes.accountlist.js +2 -216
  6. package/dist_ts/classes.claudecodeharness.d.ts +81 -2
  7. package/dist_ts/classes.claudecodeharness.js +208 -14
  8. package/dist_ts/classes.claudecodelocks.d.ts +38 -0
  9. package/dist_ts/classes.claudecodelocks.js +118 -0
  10. package/dist_ts/classes.claudestatus.d.ts +15 -2
  11. package/dist_ts/classes.claudestatus.js +100 -83
  12. package/dist_ts/classes.claudetokenrefresh.d.ts +32 -0
  13. package/dist_ts/classes.claudetokenrefresh.js +77 -0
  14. package/dist_ts/classes.cli.d.ts +14 -7
  15. package/dist_ts/classes.cli.js +126 -66
  16. package/dist_ts/classes.codexharness.d.ts +4 -0
  17. package/dist_ts/classes.codexharness.js +5 -1
  18. package/dist_ts/classes.codexstatus.d.ts +7 -2
  19. package/dist_ts/classes.codexstatus.js +49 -21
  20. package/dist_ts/classes.credentialstore.d.ts +43 -2
  21. package/dist_ts/classes.credentialstore.js +60 -13
  22. package/dist_ts/classes.fileharness.d.ts +45 -21
  23. package/dist_ts/classes.fileharness.js +64 -26
  24. package/dist_ts/classes.limits.d.ts +46 -7
  25. package/dist_ts/classes.limits.js +94 -36
  26. package/dist_ts/classes.listrenderer.d.ts +17 -0
  27. package/dist_ts/classes.listrenderer.js +313 -0
  28. package/dist_ts/classes.login.d.ts +1 -1
  29. package/dist_ts/classes.login.js +1 -1
  30. package/dist_ts/classes.opencodeharness.d.ts +4 -0
  31. package/dist_ts/classes.opencodeharness.js +8 -4
  32. package/dist_ts/classes.operations.js +3 -2
  33. package/dist_ts/classes.tui.js +4 -3
  34. package/dist_ts/classes.watch.d.ts +108 -0
  35. package/dist_ts/classes.watch.js +219 -0
  36. package/dist_ts/classes.watchlock.d.ts +33 -0
  37. package/dist_ts/classes.watchlock.js +118 -0
  38. package/dist_ts/claudehttp.d.ts +39 -0
  39. package/dist_ts/claudehttp.js +83 -0
  40. package/dist_ts/cliargs.d.ts +36 -0
  41. package/dist_ts/cliargs.js +60 -0
  42. package/dist_ts/consoletable.d.ts +21 -0
  43. package/dist_ts/consoletable.js +63 -0
  44. package/dist_ts/helpers.d.ts +7 -0
  45. package/dist_ts/helpers.js +16 -1
  46. package/dist_ts/index.d.ts +3 -0
  47. package/dist_ts/index.js +4 -1
  48. package/dist_ts/interfaces.harness.d.ts +60 -11
  49. package/dist_ts/interfaces.list.d.ts +3 -1
  50. package/dist_ts/plugins.d.ts +7 -0
  51. package/dist_ts/plugins.js +6 -1
  52. package/dist_ts/ratelimit.d.ts +8 -0
  53. package/dist_ts/ratelimit.js +13 -0
  54. package/dist_ts/watchpolicy.d.ts +44 -0
  55. package/dist_ts/watchpolicy.js +82 -0
  56. package/package.json +5 -3
  57. package/readme.md +349 -115
  58. package/ts/00_commitinfo_data.ts +3 -3
  59. package/ts/accounts.ts +122 -35
  60. package/ts/classes.accountlist.ts +2 -219
  61. package/ts/classes.claudecodeharness.ts +199 -13
  62. package/ts/classes.claudecodelocks.ts +133 -0
  63. package/ts/classes.claudestatus.ts +100 -65
  64. package/ts/classes.claudetokenrefresh.ts +85 -0
  65. package/ts/classes.cli.ts +114 -54
  66. package/ts/classes.codexharness.ts +4 -0
  67. package/ts/classes.codexstatus.ts +40 -18
  68. package/ts/classes.credentialstore.ts +78 -10
  69. package/ts/classes.fileharness.ts +76 -34
  70. package/ts/classes.limits.ts +110 -36
  71. package/ts/classes.listrenderer.ts +328 -0
  72. package/ts/classes.login.ts +1 -1
  73. package/ts/classes.opencodeharness.ts +7 -3
  74. package/ts/classes.operations.ts +2 -1
  75. package/ts/classes.tui.ts +3 -2
  76. package/ts/classes.watch.ts +263 -0
  77. package/ts/classes.watchlock.ts +100 -0
  78. package/ts/claudehttp.ts +92 -0
  79. package/ts/cliargs.ts +71 -0
  80. package/ts/consoletable.ts +62 -0
  81. package/ts/helpers.ts +14 -0
  82. package/ts/index.ts +3 -0
  83. package/ts/interfaces.harness.ts +60 -5
  84. package/ts/interfaces.list.ts +3 -1
  85. package/ts/plugins.ts +9 -0
  86. package/ts/ratelimit.ts +14 -0
  87. package/ts/watchpolicy.ts +121 -0
@@ -53,8 +53,9 @@ export interface IHarnessProcessControl {
53
53
  * A live credential that no longer holds the account the last switch activated.
54
54
  *
55
55
  * Authswitch records the hash of what it wrote, in its own store. A different hash with the same
56
- * account is that account's own token refresh; a different account is what a running instance
57
- * writing its in-memory login back looks like.
56
+ * account is that account's own token refresh; a different account means the login changed outside
57
+ * authswitch -- a new native login, or, for a harness that does not pick up swaps live, a running
58
+ * instance writing its in-memory login back.
58
59
  */
59
60
  export interface IHarnessCredentialDrift {
60
61
  slotId?: string;
@@ -97,6 +98,12 @@ export interface IHarnessAccountStatus {
97
98
  problems: string[];
98
99
  /** Optional structured data for shared presentation; never parse the display facts. */
99
100
  summary?: IHarnessStatusSummary;
101
+ /**
102
+ * Set when the provider refused a lookup for too many requests (HTTP 429). It says nothing about the account's
103
+ * usage: callers back off, never read it as an exhausted or expired login. `retryAt` is when the provider asked to
104
+ * be tried again (ISO 8601 UTC), or null when it did not say.
105
+ */
106
+ rateLimit?: { retryAt: string | null };
100
107
  }
101
108
 
102
109
  export interface IHarnessStatusFact {
@@ -138,11 +145,19 @@ export interface IAccountType {
138
145
  source: 'live' | 'stored';
139
146
  }
140
147
 
148
+ /** Live billing information only. Never infer these fields from a plan or entitlement expiry. Dates are ISO 8601 UTC. */
149
+ export interface IHarnessBilling {
150
+ hasActiveSubscription?: boolean;
151
+ autoRenew?: boolean;
152
+ renewsAt?: string;
153
+ cancelsAt?: string;
154
+ expiresAt?: string;
155
+ }
156
+
141
157
  export interface IHarnessStatusSummary {
142
158
  /** A plan name does not imply an active billing status. */
143
159
  subscription?: IAccountType;
144
- /** Live billing information only. Never infer these fields from a plan or entitlement expiry. */
145
- billing?: { hasActiveSubscription?: boolean; autoRenew?: boolean; renewsAt?: string; cancelsAt?: string; expiresAt?: string };
160
+ billing?: IHarnessBilling;
146
161
  /** Missing windows are unknown, not unused. Times are ISO 8601 UTC. */
147
162
  usageWindows?: IHarnessUsageWindow[];
148
163
  resets?: {
@@ -157,10 +172,25 @@ export interface IHarnessOutcome {
157
172
  problems: string[];
158
173
  }
159
174
 
160
- /** A read-only status lookup. */
175
+ /**
176
+ * A caller's status-reading session, such as one `authswitch watch`. Within it an adapter may reuse a lookup whose
177
+ * answer rarely changes (a Claude profile) instead of repeating it on every read; outside a session every lookup is live.
178
+ */
179
+ export interface IHarnessStatusSession {
180
+ /**
181
+ * The value `lookupArg` resolved to under `keyArg` less than `maxAgeMsArg` ago, otherwise the lookup's fresh result,
182
+ * which is then remembered. A rejected lookup is never remembered. Keys belong to the adapter that uses them, which
183
+ * prefixes them with its own id, so a key always names values of one type.
184
+ */
185
+ reuse<T>(keyArg: string, maxAgeMsArg: number, lookupArg: () => Promise<T>): Promise<T>;
186
+ }
187
+
188
+ /** One status lookup; see `IAuthHarness.readAccountStatus` for what it may and may not change. */
161
189
  export interface IHarnessStatusOptions {
162
190
  /** Cancels the lookup's requests; an adapter still bounds each request with its own timeout. */
163
191
  signal?: AbortSignal;
192
+ /** The session the read belongs to, when the caller reads the same accounts repeatedly. */
193
+ session?: IHarnessStatusSession;
164
194
  }
165
195
 
166
196
  /** A single text-only inference request. Credentials remain inside the adapter. */
@@ -188,6 +218,31 @@ export interface IAuthHarness {
188
218
  readState(): THarnessResult<IHarnessState>;
189
219
  /** Running instances of this harness, when the adapter owns their lifecycle. */
190
220
  readonly processes?: IHarnessProcessControl;
221
+ /**
222
+ * Whether running instances pick up a credential swap by themselves, on their next request.
223
+ *
224
+ * Such a harness is never offered a stop before a switch, and its outcomes carry no restart advice;
225
+ * an explicit stop request is still honoured through `processes`. Absent means running instances keep
226
+ * the login they loaded until they are restarted.
227
+ */
228
+ readonly liveSwap?: boolean;
229
+ /**
230
+ * Set when this harness's provider never reports renewal or cancellation dates, as a lower-case clause
231
+ * that completes "<label>: ...". Views then explain the missing dates once instead of per account.
232
+ */
233
+ readonly renewalUnavailableReason?: string;
234
+ /**
235
+ * Whether `authswitch watch` may switch this harness's login by itself when the active account runs out: every
236
+ * saved account's usage can be read, and a switch needs no session of the user's stopped. A helper process the
237
+ * adapter manages itself may still be restarted by its own switch -- Codex' app-server is, because its
238
+ * credential file cannot be rewritten underneath it. Absent means the watch never switches this harness.
239
+ */
240
+ readonly autoSwitch?: boolean;
241
+ /**
242
+ * The account's live status. It must never activate, switch or clear a login. It may renew the tokens of the
243
+ * saved copy it reads when the provider leaves it no other way to answer -- the Claude Code adapter does, for
244
+ * saved and inactive logins only, under its store's lock -- and must leave the active login's credential alone.
245
+ */
191
246
  readAccountStatus(accountIdArg: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus>;
192
247
  readonly loginProviders?: IHarnessLoginProvider[];
193
248
  beginLogin?(options: IHarnessLoginOptions): Promise<IHarnessLoginHandle>;
@@ -1,4 +1,4 @@
1
- import type { IAccountType, IHarnessAccount, IHarnessAccountStatus, IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
1
+ import type { IAccountType, IHarnessAccount, IHarnessAccountStatus, IHarnessBilling, IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
2
2
 
3
3
  /** Credential-free, versioned output of list --json. Missing status fields stay omitted. */
4
4
  export interface IAccountList {
@@ -41,6 +41,8 @@ export interface IAccountLimitRow {
41
41
  isActive: boolean;
42
42
  /** The adapter's plan name (`max`, `pro`), sanitised but not display-formatted; null when none is known or the row names no account. */
43
43
  accountType: IAccountType | null;
44
+ /** The account's live billing fields as its provider reported them (ISO UTC dates); null when none were reported or the row names no account. */
45
+ billing: IHarnessBilling | null;
44
46
  /** The provider's own window name, or null when no limit data is available. */
45
47
  limitType: string | null;
46
48
  scope: 'account' | 'feature' | null;
package/ts/plugins.ts CHANGED
@@ -13,6 +13,15 @@ const nativeRequire = createRequire(import.meta.url);
13
13
  /** SQLite is needed only when accessing a Codex state database, not when hosting account APIs. */
14
14
  export const loadSqlite = (): typeof import('node:sqlite') => nativeRequire('node:sqlite');
15
15
 
16
+ // third party modules
17
+ import type * as properLockfile from 'proper-lockfile';
18
+ export type TProperLockfile = typeof properLockfile;
19
+ /**
20
+ * Claude Code's own lock library, loaded on first use: it installs process exit hooks when it loads, which a
21
+ * process that never changes a Claude Code login (such as a host embedding the account service) does not need.
22
+ */
23
+ export const loadProperLockfile = (): Promise<TProperLockfile> => import('proper-lockfile');
24
+
16
25
  // @push.rocks modules
17
26
  import * as smartconsole from '@push.rocks/smartconsole';
18
27
  export { smartconsole };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * When a provider that answered HTTP 429 asked to be tried again, read from its `Retry-After` header (RFC 9110: a
3
+ * number of seconds, or an HTTP date) and returned as ISO 8601 UTC. An absent or unreadable header is null: the
4
+ * provider did not say, and the caller picks its own backoff.
5
+ */
6
+ export const retryAtFrom = (headerArg: string | null, nowArg: number): string | null => {
7
+ const value = headerArg?.trim() ?? '';
8
+ const time = /^\d{1,9}$/.test(value) ? nowArg + Number(value) * 1000 : /^[A-Za-z]{3}, .+ GMT$/.test(value) ? Date.parse(value) : Number.NaN;
9
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
10
+ };
11
+
12
+ /** The latest retry time several refused requests asked for; null when none of them said. */
13
+ export const latestRetryAt = (retryAtsArg: readonly (string | null)[]): string | null =>
14
+ retryAtsArg.filter((value): value is string => value !== null).sort().at(-1) ?? null;
@@ -0,0 +1,121 @@
1
+ import { compactUntil, usagePercentText } from './accounts.js';
2
+ import { plainText } from './formatting.js';
3
+ import type { IHarnessUsageWindow } from './interfaces.harness.js';
4
+
5
+ /** What a watch knows about one account of a harness when it decides. */
6
+ export interface IWatchReading {
7
+ id: string;
8
+ label: string;
9
+ /** Only a saved account can be switched to. */
10
+ isSaved: boolean;
11
+ /** The provider's usage windows from a reading recent enough to act on; null while the usage is unknown. */
12
+ windows: readonly IHarnessUsageWindow[] | null;
13
+ /** Why the usage is unknown, for a decision that depends on it. */
14
+ unknownReason?: string;
15
+ }
16
+
17
+ export interface IWatchPolicyInput {
18
+ activeId: string | null;
19
+ readings: readonly IWatchReading[];
20
+ /** The used percentage at which a window counts as used up. */
21
+ threshold: number;
22
+ /** A used-up account is traded for another used-up one only when that one is usable again more than this much sooner. */
23
+ intervalMs: number;
24
+ now: number;
25
+ }
26
+
27
+ /** `ok` says whether the active account is fine; a stay that is not ok still has nothing better to switch to. */
28
+ export type TWatchDecision =
29
+ | { action: 'stay'; ok: boolean; reason: string }
30
+ | { action: 'switch'; target: string; reason: string };
31
+
32
+ /**
33
+ * How far below the threshold a switch target must be on every limit, so that a switch never lands on an account that
34
+ * is about to reach it and the watch never flips back and forth.
35
+ */
36
+ const WATCH_HYSTERESIS = 10;
37
+
38
+ const accountWindows = (readingArg: IWatchReading): IHarnessUsageWindow[] => (readingArg.windows ?? []).filter(window => window.scope !== 'feature');
39
+ const featureWindows = (readingArg: IWatchReading): IHarnessUsageWindow[] => (readingArg.windows ?? []).filter(window => window.scope === 'feature');
40
+
41
+ /** The fullest of the windows at or above the threshold (the longer one on a tie), which names why an account is limited. */
42
+ const fullest = (windowsArg: readonly IHarnessUsageWindow[], thresholdArg: number): IHarnessUsageWindow | undefined =>
43
+ windowsArg.filter(window => window.usedPercent >= thresholdArg)
44
+ .sort((left, right) => right.usedPercent - left.usedPercent || right.durationSeconds - left.durationSeconds)[0];
45
+
46
+ const limitText = (windowArg: IHarnessUsageWindow, thresholdArg: number): string =>
47
+ `${plainText(windowArg.label)} ${usagePercentText(windowArg.usedPercent)} ≥ ${thresholdArg}%`;
48
+
49
+ /** When a window resets; one without a known reset never does. */
50
+ const resetTime = (windowArg: IHarnessUsageWindow): number => {
51
+ const time = windowArg.resetAt === null ? Number.NaN : Date.parse(windowArg.resetAt);
52
+ return Number.isFinite(time) ? time : Number.POSITIVE_INFINITY;
53
+ };
54
+
55
+ /** When every one of the account's used-up windows has reset. */
56
+ const usableAt = (readingArg: IWatchReading, thresholdArg: number): number =>
57
+ Math.max(...accountWindows(readingArg).filter(window => window.usedPercent >= thresholdArg).map(resetTime));
58
+
59
+ /** The reset of the account's longest window: capacity that expires first is used first. */
60
+ const longestWindowReset = (readingArg: IWatchReading): number => {
61
+ const windows = accountWindows(readingArg);
62
+ const longest = Math.max(...windows.map(window => window.durationSeconds));
63
+ return Math.min(...windows.filter(window => window.durationSeconds === longest).map(resetTime));
64
+ };
65
+
66
+ const peakUsage = (readingArg: IWatchReading): number => Math.max(...accountWindows(readingArg).map(window => window.usedPercent));
67
+
68
+ /** Numbers that are both unbounded compare as equal, so the next key decides. */
69
+ const ascending = (leftArg: number, rightArg: number): number => leftArg === rightArg ? 0 : leftArg < rightArg ? -1 : 1;
70
+ const byLabel = (leftArg: IWatchReading, rightArg: IWatchReading): number =>
71
+ leftArg.label.localeCompare(rightArg.label) || leftArg.id.localeCompare(rightArg.id);
72
+ const byPreference = (leftArg: IWatchReading, rightArg: IWatchReading): number =>
73
+ ascending(longestWindowReset(leftArg), longestWindowReset(rightArg)) || ascending(peakUsage(leftArg), peakUsage(rightArg)) || byLabel(leftArg, rightArg);
74
+
75
+ /**
76
+ * Which account a harness should use, from one set of readings. Pure: the same input always decides the same.
77
+ *
78
+ * - The active account is used up when any of its account windows is at or above the threshold. The watch then
79
+ * switches to the saved account whose account windows are all at least `WATCH_HYSTERESIS` points below it,
80
+ * preferring the one whose longest window resets soonest, then the least used, then by label.
81
+ * - When no account qualifies and every other saved account with a known reading is used up too, it switches to the
82
+ * one that is usable again first, and only when that is more than one interval sooner than the active account.
83
+ * - An active account limited only on a feature window (one model) switches to an account that is below the margin on
84
+ * every window; without one it stays, because the active account still serves everything else.
85
+ * - A reading that is unknown, whatever the cause, never counts as used up and never makes an account a target.
86
+ */
87
+ export const decideWatchAction = (inputArg: IWatchPolicyInput): TWatchDecision => {
88
+ const { threshold } = inputArg;
89
+ const stay = (okArg: boolean, reasonArg: string): TWatchDecision => ({ action: 'stay', ok: okArg, reason: reasonArg });
90
+ const active = inputArg.readings.find(reading => reading.id === inputArg.activeId);
91
+ if (!active) return stay(false, 'no active login');
92
+ if (active.windows === null) return stay(false, `usage unknown${active.unknownReason ? ` (${active.unknownReason})` : ''}`);
93
+ const margin = threshold - WATCH_HYSTERESIS;
94
+ const known = inputArg.readings.filter(reading => reading.id !== active.id && reading.isSaved && reading.windows !== null);
95
+ const below = (windowsArg: readonly IHarnessUsageWindow[]): boolean => windowsArg.every(window => window.usedPercent < margin);
96
+ const best = (candidatesArg: IWatchReading[]): IWatchReading | undefined => candidatesArg.sort(byPreference)[0];
97
+
98
+ const usedUp = fullest(accountWindows(active), threshold);
99
+ if (usedUp) {
100
+ const limit = limitText(usedUp, threshold);
101
+ const target = best(known.filter(reading => accountWindows(reading).length > 0 && below(accountWindows(reading))));
102
+ if (target) return { action: 'switch', target: target.id, reason: limit };
103
+ const exhausted = known.filter(reading => fullest(accountWindows(reading), threshold) !== undefined);
104
+ if (!exhausted.length || exhausted.length < known.length) return stay(false, `${limit}; no other saved account is known to be below ${margin}%`);
105
+ const soonest = exhausted.map(reading => ({ reading, at: usableAt(reading, threshold) }))
106
+ .sort((left, right) => ascending(left.at, right.at) || byLabel(left.reading, right.reading))[0];
107
+ if (soonest.at < usableAt(active, threshold) - inputArg.intervalMs) {
108
+ return { action: 'switch', target: soonest.reading.id, reason: `${limit}; every saved account is used up, and ${plainText(soonest.reading.label)} is usable again first (in ${compactUntil(new Date(soonest.at).toISOString(), inputArg.now)})` };
109
+ }
110
+ return stay(false, `${limit}; every saved account is used up, and none is usable again sooner`);
111
+ }
112
+
113
+ const featureLimit = fullest(featureWindows(active), threshold);
114
+ if (featureLimit) {
115
+ const limit = limitText(featureLimit, threshold);
116
+ const target = best(known.filter(reading => accountWindows(reading).length > 0 && below(reading.windows!)));
117
+ if (target) return { action: 'switch', target: target.id, reason: limit };
118
+ return stay(false, `${limit}; no other saved account is known to be below ${margin}% on every limit`);
119
+ }
120
+ return stay(true, `every limit below ${threshold}%`);
121
+ };