@pure01fx/dsh-openai-codex-auth 0.8.0 → 0.10.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
@@ -14,6 +14,9 @@ export { CODEX_PROVIDER, NATIVE_CODEX_PROVIDER } from './native-adapter.js';
14
14
  import { NativeCodexCatalog } from './catalog.js';
15
15
  import { NativeCodexHttpTransport } from './native-http.js';
16
16
  import { NativeCodexWebSocketTransport } from './native-websocket.js';
17
+ import { NativeCodexCloudClient } from './cloud-http.js';
18
+ import { registerCodexImageTools } from './cloud-tools.js';
19
+ import { registerCodexWeb } from './cloud-web-tool.js';
17
20
  import { mergeDirectUsage, normalizeUsage } from './usage.js';
18
21
  export { normalizeUsage } from './usage.js';
19
22
  export { CODEX_CLIENT_VERSION, TRACKED_CODEX_COMMIT, TRACKED_CODEX_RELEASE, TRACKED_CODEX_REPOSITORY, } from './upstream.js';
@@ -39,6 +42,10 @@ const TOKEN_REF = credentialRef('DSH_OPENAI_CODEX_TOKEN');
39
42
  const USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
40
43
  const MAX_ERROR_BODY_LENGTH = 1_024;
41
44
  const MAX_REQUEST_BODY_LENGTH = 8_192;
45
+ const ACCOUNT_USAGE_REQUEST_TIMEOUT_MS = 10_000;
46
+ const TOKEN_REFRESH_REQUEST_TIMEOUT_MS = 30_000;
47
+ const ACCOUNT_REFRESH_LOCK_WAIT_MS = 35_000;
48
+ const CREDENTIAL_REFRESH_PERSIST_WAIT_MS = 500;
42
49
  class DeviceCodeUnavailableError extends Error {
43
50
  code = 'device_code_unavailable';
44
51
  }
@@ -48,12 +55,30 @@ class CredentialNotWritableError extends Error {
48
55
  }
49
56
  class AccountNotFoundError extends Error {
50
57
  }
58
+ class CredentialRefreshUncertainError extends Error {
59
+ constructor(cause) {
60
+ super('OpenAI credential refresh outcome is uncertain; sign in to this account again', { cause });
61
+ }
62
+ }
51
63
  const PERMANENT_REFRESH_CODES = new Set([
52
64
  'refresh_token_expired',
53
65
  'refresh_token_reused',
54
66
  'refresh_token_invalidated',
55
67
  'invalid_grant',
56
68
  ]);
69
+ const DEFINITIVE_OAUTH_REFRESH_CODES = new Set([
70
+ ...PERMANENT_REFRESH_CODES,
71
+ 'access_denied',
72
+ 'invalid_client',
73
+ 'invalid_request',
74
+ 'invalid_scope',
75
+ 'server_error',
76
+ 'slow_down',
77
+ 'temporarily_unavailable',
78
+ 'temporary_failure',
79
+ 'unauthorized_client',
80
+ 'unsupported_grant_type',
81
+ ]);
57
82
  class OAuthEndpointError extends Error {
58
83
  status;
59
84
  oauthCode;
@@ -70,6 +95,33 @@ function base64Url(value) {
70
95
  function messageOf(error) {
71
96
  return error instanceof Error ? error.message : String(error);
72
97
  }
98
+ function isTimeoutAbort(signal) {
99
+ return signal?.aborted === true
100
+ && signal.reason instanceof DOMException
101
+ && signal.reason.name === 'TimeoutError';
102
+ }
103
+ function boundedAccountUsageSignal(signal) {
104
+ const timeout = AbortSignal.timeout(ACCOUNT_USAGE_REQUEST_TIMEOUT_MS);
105
+ return signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
106
+ }
107
+ function isFileLockTimeout(error) {
108
+ return error instanceof Error
109
+ && error.message.startsWith('atomic-write: timed out waiting for the writer lock at ');
110
+ }
111
+ function waitForSignal(promise, signal) {
112
+ if (signal === undefined)
113
+ return promise;
114
+ if (signal.aborted)
115
+ return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error('Request cancelled'));
116
+ return new Promise((resolveWait, rejectWait) => {
117
+ const onAbort = () => {
118
+ signal.removeEventListener('abort', onAbort);
119
+ rejectWait(signal.reason instanceof Error ? signal.reason : new Error('Request cancelled'));
120
+ };
121
+ signal.addEventListener('abort', onAbort, { once: true });
122
+ promise.then((value) => { signal.removeEventListener('abort', onAbort); resolveWait(value); }, (error) => { signal.removeEventListener('abort', onAbort); rejectWait(error); });
123
+ });
124
+ }
73
125
  function throwIfCancelled(signal) {
74
126
  if (!signal?.aborted)
75
127
  return;
@@ -143,6 +195,12 @@ function isPermanentRefreshError(error) {
143
195
  && (error.status === 401
144
196
  || (error.oauthCode !== undefined && PERMANENT_REFRESH_CODES.has(error.oauthCode.toLowerCase())));
145
197
  }
198
+ function isDefinitiveOAuthRefreshError(error) {
199
+ return error instanceof OAuthEndpointError
200
+ && (error.status === 400 || error.status === 401)
201
+ && error.oauthCode !== undefined
202
+ && DEFINITIVE_OAUTH_REFRESH_CODES.has(error.oauthCode.toLowerCase());
203
+ }
146
204
  function validateCredential(value, filename) {
147
205
  const credential = value;
148
206
  if (credential === null || typeof credential !== 'object'
@@ -258,7 +316,7 @@ async function tokenRequest(init, previous, signal) {
258
316
  }
259
317
  catch (error) {
260
318
  if (signal?.aborted)
261
- throw new Error('OpenAI login cancelled');
319
+ throw new Error(isTimeoutAbort(signal) ? 'OpenAI token request timed out' : 'OpenAI login cancelled');
262
320
  throw error;
263
321
  }
264
322
  if (!response.ok) {
@@ -643,6 +701,17 @@ export class OpenAICodexAuth extends Service {
643
701
  nativeAdapter: z.boolean().default(true),
644
702
  nativeCompatibilityRoute: z.boolean().default(false),
645
703
  nativeWebSocket: z.boolean().default(true),
704
+ cloudTools: z.object({
705
+ enabled: z.boolean().default(true),
706
+ search: z.boolean().default(true),
707
+ imageGeneration: z.boolean().default(true),
708
+ imageEditing: z.boolean().default(true),
709
+ imageInspection: z.boolean().default(true),
710
+ searchModel: z.string(), visionModel: z.string(),
711
+ imageModel: z.string().default('gpt-image-2'),
712
+ searchMode: z.union([z.const('cached'), z.const('indexed'), z.const('live')]).default('live'),
713
+ visionDetail: z.union([z.const('auto'), z.const('low'), z.const('high'), z.const('original')]).default('auto'),
714
+ }),
646
715
  });
647
716
  static inject = ['credentials', 'webServer', 'webRuntime'];
648
717
  filename;
@@ -655,6 +724,8 @@ export class OpenAICodexAuth extends Service {
655
724
  usageError;
656
725
  usageRefresh;
657
726
  usageGeneration = 0;
727
+ accountUsageRequestGeneration = 0;
728
+ accountUsageCredentialRefreshes = new Map();
658
729
  directUsageSequence = 0;
659
730
  directUsageAccountId;
660
731
  usageHasDirectDefault = false;
@@ -671,29 +742,29 @@ export class OpenAICodexAuth extends Service {
671
742
  nativeCompatibilityRoute: config.nativeCompatibilityRoute ?? false,
672
743
  nativeWebSocket: config.nativeWebSocket ?? true,
673
744
  };
745
+ const catalog = new NativeCodexCatalog({
746
+ resolveCredential: signal => this.resolveNativeCredential(signal),
747
+ warn: message => { ctx.logger.warn(message); },
748
+ });
749
+ const transportOptions = {
750
+ resolveCredential: signal => this.resolveNativeCredential(signal),
751
+ recoverCredential: (previous, signal) => this.recoverNativeCredential(previous, signal),
752
+ readImage: async (attachment, signal) => {
753
+ const store = ctx.get('attachments');
754
+ if (store === undefined) {
755
+ throw new LlmError('native Codex image input requires the attachment service', 'UNSUPPORTED');
756
+ }
757
+ return store.readImage(attachment, signal);
758
+ },
759
+ onRateLimits: observation => {
760
+ this.acceptRateLimits(observation.accountId, observation.updates);
761
+ },
762
+ onResponseUsage: observation => {
763
+ this.acceptResponseUsage(observation);
764
+ },
765
+ warn: message => { ctx.logger.warn(message); },
766
+ };
674
767
  if (this.routeConfig.nativeAdapter) {
675
- const catalog = new NativeCodexCatalog({
676
- resolveCredential: signal => this.resolveNativeCredential(signal),
677
- warn: message => { ctx.logger.warn(message); },
678
- });
679
- const transportOptions = {
680
- resolveCredential: signal => this.resolveNativeCredential(signal),
681
- recoverCredential: (previous, signal) => this.recoverNativeCredential(previous, signal),
682
- readImage: async (attachment, signal) => {
683
- const store = ctx.get('attachments');
684
- if (store === undefined) {
685
- throw new LlmError('native Codex image input requires the attachment service', 'UNSUPPORTED');
686
- }
687
- return store.readImage(attachment, signal);
688
- },
689
- onRateLimits: observation => {
690
- this.acceptRateLimits(observation.accountId, observation.updates);
691
- },
692
- onResponseUsage: observation => {
693
- this.acceptResponseUsage(observation);
694
- },
695
- warn: message => { ctx.logger.warn(message); },
696
- };
697
768
  const transport = this.routeConfig.nativeWebSocket
698
769
  ? new NativeCodexWebSocketTransport(transportOptions)
699
770
  : new NativeCodexHttpTransport(transportOptions);
@@ -707,6 +778,15 @@ export class OpenAICodexAuth extends Service {
707
778
  ], new NativeCodexAdapter(catalog, transport));
708
779
  });
709
780
  }
781
+ if (config.cloudTools?.enabled !== false) {
782
+ const cloudClient = new NativeCodexCloudClient(transportOptions);
783
+ ctx.inject(['tools'], toolCtx => {
784
+ const cloudConfig = config.cloudTools ?? {};
785
+ if (cloudConfig.search !== false)
786
+ registerCodexWeb(toolCtx, { client: cloudClient, catalog, config: cloudConfig });
787
+ registerCodexImageTools(toolCtx, { client: cloudClient, catalog, transport: transportOptions, config: cloudConfig });
788
+ });
789
+ }
710
790
  ctx.effect(async () => {
711
791
  try {
712
792
  await this.bearerToken();
@@ -755,6 +835,7 @@ export class OpenAICodexAuth extends Service {
755
835
  register('/openai-codex/browser/complete', (req, res) => this.handleBrowserComplete(req, res));
756
836
  register('/openai-codex/cancel', (req, res) => this.handleCancel(req, res));
757
837
  register('/openai-codex/accounts/current', (req, res) => this.handleSetCurrentAccount(req, res));
838
+ register('/openai-codex/accounts/usage', (req, res) => this.handleAccountUsage(req, res));
758
839
  register('/openai-codex/accounts/logout', (req, res) => this.handleAccountLogout(req, res));
759
840
  register('/openai-codex/logout', (req, res) => this.handleLogout(req, res));
760
841
  }
@@ -800,7 +881,7 @@ export class OpenAICodexAuth extends Service {
800
881
  return;
801
882
  this.responseUsage = {
802
883
  accountId: observation.accountId,
803
- amount: observation.metadata.amount,
884
+ metadata: observation.metadata,
804
885
  observedAt: Date.now(),
805
886
  };
806
887
  }
@@ -821,8 +902,7 @@ export class OpenAICodexAuth extends Service {
821
902
  const sameUsageAccount = this.usageAccountId === accountId;
822
903
  if (!sameUsageAccount)
823
904
  this.usageHasDirectDefault = false;
824
- if (hasDefaultQuota)
825
- this.usageGeneration += 1;
905
+ this.usageGeneration += 1;
826
906
  this.usageCache = mergeDirectUsage(sameUsageAccount ? this.usageCache : undefined, accepted);
827
907
  this.usageAccountId = accountId;
828
908
  this.usageError = undefined;
@@ -855,7 +935,7 @@ export class OpenAICodexAuth extends Service {
855
935
  }
856
936
  async performUsageRefresh(generation) {
857
937
  try {
858
- const credential = await withFileLock(this.filename, () => this.resolveManagedCredentialLocked());
938
+ const credential = await this.managedCredential();
859
939
  if (credential === undefined) {
860
940
  if (this.usageGeneration === generation) {
861
941
  this.usageCache = undefined;
@@ -1004,56 +1084,73 @@ export class OpenAICodexAuth extends Service {
1004
1084
  const document = parsed?.document ?? { version: 2, currentAccountId: null, accounts: [] };
1005
1085
  await this.commitDocument(this.upsertCurrentCredential(document, credential));
1006
1086
  }
1007
- async resolveManagedCredentialLocked(signal) {
1008
- throwIfCancelled(signal);
1009
- const parsed = await readCredentialDocument(this.filename);
1010
- const document = parsed?.document;
1011
- const current = currentCredential(document);
1087
+ /** Return the current managed bearer token, refreshing and migrating it when needed. */
1088
+ async bearerToken(signal) {
1012
1089
  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()) {
1090
+ while (true) {
1091
+ const prepared = await withFileLock(this.filename, async () => {
1092
+ throwIfCancelled(signal);
1093
+ const parsed = await readCredentialDocument(this.filename);
1094
+ const document = parsed?.document;
1095
+ const current = currentCredential(document);
1096
+ if (current === undefined) {
1097
+ this.setCredentialAccount(undefined);
1098
+ return undefined;
1099
+ }
1100
+ if (current.expires > Date.now() + TOKEN_REFRESH_PREEMPT_MS) {
1101
+ if (parsed?.migrated)
1102
+ await this.commitDocument(document, signal);
1103
+ else
1104
+ await this.publishCredentialToken(current.access, signal);
1105
+ this.setCredentialAccount(current.accountId);
1106
+ return { credential: current, ready: true };
1107
+ }
1108
+ return { credential: current, ready: false };
1109
+ });
1110
+ if (prepared === undefined)
1111
+ return undefined;
1112
+ if (prepared.ready)
1113
+ return prepared.credential.access;
1114
+ let refreshed;
1115
+ try {
1116
+ refreshed = await waitForSignal(this.refreshManagedAccount(prepared.credential, true), signal);
1117
+ }
1118
+ catch (error) {
1119
+ throwIfCancelled(signal);
1120
+ throw error;
1121
+ }
1122
+ if (refreshed === undefined)
1123
+ continue;
1124
+ const access = await withFileLock(this.filename, async () => {
1125
+ throwIfCancelled(signal);
1126
+ const parsed = await readCredentialDocument(this.filename);
1127
+ const document = parsed?.document;
1128
+ const current = currentCredential(document);
1129
+ if (current === undefined || current.accountId !== prepared.credential.accountId)
1130
+ return undefined;
1131
+ if (current.expires <= Date.now())
1132
+ throw new Error('OpenAI Codex access token has expired');
1033
1133
  if (parsed?.migrated)
1034
1134
  await this.commitDocument(document, signal);
1035
1135
  else
1036
1136
  await this.publishCredentialToken(current.access, signal);
1037
1137
  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');
1138
+ return current.access;
1139
+ });
1140
+ if (access !== undefined)
1141
+ return access;
1045
1142
  }
1046
- await this.commitDocument(this.upsertCurrentCredential(document, next, current.accountId), signal);
1047
- this.setCredentialAccount(next.accountId);
1048
- return next;
1049
1143
  }
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
- });
1144
+ async managedCredential(signal) {
1145
+ while (true) {
1146
+ const access = await this.bearerToken(signal);
1147
+ if (access === undefined)
1148
+ return undefined;
1149
+ const current = currentCredential((await readCredentialDocument(this.filename))?.document);
1150
+ if (current?.access === access)
1151
+ return current;
1152
+ throwIfCancelled(signal);
1153
+ }
1057
1154
  }
1058
1155
  externalNativeCredential(accessToken) {
1059
1156
  let accountId;
@@ -1073,23 +1170,20 @@ export class OpenAICodexAuth extends Service {
1073
1170
  async resolveNativeCredential(signal) {
1074
1171
  try {
1075
1172
  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);
1173
+ const managed = await this.managedCredential(signal);
1174
+ if (managed !== undefined) {
1084
1175
  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);
1089
- throwIfCancelled(signal);
1090
- this.setCredentialAccount(credential.accountId);
1091
- return credential;
1092
- });
1176
+ return { accessToken: managed.access, accountId: managed.accountId };
1177
+ }
1178
+ const external = await this.ctx.credentials.resolve(TOKEN_REF);
1179
+ throwIfCancelled(signal);
1180
+ if (external === undefined) {
1181
+ throw new LlmError('native Codex credential is not configured', 'MISSING_CREDENTIAL');
1182
+ }
1183
+ const credential = this.externalNativeCredential(external.value);
1184
+ throwIfCancelled(signal);
1185
+ this.setCredentialAccount(credential.accountId);
1186
+ return credential;
1093
1187
  }
1094
1188
  catch (error) {
1095
1189
  if (error instanceof LlmError)
@@ -1101,9 +1195,12 @@ export class OpenAICodexAuth extends Service {
1101
1195
  }
1102
1196
  }
1103
1197
  nativeRecoveryError(error) {
1198
+ const cause = error instanceof CredentialRefreshUncertainError && error.cause !== undefined
1199
+ ? error.cause
1200
+ : error;
1104
1201
  const options = {
1105
- cause: error,
1106
- ...(error instanceof OAuthEndpointError ? { status: error.status } : {}),
1202
+ cause,
1203
+ ...(cause instanceof OAuthEndpointError ? { status: cause.status } : {}),
1107
1204
  };
1108
1205
  if (error instanceof CredentialNotWritableError || isPermanentRefreshError(error)) {
1109
1206
  return new LlmError('native Codex managed credential cannot be refreshed', INVALID_CREDENTIAL_CODE, options);
@@ -1113,44 +1210,46 @@ export class OpenAICodexAuth extends Service {
1113
1210
  async recoverNativeCredential(previous, signal) {
1114
1211
  try {
1115
1212
  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);
1213
+ const prepared = await withFileLock(this.filename, async () => {
1121
1214
  throwIfCancelled(signal);
1215
+ const current = currentCredential((await readCredentialDocument(this.filename))?.document);
1122
1216
  if (current === undefined) {
1123
1217
  const external = await this.ctx.credentials.resolve(TOKEN_REF);
1124
1218
  throwIfCancelled(signal);
1125
1219
  if (external === undefined)
1126
- return false;
1220
+ return { result: false };
1127
1221
  const credential = this.externalNativeCredential(external.value);
1128
1222
  throwIfCancelled(signal);
1129
1223
  if (credential.accountId !== previous.accountId)
1130
- return false;
1224
+ return { result: false };
1131
1225
  this.setCredentialAccount(credential.accountId);
1132
- return credential.accessToken !== previous.accessToken;
1226
+ return { result: credential.accessToken !== previous.accessToken };
1133
1227
  }
1134
1228
  // A request that started under A must never recover by replaying under B.
1135
1229
  if (current.accountId !== previous.accountId)
1136
- return false;
1230
+ return { result: false };
1137
1231
  if (current.access !== previous.accessToken) {
1138
1232
  await this.publishCredentialToken(current.access, signal);
1139
1233
  throwIfCancelled(signal);
1140
1234
  this.setCredentialAccount(current.accountId);
1141
- return true;
1235
+ return { result: true };
1142
1236
  }
1143
- await this.assertCredentialWritable();
1144
- throwIfCancelled(signal);
1145
- const next = await refreshToken(current, signal);
1237
+ return { credential: current };
1238
+ });
1239
+ if (prepared.result !== undefined)
1240
+ return prepared.result;
1241
+ const refreshed = await waitForSignal(this.refreshManagedAccount(prepared.credential, true, true), signal);
1242
+ if (refreshed === undefined)
1243
+ return false;
1244
+ return withFileLock(this.filename, async () => {
1146
1245
  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);
1246
+ const current = currentCredential((await readCredentialDocument(this.filename))?.document);
1247
+ if (current === undefined || current.accountId !== previous.accountId)
1248
+ return false;
1249
+ await this.publishCredentialToken(current.access, signal);
1151
1250
  throwIfCancelled(signal);
1152
- this.setCredentialAccount(next.accountId);
1153
- return next.access !== previous.accessToken;
1251
+ this.setCredentialAccount(current.accountId);
1252
+ return current.access !== previous.accessToken;
1154
1253
  });
1155
1254
  }
1156
1255
  catch (error) {
@@ -1165,18 +1264,22 @@ export class OpenAICodexAuth extends Service {
1165
1264
  async finishCredential(credential, signal) {
1166
1265
  await this.assertCredentialWritable();
1167
1266
  throwIfCancelled(signal);
1168
- await withFileLock(this.filename, async () => {
1267
+ await this.withAccountRefreshLock(credential.accountId, async () => {
1169
1268
  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;
1269
+ await withFileLock(this.filename, async () => {
1270
+ throwIfCancelled(signal);
1271
+ await this.commitCredential(credential);
1272
+ this.setCredentialAccount(credential.accountId);
1273
+ this.usageGeneration += 1;
1274
+ if (this.usageRefresh !== undefined)
1275
+ this.usageRefresh.queued = true;
1276
+ this.usageCache = undefined;
1277
+ this.usageAccountId = undefined;
1278
+ this.directUsageAccountId = undefined;
1279
+ this.usageHasDirectDefault = false;
1280
+ this.usageError = undefined;
1281
+ });
1282
+ await this.clearAccountRefreshJournal(credential.accountId);
1180
1283
  });
1181
1284
  this.lastLoginError = undefined;
1182
1285
  }
@@ -1406,24 +1509,23 @@ export class OpenAICodexAuth extends Service {
1406
1509
  this.lastLoginError = undefined;
1407
1510
  }
1408
1511
  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);
1512
+ await this.withAccountRefreshLock(accountId, async () => {
1513
+ await this.reconcileAccountRefreshJournal(accountId);
1514
+ await withFileLock(this.filename, async () => {
1515
+ const document = (await readCredentialDocument(this.filename))?.document;
1516
+ if (document === undefined || !document.accounts.some(account => account.accountId === accountId)) {
1517
+ throw new AccountNotFoundError('OpenAI Codex account was not found');
1518
+ }
1519
+ if (document.currentAccountId !== accountId) {
1520
+ await this.assertCredentialWritable();
1521
+ await this.commitDocument({ ...document, currentAccountId: accountId });
1522
+ }
1523
+ this.resetCurrentAccountState(accountId);
1524
+ });
1419
1525
  });
1420
1526
  }
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 () => {
1527
+ async logoutAttempt(accountId, expectedPromotion) {
1528
+ return withFileLock(this.filename, async () => {
1427
1529
  let parsed;
1428
1530
  try {
1429
1531
  parsed = await readCredentialDocument(this.filename);
@@ -1440,9 +1542,8 @@ export class OpenAICodexAuth extends Service {
1440
1542
  catch (failure) {
1441
1543
  return this.failAfterPublicationRollback(previous, undefined, failure);
1442
1544
  }
1443
- removedCurrent = true;
1444
1545
  this.resetCurrentAccountState(undefined);
1445
- return;
1546
+ return true;
1446
1547
  }
1447
1548
  const document = parsed?.document;
1448
1549
  const target = accountId ?? document?.currentAccountId ?? undefined;
@@ -1450,22 +1551,54 @@ export class OpenAICodexAuth extends Service {
1450
1551
  || !document.accounts.some(account => account.accountId === target)) {
1451
1552
  if (accountId !== undefined)
1452
1553
  throw new AccountNotFoundError('OpenAI Codex account was not found');
1453
- return;
1554
+ return true;
1454
1555
  }
1455
- removedCurrent = document.currentAccountId === target;
1556
+ const removedCurrent = document.currentAccountId === target;
1456
1557
  const targetIndex = document.accounts.findIndex(account => account.accountId === target);
1457
1558
  const accounts = document.accounts.filter(account => account.accountId !== target);
1458
1559
  const currentAccountId = removedCurrent
1459
1560
  ? accounts[targetIndex]?.accountId ?? accounts[0]?.accountId ?? null
1460
1561
  : document.currentAccountId;
1562
+ const promotion = removedCurrent ? currentAccountId ?? undefined : undefined;
1563
+ if (promotion !== expectedPromotion)
1564
+ return false;
1461
1565
  if (removedCurrent)
1462
1566
  await this.assertCredentialWritable();
1463
1567
  await this.commitDocument({ version: 2, currentAccountId, accounts });
1464
- nextAccountId = currentAccountId ?? undefined;
1568
+ await this.clearAccountRefreshJournal(target);
1465
1569
  if (removedCurrent)
1466
- this.resetCurrentAccountState(nextAccountId);
1570
+ this.resetCurrentAccountState(currentAccountId ?? undefined);
1571
+ return true;
1467
1572
  });
1468
1573
  }
1574
+ async logout(accountId) {
1575
+ if (accountId === undefined)
1576
+ await this.cancelLogin(true);
1577
+ while (true) {
1578
+ let target;
1579
+ let expectedPromotion;
1580
+ try {
1581
+ const document = (await readCredentialDocument(this.filename))?.document;
1582
+ target = accountId ?? document?.currentAccountId ?? undefined;
1583
+ if (document !== undefined && target !== undefined && document.currentAccountId === target) {
1584
+ const targetIndex = document.accounts.findIndex(account => account.accountId === target);
1585
+ const accounts = document.accounts.filter(account => account.accountId !== target);
1586
+ expectedPromotion = accounts[targetIndex]?.accountId ?? accounts[0]?.accountId;
1587
+ }
1588
+ }
1589
+ catch { }
1590
+ const lockIds = [target, expectedPromotion].filter((value) => value !== undefined);
1591
+ const done = lockIds.length === 0
1592
+ ? await this.logoutAttempt(accountId, undefined)
1593
+ : await this.withAccountRefreshLocks(lockIds, async () => {
1594
+ if (expectedPromotion !== undefined)
1595
+ await this.reconcileAccountRefreshJournal(expectedPromotion);
1596
+ return this.logoutAttempt(accountId, expectedPromotion);
1597
+ });
1598
+ if (done)
1599
+ return;
1600
+ }
1601
+ }
1469
1602
  async status(refresh, callbackUrl) {
1470
1603
  let document;
1471
1604
  let credential;
@@ -1532,7 +1665,7 @@ export class OpenAICodexAuth extends Service {
1532
1665
  ? {} : { usage: this.usageCache },
1533
1666
  ...this.responseUsage === undefined || this.responseUsage.accountId !== credential.accountId
1534
1667
  ? {} : { responseUsage: {
1535
- amount: this.responseUsage.amount,
1668
+ ...this.responseUsage.metadata,
1536
1669
  observedAt: this.responseUsage.observedAt,
1537
1670
  } },
1538
1671
  ...this.usageError === undefined ? {} : { usageError: this.usageError },
@@ -1540,16 +1673,310 @@ export class OpenAICodexAuth extends Service {
1540
1673
  csrf: this.csrf,
1541
1674
  };
1542
1675
  }
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
- },
1676
+ accountRefreshJournalFilename(accountId) {
1677
+ const key = createHash('sha256').update(accountId).digest('hex').slice(0, 24);
1678
+ return `${this.filename}.account-${key}-refresh.json`;
1679
+ }
1680
+ async readAccountRefreshJournal(accountId) {
1681
+ const filename = this.accountRefreshJournalFilename(accountId);
1682
+ let value;
1683
+ try {
1684
+ value = JSON.parse(await readFile(filename, 'utf8'));
1685
+ }
1686
+ catch (error) {
1687
+ if (error.code === 'ENOENT')
1688
+ return undefined;
1689
+ throw error;
1690
+ }
1691
+ const journal = value;
1692
+ if (journal === null || typeof journal !== 'object' || journal.version !== 1
1693
+ || journal.accountId !== accountId || typeof journal.startedAt !== 'number'
1694
+ || !Number.isFinite(journal.startedAt)
1695
+ || (journal.phase !== 'pending' && journal.phase !== 'uncertain' && journal.phase !== 'refreshed')) {
1696
+ throw new Error(`openai-codex-auth: invalid credential refresh journal ${filename}`);
1697
+ }
1698
+ const stored = validateCredential(journal.stored, filename);
1699
+ if (stored.accountId !== accountId)
1700
+ throw new Error(`openai-codex-auth: invalid credential refresh journal ${filename}`);
1701
+ if (journal.phase === 'refreshed') {
1702
+ const refreshed = validateCredential(journal.refreshed, filename);
1703
+ if (refreshed.accountId !== accountId)
1704
+ throw new Error(`openai-codex-auth: invalid credential refresh journal ${filename}`);
1705
+ return { version: 1, accountId, startedAt: journal.startedAt, phase: 'refreshed', stored, refreshed };
1706
+ }
1707
+ return { version: 1, accountId, startedAt: journal.startedAt, phase: journal.phase, stored };
1708
+ }
1709
+ writeAccountRefreshJournal(journal) {
1710
+ return writeFileAtomic(this.accountRefreshJournalFilename(journal.accountId), JSON.stringify(journal), { mode: 0o600 });
1711
+ }
1712
+ async clearAccountRefreshJournal(accountId) {
1713
+ try {
1714
+ await unlink(this.accountRefreshJournalFilename(accountId));
1715
+ }
1716
+ catch (error) {
1717
+ if (error.code !== 'ENOENT')
1718
+ throw error;
1719
+ }
1720
+ }
1721
+ async removeDeadFileLock(filename) {
1722
+ const lockFilename = `${filename}.lock`;
1723
+ let owner;
1724
+ try {
1725
+ const text = (await readFile(lockFilename, 'utf8')).trim();
1726
+ owner = Number(text);
1727
+ if (!Number.isSafeInteger(owner) || owner <= 0)
1728
+ return false;
1729
+ }
1730
+ catch (error) {
1731
+ return error.code === 'ENOENT';
1732
+ }
1733
+ try {
1734
+ process.kill(owner, 0);
1735
+ return false;
1736
+ }
1737
+ catch (error) {
1738
+ if (error.code !== 'ESRCH')
1739
+ return false;
1740
+ try {
1741
+ await unlink(lockFilename);
1742
+ return true;
1743
+ }
1744
+ catch (unlinkError) {
1745
+ return unlinkError.code === 'ENOENT';
1746
+ }
1747
+ }
1748
+ }
1749
+ async withAccountRefreshLock(accountId, operation) {
1750
+ const filename = this.accountRefreshJournalFilename(accountId);
1751
+ await this.removeDeadFileLock(filename);
1752
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1753
+ try {
1754
+ return await withFileLock(filename, operation, { waitMs: ACCOUNT_REFRESH_LOCK_WAIT_MS });
1755
+ }
1756
+ catch (error) {
1757
+ if (!isFileLockTimeout(error) || attempt > 0 || !await this.removeDeadFileLock(filename))
1758
+ throw error;
1759
+ }
1760
+ }
1761
+ throw new Error('OpenAI credential refresh lock could not be acquired');
1762
+ }
1763
+ withAccountRefreshLocks(accountIds, operation) {
1764
+ const ordered = [...new Set(accountIds)].sort();
1765
+ const acquire = (index) => index === ordered.length
1766
+ ? operation()
1767
+ : this.withAccountRefreshLock(ordered[index], () => acquire(index + 1));
1768
+ return acquire(0);
1769
+ }
1770
+ async reconcileAccountRefreshJournal(accountId) {
1771
+ const journal = await this.readAccountRefreshJournal(accountId);
1772
+ if (journal === undefined)
1773
+ return undefined;
1774
+ const latest = (await readCredentialDocument(this.filename))?.document.accounts
1775
+ .find(account => account.accountId === accountId);
1776
+ if (latest === undefined
1777
+ || latest.access !== journal.stored.access
1778
+ || latest.refresh !== journal.stored.refresh
1779
+ || latest.expires !== journal.stored.expires) {
1780
+ await this.clearAccountRefreshJournal(accountId);
1781
+ return latest;
1782
+ }
1783
+ if (journal.phase !== 'refreshed')
1784
+ throw new CredentialRefreshUncertainError();
1785
+ try {
1786
+ const credential = await this.persistAccountUsageCredential(journal.stored, journal.refreshed);
1787
+ await this.clearAccountRefreshJournal(accountId);
1788
+ return credential;
1789
+ }
1790
+ catch (error) {
1791
+ if (error instanceof AccountNotFoundError)
1792
+ await this.clearAccountRefreshJournal(accountId);
1793
+ throw error;
1794
+ }
1795
+ }
1796
+ async refreshManagedAccount(stored, requireCurrent, force = false) {
1797
+ return this.withAccountRefreshLock(stored.accountId, async () => {
1798
+ const recovered = await this.reconcileAccountRefreshJournal(stored.accountId);
1799
+ if (recovered !== undefined)
1800
+ return recovered;
1801
+ const prepared = await withFileLock(this.filename, async () => {
1802
+ const document = (await readCredentialDocument(this.filename))?.document;
1803
+ const latest = document?.accounts.find(account => account.accountId === stored.accountId);
1804
+ if (document === undefined || latest === undefined)
1805
+ return undefined;
1806
+ if (requireCurrent && document.currentAccountId !== latest.accountId)
1807
+ return undefined;
1808
+ if (latest.access !== stored.access || latest.refresh !== stored.refresh || latest.expires !== stored.expires) {
1809
+ return { credential: latest, changed: true };
1810
+ }
1811
+ if ((force || latest.expires <= Date.now() + TOKEN_REFRESH_PREEMPT_MS)
1812
+ && document.currentAccountId === latest.accountId)
1813
+ await this.assertCredentialWritable();
1814
+ return { credential: latest, changed: false };
1815
+ });
1816
+ if (prepared === undefined || prepared.changed
1817
+ || (!force && prepared.credential.expires > Date.now() + TOKEN_REFRESH_PREEMPT_MS))
1818
+ return prepared?.credential;
1819
+ const credential = prepared.credential;
1820
+ const pending = {
1821
+ version: 1,
1822
+ accountId: credential.accountId,
1823
+ startedAt: Date.now(),
1824
+ phase: 'pending',
1825
+ stored: credential,
1826
+ };
1827
+ await this.writeAccountRefreshJournal(pending);
1828
+ let refreshed;
1829
+ try {
1830
+ refreshed = await refreshToken(credential, AbortSignal.timeout(TOKEN_REFRESH_REQUEST_TIMEOUT_MS));
1831
+ }
1832
+ catch (error) {
1833
+ if (isDefinitiveOAuthRefreshError(error)) {
1834
+ await this.clearAccountRefreshJournal(credential.accountId);
1835
+ if (!force && !isPermanentRefreshError(error) && credential.expires > Date.now())
1836
+ return credential;
1837
+ throw error;
1838
+ }
1839
+ await this.writeAccountRefreshJournal({ ...pending, phase: 'uncertain' });
1840
+ throw new CredentialRefreshUncertainError(error);
1841
+ }
1842
+ if (refreshed.accountId !== credential.accountId) {
1843
+ await this.writeAccountRefreshJournal({ ...pending, phase: 'uncertain' });
1844
+ throw new Error('OpenAI refresh changed the ChatGPT account identity');
1845
+ }
1846
+ await this.writeAccountRefreshJournal({ ...pending, phase: 'refreshed', refreshed });
1847
+ try {
1848
+ const persisted = await this.persistAccountUsageCredential(credential, refreshed);
1849
+ await this.clearAccountRefreshJournal(credential.accountId);
1850
+ return persisted;
1851
+ }
1852
+ catch (error) {
1853
+ if (error instanceof AccountNotFoundError)
1854
+ await this.clearAccountRefreshJournal(credential.accountId);
1855
+ throw error;
1856
+ }
1857
+ });
1858
+ }
1859
+ async persistAccountUsageCredential(stored, refreshed) {
1860
+ await this.removeDeadFileLock(this.filename);
1861
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1862
+ try {
1863
+ return await withFileLock(this.filename, async () => {
1864
+ const document = (await readCredentialDocument(this.filename))?.document;
1865
+ const latest = document?.accounts.find(account => account.accountId === stored.accountId);
1866
+ if (document === undefined || latest === undefined) {
1867
+ throw new AccountNotFoundError('OpenAI Codex account was removed while its quota was refreshing');
1868
+ }
1869
+ if (latest.access !== stored.access || latest.refresh !== stored.refresh || latest.expires !== stored.expires) {
1870
+ return latest;
1871
+ }
1872
+ const nextDocument = {
1873
+ ...document,
1874
+ accounts: document.accounts.map(account => account.accountId === stored.accountId ? refreshed : account),
1875
+ };
1876
+ if (document.currentAccountId === stored.accountId) {
1877
+ await this.assertCredentialWritable();
1878
+ await this.commitDocument(nextDocument);
1879
+ }
1880
+ else {
1881
+ await this.write(nextDocument);
1882
+ }
1883
+ return refreshed;
1884
+ }, { waitMs: CREDENTIAL_REFRESH_PERSIST_WAIT_MS });
1885
+ }
1886
+ catch (error) {
1887
+ if (!isFileLockTimeout(error) || attempt > 0 || !await this.removeDeadFileLock(this.filename))
1888
+ throw error;
1889
+ }
1890
+ }
1891
+ throw new Error('OpenAI rotated credential could not be persisted');
1892
+ }
1893
+ refreshAccountUsageCredential(stored) {
1894
+ const active = this.accountUsageCredentialRefreshes.get(stored.accountId);
1895
+ if (active !== undefined)
1896
+ return active;
1897
+ const operation = this.refreshManagedAccount(stored, false).then((credential) => {
1898
+ if (credential === undefined) {
1899
+ throw new AccountNotFoundError('OpenAI Codex account was removed while its quota was refreshing');
1900
+ }
1901
+ return credential;
1902
+ });
1903
+ let tracked;
1904
+ tracked = operation.finally(() => {
1905
+ if (this.accountUsageCredentialRefreshes.get(stored.accountId) === tracked) {
1906
+ this.accountUsageCredentialRefreshes.delete(stored.accountId);
1907
+ }
1908
+ });
1909
+ this.accountUsageCredentialRefreshes.set(stored.accountId, tracked);
1910
+ return tracked;
1911
+ }
1912
+ async accountUsageCredential(stored, signal) {
1913
+ if (stored.expires > Date.now() + TOKEN_REFRESH_PREEMPT_MS)
1914
+ return stored;
1915
+ return waitForSignal(this.refreshAccountUsageCredential(stored), signal);
1916
+ }
1917
+ async accountUsages(signal) {
1918
+ const prepared = await withFileLock(this.filename, async () => {
1919
+ const document = (await readCredentialDocument(this.filename))?.document;
1920
+ if (document === undefined)
1921
+ return undefined;
1922
+ return {
1923
+ cacheGeneration: this.usageGeneration,
1924
+ requestGeneration: ++this.accountUsageRequestGeneration,
1925
+ tasks: document.accounts.map(stored => ({
1926
+ accountId: stored.accountId,
1927
+ credential: this.accountUsageCredential(stored, signal),
1928
+ })),
1929
+ };
1552
1930
  });
1931
+ if (prepared === undefined)
1932
+ return [];
1933
+ const results = await Promise.all(prepared.tasks.map(async (task) => {
1934
+ try {
1935
+ const credential = await task.credential;
1936
+ return {
1937
+ accountId: task.accountId,
1938
+ usage: await this.fetchUsage(credential, boundedAccountUsageSignal(signal)),
1939
+ };
1940
+ }
1941
+ catch (error) {
1942
+ return { accountId: task.accountId, error: messageOf(error) };
1943
+ }
1944
+ }));
1945
+ if (!signal?.aborted) {
1946
+ const currentAccountId = (await readCredentialDocument(this.filename))?.document.currentAccountId;
1947
+ const current = results.find(row => row.accountId === currentAccountId);
1948
+ if (current?.usage !== undefined
1949
+ && this.usageGeneration === prepared.cacheGeneration
1950
+ && this.accountUsageRequestGeneration === prepared.requestGeneration) {
1951
+ this.usageGeneration += 1;
1952
+ this.usageCache = current.usage;
1953
+ this.usageAccountId = currentAccountId ?? undefined;
1954
+ this.usageHasDirectDefault = false;
1955
+ this.usageError = undefined;
1956
+ }
1957
+ }
1958
+ return results;
1959
+ }
1960
+ async fetchUsage(credential, signal) {
1961
+ let response;
1962
+ try {
1963
+ response = await fetch(USAGE_URL, {
1964
+ redirect: 'error',
1965
+ ...signal === undefined ? {} : { signal },
1966
+ headers: {
1967
+ accept: 'application/json',
1968
+ authorization: `Bearer ${credential.access}`,
1969
+ 'chatgpt-account-id': credential.accountId,
1970
+ 'user-agent': 'dsh-openai-codex-auth/0.5.0',
1971
+ },
1972
+ });
1973
+ }
1974
+ catch (error) {
1975
+ if (signal?.aborted) {
1976
+ throw new Error(isTimeoutAbort(signal) ? 'Codex usage request timed out' : 'Codex usage request cancelled');
1977
+ }
1978
+ throw error;
1979
+ }
1553
1980
  if (!response.ok)
1554
1981
  throw new Error(`Codex usage request failed (HTTP ${response.status})`);
1555
1982
  return normalizeUsage(await response.json());
@@ -1602,6 +2029,31 @@ export class OpenAICodexAuth extends Service {
1602
2029
  this.sendJson(res, 500, { error: messageOf(error) });
1603
2030
  }
1604
2031
  }
2032
+ async handleAccountUsage(req, res) {
2033
+ if (!this.trustedManagementRequest(req, res))
2034
+ return;
2035
+ if (req.method !== 'POST') {
2036
+ this.sendJson(res, 405, { error: 'POST only' }, { allow: 'POST' });
2037
+ return;
2038
+ }
2039
+ if (!this.requireCsrf(req, res))
2040
+ return;
2041
+ const abort = new AbortController();
2042
+ const onAborted = () => { abort.abort(new Error('Account quota request cancelled')); };
2043
+ const observesAbort = typeof req.once === 'function' && typeof req.off === 'function';
2044
+ if (observesAbort)
2045
+ req.once('aborted', onAborted);
2046
+ try {
2047
+ this.sendJson(res, 200, { accounts: await this.accountUsages(abort.signal) });
2048
+ }
2049
+ catch (error) {
2050
+ this.sendJson(res, error instanceof CredentialNotWritableError ? 409 : 500, { error: messageOf(error) });
2051
+ }
2052
+ finally {
2053
+ if (observesAbort)
2054
+ req.off('aborted', onAborted);
2055
+ }
2056
+ }
1605
2057
  async handleDeviceStart(req, res) {
1606
2058
  if (!this.trustedManagementRequest(req, res))
1607
2059
  return;