usage-panel-opencode 0.0.0-stage → 0.1.1

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.
package/src/index.ts ADDED
@@ -0,0 +1,761 @@
1
+ import { Plugin } from '@opencode/plugin';
2
+ import { normalizeIntervalSeconds, type PanelPreferences, resolvePreferences } from './config';
3
+ import { type ResolvedAccount, resolveAccount } from './core/account';
4
+ import { canAttempt, nextRetryDelay } from './core/backoff';
5
+ import { type HeaderUsage, normalizeSnapshot } from './core/snapshot';
6
+ import { initialPanelState, type PanelRuntimeState, transitionPanelState } from './core/state';
7
+ import { fetchUsage, type UsageClientError } from './core/usage-client';
8
+ import { readUsageHeaders } from './core/usage-headers';
9
+ import {
10
+ type PanelError,
11
+ type PanelSnapshot,
12
+ type RefreshNotice,
13
+ Usage,
14
+ type UsageUpdateEvent,
15
+ } from './rpc';
16
+
17
+ const PREFERENCES_KEY = 'preferences';
18
+ const MAX_INSTANCE_ID_LENGTH = 128;
19
+ const AUTH_CHECK_INTERVAL_MS = 60_000;
20
+
21
+ interface HeaderObservation {
22
+ values: HeaderUsage;
23
+ accountIdentity: string;
24
+ requestSequence: number;
25
+ }
26
+
27
+ interface RequestOwner {
28
+ accountIdentity: string;
29
+ instanceIds: string[];
30
+ sequence: number;
31
+ }
32
+
33
+ interface TuiInstance {
34
+ id: string;
35
+ attached: boolean;
36
+ listening: boolean;
37
+ panelState: PanelRuntimeState;
38
+ activeIdentity: string | null;
39
+ activeAccount: ResolvedAccount | null;
40
+ latestHeaders: HeaderObservation | null;
41
+ lastHeaderSequence: number;
42
+ lastServiceData: unknown;
43
+ lastServiceFetchedAt: number | null;
44
+ lastAttemptAt: number | null;
45
+ failureCount: number;
46
+ authSuspended: boolean;
47
+ retryAt: number | null;
48
+ timer: ReturnType<typeof setTimeout> | undefined;
49
+ inFlight: Promise<PanelSnapshot> | null;
50
+ requestController: AbortController | null;
51
+ generation: number;
52
+ }
53
+
54
+ function isRecord(value: unknown): value is Record<string, unknown> {
55
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
56
+ }
57
+
58
+ function responseHeaders(eventValue: unknown): Headers | Record<string, string | undefined> | null {
59
+ if (!isRecord(eventValue) || !isRecord(eventValue.response)) return null;
60
+ const response = eventValue.response;
61
+ if (response.headers instanceof Headers) return response.headers;
62
+ if (!isRecord(response.headers)) return null;
63
+ const result: Record<string, string | undefined> = {};
64
+ for (const [key, value] of Object.entries(response.headers)) {
65
+ if (typeof value === 'string') result[key] = value;
66
+ }
67
+ return result;
68
+ }
69
+
70
+ function requestFromEvent(eventValue: unknown): Request | null {
71
+ if (!isRecord(eventValue) || !isRecord(eventValue.request)) return null;
72
+ const request = eventValue.request;
73
+ return typeof request.url === 'string' ? (request as unknown as Request) : null;
74
+ }
75
+
76
+ function isOpenAIProviderRequest(eventValue: unknown, request: Request): boolean {
77
+ if (!isRecord(eventValue) || !isRecord(eventValue.model)) return false;
78
+ if (eventValue.model.providerID !== 'openai') return false;
79
+ try {
80
+ return new URL(request.url).hostname === 'api.openai.com';
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function requestedInstanceId(input: unknown): string | null {
87
+ if (!isRecord(input) || typeof input.instanceId !== 'string') return null;
88
+ const id = input.instanceId.trim();
89
+ return id.length > 0 && id.length <= MAX_INSTANCE_ID_LENGTH ? id : null;
90
+ }
91
+
92
+ function safeError(error: UsageClientError): PanelError {
93
+ const messages: Record<UsageClientError['kind'], string> = {
94
+ network: 'No se pudo consultar el consumo de la suscripción.',
95
+ rate_limited: 'OpenAI limitó temporalmente las consultas de consumo.',
96
+ unauthorized: 'La conexión de OpenAI necesita volver a autenticarse.',
97
+ forbidden: 'OpenAI no permitió consultar el consumo de esta cuenta.',
98
+ malformed: 'OpenAI devolvió datos de consumo con un formato no reconocido.',
99
+ unknown: 'No se pudo obtener el consumo de la suscripción.',
100
+ };
101
+ return {
102
+ kind: error.kind,
103
+ message: messages[error.kind],
104
+ action:
105
+ error.kind === 'unauthorized'
106
+ ? '/connect → OpenAI'
107
+ : error.kind === 'forbidden'
108
+ ? 'Comprueba que la cuenta tenga acceso a la suscripción.'
109
+ : error.kind === 'malformed'
110
+ ? 'Actualiza el plugin y vuelve a intentarlo.'
111
+ : 'Vuelve a intentarlo en unos momentos.',
112
+ };
113
+ }
114
+
115
+ function makeInstance(id: string): TuiInstance {
116
+ return {
117
+ id,
118
+ attached: false,
119
+ listening: false,
120
+ panelState: initialPanelState(),
121
+ activeIdentity: null,
122
+ activeAccount: null,
123
+ latestHeaders: null,
124
+ lastHeaderSequence: 0,
125
+ lastServiceData: null,
126
+ lastServiceFetchedAt: null,
127
+ lastAttemptAt: null,
128
+ failureCount: 0,
129
+ authSuspended: false,
130
+ retryAt: null,
131
+ timer: undefined,
132
+ inFlight: null,
133
+ requestController: null,
134
+ generation: 0,
135
+ };
136
+ }
137
+
138
+ export default Plugin.define({
139
+ id: 'openai-usage.server',
140
+ async setup(ctx) {
141
+ const stored = await ctx.storage.get(PREFERENCES_KEY);
142
+ let preferences: PanelPreferences = resolvePreferences(ctx.options, stored);
143
+ const instances = new Map<string, TuiInstance>();
144
+ const requestOwners = new WeakMap<Request, RequestOwner>();
145
+ let requestSequence = 0;
146
+ let emitRpcEvent: ((event: UsageUpdateEvent) => Promise<void>) | undefined;
147
+ let disposeRpc: (() => Promise<void>) | undefined;
148
+
149
+ const getInstance = (id: string | null): TuiInstance | null => {
150
+ if (!id) return null;
151
+ let instance = instances.get(id);
152
+ if (!instance) {
153
+ instance = makeInstance(id);
154
+ instances.set(id, instance);
155
+ }
156
+ return instance;
157
+ };
158
+
159
+ const panelSnapshot = (instance: TuiInstance): PanelSnapshot => {
160
+ const fetchedAt = instance.panelState.snapshot?.origin.fetchedAt;
161
+ return {
162
+ state: instance.panelState.state,
163
+ snapshot: instance.panelState.snapshot,
164
+ error: instance.panelState.error,
165
+ preferences: { ...preferences },
166
+ ...(typeof fetchedAt === 'number' ? { ageMs: Math.max(0, Date.now() - fetchedAt) } : {}),
167
+ ...(instance.retryAt !== null ? { retryAt: instance.retryAt } : {}),
168
+ };
169
+ };
170
+
171
+ const initialSnapshot = (): PanelSnapshot => ({
172
+ state: 'loading',
173
+ snapshot: null,
174
+ preferences: { ...preferences },
175
+ });
176
+
177
+ const emitUpdate = async (instance: TuiInstance, finalDisabledUpdate = false) => {
178
+ if (
179
+ !emitRpcEvent ||
180
+ instances.get(instance.id) !== instance ||
181
+ (finalDisabledUpdate ? !instance.attached : !instance.listening || !preferences.enabled)
182
+ ) {
183
+ return;
184
+ }
185
+ try {
186
+ await emitRpcEvent({ instanceId: instance.id, data: panelSnapshot(instance) });
187
+ } catch {
188
+ // A disconnected TUI has no listener; its next snapshot RPC reads its own state.
189
+ }
190
+ };
191
+
192
+ const clearTimer = (instance: TuiInstance, clearRetryAt = true) => {
193
+ if (instance.timer) clearTimeout(instance.timer);
194
+ instance.timer = undefined;
195
+ if (clearRetryAt) instance.retryAt = null;
196
+ };
197
+
198
+ const scheduleAuthCheck = (instance: TuiInstance) => {
199
+ clearTimer(instance, false);
200
+ if (!instance.listening || !preferences.enabled || !instance.authSuspended) {
201
+ instance.retryAt = null;
202
+ return;
203
+ }
204
+ instance.retryAt = null;
205
+ instance.timer = setTimeout(() => {
206
+ instance.timer = undefined;
207
+ void probeCredential(instance);
208
+ }, AUTH_CHECK_INTERVAL_MS);
209
+ };
210
+
211
+ const schedule = (
212
+ instance: TuiInstance,
213
+ delayMs: number | null,
214
+ retry: boolean,
215
+ fixedRetryAt?: number,
216
+ ) => {
217
+ clearTimer(instance, false);
218
+ if (!instance.listening || !preferences.enabled) {
219
+ instance.retryAt = null;
220
+ return;
221
+ }
222
+ if (instance.authSuspended) {
223
+ scheduleAuthCheck(instance);
224
+ return;
225
+ }
226
+ if (delayMs === null) {
227
+ instance.retryAt = null;
228
+ return;
229
+ }
230
+ const delay = Math.max(0, delayMs);
231
+ instance.retryAt = retry ? (fixedRetryAt ?? Date.now() + delay) : null;
232
+ instance.timer = setTimeout(() => {
233
+ instance.timer = undefined;
234
+ instance.retryAt = null;
235
+ void refresh(instance);
236
+ }, delay);
237
+ };
238
+
239
+ const resolveActiveAccount = async (): Promise<ResolvedAccount> => {
240
+ try {
241
+ const connection = await ctx.integration.connection.active('openai');
242
+ const credential = connection
243
+ ? await ctx.integration.connection.resolve(connection)
244
+ : undefined;
245
+ return resolveAccount(connection, credential);
246
+ } catch {
247
+ return resolveAccount(undefined, undefined);
248
+ }
249
+ };
250
+
251
+ const setAccount = (instance: TuiInstance, resolved: ResolvedAccount): boolean => {
252
+ const previousToken = instance.activeAccount?.accessToken ?? null;
253
+ const previousKind = instance.activeAccount?.account.credentialKind ?? null;
254
+ const changed =
255
+ (instance.activeIdentity !== null && instance.activeIdentity !== resolved.identity) ||
256
+ (instance.authSuspended &&
257
+ (previousToken !== resolved.accessToken ||
258
+ previousKind !== resolved.account.credentialKind));
259
+ if (changed) {
260
+ instance.latestHeaders = null;
261
+ instance.lastHeaderSequence = 0;
262
+ instance.lastServiceData = null;
263
+ instance.lastServiceFetchedAt = null;
264
+ instance.panelState = transitionPanelState(instance.panelState, {
265
+ type: 'account_changed',
266
+ });
267
+ instance.failureCount = 0;
268
+ instance.authSuspended = false;
269
+ instance.retryAt = null;
270
+ }
271
+ instance.activeIdentity = resolved.identity;
272
+ instance.activeAccount = resolved;
273
+ return changed;
274
+ };
275
+
276
+ const current = (instance: TuiInstance, generation?: number): boolean =>
277
+ instances.get(instance.id) === instance &&
278
+ instance.listening &&
279
+ preferences.enabled &&
280
+ (generation === undefined || instance.generation === generation);
281
+
282
+ const setFailure = (instance: TuiInstance, error: UsageClientError) => {
283
+ const safe = safeError(error);
284
+ if (error.kind === 'unauthorized') {
285
+ instance.panelState = transitionPanelState(instance.panelState, {
286
+ type: 'needs_auth',
287
+ error: safe,
288
+ });
289
+ } else {
290
+ instance.panelState = transitionPanelState(instance.panelState, {
291
+ type: 'failure',
292
+ error: safe,
293
+ });
294
+ }
295
+ };
296
+
297
+ async function doRefresh(instance: TuiInstance, signal?: AbortSignal): Promise<PanelSnapshot> {
298
+ if (!current(instance) || instance.authSuspended) return panelSnapshot(instance);
299
+ const now = Date.now();
300
+ if (!canAttempt(instance.lastAttemptAt, now)) return panelSnapshot(instance);
301
+ instance.lastAttemptAt = now;
302
+ instance.retryAt = null;
303
+
304
+ const requestGeneration = ++instance.generation;
305
+ const controller = new AbortController();
306
+ instance.requestController = controller;
307
+ const abortFromRpc = () => controller.abort(signal?.reason);
308
+ if (signal?.aborted) abortFromRpc();
309
+ else signal?.addEventListener('abort', abortFromRpc, { once: true });
310
+
311
+ try {
312
+ const account = await resolveActiveAccount();
313
+ if (!current(instance, requestGeneration) || controller.signal.aborted)
314
+ return panelSnapshot(instance);
315
+ const accountChanged = setAccount(instance, account);
316
+ if (accountChanged) await emitUpdate(instance);
317
+
318
+ if (account.account.credentialKind === 'key') {
319
+ instance.panelState = transitionPanelState(instance.panelState, {
320
+ type: 'no_subscription',
321
+ });
322
+ instance.failureCount = 0;
323
+ await emitUpdate(instance);
324
+ schedule(instance, preferences.intervalSeconds * 1000, false);
325
+ return panelSnapshot(instance);
326
+ }
327
+ if (account.account.credentialKind !== 'oauth' || !account.accessToken) {
328
+ instance.panelState = transitionPanelState(instance.panelState, {
329
+ type: 'needs_auth',
330
+ error: {
331
+ kind: 'unauthorized',
332
+ message: 'Conecta OpenAI con «Sign in with ChatGPT» para ver el consumo.',
333
+ action: '/connect → OpenAI',
334
+ },
335
+ });
336
+ instance.authSuspended = true;
337
+ schedule(instance, null, false);
338
+ await emitUpdate(instance);
339
+ return panelSnapshot(instance);
340
+ }
341
+
342
+ const result = await fetchUsage({
343
+ accessToken: account.accessToken,
344
+ accountId: account.usageAccountId,
345
+ signal: controller.signal,
346
+ });
347
+ if (!current(instance, requestGeneration) || controller.signal.aborted)
348
+ return panelSnapshot(instance);
349
+
350
+ const latestAccount = await resolveActiveAccount();
351
+ if (!current(instance, requestGeneration) || controller.signal.aborted)
352
+ return panelSnapshot(instance);
353
+ if (latestAccount.identity !== account.identity) {
354
+ setAccount(instance, latestAccount);
355
+ await emitUpdate(instance);
356
+ schedule(instance, preferences.intervalSeconds * 1000, false);
357
+ return panelSnapshot(instance);
358
+ }
359
+ setAccount(instance, latestAccount);
360
+
361
+ if (result.ok) {
362
+ instance.lastServiceData = result.data;
363
+ instance.lastServiceFetchedAt = result.fetchedAt;
364
+ const headers =
365
+ instance.latestHeaders?.accountIdentity === latestAccount.identity
366
+ ? instance.latestHeaders.values
367
+ : null;
368
+ const snapshot = normalizeSnapshot({
369
+ account: latestAccount.account,
370
+ service: result.data,
371
+ headers,
372
+ fetchedAt: result.fetchedAt,
373
+ serviceFetchedAt: result.fetchedAt,
374
+ sensitiveValues: [latestAccount.accessToken],
375
+ sensitiveAccountIds: [latestAccount.usageAccountId],
376
+ });
377
+ instance.panelState = transitionPanelState(instance.panelState, {
378
+ type: 'success',
379
+ snapshot,
380
+ });
381
+ instance.failureCount = 0;
382
+ instance.authSuspended = false;
383
+ await emitUpdate(instance);
384
+ schedule(instance, preferences.intervalSeconds * 1000, false);
385
+ return panelSnapshot(instance);
386
+ }
387
+
388
+ // An old header observation is not a successful refresh and must not mask this failure.
389
+ setFailure(instance, result.error);
390
+ instance.failureCount += 1;
391
+ const delay = nextRetryDelay(
392
+ instance.failureCount - 1,
393
+ result.error.kind,
394
+ result.error.retryAfterMs,
395
+ );
396
+ instance.authSuspended = delay === null;
397
+ schedule(instance, delay, true);
398
+ await emitUpdate(instance);
399
+ return panelSnapshot(instance);
400
+ } catch {
401
+ if (current(instance, requestGeneration)) {
402
+ const error: UsageClientError = {
403
+ kind: 'network',
404
+ message: 'No se pudo consultar el consumo de la suscripción.',
405
+ };
406
+ setFailure(instance, error);
407
+ instance.failureCount += 1;
408
+ const delay = nextRetryDelay(instance.failureCount - 1, 'network');
409
+ schedule(instance, delay, true);
410
+ await emitUpdate(instance);
411
+ }
412
+ return panelSnapshot(instance);
413
+ } finally {
414
+ signal?.removeEventListener('abort', abortFromRpc);
415
+ if (instance.requestController === controller) instance.requestController = null;
416
+ }
417
+ }
418
+
419
+ function refresh(instance: TuiInstance, signal?: AbortSignal): Promise<PanelSnapshot> {
420
+ if (instance.inFlight) return Promise.resolve(panelSnapshot(instance));
421
+ const currentRefresh = doRefresh(instance, signal);
422
+ instance.inFlight = currentRefresh;
423
+ void currentRefresh.finally(() => {
424
+ if (instance.inFlight === currentRefresh) instance.inFlight = null;
425
+ });
426
+ return currentRefresh;
427
+ }
428
+
429
+ async function probeCredential(instance: TuiInstance): Promise<void> {
430
+ if (!current(instance) || !instance.authSuspended) return;
431
+
432
+ const previousIdentity = instance.activeIdentity;
433
+ const previousToken = instance.activeAccount?.accessToken ?? null;
434
+ const previousKind = instance.activeAccount?.account.credentialKind ?? null;
435
+ const resolved = await resolveActiveAccount();
436
+ if (!current(instance) || !instance.authSuspended) return;
437
+
438
+ const credentialChanged =
439
+ resolved.identity !== previousIdentity ||
440
+ resolved.accessToken !== previousToken ||
441
+ resolved.account.credentialKind !== previousKind;
442
+ if (!credentialChanged) {
443
+ scheduleAuthCheck(instance);
444
+ return;
445
+ }
446
+
447
+ setAccount(instance, resolved);
448
+ instance.authSuspended = false;
449
+ instance.failureCount = 0;
450
+
451
+ if (resolved.account.credentialKind === 'key') {
452
+ instance.panelState = transitionPanelState(instance.panelState, {
453
+ type: 'no_subscription',
454
+ });
455
+ await emitUpdate(instance);
456
+ schedule(instance, preferences.intervalSeconds * 1000, false);
457
+ return;
458
+ }
459
+
460
+ if (resolved.account.credentialKind !== 'oauth' || !resolved.accessToken) {
461
+ instance.panelState = transitionPanelState(instance.panelState, {
462
+ type: 'needs_auth',
463
+ error: {
464
+ kind: 'unauthorized',
465
+ message: 'Conecta OpenAI con «Sign in with ChatGPT» para ver el consumo.',
466
+ action: '/connect → OpenAI',
467
+ },
468
+ });
469
+ instance.authSuspended = true;
470
+ scheduleAuthCheck(instance);
471
+ await emitUpdate(instance);
472
+ return;
473
+ }
474
+
475
+ await emitUpdate(instance);
476
+ if (canAttempt(instance.lastAttemptAt, Date.now())) {
477
+ void refresh(instance);
478
+ return;
479
+ }
480
+ const retryAt = (instance.lastAttemptAt ?? Date.now()) + 60_000;
481
+ schedule(instance, Math.max(0, retryAt - Date.now()), true, retryAt);
482
+ }
483
+
484
+ async function refreshManually(
485
+ instance: TuiInstance,
486
+ signal?: AbortSignal,
487
+ ): Promise<PanelSnapshot> {
488
+ if (!instance.listening || !preferences.enabled) return panelSnapshot(instance);
489
+
490
+ if (instance.authSuspended) {
491
+ const previousToken = instance.activeAccount?.accessToken;
492
+ const resolved = await resolveActiveAccount();
493
+ if (
494
+ resolved.identity !== instance.activeIdentity ||
495
+ (resolved.accessToken !== null && resolved.accessToken !== previousToken)
496
+ ) {
497
+ setAccount(instance, resolved);
498
+ instance.authSuspended = false;
499
+ instance.failureCount = 0;
500
+ if (!canAttempt(instance.lastAttemptAt, Date.now())) {
501
+ const retryAt = (instance.lastAttemptAt ?? Date.now()) + 60_000;
502
+ schedule(instance, Math.max(0, retryAt - Date.now()), true, retryAt);
503
+ }
504
+ } else {
505
+ return panelSnapshot(instance);
506
+ }
507
+ }
508
+
509
+ if (instance.inFlight) {
510
+ const retryAt = instance.retryAt ?? (instance.lastAttemptAt ?? Date.now()) + 60_000;
511
+ const notice: RefreshNotice = {
512
+ kind: 'in_progress',
513
+ retryAt,
514
+ message: 'Ya hay una consulta en curso; se conserva el dato actual.',
515
+ };
516
+ return { ...panelSnapshot(instance), refreshNotice: notice };
517
+ }
518
+
519
+ const now = Date.now();
520
+ const retryAt =
521
+ instance.retryAt ??
522
+ (instance.lastAttemptAt === null ? now : instance.lastAttemptAt + 60_000);
523
+ if (instance.retryAt !== null && now < instance.retryAt) {
524
+ const notice: RefreshNotice = {
525
+ kind: 'limited',
526
+ retryAt: instance.retryAt,
527
+ message: 'Se conserva el dato actual; se respeta la espera de reintento.',
528
+ };
529
+ return { ...panelSnapshot(instance), refreshNotice: notice };
530
+ }
531
+ if (!canAttempt(instance.lastAttemptAt, now)) {
532
+ const notice: RefreshNotice = {
533
+ kind: 'limited',
534
+ retryAt,
535
+ message: 'Se conserva el dato actual; se respeta el intervalo mínimo de 60 segundos.',
536
+ };
537
+ return { ...panelSnapshot(instance), refreshNotice: notice };
538
+ }
539
+ return refresh(instance, signal);
540
+ }
541
+
542
+ const deactivate = (instance: TuiInstance, detach: boolean) => {
543
+ instance.listening = false;
544
+ if (detach) instance.attached = false;
545
+ clearTimer(instance);
546
+ instance.generation += 1;
547
+ instance.requestController?.abort();
548
+ instance.requestController = null;
549
+ instance.inFlight = null;
550
+ instance.activeIdentity = null;
551
+ instance.activeAccount = null;
552
+ instance.latestHeaders = null;
553
+ instance.lastHeaderSequence = 0;
554
+ instance.lastServiceData = null;
555
+ instance.lastServiceFetchedAt = null;
556
+ instance.lastAttemptAt = null;
557
+ instance.failureCount = 0;
558
+ instance.authSuspended = false;
559
+ instance.panelState = transitionPanelState(instance.panelState, { type: 'stop' });
560
+ };
561
+
562
+ const startInstance = (instance: TuiInstance) => {
563
+ if (instance.attached) return;
564
+ instance.attached = true;
565
+ if (!preferences.enabled) return;
566
+ instance.listening = true;
567
+ void refresh(instance);
568
+ };
569
+
570
+ const registration = await ctx.rpc.register(Usage, {
571
+ snapshot: async (input) => {
572
+ const instance = getInstance(requestedInstanceId(input));
573
+ return instance ? panelSnapshot(instance) : initialSnapshot();
574
+ },
575
+ refresh: async (input, rpcContext) => {
576
+ const instance = getInstance(requestedInstanceId(input));
577
+ return instance ? refreshManually(instance, rpcContext?.signal) : initialSnapshot();
578
+ },
579
+ setPreferences: async (input) => {
580
+ const patch = isRecord(input) ? input : {};
581
+ const wasEnabled = preferences.enabled;
582
+ const intervalValue = Object.hasOwn(patch, 'intervalSeconds')
583
+ ? normalizeIntervalSeconds(patch.intervalSeconds)
584
+ : preferences.intervalSeconds;
585
+ preferences = {
586
+ enabled: typeof patch.enabled === 'boolean' ? patch.enabled : preferences.enabled,
587
+ intervalSeconds: intervalValue,
588
+ visible: typeof patch.visible === 'boolean' ? patch.visible : preferences.visible,
589
+ };
590
+ await ctx.storage.set(PREFERENCES_KEY, { ...preferences });
591
+
592
+ for (const instance of instances.values()) {
593
+ if (!preferences.enabled) {
594
+ deactivate(instance, false);
595
+ await emitUpdate(instance, true);
596
+ continue;
597
+ }
598
+ if (!wasEnabled && preferences.enabled) {
599
+ if (instance.attached) {
600
+ instance.listening = true;
601
+ void refresh(instance);
602
+ }
603
+ continue;
604
+ }
605
+ if (!instance.listening) continue;
606
+ if (instance.authSuspended) {
607
+ scheduleAuthCheck(instance);
608
+ await emitUpdate(instance);
609
+ continue;
610
+ }
611
+ if (instance.retryAt !== null) {
612
+ const retryAt = instance.retryAt;
613
+ schedule(instance, Math.max(0, retryAt - Date.now()), true, retryAt);
614
+ } else if (instance.lastAttemptAt !== null) {
615
+ schedule(
616
+ instance,
617
+ Math.max(0, instance.lastAttemptAt + preferences.intervalSeconds * 1000 - Date.now()),
618
+ false,
619
+ );
620
+ }
621
+ await emitUpdate(instance);
622
+ }
623
+
624
+ const active = [...instances.values()].find((instance) => instance.listening);
625
+ return active ? panelSnapshot(active) : initialSnapshot();
626
+ },
627
+ start: async (input) => {
628
+ const instance = getInstance(requestedInstanceId(input));
629
+ if (instance) startInstance(instance);
630
+ return {};
631
+ },
632
+ stop: async (input) => {
633
+ const id = requestedInstanceId(input);
634
+ const instance = id ? instances.get(id) : undefined;
635
+ if (instance) {
636
+ deactivate(instance, true);
637
+ instances.delete(instance.id);
638
+ }
639
+ return {};
640
+ },
641
+ });
642
+ emitRpcEvent = (event) => registration.events.emit('updated', { ...event });
643
+ disposeRpc = () => registration.dispose();
644
+
645
+ const requestHook = await ctx.session.hook(
646
+ 'http.request',
647
+ async (event) => {
648
+ const request = requestFromEvent(event);
649
+ if (!request || !isOpenAIProviderRequest(event, request)) return;
650
+ const targets = [...instances.values()].filter((instance) => instance.listening);
651
+ if (targets.length === 0) return;
652
+ const resolved = await resolveActiveAccount();
653
+
654
+ const instanceIds: string[] = [];
655
+ for (const instance of targets) {
656
+ if (!current(instance)) continue;
657
+ const previousToken = instance.activeAccount?.accessToken;
658
+ const previousIdentity = instance.activeIdentity;
659
+ const wasSuspended = instance.authSuspended;
660
+ const accountChanged = setAccount(instance, resolved);
661
+ if (accountChanged) await emitUpdate(instance);
662
+ if (resolved.account.credentialKind !== 'oauth' || !resolved.accessToken) continue;
663
+ if (
664
+ wasSuspended &&
665
+ (resolved.identity !== previousIdentity || resolved.accessToken !== previousToken)
666
+ ) {
667
+ instance.authSuspended = false;
668
+ instance.failureCount = 0;
669
+ if (canAttempt(instance.lastAttemptAt, Date.now())) {
670
+ void refresh(instance);
671
+ } else {
672
+ const retryAt = (instance.lastAttemptAt ?? Date.now()) + 60_000;
673
+ schedule(instance, Math.max(0, retryAt - Date.now()), true, retryAt);
674
+ }
675
+ }
676
+ if (instance.authSuspended || instance.activeIdentity !== resolved.identity) continue;
677
+ instance.activeAccount = resolved;
678
+ instanceIds.push(instance.id);
679
+ }
680
+ if (instanceIds.length === 0) return;
681
+ requestOwners.set(request, {
682
+ accountIdentity: resolved.identity,
683
+ instanceIds,
684
+ sequence: ++requestSequence,
685
+ });
686
+ },
687
+ { providerID: 'openai' },
688
+ );
689
+
690
+ const responseHook = await ctx.session.hook(
691
+ 'http.response',
692
+ async (event) => {
693
+ const request = requestFromEvent(event);
694
+ if (!request || !isOpenAIProviderRequest(event, request)) return;
695
+ const owner = requestOwners.get(request);
696
+ if (!owner) return;
697
+ requestOwners.delete(request);
698
+ const headers = readUsageHeaders(responseHeaders(event));
699
+ if (!headers) return;
700
+
701
+ const resolved = await resolveActiveAccount();
702
+ if (resolved.identity !== owner.accountIdentity) return;
703
+ for (const id of owner.instanceIds) {
704
+ const instance = instances.get(id);
705
+ if (
706
+ !instance ||
707
+ !current(instance) ||
708
+ instance.activeIdentity !== owner.accountIdentity ||
709
+ owner.sequence <= instance.lastHeaderSequence
710
+ ) {
711
+ continue;
712
+ }
713
+
714
+ instance.lastHeaderSequence = owner.sequence;
715
+ instance.latestHeaders = {
716
+ values: headers,
717
+ accountIdentity: owner.accountIdentity,
718
+ requestSequence: owner.sequence,
719
+ };
720
+ const account = instance.activeAccount;
721
+ if (!account) continue;
722
+
723
+ const previousError = instance.panelState.error;
724
+ const wasRecovering =
725
+ instance.panelState.state === 'stale' || instance.panelState.state === 'needs_auth';
726
+ const snapshot = normalizeSnapshot({
727
+ account: account.account,
728
+ service: instance.lastServiceData,
729
+ headers,
730
+ fetchedAt: Date.now(),
731
+ serviceFetchedAt: instance.lastServiceFetchedAt,
732
+ sensitiveValues: [account.accessToken],
733
+ sensitiveAccountIds: [account.usageAccountId],
734
+ detail:
735
+ 'Porcentajes obtenidos de respuestas de OpenAI correlacionadas; el reinicio no está disponible por esta fuente.',
736
+ });
737
+ instance.panelState = transitionPanelState(instance.panelState, {
738
+ type: 'success',
739
+ snapshot,
740
+ });
741
+ if (wasRecovering && previousError) {
742
+ instance.panelState = transitionPanelState(instance.panelState, {
743
+ type: 'failure',
744
+ error: previousError,
745
+ });
746
+ }
747
+ await emitUpdate(instance);
748
+ }
749
+ },
750
+ { providerID: 'openai' },
751
+ );
752
+
753
+ return () => {
754
+ for (const instance of instances.values()) deactivate(instance, true);
755
+ instances.clear();
756
+ void requestHook.dispose();
757
+ void responseHook.dispose();
758
+ void disposeRpc?.();
759
+ };
760
+ },
761
+ });