@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/CHANGELOG.md +18 -0
- package/CODEX-COMPATIBILITY.md +93 -0
- package/README.md +60 -1
- package/client.js +95 -12
- package/lib/catalog.d.ts +6 -0
- package/lib/catalog.js +24 -0
- package/lib/cloud-context.d.ts +20 -0
- package/lib/cloud-context.js +89 -0
- package/lib/cloud-http.d.ts +28 -0
- package/lib/cloud-http.js +170 -0
- package/lib/cloud-images.d.ts +51 -0
- package/lib/cloud-images.js +122 -0
- package/lib/cloud-media.d.ts +52 -0
- package/lib/cloud-media.js +112 -0
- package/lib/cloud-search.d.ts +31 -0
- package/lib/cloud-search.js +172 -0
- package/lib/cloud-tools.d.ts +25 -0
- package/lib/cloud-tools.js +129 -0
- package/lib/cloud-vision.d.ts +34 -0
- package/lib/cloud-vision.js +108 -0
- package/lib/cloud-web-tool.d.ts +12 -0
- package/lib/cloud-web-tool.js +241 -0
- package/lib/index.d.ts +21 -1
- package/lib/index.js +601 -149
- package/lib/native-adapter.d.ts +5 -0
- package/lib/native-adapter.js +22 -10
- package/lib/native-http.d.ts +2 -0
- package/lib/native-http.js +3 -0
- package/lib/native-websocket.js +11 -5
- package/lib/replay.d.ts +1 -0
- package/lib/replay.js +8 -2
- package/lib/response-usage.d.ts +4 -2
- package/lib/response-usage.js +26 -6
- package/lib/responses.d.ts +10 -1
- package/lib/responses.js +86 -10
- package/lib/upstream.d.ts +6 -4
- package/lib/upstream.js +6 -4
- package/lib/usage.d.ts +1 -0
- package/lib/usage.js +10 -3
- package/package.json +62 -9
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
1014
|
-
this.
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
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
|
-
|
|
1077
|
-
|
|
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
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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
|
|
1106
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
await this.
|
|
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(
|
|
1153
|
-
return
|
|
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
|
|
1267
|
+
await this.withAccountRefreshLock(credential.accountId, async () => {
|
|
1169
1268
|
throwIfCancelled(signal);
|
|
1170
|
-
await this.
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
this.
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
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
|
|
1422
|
-
|
|
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
|
-
|
|
1568
|
+
await this.clearAccountRefreshJournal(target);
|
|
1465
1569
|
if (removedCurrent)
|
|
1466
|
-
this.resetCurrentAccountState(
|
|
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
|
-
|
|
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
|
-
|
|
1544
|
-
const
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
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;
|