@cortexkit/opencode-anthropic-auth 1.16.0 → 1.18.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.
@@ -15,6 +15,15 @@ export interface AccountQuota {
15
15
  five_hour?: QuotaWindow
16
16
  seven_day?: QuotaWindow
17
17
  scoped?: ScopedQuotaWindow[]
18
+ extraUsage?: {
19
+ used: { amountMinor: number; currency: string; exponent: number }
20
+ limit: { amountMinor: number; currency: string; exponent: number }
21
+ utilizationPercent?: number
22
+ severity?: string
23
+ exhausted: boolean
24
+ }
25
+ bindingWindow?: string
26
+ fallbackAdvised?: boolean
18
27
  }
19
28
 
20
29
  export interface SidebarAccountState {
@@ -25,6 +34,7 @@ export interface SidebarAccountState {
25
34
  // True when the account's refresh token is permanently dead (400
26
35
  // invalid_grant) and it needs a re-login — distinct from a transient backoff.
27
36
  needsReauth: boolean
37
+ tierLabel?: string
28
38
  }
29
39
 
30
40
  export interface FableRecoverySidebarState {
@@ -32,11 +42,18 @@ export interface FableRecoverySidebarState {
32
42
  mode: 'opus' | 'fable'
33
43
  remaining: number
34
44
  changedAt: number
45
+ /**
46
+ * The model id the user originally requested (e.g. 'claude-fable-5' or
47
+ * 'claude-opus-5'). Optional for backward compatibility with pre-Opus-5
48
+ * state files; older recoveries default to Fable 5 in the summary.
49
+ */
50
+ requestedModelId?: string
35
51
  }
36
52
 
37
53
  export interface SidebarState {
38
54
  main: {
39
55
  quota: AccountQuota | null
56
+ tierLabel?: string
40
57
  quotaBackedOff?: boolean
41
58
  quotaBackoffUntil?: number
42
59
  refreshBackedOff?: boolean
@@ -56,9 +73,20 @@ export interface SidebarState {
56
73
  lastUpdated: number
57
74
  }
58
75
 
59
- import { mkdir, readFile, writeFile } from 'node:fs/promises'
76
+ import { randomUUID } from 'node:crypto'
77
+ import {
78
+ mkdir,
79
+ readFile,
80
+ rename,
81
+ rm,
82
+ rmdir,
83
+ stat,
84
+ unlink,
85
+ writeFile,
86
+ } from 'node:fs/promises'
60
87
  import { tmpdir } from 'node:os'
61
88
  import { dirname, join } from 'node:path'
89
+ import { logger } from '@cortexkit/anthropic-auth-core'
62
90
 
63
91
  const STATE_FILE_ENV = 'OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE'
64
92
  const DEFAULT_STATE_DIR = join(tmpdir(), 'opencode-anthropic-auth')
@@ -136,9 +164,53 @@ function normalizeAccountQuota(value: unknown): AccountQuota | null {
136
164
  quota.scoped = scoped
137
165
  }
138
166
 
167
+ if (isRecord(value.extraUsage)) {
168
+ const used = normalizeQuotaMoney(value.extraUsage.used)
169
+ const limit = normalizeQuotaMoney(value.extraUsage.limit)
170
+ if (used && limit && typeof value.extraUsage.exhausted === 'boolean') {
171
+ quota.extraUsage = {
172
+ used,
173
+ limit,
174
+ ...(typeof value.extraUsage.utilizationPercent === 'number' &&
175
+ Number.isFinite(value.extraUsage.utilizationPercent) && {
176
+ utilizationPercent: value.extraUsage.utilizationPercent,
177
+ }),
178
+ ...(typeof value.extraUsage.severity === 'string' && {
179
+ severity: value.extraUsage.severity,
180
+ }),
181
+ exhausted: value.extraUsage.exhausted,
182
+ }
183
+ }
184
+ }
185
+ if (typeof value.bindingWindow === 'string' && value.bindingWindow.trim()) {
186
+ quota.bindingWindow = value.bindingWindow.trim()
187
+ }
188
+ if (typeof value.fallbackAdvised === 'boolean') {
189
+ quota.fallbackAdvised = value.fallbackAdvised
190
+ }
191
+
139
192
  return Object.keys(quota).length ? quota : null
140
193
  }
141
194
 
195
+ function normalizeQuotaMoney(value: unknown) {
196
+ if (!isRecord(value)) return undefined
197
+ if (
198
+ !Number.isInteger(value.amountMinor) ||
199
+ typeof value.currency !== 'string' ||
200
+ !/^[A-Za-z]{3}$/.test(value.currency.trim()) ||
201
+ !Number.isInteger(value.exponent) ||
202
+ (value.exponent as number) < 0 ||
203
+ (value.exponent as number) > 20
204
+ ) {
205
+ return undefined
206
+ }
207
+ return {
208
+ amountMinor: value.amountMinor as number,
209
+ currency: value.currency.trim(),
210
+ exponent: value.exponent as number,
211
+ }
212
+ }
213
+
142
214
  export function normalizeSidebarState(raw: unknown): SidebarState {
143
215
  if (!isRecord(raw)) return { ...DEFAULT_SIDEBAR_STATE }
144
216
 
@@ -146,6 +218,9 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
146
218
  if (isRecord(raw.main)) {
147
219
  const m = raw.main
148
220
  main.quota = normalizeAccountQuota(m.quota)
221
+ if (typeof m.tierLabel === 'string' && m.tierLabel.trim()) {
222
+ main.tierLabel = m.tierLabel.trim()
223
+ }
149
224
  if (typeof m.quotaBackedOff === 'boolean')
150
225
  main.quotaBackedOff = m.quotaBackedOff
151
226
  if (typeof m.quotaBackoffUntil === 'number')
@@ -167,6 +242,10 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
167
242
  enabled: typeof entry.enabled === 'boolean' ? entry.enabled : false,
168
243
  needsReauth:
169
244
  typeof entry.needsReauth === 'boolean' ? entry.needsReauth : false,
245
+ tierLabel:
246
+ typeof entry.tierLabel === 'string' && entry.tierLabel.trim()
247
+ ? entry.tierLabel.trim()
248
+ : undefined,
170
249
  }))
171
250
  : []
172
251
 
@@ -214,6 +293,10 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
214
293
  mode: recovery.mode,
215
294
  remaining: Math.max(0, Math.floor(recovery.remaining)),
216
295
  changedAt: recovery.changedAt,
296
+ requestedModelId:
297
+ typeof recovery.requestedModelId === 'string'
298
+ ? recovery.requestedModelId
299
+ : undefined,
217
300
  },
218
301
  ]
219
302
  })
@@ -238,23 +321,327 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
238
321
 
239
322
  let writeChain: Promise<void> = Promise.resolve()
240
323
 
241
- export async function getSidebarState(): Promise<SidebarState> {
324
+ // Wait through ordinary contention while staying below the 2s stale-eviction window.
325
+ const SIDEBAR_LOCK_BUDGET_MS = 1_000
326
+ const SIDEBAR_LOCK_STALE_MS = 2_000
327
+ const SIDEBAR_LOCK_RETRY_MIN_MS = 5
328
+ const SIDEBAR_LOCK_RETRY_MAX_MS = 15
329
+
330
+ interface SidebarStateWriteTestHooks {
331
+ afterMergeRead?: (stateFile: string) => void | Promise<void>
332
+ beforeRename?: (stateFile: string, tempFile: string) => void | Promise<void>
333
+ afterRename?: (stateFile: string) => void | Promise<void>
334
+ afterStaleLockStat?: (lockDir: string) => void | Promise<void>
335
+ onStaleLockClaimed?: (lockDir: string) => void | Promise<void>
336
+ onLockAcquired?: (lockDir: string) => void | Promise<void>
337
+ beforeReleaseDirectoryRemoval?: (lockDir: string) => void | Promise<void>
338
+ lockBudgetMs?: number
339
+ lockRetryMinMs?: number
340
+ lockRetryMaxMs?: number
341
+ }
342
+
343
+ let sidebarStateWriteTestHooks: SidebarStateWriteTestHooks | null = null
344
+
345
+ export function __setSidebarStateWriteTestHooks(
346
+ hooks: SidebarStateWriteTestHooks | null,
347
+ ): void {
348
+ sidebarStateWriteTestHooks = hooks
349
+ }
350
+
351
+ // Boot-routing test hooks live here rather than in index.ts: the plugin entry
352
+ // module must export nothing beyond the plugin factory — opencode's plugin
353
+ // loader treats extra entry-point exports as plugin candidates and fails to
354
+ // load the plugin (same incident class as the removed
355
+ // __setBootProfileHydrationForTest export).
356
+ export interface InitialSidebarRoutingTestHooks {
357
+ beforeSidebarRead?: () => void | Promise<void>
358
+ beforeStorageLoad?: () => void | Promise<void>
359
+ afterStorageLoad?: () => void | Promise<void>
360
+ }
361
+
362
+ let initialSidebarRoutingTestHooks: InitialSidebarRoutingTestHooks | null = null
363
+
364
+ export function __setInitialSidebarRoutingTestHooks(
365
+ hooks: InitialSidebarRoutingTestHooks | null,
366
+ ): void {
367
+ initialSidebarRoutingTestHooks = hooks
368
+ }
369
+
370
+ export function getInitialSidebarRoutingTestHooks(): InitialSidebarRoutingTestHooks | null {
371
+ return initialSidebarRoutingTestHooks
372
+ }
373
+
374
+ function sleep(ms: number): Promise<void> {
375
+ return new Promise((resolve) => setTimeout(resolve, ms))
376
+ }
377
+
378
+ async function acquireSidebarStateLock(stateFile: string): Promise<{
379
+ release: () => Promise<void>
380
+ ownsLock: () => Promise<boolean>
381
+ } | null> {
382
+ const lockDir = `${stateFile}.lock`
383
+ const budgetMs =
384
+ sidebarStateWriteTestHooks?.lockBudgetMs ?? SIDEBAR_LOCK_BUDGET_MS
385
+ const retryMinMs =
386
+ sidebarStateWriteTestHooks?.lockRetryMinMs ?? SIDEBAR_LOCK_RETRY_MIN_MS
387
+ const retryMaxMs =
388
+ sidebarStateWriteTestHooks?.lockRetryMaxMs ?? SIDEBAR_LOCK_RETRY_MAX_MS
389
+ const deadline = Date.now() + budgetMs
390
+
391
+ while (true) {
392
+ try {
393
+ await mkdir(lockDir)
394
+ const ownerId = randomUUID()
395
+ const ownerFile = join(lockDir, ownerId)
396
+ await writeFile(ownerFile, '', {
397
+ encoding: 'utf8',
398
+ mode: 0o600,
399
+ flag: 'wx',
400
+ })
401
+ await sidebarStateWriteTestHooks?.onLockAcquired?.(lockDir)
402
+ return {
403
+ ownsLock: async () => {
404
+ try {
405
+ await stat(ownerFile)
406
+ return true
407
+ } catch {
408
+ return false
409
+ }
410
+ },
411
+ release: async () => {
412
+ // Unlinking this acquisition's unique file makes the filesystem reject
413
+ // a stale release after eviction handed the path to a successor.
414
+ try {
415
+ await unlink(ownerFile)
416
+ } catch {
417
+ return
418
+ }
419
+ await sidebarStateWriteTestHooks?.beforeReleaseDirectoryRemoval?.(
420
+ lockDir,
421
+ )
422
+ await rmdir(lockDir).catch(() => {})
423
+ },
424
+ }
425
+ } catch (error) {
426
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
427
+ logger.trace('sidebar', 'state lock unavailable; write skipped', {
428
+ stateFile,
429
+ error: error instanceof Error ? error.message : String(error),
430
+ })
431
+ return null
432
+ }
433
+ }
434
+
435
+ try {
436
+ const lockStat = await stat(lockDir)
437
+ if (Date.now() - lockStat.mtimeMs > SIDEBAR_LOCK_STALE_MS) {
438
+ await sidebarStateWriteTestHooks?.afterStaleLockStat?.(lockDir)
439
+ const evictedLockDir = `${lockDir}.evict-${process.pid}-${randomUUID()}`
440
+ try {
441
+ await rename(lockDir, evictedLockDir)
442
+ } catch (error) {
443
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
444
+ throw error
445
+ }
446
+ await sidebarStateWriteTestHooks?.onStaleLockClaimed?.(lockDir)
447
+ await rm(evictedLockDir, { recursive: true, force: true }).catch(
448
+ () => {},
449
+ )
450
+ continue
451
+ }
452
+ } catch (error) {
453
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
454
+ logger.trace('sidebar', 'state lock inspection failed; write skipped', {
455
+ stateFile,
456
+ error: error instanceof Error ? error.message : String(error),
457
+ })
458
+ return null
459
+ }
460
+
461
+ const remainingMs = deadline - Date.now()
462
+ if (remainingMs <= 0) {
463
+ logger.warn('sidebar', 'lock budget exhausted, write skipped', {
464
+ stateFile,
465
+ budgetMs,
466
+ })
467
+ return null
468
+ }
469
+ const jitterMs =
470
+ retryMinMs + Math.floor(Math.random() * (retryMaxMs - retryMinMs + 1))
471
+ await sleep(Math.min(jitterMs, remainingMs))
472
+ }
473
+ }
474
+
475
+ export async function __acquireSidebarStateLockForTest(
476
+ stateFile: string,
477
+ ): Promise<(() => Promise<void>) | null> {
478
+ return (await acquireSidebarStateLock(stateFile))?.release ?? null
479
+ }
480
+
481
+ async function writeSidebarStateAtomic(
482
+ stateFile: string,
483
+ state: SidebarState,
484
+ ownsLock: () => Promise<boolean>,
485
+ ): Promise<'written' | 'lock-lost-before-rename' | 'lock-lost-after-rename'> {
486
+ const tempFile = `${stateFile}.${process.pid}.${randomUUID()}.tmp`
487
+ await writeFile(tempFile, JSON.stringify(state), {
488
+ encoding: 'utf8',
489
+ mode: 0o600,
490
+ })
242
491
  try {
243
- const raw = await readFile(getSidebarStateFile(), 'utf8')
492
+ if (!(await ownsLock())) {
493
+ logger.trace('sidebar', 'state lock lost before rename; write aborted', {
494
+ stateFile,
495
+ })
496
+ await rm(tempFile, { force: true }).catch(() => {})
497
+ return 'lock-lost-before-rename'
498
+ }
499
+ await sidebarStateWriteTestHooks?.beforeRename?.(stateFile, tempFile)
500
+ // No production await separates this ownership fence from rename. A process
501
+ // freeze between the adjacent syscalls remains possible, so rename is also
502
+ // fenced from the other side below.
503
+ if (!(await ownsLock())) {
504
+ logger.trace('sidebar', 'state lock lost before rename; write aborted', {
505
+ stateFile,
506
+ })
507
+ await rm(tempFile, { force: true }).catch(() => {})
508
+ return 'lock-lost-before-rename'
509
+ }
510
+ await rename(tempFile, stateFile)
511
+ await sidebarStateWriteTestHooks?.afterRename?.(stateFile)
512
+ if (!(await ownsLock())) return 'lock-lost-after-rename'
513
+ return 'written'
514
+ } catch (error) {
515
+ await rm(tempFile, { force: true }).catch(() => {})
516
+ throw error
517
+ }
518
+ }
519
+
520
+ export async function getSidebarState(
521
+ stateFile = getSidebarStateFile(),
522
+ ): Promise<SidebarState> {
523
+ try {
524
+ const raw = await readFile(stateFile, 'utf8')
244
525
  return normalizeSidebarState(JSON.parse(raw))
245
526
  } catch {
246
527
  return DEFAULT_SIDEBAR_STATE
247
528
  }
248
529
  }
249
530
 
531
+ export interface SidebarStateWriteOptions {
532
+ routingAuthoritative?: boolean
533
+ resolvePreservedRouting?: (current: SidebarState) =>
534
+ | (Pick<SidebarState, 'activeId' | 'route'> & {
535
+ state?: SidebarState
536
+ })
537
+ | undefined
538
+ | Promise<
539
+ | (Pick<SidebarState, 'activeId' | 'route'> & {
540
+ state?: SidebarState
541
+ })
542
+ | undefined
543
+ >
544
+ onRoutingResolved?: (
545
+ routing: Pick<SidebarState, 'activeId' | 'route'>,
546
+ ) => void
547
+ }
548
+
250
549
  export async function setSidebarState(
251
550
  state: SidebarState,
252
551
  stateFile = getSidebarStateFile(),
552
+ options: SidebarStateWriteOptions = {},
253
553
  ): Promise<void> {
254
554
  writeChain = writeChain
255
555
  .then(async () => {
256
556
  await mkdir(dirname(stateFile), { recursive: true })
257
- await writeFile(stateFile, JSON.stringify(state), 'utf8')
557
+ for (let attempt = 0; attempt < 2; attempt++) {
558
+ const repairingPostRenameLoss = attempt === 1
559
+ const lock = await acquireSidebarStateLock(stateFile)
560
+ // Sidebar frames are display-only and refresh within seconds; dropping one
561
+ // is safer than clobbering routing state that may stay authoritative until
562
+ // another process handles its next request.
563
+ if (!lock) {
564
+ if (repairingPostRenameLoss) {
565
+ logger.warn(
566
+ 'sidebar',
567
+ 'post-rename repair lock unavailable; write skipped',
568
+ { stateFile },
569
+ )
570
+ }
571
+ return
572
+ }
573
+
574
+ let stateToWrite = state
575
+ let result:
576
+ | 'written'
577
+ | 'lock-lost-before-rename'
578
+ | 'lock-lost-after-rename'
579
+ try {
580
+ if (options.routingAuthoritative === false) {
581
+ const current = await getSidebarState(stateFile)
582
+ const preservedRouting =
583
+ await options.resolvePreservedRouting?.(current)
584
+ if (preservedRouting) {
585
+ const preservedState = preservedRouting.state ?? state
586
+ stateToWrite = {
587
+ ...preservedState,
588
+ activeId: preservedRouting.activeId,
589
+ route: preservedRouting.route,
590
+ lastUpdated: Math.max(
591
+ preservedState.lastUpdated,
592
+ current.lastUpdated,
593
+ ),
594
+ }
595
+ }
596
+ await sidebarStateWriteTestHooks?.afterMergeRead?.(stateFile)
597
+ } else if (repairingPostRenameLoss) {
598
+ const current = await getSidebarState(stateFile)
599
+ // A successor owns its fresh account/quota and recovery snapshots;
600
+ // this routing frame may only republish its decision and shared UI state.
601
+ stateToWrite = {
602
+ ...current,
603
+ activeId: state.activeId,
604
+ route: state.route,
605
+ relay: state.relay,
606
+ fastMode: state.fastMode,
607
+ cacheKeep: state.cacheKeep,
608
+ lastUpdated: Math.max(current.lastUpdated, state.lastUpdated),
609
+ }
610
+ }
611
+ result = await writeSidebarStateAtomic(
612
+ stateFile,
613
+ stateToWrite,
614
+ lock.ownsLock,
615
+ )
616
+ } finally {
617
+ await lock.release()
618
+ }
619
+
620
+ if (result === 'lock-lost-after-rename') {
621
+ if (repairingPostRenameLoss) {
622
+ logger.warn(
623
+ 'sidebar',
624
+ 'post-rename repair lost lock; write skipped',
625
+ { stateFile },
626
+ )
627
+ return
628
+ }
629
+ logger.warn(
630
+ 'sidebar',
631
+ 'state lock lost after rename; repairing write',
632
+ {
633
+ stateFile,
634
+ },
635
+ )
636
+ continue
637
+ }
638
+ if (result === 'lock-lost-before-rename') return
639
+ options.onRoutingResolved?.({
640
+ activeId: stateToWrite.activeId,
641
+ route: stateToWrite.route,
642
+ })
643
+ return
644
+ }
258
645
  })
259
646
  .catch(() => {
260
647
  // Best-effort — sidebar is non-critical
@@ -300,6 +687,19 @@ export function formatScopedQuotaLabel(title: string) {
300
687
  return /^fable$/i.test(label) ? 'Fa' : label
301
688
  }
302
689
 
690
+ /**
691
+ * Pretty label for a recoverable-refusal source model (Fable 5 or Opus 5).
692
+ * Falls back to the raw id for unrecognized ids so the sidebar never goes
693
+ * blank on a future model that has not yet been cataloged here.
694
+ */
695
+ export function formatFallbackModelLabel(modelId: string | undefined): string {
696
+ if (modelId === 'claude-fable-5' || modelId?.startsWith('claude-fable-5-'))
697
+ return 'Fable 5'
698
+ if (modelId === 'claude-opus-5' || modelId?.startsWith('claude-opus-5-'))
699
+ return 'Opus 5'
700
+ return modelId ?? 'Fable 5'
701
+ }
702
+
303
703
  export function getFableRecoverySummary(
304
704
  state: SidebarState,
305
705
  sessionId: string,
@@ -308,7 +708,9 @@ export function getFableRecoverySummary(
308
708
  (candidate) => candidate.sessionId === sessionId,
309
709
  )
310
710
  if (!recovery) return undefined
311
- if (recovery.mode === 'fable') return 'Fable 5 · restored'
711
+ if (recovery.mode === 'fable') {
712
+ return `${formatFallbackModelLabel(recovery.requestedModelId)} · restored`
713
+ }
312
714
  return `Opus 4.8 · ${recovery.remaining} left`
313
715
  }
314
716
 
@@ -32,6 +32,26 @@ export function buildKillswitchThresholdSeed(
32
32
  return seedParts.join(' ')
33
33
  }
34
34
 
35
+ export function buildAccountDialogOption(account: {
36
+ id: string
37
+ label: string
38
+ role: string
39
+ enabled: boolean
40
+ quotaPercent: number | null
41
+ tierLabel?: string
42
+ }) {
43
+ const pct =
44
+ account.quotaPercent != null
45
+ ? ` ${Math.round(account.quotaPercent)}%`
46
+ : ' \u2013%'
47
+ const status = !account.enabled ? ' (disabled)' : ''
48
+ return {
49
+ title: `${account.label} [${account.role}]${status}${pct}`,
50
+ value: account.id,
51
+ ...(account.tierLabel && { description: account.tierLabel }),
52
+ }
53
+ }
54
+
35
55
  function showText(api: TuiPluginApi, text: string) {
36
56
  api.ui.dialog.setSize('xlarge')
37
57
  api.ui.dialog.replace(() => (
@@ -253,6 +273,7 @@ export function openCommandDialog(
253
273
  role: string
254
274
  enabled: boolean
255
275
  quotaPercent: number | null
276
+ tierLabel?: string
256
277
  }>) ?? []
257
278
 
258
279
  const updateAccounts = (r: {
@@ -278,17 +299,7 @@ export function openCommandDialog(
278
299
  value: '__add__',
279
300
  description: 'Add an API key or OAuth fallback account',
280
301
  },
281
- ...accounts.map((a) => {
282
- const pct =
283
- a.quotaPercent != null
284
- ? ` ${Math.round(a.quotaPercent)}%`
285
- : ' \u2013%'
286
- const status = !a.enabled ? ' (disabled)' : ''
287
- return {
288
- title: `${a.label} [${a.role}]${status}${pct}`,
289
- value: a.id,
290
- }
291
- }),
302
+ ...accounts.map(buildAccountDialogOption),
292
303
  ]
293
304
  api.ui.dialog.setSize('xlarge')
294
305
  api.ui.dialog.replace(() => (