@pure01fx/dsh-openai-codex-auth 0.8.0 → 0.9.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.
package/lib/index.js CHANGED
@@ -39,6 +39,10 @@ const TOKEN_REF = credentialRef('DSH_OPENAI_CODEX_TOKEN');
39
39
  const USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
40
40
  const MAX_ERROR_BODY_LENGTH = 1_024;
41
41
  const MAX_REQUEST_BODY_LENGTH = 8_192;
42
+ const ACCOUNT_USAGE_REQUEST_TIMEOUT_MS = 10_000;
43
+ const TOKEN_REFRESH_REQUEST_TIMEOUT_MS = 30_000;
44
+ const ACCOUNT_REFRESH_LOCK_WAIT_MS = 35_000;
45
+ const CREDENTIAL_REFRESH_PERSIST_WAIT_MS = 500;
42
46
  class DeviceCodeUnavailableError extends Error {
43
47
  code = 'device_code_unavailable';
44
48
  }
@@ -48,12 +52,30 @@ class CredentialNotWritableError extends Error {
48
52
  }
49
53
  class AccountNotFoundError extends Error {
50
54
  }
55
+ class CredentialRefreshUncertainError extends Error {
56
+ constructor(cause) {
57
+ super('OpenAI credential refresh outcome is uncertain; sign in to this account again', { cause });
58
+ }
59
+ }
51
60
  const PERMANENT_REFRESH_CODES = new Set([
52
61
  'refresh_token_expired',
53
62
  'refresh_token_reused',
54
63
  'refresh_token_invalidated',
55
64
  'invalid_grant',
56
65
  ]);
66
+ const DEFINITIVE_OAUTH_REFRESH_CODES = new Set([
67
+ ...PERMANENT_REFRESH_CODES,
68
+ 'access_denied',
69
+ 'invalid_client',
70
+ 'invalid_request',
71
+ 'invalid_scope',
72
+ 'server_error',
73
+ 'slow_down',
74
+ 'temporarily_unavailable',
75
+ 'temporary_failure',
76
+ 'unauthorized_client',
77
+ 'unsupported_grant_type',
78
+ ]);
57
79
  class OAuthEndpointError extends Error {
58
80
  status;
59
81
  oauthCode;
@@ -70,6 +92,33 @@ function base64Url(value) {
70
92
  function messageOf(error) {
71
93
  return error instanceof Error ? error.message : String(error);
72
94
  }
95
+ function isTimeoutAbort(signal) {
96
+ return signal?.aborted === true
97
+ && signal.reason instanceof DOMException
98
+ && signal.reason.name === 'TimeoutError';
99
+ }
100
+ function boundedAccountUsageSignal(signal) {
101
+ const timeout = AbortSignal.timeout(ACCOUNT_USAGE_REQUEST_TIMEOUT_MS);
102
+ return signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
103
+ }
104
+ function isFileLockTimeout(error) {
105
+ return error instanceof Error
106
+ && error.message.startsWith('atomic-write: timed out waiting for the writer lock at ');
107
+ }
108
+ function waitForSignal(promise, signal) {
109
+ if (signal === undefined)
110
+ return promise;
111
+ if (signal.aborted)
112
+ return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error('Request cancelled'));
113
+ return new Promise((resolveWait, rejectWait) => {
114
+ const onAbort = () => {
115
+ signal.removeEventListener('abort', onAbort);
116
+ rejectWait(signal.reason instanceof Error ? signal.reason : new Error('Request cancelled'));
117
+ };
118
+ signal.addEventListener('abort', onAbort, { once: true });
119
+ promise.then((value) => { signal.removeEventListener('abort', onAbort); resolveWait(value); }, (error) => { signal.removeEventListener('abort', onAbort); rejectWait(error); });
120
+ });
121
+ }
73
122
  function throwIfCancelled(signal) {
74
123
  if (!signal?.aborted)
75
124
  return;
@@ -143,6 +192,12 @@ function isPermanentRefreshError(error) {
143
192
  && (error.status === 401
144
193
  || (error.oauthCode !== undefined && PERMANENT_REFRESH_CODES.has(error.oauthCode.toLowerCase())));
145
194
  }
195
+ function isDefinitiveOAuthRefreshError(error) {
196
+ return error instanceof OAuthEndpointError
197
+ && (error.status === 400 || error.status === 401)
198
+ && error.oauthCode !== undefined
199
+ && DEFINITIVE_OAUTH_REFRESH_CODES.has(error.oauthCode.toLowerCase());
200
+ }
146
201
  function validateCredential(value, filename) {
147
202
  const credential = value;
148
203
  if (credential === null || typeof credential !== 'object'
@@ -258,7 +313,7 @@ async function tokenRequest(init, previous, signal) {
258
313
  }
259
314
  catch (error) {
260
315
  if (signal?.aborted)
261
- throw new Error('OpenAI login cancelled');
316
+ throw new Error(isTimeoutAbort(signal) ? 'OpenAI token request timed out' : 'OpenAI login cancelled');
262
317
  throw error;
263
318
  }
264
319
  if (!response.ok) {
@@ -655,6 +710,8 @@ export class OpenAICodexAuth extends Service {
655
710
  usageError;
656
711
  usageRefresh;
657
712
  usageGeneration = 0;
713
+ accountUsageRequestGeneration = 0;
714
+ accountUsageCredentialRefreshes = new Map();
658
715
  directUsageSequence = 0;
659
716
  directUsageAccountId;
660
717
  usageHasDirectDefault = false;
@@ -755,6 +812,7 @@ export class OpenAICodexAuth extends Service {
755
812
  register('/openai-codex/browser/complete', (req, res) => this.handleBrowserComplete(req, res));
756
813
  register('/openai-codex/cancel', (req, res) => this.handleCancel(req, res));
757
814
  register('/openai-codex/accounts/current', (req, res) => this.handleSetCurrentAccount(req, res));
815
+ register('/openai-codex/accounts/usage', (req, res) => this.handleAccountUsage(req, res));
758
816
  register('/openai-codex/accounts/logout', (req, res) => this.handleAccountLogout(req, res));
759
817
  register('/openai-codex/logout', (req, res) => this.handleLogout(req, res));
760
818
  }
@@ -800,7 +858,7 @@ export class OpenAICodexAuth extends Service {
800
858
  return;
801
859
  this.responseUsage = {
802
860
  accountId: observation.accountId,
803
- amount: observation.metadata.amount,
861
+ metadata: observation.metadata,
804
862
  observedAt: Date.now(),
805
863
  };
806
864
  }
@@ -821,8 +879,7 @@ export class OpenAICodexAuth extends Service {
821
879
  const sameUsageAccount = this.usageAccountId === accountId;
822
880
  if (!sameUsageAccount)
823
881
  this.usageHasDirectDefault = false;
824
- if (hasDefaultQuota)
825
- this.usageGeneration += 1;
882
+ this.usageGeneration += 1;
826
883
  this.usageCache = mergeDirectUsage(sameUsageAccount ? this.usageCache : undefined, accepted);
827
884
  this.usageAccountId = accountId;
828
885
  this.usageError = undefined;
@@ -855,7 +912,7 @@ export class OpenAICodexAuth extends Service {
855
912
  }
856
913
  async performUsageRefresh(generation) {
857
914
  try {
858
- const credential = await withFileLock(this.filename, () => this.resolveManagedCredentialLocked());
915
+ const credential = await this.managedCredential();
859
916
  if (credential === undefined) {
860
917
  if (this.usageGeneration === generation) {
861
918
  this.usageCache = undefined;
@@ -1004,56 +1061,73 @@ export class OpenAICodexAuth extends Service {
1004
1061
  const document = parsed?.document ?? { version: 2, currentAccountId: null, accounts: [] };
1005
1062
  await this.commitDocument(this.upsertCurrentCredential(document, credential));
1006
1063
  }
1007
- async resolveManagedCredentialLocked(signal) {
1008
- throwIfCancelled(signal);
1009
- const parsed = await readCredentialDocument(this.filename);
1010
- const document = parsed?.document;
1011
- const current = currentCredential(document);
1064
+ /** Return the current managed bearer token, refreshing and migrating it when needed. */
1065
+ async bearerToken(signal) {
1012
1066
  throwIfCancelled(signal);
1013
- if (current === undefined) {
1014
- this.setCredentialAccount(undefined);
1015
- return undefined;
1016
- }
1017
- if (current.expires > Date.now() + TOKEN_REFRESH_PREEMPT_MS) {
1018
- if (parsed?.migrated)
1019
- await this.commitDocument(document, signal);
1020
- else
1021
- await this.publishCredentialToken(current.access, signal);
1022
- this.setCredentialAccount(current.accountId);
1023
- return current;
1024
- }
1025
- await this.assertCredentialWritable();
1026
- let next;
1027
- try {
1028
- next = await refreshToken(current, signal);
1029
- }
1030
- catch (error) {
1031
- throwIfCancelled(signal);
1032
- if (!isPermanentRefreshError(error) && current.expires > Date.now()) {
1067
+ while (true) {
1068
+ const prepared = await withFileLock(this.filename, async () => {
1069
+ throwIfCancelled(signal);
1070
+ const parsed = await readCredentialDocument(this.filename);
1071
+ const document = parsed?.document;
1072
+ const current = currentCredential(document);
1073
+ if (current === undefined) {
1074
+ this.setCredentialAccount(undefined);
1075
+ return undefined;
1076
+ }
1077
+ if (current.expires > Date.now() + TOKEN_REFRESH_PREEMPT_MS) {
1078
+ if (parsed?.migrated)
1079
+ await this.commitDocument(document, signal);
1080
+ else
1081
+ await this.publishCredentialToken(current.access, signal);
1082
+ this.setCredentialAccount(current.accountId);
1083
+ return { credential: current, ready: true };
1084
+ }
1085
+ return { credential: current, ready: false };
1086
+ });
1087
+ if (prepared === undefined)
1088
+ return undefined;
1089
+ if (prepared.ready)
1090
+ return prepared.credential.access;
1091
+ let refreshed;
1092
+ try {
1093
+ refreshed = await waitForSignal(this.refreshManagedAccount(prepared.credential, true), signal);
1094
+ }
1095
+ catch (error) {
1096
+ throwIfCancelled(signal);
1097
+ throw error;
1098
+ }
1099
+ if (refreshed === undefined)
1100
+ continue;
1101
+ const access = await withFileLock(this.filename, async () => {
1102
+ throwIfCancelled(signal);
1103
+ const parsed = await readCredentialDocument(this.filename);
1104
+ const document = parsed?.document;
1105
+ const current = currentCredential(document);
1106
+ if (current === undefined || current.accountId !== prepared.credential.accountId)
1107
+ return undefined;
1108
+ if (current.expires <= Date.now())
1109
+ throw new Error('OpenAI Codex access token has expired');
1033
1110
  if (parsed?.migrated)
1034
1111
  await this.commitDocument(document, signal);
1035
1112
  else
1036
1113
  await this.publishCredentialToken(current.access, signal);
1037
1114
  this.setCredentialAccount(current.accountId);
1038
- return current;
1039
- }
1040
- throw error;
1041
- }
1042
- throwIfCancelled(signal);
1043
- if (next.accountId !== current.accountId) {
1044
- throw new Error('OpenAI refresh changed the ChatGPT account identity');
1115
+ return current.access;
1116
+ });
1117
+ if (access !== undefined)
1118
+ return access;
1045
1119
  }
1046
- await this.commitDocument(this.upsertCurrentCredential(document, next, current.accountId), signal);
1047
- this.setCredentialAccount(next.accountId);
1048
- return next;
1049
1120
  }
1050
- /** Return the current managed bearer token, refreshing and migrating it when needed. */
1051
- async bearerToken(signal) {
1052
- throwIfCancelled(signal);
1053
- return withFileLock(this.filename, async () => {
1054
- const credential = await this.resolveManagedCredentialLocked(signal);
1055
- return credential?.access;
1056
- });
1121
+ async managedCredential(signal) {
1122
+ while (true) {
1123
+ const access = await this.bearerToken(signal);
1124
+ if (access === undefined)
1125
+ return undefined;
1126
+ const current = currentCredential((await readCredentialDocument(this.filename))?.document);
1127
+ if (current?.access === access)
1128
+ return current;
1129
+ throwIfCancelled(signal);
1130
+ }
1057
1131
  }
1058
1132
  externalNativeCredential(accessToken) {
1059
1133
  let accountId;
@@ -1073,23 +1147,20 @@ export class OpenAICodexAuth extends Service {
1073
1147
  async resolveNativeCredential(signal) {
1074
1148
  try {
1075
1149
  throwIfCancelled(signal);
1076
- return await withFileLock(this.filename, async () => {
1077
- throwIfCancelled(signal);
1078
- const managed = await this.resolveManagedCredentialLocked(signal);
1079
- if (managed !== undefined) {
1080
- throwIfCancelled(signal);
1081
- return { accessToken: managed.access, accountId: managed.accountId };
1082
- }
1083
- const external = await this.ctx.credentials.resolve(TOKEN_REF);
1084
- throwIfCancelled(signal);
1085
- if (external === undefined) {
1086
- throw new LlmError('native Codex credential is not configured', 'MISSING_CREDENTIAL');
1087
- }
1088
- const credential = this.externalNativeCredential(external.value);
1150
+ const managed = await this.managedCredential(signal);
1151
+ if (managed !== undefined) {
1089
1152
  throwIfCancelled(signal);
1090
- this.setCredentialAccount(credential.accountId);
1091
- return credential;
1092
- });
1153
+ return { accessToken: managed.access, accountId: managed.accountId };
1154
+ }
1155
+ const external = await this.ctx.credentials.resolve(TOKEN_REF);
1156
+ throwIfCancelled(signal);
1157
+ if (external === undefined) {
1158
+ throw new LlmError('native Codex credential is not configured', 'MISSING_CREDENTIAL');
1159
+ }
1160
+ const credential = this.externalNativeCredential(external.value);
1161
+ throwIfCancelled(signal);
1162
+ this.setCredentialAccount(credential.accountId);
1163
+ return credential;
1093
1164
  }
1094
1165
  catch (error) {
1095
1166
  if (error instanceof LlmError)
@@ -1101,9 +1172,12 @@ export class OpenAICodexAuth extends Service {
1101
1172
  }
1102
1173
  }
1103
1174
  nativeRecoveryError(error) {
1175
+ const cause = error instanceof CredentialRefreshUncertainError && error.cause !== undefined
1176
+ ? error.cause
1177
+ : error;
1104
1178
  const options = {
1105
- cause: error,
1106
- ...(error instanceof OAuthEndpointError ? { status: error.status } : {}),
1179
+ cause,
1180
+ ...(cause instanceof OAuthEndpointError ? { status: cause.status } : {}),
1107
1181
  };
1108
1182
  if (error instanceof CredentialNotWritableError || isPermanentRefreshError(error)) {
1109
1183
  return new LlmError('native Codex managed credential cannot be refreshed', INVALID_CREDENTIAL_CODE, options);
@@ -1113,44 +1187,46 @@ export class OpenAICodexAuth extends Service {
1113
1187
  async recoverNativeCredential(previous, signal) {
1114
1188
  try {
1115
1189
  throwIfCancelled(signal);
1116
- return await withFileLock(this.filename, async () => {
1117
- throwIfCancelled(signal);
1118
- const parsed = await readCredentialDocument(this.filename);
1119
- const document = parsed?.document;
1120
- const current = currentCredential(document);
1190
+ const prepared = await withFileLock(this.filename, async () => {
1121
1191
  throwIfCancelled(signal);
1192
+ const current = currentCredential((await readCredentialDocument(this.filename))?.document);
1122
1193
  if (current === undefined) {
1123
1194
  const external = await this.ctx.credentials.resolve(TOKEN_REF);
1124
1195
  throwIfCancelled(signal);
1125
1196
  if (external === undefined)
1126
- return false;
1197
+ return { result: false };
1127
1198
  const credential = this.externalNativeCredential(external.value);
1128
1199
  throwIfCancelled(signal);
1129
1200
  if (credential.accountId !== previous.accountId)
1130
- return false;
1201
+ return { result: false };
1131
1202
  this.setCredentialAccount(credential.accountId);
1132
- return credential.accessToken !== previous.accessToken;
1203
+ return { result: credential.accessToken !== previous.accessToken };
1133
1204
  }
1134
1205
  // A request that started under A must never recover by replaying under B.
1135
1206
  if (current.accountId !== previous.accountId)
1136
- return false;
1207
+ return { result: false };
1137
1208
  if (current.access !== previous.accessToken) {
1138
1209
  await this.publishCredentialToken(current.access, signal);
1139
1210
  throwIfCancelled(signal);
1140
1211
  this.setCredentialAccount(current.accountId);
1141
- return true;
1212
+ return { result: true };
1142
1213
  }
1143
- await this.assertCredentialWritable();
1144
- throwIfCancelled(signal);
1145
- const next = await refreshToken(current, signal);
1214
+ return { credential: current };
1215
+ });
1216
+ if (prepared.result !== undefined)
1217
+ return prepared.result;
1218
+ const refreshed = await waitForSignal(this.refreshManagedAccount(prepared.credential, true, true), signal);
1219
+ if (refreshed === undefined)
1220
+ return false;
1221
+ return withFileLock(this.filename, async () => {
1146
1222
  throwIfCancelled(signal);
1147
- if (next.accountId !== previous.accountId) {
1148
- throw new Error('OpenAI refresh changed the ChatGPT account identity');
1149
- }
1150
- await this.commitDocument(this.upsertCurrentCredential(document, next, current.accountId), signal);
1223
+ const current = currentCredential((await readCredentialDocument(this.filename))?.document);
1224
+ if (current === undefined || current.accountId !== previous.accountId)
1225
+ return false;
1226
+ await this.publishCredentialToken(current.access, signal);
1151
1227
  throwIfCancelled(signal);
1152
- this.setCredentialAccount(next.accountId);
1153
- return next.access !== previous.accessToken;
1228
+ this.setCredentialAccount(current.accountId);
1229
+ return current.access !== previous.accessToken;
1154
1230
  });
1155
1231
  }
1156
1232
  catch (error) {
@@ -1165,18 +1241,22 @@ export class OpenAICodexAuth extends Service {
1165
1241
  async finishCredential(credential, signal) {
1166
1242
  await this.assertCredentialWritable();
1167
1243
  throwIfCancelled(signal);
1168
- await withFileLock(this.filename, async () => {
1244
+ await this.withAccountRefreshLock(credential.accountId, async () => {
1169
1245
  throwIfCancelled(signal);
1170
- await this.commitCredential(credential);
1171
- this.setCredentialAccount(credential.accountId);
1172
- this.usageGeneration += 1;
1173
- if (this.usageRefresh !== undefined)
1174
- this.usageRefresh.queued = true;
1175
- this.usageCache = undefined;
1176
- this.usageAccountId = undefined;
1177
- this.directUsageAccountId = undefined;
1178
- this.usageHasDirectDefault = false;
1179
- this.usageError = undefined;
1246
+ await withFileLock(this.filename, async () => {
1247
+ throwIfCancelled(signal);
1248
+ await this.commitCredential(credential);
1249
+ this.setCredentialAccount(credential.accountId);
1250
+ this.usageGeneration += 1;
1251
+ if (this.usageRefresh !== undefined)
1252
+ this.usageRefresh.queued = true;
1253
+ this.usageCache = undefined;
1254
+ this.usageAccountId = undefined;
1255
+ this.directUsageAccountId = undefined;
1256
+ this.usageHasDirectDefault = false;
1257
+ this.usageError = undefined;
1258
+ });
1259
+ await this.clearAccountRefreshJournal(credential.accountId);
1180
1260
  });
1181
1261
  this.lastLoginError = undefined;
1182
1262
  }
@@ -1406,24 +1486,23 @@ export class OpenAICodexAuth extends Service {
1406
1486
  this.lastLoginError = undefined;
1407
1487
  }
1408
1488
  async setCurrentAccount(accountId) {
1409
- await withFileLock(this.filename, async () => {
1410
- const document = (await readCredentialDocument(this.filename))?.document;
1411
- if (document === undefined || !document.accounts.some(account => account.accountId === accountId)) {
1412
- throw new AccountNotFoundError('OpenAI Codex account was not found');
1413
- }
1414
- if (document.currentAccountId !== accountId) {
1415
- await this.assertCredentialWritable();
1416
- await this.commitDocument({ ...document, currentAccountId: accountId });
1417
- }
1418
- this.resetCurrentAccountState(accountId);
1489
+ await this.withAccountRefreshLock(accountId, async () => {
1490
+ await this.reconcileAccountRefreshJournal(accountId);
1491
+ await withFileLock(this.filename, async () => {
1492
+ const document = (await readCredentialDocument(this.filename))?.document;
1493
+ if (document === undefined || !document.accounts.some(account => account.accountId === accountId)) {
1494
+ throw new AccountNotFoundError('OpenAI Codex account was not found');
1495
+ }
1496
+ if (document.currentAccountId !== accountId) {
1497
+ await this.assertCredentialWritable();
1498
+ await this.commitDocument({ ...document, currentAccountId: accountId });
1499
+ }
1500
+ this.resetCurrentAccountState(accountId);
1501
+ });
1419
1502
  });
1420
1503
  }
1421
- async logout(accountId) {
1422
- if (accountId === undefined)
1423
- await this.cancelLogin(true);
1424
- let nextAccountId;
1425
- let removedCurrent = false;
1426
- await withFileLock(this.filename, async () => {
1504
+ async logoutAttempt(accountId, expectedPromotion) {
1505
+ return withFileLock(this.filename, async () => {
1427
1506
  let parsed;
1428
1507
  try {
1429
1508
  parsed = await readCredentialDocument(this.filename);
@@ -1440,9 +1519,8 @@ export class OpenAICodexAuth extends Service {
1440
1519
  catch (failure) {
1441
1520
  return this.failAfterPublicationRollback(previous, undefined, failure);
1442
1521
  }
1443
- removedCurrent = true;
1444
1522
  this.resetCurrentAccountState(undefined);
1445
- return;
1523
+ return true;
1446
1524
  }
1447
1525
  const document = parsed?.document;
1448
1526
  const target = accountId ?? document?.currentAccountId ?? undefined;
@@ -1450,22 +1528,54 @@ export class OpenAICodexAuth extends Service {
1450
1528
  || !document.accounts.some(account => account.accountId === target)) {
1451
1529
  if (accountId !== undefined)
1452
1530
  throw new AccountNotFoundError('OpenAI Codex account was not found');
1453
- return;
1531
+ return true;
1454
1532
  }
1455
- removedCurrent = document.currentAccountId === target;
1533
+ const removedCurrent = document.currentAccountId === target;
1456
1534
  const targetIndex = document.accounts.findIndex(account => account.accountId === target);
1457
1535
  const accounts = document.accounts.filter(account => account.accountId !== target);
1458
1536
  const currentAccountId = removedCurrent
1459
1537
  ? accounts[targetIndex]?.accountId ?? accounts[0]?.accountId ?? null
1460
1538
  : document.currentAccountId;
1539
+ const promotion = removedCurrent ? currentAccountId ?? undefined : undefined;
1540
+ if (promotion !== expectedPromotion)
1541
+ return false;
1461
1542
  if (removedCurrent)
1462
1543
  await this.assertCredentialWritable();
1463
1544
  await this.commitDocument({ version: 2, currentAccountId, accounts });
1464
- nextAccountId = currentAccountId ?? undefined;
1545
+ await this.clearAccountRefreshJournal(target);
1465
1546
  if (removedCurrent)
1466
- this.resetCurrentAccountState(nextAccountId);
1547
+ this.resetCurrentAccountState(currentAccountId ?? undefined);
1548
+ return true;
1467
1549
  });
1468
1550
  }
1551
+ async logout(accountId) {
1552
+ if (accountId === undefined)
1553
+ await this.cancelLogin(true);
1554
+ while (true) {
1555
+ let target;
1556
+ let expectedPromotion;
1557
+ try {
1558
+ const document = (await readCredentialDocument(this.filename))?.document;
1559
+ target = accountId ?? document?.currentAccountId ?? undefined;
1560
+ if (document !== undefined && target !== undefined && document.currentAccountId === target) {
1561
+ const targetIndex = document.accounts.findIndex(account => account.accountId === target);
1562
+ const accounts = document.accounts.filter(account => account.accountId !== target);
1563
+ expectedPromotion = accounts[targetIndex]?.accountId ?? accounts[0]?.accountId;
1564
+ }
1565
+ }
1566
+ catch { }
1567
+ const lockIds = [target, expectedPromotion].filter((value) => value !== undefined);
1568
+ const done = lockIds.length === 0
1569
+ ? await this.logoutAttempt(accountId, undefined)
1570
+ : await this.withAccountRefreshLocks(lockIds, async () => {
1571
+ if (expectedPromotion !== undefined)
1572
+ await this.reconcileAccountRefreshJournal(expectedPromotion);
1573
+ return this.logoutAttempt(accountId, expectedPromotion);
1574
+ });
1575
+ if (done)
1576
+ return;
1577
+ }
1578
+ }
1469
1579
  async status(refresh, callbackUrl) {
1470
1580
  let document;
1471
1581
  let credential;
@@ -1532,7 +1642,7 @@ export class OpenAICodexAuth extends Service {
1532
1642
  ? {} : { usage: this.usageCache },
1533
1643
  ...this.responseUsage === undefined || this.responseUsage.accountId !== credential.accountId
1534
1644
  ? {} : { responseUsage: {
1535
- amount: this.responseUsage.amount,
1645
+ ...this.responseUsage.metadata,
1536
1646
  observedAt: this.responseUsage.observedAt,
1537
1647
  } },
1538
1648
  ...this.usageError === undefined ? {} : { usageError: this.usageError },
@@ -1540,16 +1650,310 @@ export class OpenAICodexAuth extends Service {
1540
1650
  csrf: this.csrf,
1541
1651
  };
1542
1652
  }
1543
- async fetchUsage(credential) {
1544
- const response = await fetch(USAGE_URL, {
1545
- redirect: 'error',
1546
- headers: {
1547
- accept: 'application/json',
1548
- authorization: `Bearer ${credential.access}`,
1549
- 'chatgpt-account-id': credential.accountId,
1550
- 'user-agent': 'dsh-openai-codex-auth/0.5.0',
1551
- },
1653
+ accountRefreshJournalFilename(accountId) {
1654
+ const key = createHash('sha256').update(accountId).digest('hex').slice(0, 24);
1655
+ return `${this.filename}.account-${key}-refresh.json`;
1656
+ }
1657
+ async readAccountRefreshJournal(accountId) {
1658
+ const filename = this.accountRefreshJournalFilename(accountId);
1659
+ let value;
1660
+ try {
1661
+ value = JSON.parse(await readFile(filename, 'utf8'));
1662
+ }
1663
+ catch (error) {
1664
+ if (error.code === 'ENOENT')
1665
+ return undefined;
1666
+ throw error;
1667
+ }
1668
+ const journal = value;
1669
+ if (journal === null || typeof journal !== 'object' || journal.version !== 1
1670
+ || journal.accountId !== accountId || typeof journal.startedAt !== 'number'
1671
+ || !Number.isFinite(journal.startedAt)
1672
+ || (journal.phase !== 'pending' && journal.phase !== 'uncertain' && journal.phase !== 'refreshed')) {
1673
+ throw new Error(`openai-codex-auth: invalid credential refresh journal ${filename}`);
1674
+ }
1675
+ const stored = validateCredential(journal.stored, filename);
1676
+ if (stored.accountId !== accountId)
1677
+ throw new Error(`openai-codex-auth: invalid credential refresh journal ${filename}`);
1678
+ if (journal.phase === 'refreshed') {
1679
+ const refreshed = validateCredential(journal.refreshed, filename);
1680
+ if (refreshed.accountId !== accountId)
1681
+ throw new Error(`openai-codex-auth: invalid credential refresh journal ${filename}`);
1682
+ return { version: 1, accountId, startedAt: journal.startedAt, phase: 'refreshed', stored, refreshed };
1683
+ }
1684
+ return { version: 1, accountId, startedAt: journal.startedAt, phase: journal.phase, stored };
1685
+ }
1686
+ writeAccountRefreshJournal(journal) {
1687
+ return writeFileAtomic(this.accountRefreshJournalFilename(journal.accountId), JSON.stringify(journal), { mode: 0o600 });
1688
+ }
1689
+ async clearAccountRefreshJournal(accountId) {
1690
+ try {
1691
+ await unlink(this.accountRefreshJournalFilename(accountId));
1692
+ }
1693
+ catch (error) {
1694
+ if (error.code !== 'ENOENT')
1695
+ throw error;
1696
+ }
1697
+ }
1698
+ async removeDeadFileLock(filename) {
1699
+ const lockFilename = `${filename}.lock`;
1700
+ let owner;
1701
+ try {
1702
+ const text = (await readFile(lockFilename, 'utf8')).trim();
1703
+ owner = Number(text);
1704
+ if (!Number.isSafeInteger(owner) || owner <= 0)
1705
+ return false;
1706
+ }
1707
+ catch (error) {
1708
+ return error.code === 'ENOENT';
1709
+ }
1710
+ try {
1711
+ process.kill(owner, 0);
1712
+ return false;
1713
+ }
1714
+ catch (error) {
1715
+ if (error.code !== 'ESRCH')
1716
+ return false;
1717
+ try {
1718
+ await unlink(lockFilename);
1719
+ return true;
1720
+ }
1721
+ catch (unlinkError) {
1722
+ return unlinkError.code === 'ENOENT';
1723
+ }
1724
+ }
1725
+ }
1726
+ async withAccountRefreshLock(accountId, operation) {
1727
+ const filename = this.accountRefreshJournalFilename(accountId);
1728
+ await this.removeDeadFileLock(filename);
1729
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1730
+ try {
1731
+ return await withFileLock(filename, operation, { waitMs: ACCOUNT_REFRESH_LOCK_WAIT_MS });
1732
+ }
1733
+ catch (error) {
1734
+ if (!isFileLockTimeout(error) || attempt > 0 || !await this.removeDeadFileLock(filename))
1735
+ throw error;
1736
+ }
1737
+ }
1738
+ throw new Error('OpenAI credential refresh lock could not be acquired');
1739
+ }
1740
+ withAccountRefreshLocks(accountIds, operation) {
1741
+ const ordered = [...new Set(accountIds)].sort();
1742
+ const acquire = (index) => index === ordered.length
1743
+ ? operation()
1744
+ : this.withAccountRefreshLock(ordered[index], () => acquire(index + 1));
1745
+ return acquire(0);
1746
+ }
1747
+ async reconcileAccountRefreshJournal(accountId) {
1748
+ const journal = await this.readAccountRefreshJournal(accountId);
1749
+ if (journal === undefined)
1750
+ return undefined;
1751
+ const latest = (await readCredentialDocument(this.filename))?.document.accounts
1752
+ .find(account => account.accountId === accountId);
1753
+ if (latest === undefined
1754
+ || latest.access !== journal.stored.access
1755
+ || latest.refresh !== journal.stored.refresh
1756
+ || latest.expires !== journal.stored.expires) {
1757
+ await this.clearAccountRefreshJournal(accountId);
1758
+ return latest;
1759
+ }
1760
+ if (journal.phase !== 'refreshed')
1761
+ throw new CredentialRefreshUncertainError();
1762
+ try {
1763
+ const credential = await this.persistAccountUsageCredential(journal.stored, journal.refreshed);
1764
+ await this.clearAccountRefreshJournal(accountId);
1765
+ return credential;
1766
+ }
1767
+ catch (error) {
1768
+ if (error instanceof AccountNotFoundError)
1769
+ await this.clearAccountRefreshJournal(accountId);
1770
+ throw error;
1771
+ }
1772
+ }
1773
+ async refreshManagedAccount(stored, requireCurrent, force = false) {
1774
+ return this.withAccountRefreshLock(stored.accountId, async () => {
1775
+ const recovered = await this.reconcileAccountRefreshJournal(stored.accountId);
1776
+ if (recovered !== undefined)
1777
+ return recovered;
1778
+ const prepared = await withFileLock(this.filename, async () => {
1779
+ const document = (await readCredentialDocument(this.filename))?.document;
1780
+ const latest = document?.accounts.find(account => account.accountId === stored.accountId);
1781
+ if (document === undefined || latest === undefined)
1782
+ return undefined;
1783
+ if (requireCurrent && document.currentAccountId !== latest.accountId)
1784
+ return undefined;
1785
+ if (latest.access !== stored.access || latest.refresh !== stored.refresh || latest.expires !== stored.expires) {
1786
+ return { credential: latest, changed: true };
1787
+ }
1788
+ if ((force || latest.expires <= Date.now() + TOKEN_REFRESH_PREEMPT_MS)
1789
+ && document.currentAccountId === latest.accountId)
1790
+ await this.assertCredentialWritable();
1791
+ return { credential: latest, changed: false };
1792
+ });
1793
+ if (prepared === undefined || prepared.changed
1794
+ || (!force && prepared.credential.expires > Date.now() + TOKEN_REFRESH_PREEMPT_MS))
1795
+ return prepared?.credential;
1796
+ const credential = prepared.credential;
1797
+ const pending = {
1798
+ version: 1,
1799
+ accountId: credential.accountId,
1800
+ startedAt: Date.now(),
1801
+ phase: 'pending',
1802
+ stored: credential,
1803
+ };
1804
+ await this.writeAccountRefreshJournal(pending);
1805
+ let refreshed;
1806
+ try {
1807
+ refreshed = await refreshToken(credential, AbortSignal.timeout(TOKEN_REFRESH_REQUEST_TIMEOUT_MS));
1808
+ }
1809
+ catch (error) {
1810
+ if (isDefinitiveOAuthRefreshError(error)) {
1811
+ await this.clearAccountRefreshJournal(credential.accountId);
1812
+ if (!force && !isPermanentRefreshError(error) && credential.expires > Date.now())
1813
+ return credential;
1814
+ throw error;
1815
+ }
1816
+ await this.writeAccountRefreshJournal({ ...pending, phase: 'uncertain' });
1817
+ throw new CredentialRefreshUncertainError(error);
1818
+ }
1819
+ if (refreshed.accountId !== credential.accountId) {
1820
+ await this.writeAccountRefreshJournal({ ...pending, phase: 'uncertain' });
1821
+ throw new Error('OpenAI refresh changed the ChatGPT account identity');
1822
+ }
1823
+ await this.writeAccountRefreshJournal({ ...pending, phase: 'refreshed', refreshed });
1824
+ try {
1825
+ const persisted = await this.persistAccountUsageCredential(credential, refreshed);
1826
+ await this.clearAccountRefreshJournal(credential.accountId);
1827
+ return persisted;
1828
+ }
1829
+ catch (error) {
1830
+ if (error instanceof AccountNotFoundError)
1831
+ await this.clearAccountRefreshJournal(credential.accountId);
1832
+ throw error;
1833
+ }
1552
1834
  });
1835
+ }
1836
+ async persistAccountUsageCredential(stored, refreshed) {
1837
+ await this.removeDeadFileLock(this.filename);
1838
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1839
+ try {
1840
+ return await withFileLock(this.filename, async () => {
1841
+ const document = (await readCredentialDocument(this.filename))?.document;
1842
+ const latest = document?.accounts.find(account => account.accountId === stored.accountId);
1843
+ if (document === undefined || latest === undefined) {
1844
+ throw new AccountNotFoundError('OpenAI Codex account was removed while its quota was refreshing');
1845
+ }
1846
+ if (latest.access !== stored.access || latest.refresh !== stored.refresh || latest.expires !== stored.expires) {
1847
+ return latest;
1848
+ }
1849
+ const nextDocument = {
1850
+ ...document,
1851
+ accounts: document.accounts.map(account => account.accountId === stored.accountId ? refreshed : account),
1852
+ };
1853
+ if (document.currentAccountId === stored.accountId) {
1854
+ await this.assertCredentialWritable();
1855
+ await this.commitDocument(nextDocument);
1856
+ }
1857
+ else {
1858
+ await this.write(nextDocument);
1859
+ }
1860
+ return refreshed;
1861
+ }, { waitMs: CREDENTIAL_REFRESH_PERSIST_WAIT_MS });
1862
+ }
1863
+ catch (error) {
1864
+ if (!isFileLockTimeout(error) || attempt > 0 || !await this.removeDeadFileLock(this.filename))
1865
+ throw error;
1866
+ }
1867
+ }
1868
+ throw new Error('OpenAI rotated credential could not be persisted');
1869
+ }
1870
+ refreshAccountUsageCredential(stored) {
1871
+ const active = this.accountUsageCredentialRefreshes.get(stored.accountId);
1872
+ if (active !== undefined)
1873
+ return active;
1874
+ const operation = this.refreshManagedAccount(stored, false).then((credential) => {
1875
+ if (credential === undefined) {
1876
+ throw new AccountNotFoundError('OpenAI Codex account was removed while its quota was refreshing');
1877
+ }
1878
+ return credential;
1879
+ });
1880
+ let tracked;
1881
+ tracked = operation.finally(() => {
1882
+ if (this.accountUsageCredentialRefreshes.get(stored.accountId) === tracked) {
1883
+ this.accountUsageCredentialRefreshes.delete(stored.accountId);
1884
+ }
1885
+ });
1886
+ this.accountUsageCredentialRefreshes.set(stored.accountId, tracked);
1887
+ return tracked;
1888
+ }
1889
+ async accountUsageCredential(stored, signal) {
1890
+ if (stored.expires > Date.now() + TOKEN_REFRESH_PREEMPT_MS)
1891
+ return stored;
1892
+ return waitForSignal(this.refreshAccountUsageCredential(stored), signal);
1893
+ }
1894
+ async accountUsages(signal) {
1895
+ const prepared = await withFileLock(this.filename, async () => {
1896
+ const document = (await readCredentialDocument(this.filename))?.document;
1897
+ if (document === undefined)
1898
+ return undefined;
1899
+ return {
1900
+ cacheGeneration: this.usageGeneration,
1901
+ requestGeneration: ++this.accountUsageRequestGeneration,
1902
+ tasks: document.accounts.map(stored => ({
1903
+ accountId: stored.accountId,
1904
+ credential: this.accountUsageCredential(stored, signal),
1905
+ })),
1906
+ };
1907
+ });
1908
+ if (prepared === undefined)
1909
+ return [];
1910
+ const results = await Promise.all(prepared.tasks.map(async (task) => {
1911
+ try {
1912
+ const credential = await task.credential;
1913
+ return {
1914
+ accountId: task.accountId,
1915
+ usage: await this.fetchUsage(credential, boundedAccountUsageSignal(signal)),
1916
+ };
1917
+ }
1918
+ catch (error) {
1919
+ return { accountId: task.accountId, error: messageOf(error) };
1920
+ }
1921
+ }));
1922
+ if (!signal?.aborted) {
1923
+ const currentAccountId = (await readCredentialDocument(this.filename))?.document.currentAccountId;
1924
+ const current = results.find(row => row.accountId === currentAccountId);
1925
+ if (current?.usage !== undefined
1926
+ && this.usageGeneration === prepared.cacheGeneration
1927
+ && this.accountUsageRequestGeneration === prepared.requestGeneration) {
1928
+ this.usageGeneration += 1;
1929
+ this.usageCache = current.usage;
1930
+ this.usageAccountId = currentAccountId ?? undefined;
1931
+ this.usageHasDirectDefault = false;
1932
+ this.usageError = undefined;
1933
+ }
1934
+ }
1935
+ return results;
1936
+ }
1937
+ async fetchUsage(credential, signal) {
1938
+ let response;
1939
+ try {
1940
+ response = await fetch(USAGE_URL, {
1941
+ redirect: 'error',
1942
+ ...signal === undefined ? {} : { signal },
1943
+ headers: {
1944
+ accept: 'application/json',
1945
+ authorization: `Bearer ${credential.access}`,
1946
+ 'chatgpt-account-id': credential.accountId,
1947
+ 'user-agent': 'dsh-openai-codex-auth/0.5.0',
1948
+ },
1949
+ });
1950
+ }
1951
+ catch (error) {
1952
+ if (signal?.aborted) {
1953
+ throw new Error(isTimeoutAbort(signal) ? 'Codex usage request timed out' : 'Codex usage request cancelled');
1954
+ }
1955
+ throw error;
1956
+ }
1553
1957
  if (!response.ok)
1554
1958
  throw new Error(`Codex usage request failed (HTTP ${response.status})`);
1555
1959
  return normalizeUsage(await response.json());
@@ -1602,6 +2006,31 @@ export class OpenAICodexAuth extends Service {
1602
2006
  this.sendJson(res, 500, { error: messageOf(error) });
1603
2007
  }
1604
2008
  }
2009
+ async handleAccountUsage(req, res) {
2010
+ if (!this.trustedManagementRequest(req, res))
2011
+ return;
2012
+ if (req.method !== 'POST') {
2013
+ this.sendJson(res, 405, { error: 'POST only' }, { allow: 'POST' });
2014
+ return;
2015
+ }
2016
+ if (!this.requireCsrf(req, res))
2017
+ return;
2018
+ const abort = new AbortController();
2019
+ const onAborted = () => { abort.abort(new Error('Account quota request cancelled')); };
2020
+ const observesAbort = typeof req.once === 'function' && typeof req.off === 'function';
2021
+ if (observesAbort)
2022
+ req.once('aborted', onAborted);
2023
+ try {
2024
+ this.sendJson(res, 200, { accounts: await this.accountUsages(abort.signal) });
2025
+ }
2026
+ catch (error) {
2027
+ this.sendJson(res, error instanceof CredentialNotWritableError ? 409 : 500, { error: messageOf(error) });
2028
+ }
2029
+ finally {
2030
+ if (observesAbort)
2031
+ req.off('aborted', onAborted);
2032
+ }
2033
+ }
1605
2034
  async handleDeviceStart(req, res) {
1606
2035
  if (!this.trustedManagementRequest(req, res))
1607
2036
  return;