@modelprofile.com/authswitch 3.0.0 → 3.2.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.
@@ -1,8 +1,107 @@
1
1
  import { credentialRecord, credentialText } from './classes.credentialstore.js';
2
- import type { IHarnessAccountStatus, IHarnessStatusSummary } from './interfaces.harness.js';
2
+ import { plainText } from './formatting.js';
3
+ import type { IHarnessAccountStatus, IHarnessUsageWindow, TUsageSeverity } from './interfaces.harness.js';
3
4
 
4
5
  class ClaudeStatusError extends Error {}
5
6
 
7
+ interface IClaudeUsageWindows {
8
+ windows: IHarnessUsageWindow[];
9
+ /** Server rows whose reset group the window model cannot represent, named by kind and group. */
10
+ unsupported: string[];
11
+ }
12
+
13
+ /** Reset times are normalised to ISO 8601 UTC; an unparsable one rejects the whole usage response. */
14
+ const resetTimestamp = (value: unknown): string | null => value == null ? null : new Date(String(value)).toISOString();
15
+
16
+ const SERVER_NAME_LENGTH = 64;
17
+
18
+ /** A server-supplied name becomes label text: no control characters, bounded by code points, and never blank. */
19
+ const serverName = (value: unknown): string => {
20
+ const name = typeof value === 'string' ? Array.from(plainText(value).trim()).slice(0, SERVER_NAME_LENGTH).join('').trimEnd() : '';
21
+ if (!name) throw new Error('Unsupported Claude usage name.');
22
+ return name;
23
+ };
24
+
25
+ /** Keyed windows, read only from a usage body that carries no limits[] rows. */
26
+ const KEYED_WINDOWS = [
27
+ ['five_hour', 'Claude five-hour', 18000, 'account'], ['seven_day', 'Claude weekly', 604800, 'account'],
28
+ ['seven_day_oauth_apps', 'OAuth apps weekly', 604800, 'feature'], ['seven_day_opus', 'Opus weekly', 604800, 'feature'], ['seven_day_sonnet', 'Sonnet weekly', 604800, 'feature'],
29
+ ] as const;
30
+
31
+ const keyedWindows = (usage: Record<string, unknown>): IHarnessUsageWindow[] => {
32
+ const windows: IHarnessUsageWindow[] = [];
33
+ for (const [key, label, durationSeconds, scope] of KEYED_WINDOWS) {
34
+ if (usage[key] == null) continue;
35
+ const window = credentialRecord(usage[key]);
36
+ if (window.utilization == null) continue;
37
+ if (typeof window.utilization !== 'number' || !Number.isFinite(window.utilization) || window.utilization < 0) throw new Error('Unsupported Claude usage utilization.');
38
+ windows.push({ label, durationSeconds, scope, usedPercent: window.utilization, resetAt: resetTimestamp(window.resets_at) });
39
+ }
40
+ return windows;
41
+ };
42
+
43
+ /**
44
+ * The limits[] row groups the window model represents: each group's duration, the period word its
45
+ * labels use, and the kind of the group's unscoped account meter, which keeps the plain label.
46
+ */
47
+ const LIMIT_GROUPS: ReadonlyMap<string, { durationSeconds: number; period: string; accountKind: string }> = new Map([
48
+ ['session', { durationSeconds: 18000, period: 'five-hour', accountKind: 'session' }],
49
+ ['weekly', { durationSeconds: 604800, period: 'weekly', accountKind: 'weekly_all' }],
50
+ ]);
51
+
52
+ const isUsageSeverity = (value: unknown): value is TUsageSeverity => value === 'normal' || value === 'warning' || value === 'critical';
53
+
54
+ /** A scope part is absent (null or omitted) or names the model or surface a row is for. */
55
+ const scopeSubject = (value: unknown): string | undefined => value == null ? undefined : serverName(credentialRecord(value).display_name);
56
+
57
+ /**
58
+ * The server's usage rows, in the server's order. An unscoped row is an account meter; a model- or
59
+ * surface-scoped row is a feature quota named by the server's own label. Rows are classified on the
60
+ * kind and group exactly as sent; only the text shown to users is sanitised. Every row is validated
61
+ * before a row in an unrepresentable group is set aside, so a malformed row can never hide behind one.
62
+ */
63
+ const limitWindows = (rows: readonly unknown[]): IClaudeUsageWindows => {
64
+ const windows: IHarnessUsageWindow[] = [];
65
+ const unsupported = new Set<string>();
66
+ for (const value of rows) {
67
+ const row = credentialRecord(value);
68
+ const { kind, group } = row;
69
+ if (typeof kind !== 'string' || typeof group !== 'string') throw new Error('Unsupported Claude usage meter.');
70
+ const kindName = serverName(kind);
71
+ const groupName = serverName(group);
72
+ const usedPercent = row.percent;
73
+ if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent) || usedPercent < 0) throw new Error('Unsupported Claude usage percentage.');
74
+ const resetAt = resetTimestamp(row.resets_at);
75
+ const scope: Record<string, unknown> = row.scope == null ? {} : credentialRecord(row.scope);
76
+ const model = scopeSubject(scope.model);
77
+ const surface = scopeSubject(scope.surface);
78
+ const subject = model ?? surface;
79
+ const meter = LIMIT_GROUPS.get(group);
80
+ if (!meter) {
81
+ unsupported.add(`${kindName} in group ${groupName}`);
82
+ continue;
83
+ }
84
+ const severity = row.severity;
85
+ windows.push({
86
+ label: subject === undefined ? `Claude ${meter.period}${kind === meter.accountKind ? '' : ` (${kindName})`}` : `${subject} ${meter.period}`,
87
+ durationSeconds: meter.durationSeconds, scope: subject === undefined ? 'account' : 'feature', usedPercent, resetAt,
88
+ ...(isUsageSeverity(severity) ? { severity } : {}),
89
+ ...(row.is_active === true ? { headline: true as const } : {}),
90
+ });
91
+ }
92
+ return { windows, unsupported: [...unsupported] };
93
+ };
94
+
95
+ /**
96
+ * A usage body's windows. Its limits[] rows are authoritative whenever the server sends any; the keyed
97
+ * fields describe the same meters and are read only from a body without rows.
98
+ */
99
+ const claudeUsageWindows = (usage: Record<string, unknown>): IClaudeUsageWindows => {
100
+ const limits = usage.limits;
101
+ if (limits != null && !Array.isArray(limits)) throw new Error('Unsupported Claude usage limits.');
102
+ return Array.isArray(limits) && limits.length ? limitWindows(limits) : { windows: keyedWindows(usage), unsupported: [] };
103
+ };
104
+
6
105
  /** Read-only Claude Code OAuth account endpoints. No refresh, inference, or browser session is used. */
7
106
  export class ClaudeAccountStatus {
8
107
  constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {}
@@ -64,18 +163,7 @@ export class ClaudeAccountStatus {
64
163
  } else result.problems.push(`Profile: ${profile.reason instanceof ClaudeStatusError ? profile.reason.message : 'Lookup failed.'}`);
65
164
  if (usage.status === 'fulfilled') {
66
165
  try {
67
- const windows: NonNullable<IHarnessStatusSummary['usageWindows']> = [];
68
- for (const [key, label, durationSeconds, scope] of [
69
- ['five_hour', 'Claude five-hour', 18000, 'account'], ['seven_day', 'Claude weekly', 604800, 'account'],
70
- ['seven_day_oauth_apps', 'OAuth apps weekly', 604800, 'feature'], ['seven_day_opus', 'Opus weekly', 604800, 'feature'], ['seven_day_sonnet', 'Sonnet weekly', 604800, 'feature'],
71
- ] as const) {
72
- if (usage.value[key] == null) continue;
73
- const window = credentialRecord(usage.value[key]);
74
- if (window.utilization == null) continue;
75
- if (typeof window.utilization !== 'number' || !Number.isFinite(window.utilization) || window.utilization < 0) throw new Error();
76
- const resetAt = window.resets_at == null ? null : new Date(String(window.resets_at)).toISOString();
77
- windows.push({ label, durationSeconds, scope, usedPercent: window.utilization, resetAt });
78
- }
166
+ const { windows, unsupported } = claudeUsageWindows(usage.value);
79
167
  result.summary = { ...result.summary, usageWindows: windows };
80
168
  for (const window of windows) result.facts.push({ section: 'Usage', summaryKey: 'usageWindows', label: window.label, value: `${window.usedPercent}% used; ${window.resetAt ? 'resets ' + window.resetAt : 'reset not scheduled'}` });
81
169
  if (usage.value.extra_usage != null) {
@@ -90,7 +178,7 @@ export class ClaudeAccountStatus {
90
178
  }
91
179
  if (limit !== undefined && limit > 0 && used !== undefined) result.facts.push({ section: 'Extra usage', label: 'Monthly budget used', value: `${Math.round(used / limit * 10000) / 100}%` });
92
180
  }
93
- if (Array.isArray(usage.value.limits) && usage.value.limits.length) result.facts.push({ section: 'Availability', label: 'Additional limits', value: 'The service also returned limits without a supported reset-window contract.' });
181
+ if (unsupported.length) result.facts.push({ section: 'Availability', label: 'Unsupported limits', value: `The service returned limits without a supported reset window, which are not shown: ${unsupported.join(', ')}.` });
94
182
  } catch { result.problems.push('Claude usage response is unsupported; missing values were not inferred.'); }
95
183
  } else result.problems.push(`Usage: ${usage.reason instanceof ClaudeStatusError ? usage.reason.message : 'Lookup failed.'}`);
96
184
  result.facts.push({ section: 'Availability', label: 'Billing dates and earned resets', value: 'Renewal dates, cancellation dates and earned reset credits are not exposed by these Claude Code endpoints.' });
@@ -2,7 +2,7 @@ import * as plugins from './plugins.js';
2
2
  import { compactSince, compactUntil, credentialDriftNote, orderedUsageWindows } from './accounts.js';
3
3
  import { consoleHeading, consoleTable } from './classes.accountlist.js';
4
4
  import type { IAccountList, IAccountLimitRow, IAccountLimits, IActiveAccountDrift, IActiveAccountRow, IActiveAccounts, IHarnessAccountList } from './interfaces.list.js';
5
- import type { IHarnessCredentialDrift } from './interfaces.harness.js';
5
+ import type { IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
6
6
  import { dim, plainText } from './formatting.js';
7
7
 
8
8
  type TListedAccount = IHarnessAccountList['accounts'][number];
@@ -35,7 +35,7 @@ const missingLimitReason = (accountArg: TListedAccount): string => {
35
35
  if (accountArg.status.summary?.usageWindows === undefined) {
36
36
  return availability[0] ?? 'This login does not expose a usage or quota API.';
37
37
  }
38
- return 'The provider reported no usage window for this account.';
38
+ return 'The provider reported no usage window that authswitch can show for this account.';
39
39
  };
40
40
 
41
41
  const accountKeys = (harnessArg: IHarnessAccountList, accountArg: TListedAccount) => ({
@@ -49,10 +49,35 @@ const accountKeys = (harnessArg: IHarnessAccountList, accountArg: TListedAccount
49
49
  const harnessProblem = (harnessArg: IHarnessAccountList): string | null =>
50
50
  harnessArg.problems.length ? harnessArg.problems.map(plainText).join(' ') : null;
51
51
 
52
- /** Provider then account, keeping each account's windows in the order the adapter ranked them. */
53
- const byProviderThenAccount = <TRow extends { provider: string; account: string | null }>(rowsArg: TRow[]): TRow[] =>
54
- [...rowsArg].sort((left, right) => left.provider.localeCompare(right.provider)
55
- || (left.account ?? '').localeCompare(right.account ?? ''));
52
+ /** The identity fields both condensed row types share; labels are for display, these are for order and grouping. */
53
+ type TCondensedRow = Pick<IAccountLimitRow, 'harnessId' | 'provider' | 'account' | 'accountId' | 'slotId'>;
54
+
55
+ /**
56
+ * Provider then account, keeping each account's windows in the order the adapter ranked them.
57
+ *
58
+ * Every harness and every account stays one consecutive run for the table's grouping: a harness is placed
59
+ * by its first provider name, so no other label sorts between its slots, and ids break label ties, so two
60
+ * harnesses sharing a label or two accounts sharing an email never interleave.
61
+ */
62
+ const byProviderThenAccount = <TRow extends TCondensedRow>(rowsArg: TRow[]): TRow[] => {
63
+ const firstProvider = new Map<string, string>();
64
+ for (const row of rowsArg) {
65
+ const first = firstProvider.get(row.harnessId);
66
+ if (first === undefined || row.provider.localeCompare(first) < 0) firstProvider.set(row.harnessId, row.provider);
67
+ }
68
+ return [...rowsArg].sort((left, right) => firstProvider.get(left.harnessId)!.localeCompare(firstProvider.get(right.harnessId)!)
69
+ || left.harnessId.localeCompare(right.harnessId)
70
+ || left.provider.localeCompare(right.provider)
71
+ || (left.account ?? '').localeCompare(right.account ?? '')
72
+ || (left.slotId ?? '').localeCompare(right.slotId ?? '')
73
+ || (left.accountId ?? '').localeCompare(right.accountId ?? ''));
74
+ };
75
+
76
+ /** One colour line per harness: an OpenCode installation's slots share it. */
77
+ const harnessRuns = (rowArg: TCondensedRow): string => rowArg.harnessId;
78
+
79
+ /** One divider run per account identity; a provider-level row without an account is its own run. */
80
+ const accountRuns = (rowArg: TCondensedRow): string => JSON.stringify([rowArg.harnessId, rowArg.slotId ?? null, rowArg.accountId]);
56
81
 
57
82
  const noteFor = (rowArg: { provider: string; account: string | null; unavailableReason: string | null }): string | null =>
58
83
  rowArg.unavailableReason === null ? null
@@ -80,7 +105,7 @@ export const accountLimits = (listArg: IAccountList): IAccountLimits => {
80
105
  for (const harness of listArg.harnesses) {
81
106
  const problem = harnessProblem(harness);
82
107
  const empty = {
83
- limitType: null, scope: null, windowSeconds: null, usedPercent: null, resetAt: null, resetsIn: UNAVAILABLE,
108
+ limitType: null, scope: null, windowSeconds: null, usedPercent: null, resetAt: null, resetsIn: UNAVAILABLE, severity: null, headline: false,
84
109
  } as const;
85
110
  if (problem !== null) {
86
111
  rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, ...empty, unavailableReason: problem });
@@ -102,7 +127,7 @@ export const accountLimits = (listArg: IAccountList): IAccountLimits => {
102
127
  ...accountKeys(harness, account), isActive: account.isActive,
103
128
  limitType: plainText(window.label), scope: window.scope ?? 'account', windowSeconds: window.durationSeconds,
104
129
  usedPercent: window.usedPercent, resetAt: window.resetAt, resetsIn: compactUntil(window.resetAt, now, UNAVAILABLE),
105
- unavailableReason: null,
130
+ severity: window.severity ?? null, headline: window.headline === true, unavailableReason: null,
106
131
  });
107
132
  }
108
133
  }
@@ -165,6 +190,13 @@ export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
165
190
 
166
191
  const percent = (rowArg: IAccountLimitRow): string => rowArg.usedPercent === null ? UNAVAILABLE : `${rowArg.usedPercent}%`;
167
192
 
193
+ const SEVERITY_COLORS: Record<TUsageSeverity, plugins.smartconsole.TColorName | undefined> = { normal: undefined, warning: 'orange', critical: 'red' };
194
+
195
+ /** The provider's own reading colours a window; without one, n/a and exhausted windows stand out. */
196
+ const percentColor = (rowArg: IAccountLimitRow): plugins.smartconsole.TColorName | undefined =>
197
+ rowArg.severity !== null ? SEVERITY_COLORS[rowArg.severity]
198
+ : rowArg.usedPercent === null ? 'orange' : rowArg.usedPercent >= 100 ? 'red' : undefined;
199
+
168
200
  /** Condensed presentation for `authswitch limits` and `authswitch active`. */
169
201
  export class CondensedRenderer {
170
202
  constructor(private readonly out: plugins.smartconsole.SmartConsole) {}
@@ -183,9 +215,9 @@ export class CondensedRenderer {
183
215
  { key: 'account', title: 'Account', value: row => row.account ?? UNAVAILABLE, style: row => ({ bold: row.isActive }) },
184
216
  { key: 'limit', title: 'Limit type', value: row => row.limitType ?? UNAVAILABLE },
185
217
  { key: 'used', title: 'Used %', value: percent, align: 'right',
186
- style: row => ({ foreground: row.usedPercent === null ? 'orange' : row.usedPercent >= 100 ? 'red' : undefined }) },
218
+ style: row => ({ foreground: percentColor(row) }) },
187
219
  { key: 'reset', title: 'Resets in', value: row => row.resetsIn, align: 'right' },
188
- ]);
220
+ ], { divider: accountRuns, colorLine: harnessRuns });
189
221
  this.notes(limitsArg.notes, 'n/a — ');
190
222
  }
191
223
 
@@ -203,7 +235,7 @@ export class CondensedRenderer {
203
235
  style: row => ({ foreground: row.savedAt === null && row.account !== null ? 'orange' : undefined }) },
204
236
  { key: 'source', title: 'Source', value: row => row.drift === null ? row.source : `${row.source} (changed)`,
205
237
  style: row => ({ foreground: row.drift !== null ? 'red' : row.source === 'credential file' ? 'green' : 'orange' }) },
206
- ]);
238
+ ], { colorLine: harnessRuns });
207
239
  this.notes(activeArg.notes, 'note — ');
208
240
  }
209
241
  }
package/ts/classes.tui.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type { IAuthHarness, IHarnessOutcome, IHarnessProcess, IHarnessState, IHarnessStopOutcome } from './interfaces.harness.js';
3
- import { accountDetails, accountPlan, accountQuotaSummary, accountResets, accountState, accountUsageWindows, usagePeriod, until, readAccountRows, type IAccountRow } from './accounts.js';
3
+ import { accountDetails, accountPlan, accountQuotaSummary, accountResets, accountState, accountUsageWindows, usageWindowName, until, readAccountRows, type IAccountRow } from './accounts.js';
4
4
  import { plainText } from './formatting.js';
5
5
  import { AuthSwitchOperations } from './classes.operations.js';
6
6
  import { describeHarnessProcesses, describeStopOutcome } from './classes.harnessprocesses.js';
@@ -140,9 +140,10 @@ export class AuthSwitchTui {
140
140
  const tabs = ui.tabs([{ title: 'Account details', content: details }, { title: 'Activity log', content: ui.logs }]);
141
141
  await ui.run({
142
142
  view: () => {
143
- const usage = accountUsageWindows({ status: table.selected?.status })?.slice(0, 2).map(window => ui.progress(
144
- `${usagePeriod(window)} (${window.usedPercent}% used; reset in ${until(window.resetAt, Date.now())})`, Math.min(100, window.usedPercent), 100,
145
- )) ?? [];
143
+ const shown = accountUsageWindows({ status: table.selected?.status }) ?? [];
144
+ const usage = shown.slice(0, 2).map(window => ui.progress(
145
+ `${usageWindowName(window, shown)} (${window.usedPercent}% used; reset in ${until(window.resetAt, Date.now())})`, Math.min(100, window.usedPercent), 100,
146
+ ));
146
147
  const accountView = ui.column([
147
148
  ui.panel(`${plainText(harness.label)} accounts`, table),
148
149
  ui.panel('Status and diagnostics', ui.column([...usage, tabs])),
@@ -108,20 +108,32 @@ export interface IHarnessStatusFact {
108
108
  summaryKey?: 'subscription' | 'billing' | 'usageWindows' | 'resets' | 'resetDetails';
109
109
  }
110
110
 
111
+ /** The provider's own reading of a usage window. Adapters set it only when the provider reports one of these. */
112
+ export type TUsageSeverity = 'normal' | 'warning' | 'critical';
113
+
114
+ export interface IHarnessUsageWindow {
115
+ label: string;
116
+ /**
117
+ * Feature-specific quotas (a model, a surface, a product feature) do not determine the general
118
+ * account summary unless the provider picks one as its headline. Omission means account.
119
+ */
120
+ scope?: 'account' | 'feature';
121
+ durationSeconds: number;
122
+ usedPercent: number;
123
+ resetAt: string | null;
124
+ /** Omitted when the provider gives no reading; consumers then apply their own thresholds. */
125
+ severity?: TUsageSeverity;
126
+ /** Set when the provider picks this window as the one a single-value summary shows. */
127
+ headline?: true;
128
+ }
129
+
111
130
  export interface IHarnessStatusSummary {
112
131
  /** A plan name does not imply an active billing status. */
113
132
  subscription?: { plan: string; source: 'live' | 'stored' };
114
133
  /** Live billing information only. Never infer these fields from a plan or entitlement expiry. */
115
134
  billing?: { hasActiveSubscription?: boolean; autoRenew?: boolean; renewsAt?: string; cancelsAt?: string; expiresAt?: string };
116
135
  /** Missing windows are unknown, not unused. Times are ISO 8601 UTC. */
117
- usageWindows?: {
118
- label: string;
119
- /** Feature-specific quotas do not determine the general account summary. Omission means account. */
120
- scope?: 'account' | 'feature';
121
- durationSeconds: number;
122
- usedPercent: number;
123
- resetAt: string | null;
124
- }[];
136
+ usageWindows?: IHarnessUsageWindow[];
125
137
  resets?: {
126
138
  available: number;
127
139
  /** May be a partial list. Omission means the detail lookup was unavailable. */
@@ -1,4 +1,4 @@
1
- import type { IHarnessAccount, IHarnessAccountStatus, IHarnessCredentialDrift } from './interfaces.harness.js';
1
+ import type { IHarnessAccount, IHarnessAccountStatus, 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 {
@@ -48,6 +48,10 @@ export interface IAccountLimitRow {
48
48
  resetAt: string | null;
49
49
  /** Human countdown for the same `resetAt`, relative to `generatedAt`. */
50
50
  resetsIn: string;
51
+ /** The provider's reading of this window; null when it gave none or the row has no window. */
52
+ severity: TUsageSeverity | null;
53
+ /** Whether the provider picks this window as the one a single-value summary shows. */
54
+ headline: boolean;
51
55
  unavailableReason: string | null;
52
56
  }
53
57