@modelprofile.com/authswitch 9.2.0 → 10.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +2 -2
- package/dist_ts/authority-contract.d.ts +52 -6
- package/dist_ts/authority-contract.js +1 -1
- package/dist_ts/classes.authoritybroker.d.ts +20 -4
- package/dist_ts/classes.authoritybroker.js +44 -8
- package/dist_ts/classes.authoritycli.d.ts +4 -1
- package/dist_ts/classes.authoritycli.js +42 -7
- package/dist_ts/classes.authorityclient.d.ts +9 -1
- package/dist_ts/classes.authorityclient.js +9 -3
- package/dist_ts/classes.authoritydaemon.js +8 -5
- package/dist_ts/classes.authoritydatabase.d.ts +6 -2
- package/dist_ts/classes.authoritydatabase.js +13 -6
- package/dist_ts/classes.authorityimport.d.ts +4 -4
- package/dist_ts/classes.authorityimport.js +8 -7
- package/dist_ts/classes.authoritymodels.d.ts +11 -3
- package/dist_ts/classes.authoritymodels.js +43 -13
- package/dist_ts/classes.authoritypreuse.d.ts +8 -2
- package/dist_ts/classes.authoritypreuse.js +27 -17
- package/dist_ts/classes.authorityusage.d.ts +4 -3
- package/dist_ts/classes.authorityusage.js +3 -5
- package/dist_ts/classes.claudeauthority.d.ts +5 -0
- package/dist_ts/classes.claudeauthority.js +47 -4
- package/dist_ts/classes.claudecodelocks.d.ts +6 -0
- package/dist_ts/classes.claudecodelocks.js +30 -2
- package/dist_ts/classes.claudenative.d.ts +37 -2
- package/dist_ts/classes.claudenative.js +142 -13
- package/dist_ts/classes.cli.js +3 -5
- package/dist_ts/classes.codexpreuse.d.ts +14 -3
- package/dist_ts/classes.codexpreuse.js +127 -44
- package/dist_ts/classes.codexstatus.js +44 -95
- package/dist_ts/classes.login.d.ts +20 -10
- package/dist_ts/classes.login.js +14 -13
- package/dist_ts/classes.openaiprovider.d.ts +53 -0
- package/dist_ts/classes.openaiprovider.js +340 -0
- package/dist_ts/classes.opencodeharness.js +3 -2
- package/dist_ts/classes.service.js +3 -5
- package/dist_ts/index.d.ts +2 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/interfaces.openai.d.ts +118 -0
- package/dist_ts/interfaces.openai.js +3 -0
- package/dist_ts/openaiauth.d.ts +76 -0
- package/dist_ts/openaiauth.js +371 -0
- package/dist_ts/openaiusage.d.ts +59 -0
- package/dist_ts/openaiusage.js +222 -0
- package/dist_ts/plugins.d.ts +1 -3
- package/dist_ts/plugins.js +2 -4
- package/dist_ts/preuse.d.ts +50 -0
- package/dist_ts/preuse.js +63 -4
- package/dist_ts/ts_migration/legacysources/shared.js +4 -3
- package/openai-codex-license.txt +201 -0
- package/package.json +6 -4
- package/readme.md +102 -15
- package/third-party-notices.md +29 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-contract.ts +56 -8
- package/ts/classes.authoritybroker.ts +52 -13
- package/ts/classes.authoritycli.ts +48 -6
- package/ts/classes.authorityclient.ts +11 -3
- package/ts/classes.authoritydaemon.ts +7 -4
- package/ts/classes.authoritydatabase.ts +14 -6
- package/ts/classes.authorityimport.ts +12 -12
- package/ts/classes.authoritymodels.ts +42 -14
- package/ts/classes.authoritypreuse.ts +30 -17
- package/ts/classes.authorityusage.ts +8 -9
- package/ts/classes.claudeauthority.ts +37 -3
- package/ts/classes.claudecodelocks.ts +31 -1
- package/ts/classes.claudenative.ts +161 -14
- package/ts/classes.cli.ts +3 -3
- package/ts/classes.codexpreuse.ts +130 -40
- package/ts/classes.codexstatus.ts +40 -73
- package/ts/classes.login.ts +34 -21
- package/ts/classes.openaiprovider.ts +365 -0
- package/ts/classes.opencodeharness.ts +3 -2
- package/ts/classes.service.ts +3 -4
- package/ts/index.ts +2 -0
- package/ts/interfaces.openai.ts +142 -0
- package/ts/openaiauth.ts +422 -0
- package/ts/openaiusage.ts +247 -0
- package/ts/plugins.ts +1 -3
- package/ts/preuse.ts +88 -3
- package/ts/ts_migration/legacysources/shared.ts +4 -3
package/ts/openaiauth.ts
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// OpenAI protocol portions are adapted in TypeScript from OpenAI Codex (Apache-2.0) and modified by Task Venture Capital GmbH; see third-party-notices.md.
|
|
2
|
+
|
|
3
|
+
import type { TOpenAiRequestOutcome } from './interfaces.openai.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The ChatGPT sign-in protocol Codex uses (`codex-rs/login`: `device_code_auth.rs`, `auth/manager.rs`,
|
|
7
|
+
* `auth/revoke.rs`, `oauth/client.rs`), which the authority runs as the one party that signs in and refreshes:
|
|
8
|
+
* the device code, its poll, the authorization-code exchange, the refresh-token grant, the revocation and the
|
|
9
|
+
* claims of the tokens. Every failure is a fixed message: no response body, token or header crosses an error.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const OPENAI_AUTH_ISSUER = 'https://auth.openai.com';
|
|
13
|
+
export const OPENAI_AUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
14
|
+
|
|
15
|
+
const DEVICE_CODE_TIMEOUT_MS = 15 * 60 * 1000;
|
|
16
|
+
/** Codex's `REVOKE_HTTP_TIMEOUT`. */
|
|
17
|
+
const REVOKE_TIMEOUT_MS = 10_000;
|
|
18
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
19
|
+
const MAX_TOKEN_BYTES = 16 * 1024;
|
|
20
|
+
const MAX_AUTHORIZATION_CODE_BYTES = 8 * 1024;
|
|
21
|
+
const MAX_IDENTIFIER_BYTES = 1024;
|
|
22
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
23
|
+
const textEncoder = new TextEncoder();
|
|
24
|
+
const textDecoder = new TextDecoder();
|
|
25
|
+
const utf8ByteLength = (valueArg: string): number => textEncoder.encode(valueArg).byteLength;
|
|
26
|
+
|
|
27
|
+
export class OpenAiAuthError extends Error {
|
|
28
|
+
public readonly status?: number;
|
|
29
|
+
public readonly requestOutcome?: TOpenAiRequestOutcome;
|
|
30
|
+
public readonly retryAfterMs?: number;
|
|
31
|
+
|
|
32
|
+
constructor(message: string, options: { status?: number; requestOutcome?: TOpenAiRequestOutcome;
|
|
33
|
+
retryAfterMs?: number } = {}) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'OpenAiAuthError';
|
|
36
|
+
this.status = options.status;
|
|
37
|
+
this.requestOutcome = options.requestOutcome;
|
|
38
|
+
this.retryAfterMs = options.retryAfterMs;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface IOpenAiAuthOptions {
|
|
43
|
+
fetch?: typeof fetch;
|
|
44
|
+
/** Aborts network requests and device-code polling. */
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface IOpenAiDeviceCodePollOptions extends IOpenAiAuthOptions {
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
sleep?: (ms: number) => Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What the claims of a ChatGPT token say about its account; never the token itself. */
|
|
54
|
+
export interface IOpenAiTokenClaims {
|
|
55
|
+
email?: string;
|
|
56
|
+
chatgptPlanType?: string;
|
|
57
|
+
chatgptUserId?: string;
|
|
58
|
+
chatgptAccountId?: string;
|
|
59
|
+
chatgptAccountIsFedramp: boolean;
|
|
60
|
+
expiresAt?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface IOpenAiDeviceCode {
|
|
64
|
+
verificationUrl: string;
|
|
65
|
+
userCode: string;
|
|
66
|
+
deviceAuthId: string;
|
|
67
|
+
intervalSeconds: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface IOpenAiDeviceAuthorization {
|
|
71
|
+
authorizationCode: string;
|
|
72
|
+
codeChallenge: string;
|
|
73
|
+
codeVerifier: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The tokens one exchange or refresh produced, with the claims of the ID token (or access token). */
|
|
77
|
+
export interface IOpenAiTokenSet {
|
|
78
|
+
accessToken: string;
|
|
79
|
+
refreshToken: string;
|
|
80
|
+
idToken?: string;
|
|
81
|
+
claims: IOpenAiTokenClaims;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface ITokenResponse {
|
|
85
|
+
id_token?: unknown;
|
|
86
|
+
access_token?: unknown;
|
|
87
|
+
refresh_token?: unknown;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const getFetch = (options: IOpenAiAuthOptions): typeof fetch => {
|
|
91
|
+
const fetchFunction = options.fetch ?? globalThis.fetch;
|
|
92
|
+
if (!fetchFunction) throw new OpenAiAuthError('fetch is not available for OpenAI authentication.');
|
|
93
|
+
return fetchFunction;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const throwIfAborted = (options: IOpenAiAuthOptions): void => {
|
|
97
|
+
if (options.signal?.aborted) throw new OpenAiAuthError('OpenAI authentication was aborted.');
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const asString = (value: unknown, name: string, maximumBytes = MAX_IDENTIFIER_BYTES): string => {
|
|
101
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
102
|
+
throw new OpenAiAuthError(`OpenAI auth response is missing ${name}.`);
|
|
103
|
+
}
|
|
104
|
+
if (utf8ByteLength(value) > maximumBytes) throw new OpenAiAuthError(`OpenAI auth response contains an invalid ${name}.`);
|
|
105
|
+
return value;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const asOptionalString = (value: unknown, maximumBytes = MAX_IDENTIFIER_BYTES): string | undefined => {
|
|
109
|
+
if (typeof value !== 'string' || value.length === 0) return undefined;
|
|
110
|
+
if (utf8ByteLength(value) > maximumBytes) throw new OpenAiAuthError('OpenAI auth response contains an oversized string.');
|
|
111
|
+
return value;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Codex reads the interval as a string (`deserialize_interval`); both a number and a numeric string are taken. */
|
|
115
|
+
const asIntervalSeconds = (value: unknown): number => {
|
|
116
|
+
const interval = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10);
|
|
117
|
+
if (!Number.isFinite(interval) || interval <= 0 || interval > 300) {
|
|
118
|
+
throw new OpenAiAuthError('OpenAI device-code response has an invalid interval.');
|
|
119
|
+
}
|
|
120
|
+
return interval;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const readResponseText = async (response: Response, options: IOpenAiAuthOptions): Promise<string> => {
|
|
124
|
+
const contentLength = response.headers.get('content-length');
|
|
125
|
+
if (contentLength !== null && (!/^\d+$/.test(contentLength) || Number(contentLength) > MAX_RESPONSE_BYTES)) {
|
|
126
|
+
await response.body?.cancel().catch(() => undefined);
|
|
127
|
+
throw new OpenAiAuthError('OpenAI auth response exceeded its size limit.', { status: response.status });
|
|
128
|
+
}
|
|
129
|
+
if (!response.body) return '';
|
|
130
|
+
const reader = response.body.getReader();
|
|
131
|
+
const chunks: Uint8Array[] = [];
|
|
132
|
+
let byteLength = 0;
|
|
133
|
+
let completed = false;
|
|
134
|
+
const onAbort = (): void => { void reader.cancel().catch(() => undefined); };
|
|
135
|
+
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
136
|
+
if (options.signal?.aborted) onAbort();
|
|
137
|
+
try {
|
|
138
|
+
while (true) {
|
|
139
|
+
throwIfAborted(options);
|
|
140
|
+
const { done, value } = await reader.read();
|
|
141
|
+
throwIfAborted(options);
|
|
142
|
+
if (done) break;
|
|
143
|
+
byteLength += value.byteLength;
|
|
144
|
+
if (byteLength > MAX_RESPONSE_BYTES) {
|
|
145
|
+
await reader.cancel().catch(() => undefined);
|
|
146
|
+
throw new OpenAiAuthError('OpenAI auth response exceeded its size limit.', { status: response.status });
|
|
147
|
+
}
|
|
148
|
+
chunks.push(value);
|
|
149
|
+
}
|
|
150
|
+
completed = true;
|
|
151
|
+
} finally {
|
|
152
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
153
|
+
if (!completed) void reader.cancel().catch(() => undefined);
|
|
154
|
+
reader.releaseLock();
|
|
155
|
+
}
|
|
156
|
+
const bytes = new Uint8Array(byteLength);
|
|
157
|
+
let offset = 0;
|
|
158
|
+
for (const chunk of chunks) {
|
|
159
|
+
bytes.set(chunk, offset);
|
|
160
|
+
offset += chunk.byteLength;
|
|
161
|
+
}
|
|
162
|
+
return textDecoder.decode(bytes);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const readJson = async (response: Response, context: string, options: IOpenAiAuthOptions): Promise<unknown> => {
|
|
166
|
+
const body = await readResponseText(response, options);
|
|
167
|
+
if (!response.ok) throw new OpenAiAuthError(`${context} failed with status ${response.status}.`, { status: response.status });
|
|
168
|
+
try { return body ? JSON.parse(body) : {}; }
|
|
169
|
+
catch { throw new OpenAiAuthError(`${context} returned invalid JSON.`, { status: response.status }); }
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const parseRetryAfterMs = (header: string | null): number | undefined => {
|
|
173
|
+
if (!header || header.length > 128) return undefined;
|
|
174
|
+
const maximumDelayMs = 24 * 60 * 60 * 1000;
|
|
175
|
+
if (/^\d{1,10}$/.test(header)) return Math.min(Number(header) * 1000, maximumDelayMs);
|
|
176
|
+
const retryAt = Date.parse(header);
|
|
177
|
+
if (!Number.isFinite(retryAt)) return undefined;
|
|
178
|
+
return Math.min(Math.max(0, retryAt - Date.now()), maximumDelayMs);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* One bounded request. Its failure says whether the request was handed to fetch at all: a refresh whose outcome
|
|
183
|
+
* is `outcomeUnknown` may have rotated the grant and is never sent again.
|
|
184
|
+
*/
|
|
185
|
+
const runFetch = async <TResult>(url: string, init: RequestInit, options: IOpenAiAuthOptions,
|
|
186
|
+
handle: (response: Response, requestOptions: IOpenAiAuthOptions) => Promise<TResult>,
|
|
187
|
+
timeoutMs = REQUEST_TIMEOUT_MS): Promise<TResult> => {
|
|
188
|
+
if (options.signal?.aborted) {
|
|
189
|
+
throw new OpenAiAuthError('OpenAI authentication was aborted.', { requestOutcome: 'notSent' });
|
|
190
|
+
}
|
|
191
|
+
const controller = new AbortController();
|
|
192
|
+
let response: Response | undefined;
|
|
193
|
+
let requestStarted = false;
|
|
194
|
+
const onExternalAbort = (): void => controller.abort();
|
|
195
|
+
const onRequestAbort = (): void => { void response?.body?.cancel().catch(() => undefined); };
|
|
196
|
+
options.signal?.addEventListener('abort', onExternalAbort, { once: true });
|
|
197
|
+
let rejectOnAbort!: () => void;
|
|
198
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
199
|
+
rejectOnAbort = () => reject(new Error('OpenAI authentication request aborted.'));
|
|
200
|
+
controller.signal.addEventListener('abort', rejectOnAbort, { once: true });
|
|
201
|
+
});
|
|
202
|
+
controller.signal.addEventListener('abort', onRequestAbort, { once: true });
|
|
203
|
+
if (options.signal?.aborted) controller.abort();
|
|
204
|
+
const timer = setTimeout(() => controller.abort(), Math.min(timeoutMs, REQUEST_TIMEOUT_MS));
|
|
205
|
+
const requestOptions = { ...options, signal: controller.signal };
|
|
206
|
+
try {
|
|
207
|
+
return await Promise.race([
|
|
208
|
+
(async () => {
|
|
209
|
+
const fetchImplementation = getFetch(options);
|
|
210
|
+
requestStarted = true;
|
|
211
|
+
response = await fetchImplementation(url, { ...init, redirect: 'error', signal: controller.signal });
|
|
212
|
+
if (controller.signal.aborted) await response.body?.cancel().catch(() => undefined);
|
|
213
|
+
throwIfAborted(requestOptions);
|
|
214
|
+
return handle(response, requestOptions);
|
|
215
|
+
})(),
|
|
216
|
+
aborted,
|
|
217
|
+
]);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
const requestOutcome: TOpenAiRequestOutcome = requestStarted ? 'outcomeUnknown' : 'notSent';
|
|
220
|
+
const retryAfterMs = response ? parseRetryAfterMs(response.headers.get('retry-after')) : undefined;
|
|
221
|
+
const status = error instanceof OpenAiAuthError && error.status !== undefined ? error.status : response?.status;
|
|
222
|
+
throw new OpenAiAuthError(error instanceof OpenAiAuthError ? error.message
|
|
223
|
+
: options.signal?.aborted ? 'OpenAI authentication was aborted.' : 'OpenAI authentication request failed.', {
|
|
224
|
+
requestOutcome,
|
|
225
|
+
...(status !== undefined ? { status } : {}),
|
|
226
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
227
|
+
});
|
|
228
|
+
} finally {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
options.signal?.removeEventListener('abort', onExternalAbort);
|
|
231
|
+
controller.signal.removeEventListener('abort', rejectOnAbort);
|
|
232
|
+
controller.signal.removeEventListener('abort', onRequestAbort);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const postJson = (url: string, body: unknown, options: IOpenAiAuthOptions): Promise<unknown> =>
|
|
237
|
+
runFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) },
|
|
238
|
+
options, (response, requestOptions) => readJson(response, `POST ${url}`, requestOptions));
|
|
239
|
+
|
|
240
|
+
const postForm = (url: string, body: URLSearchParams, options: IOpenAiAuthOptions): Promise<unknown> =>
|
|
241
|
+
runFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() },
|
|
242
|
+
options, (response, requestOptions) => readJson(response, `POST ${url}`, requestOptions));
|
|
243
|
+
|
|
244
|
+
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
|
|
245
|
+
|
|
246
|
+
const sleepWithAbort = async (milliseconds: number, options: IOpenAiDeviceCodePollOptions): Promise<void> => {
|
|
247
|
+
throwIfAborted(options);
|
|
248
|
+
const signal = options.signal;
|
|
249
|
+
if (!signal) {
|
|
250
|
+
await (options.sleep ?? sleep)(milliseconds);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
let abortListener: (() => void) | undefined;
|
|
254
|
+
let timer: NodeJS.Timeout | undefined;
|
|
255
|
+
try {
|
|
256
|
+
await Promise.race([
|
|
257
|
+
options.sleep ? options.sleep(milliseconds) : new Promise<void>(resolve => { timer = setTimeout(resolve, milliseconds); }),
|
|
258
|
+
new Promise<never>((_resolve, reject) => {
|
|
259
|
+
abortListener = () => reject(new OpenAiAuthError('OpenAI authentication was aborted.'));
|
|
260
|
+
signal.addEventListener('abort', abortListener, { once: true });
|
|
261
|
+
if (signal.aborted) abortListener();
|
|
262
|
+
}),
|
|
263
|
+
]);
|
|
264
|
+
} finally {
|
|
265
|
+
if (timer) clearTimeout(timer);
|
|
266
|
+
if (abortListener) signal.removeEventListener('abort', abortListener);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const parseJwtPayload = (jwt: string): Record<string, unknown> => {
|
|
271
|
+
const parts = jwt.split('.');
|
|
272
|
+
if (parts.length !== 3 || !parts[1]) throw new OpenAiAuthError('OpenAI auth returned an invalid token.');
|
|
273
|
+
try {
|
|
274
|
+
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
275
|
+
const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '='));
|
|
276
|
+
const value: unknown = JSON.parse(textDecoder.decode(Uint8Array.from(binary, character => character.charCodeAt(0))));
|
|
277
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error();
|
|
278
|
+
return value as Record<string, unknown>;
|
|
279
|
+
} catch { throw new OpenAiAuthError('OpenAI token claims could not be parsed.'); }
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const claimRecord = (value: unknown): Record<string, unknown> | undefined =>
|
|
283
|
+
value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
284
|
+
|
|
285
|
+
/** Reads the account claims of a ChatGPT ID or access token. Throws `OpenAiAuthError` for anything but a JWT. */
|
|
286
|
+
export const parseOpenAiTokenClaims = (token: string): IOpenAiTokenClaims => {
|
|
287
|
+
const claims = parseJwtPayload(token);
|
|
288
|
+
const profile = claimRecord(claims['https://api.openai.com/profile']);
|
|
289
|
+
const auth = claimRecord(claims['https://api.openai.com/auth']);
|
|
290
|
+
const expiresAtSeconds = typeof claims.exp === 'number' ? claims.exp : undefined;
|
|
291
|
+
return {
|
|
292
|
+
email: asOptionalString(claims.email) ?? asOptionalString(profile?.email),
|
|
293
|
+
chatgptPlanType: asOptionalString(auth?.chatgpt_plan_type),
|
|
294
|
+
chatgptUserId: asOptionalString(auth?.chatgpt_user_id) ?? asOptionalString(auth?.user_id),
|
|
295
|
+
chatgptAccountId: asOptionalString(auth?.chatgpt_account_id),
|
|
296
|
+
chatgptAccountIsFedramp: auth?.chatgpt_account_is_fedramp === true,
|
|
297
|
+
expiresAt: expiresAtSeconds !== undefined ? new Date(expiresAtSeconds * 1000).toISOString() : undefined,
|
|
298
|
+
};
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const createTokenSet = (response: ITokenResponse, existing?: IOpenAiTokenSet): IOpenAiTokenSet => {
|
|
302
|
+
const accessToken = asOptionalString(response.access_token, MAX_TOKEN_BYTES) ?? existing?.accessToken;
|
|
303
|
+
const refreshToken = asOptionalString(response.refresh_token, MAX_TOKEN_BYTES) ?? existing?.refreshToken;
|
|
304
|
+
const idToken = asOptionalString(response.id_token, MAX_TOKEN_BYTES) ?? existing?.idToken;
|
|
305
|
+
if (!accessToken) throw new OpenAiAuthError('OpenAI auth response is missing access_token.');
|
|
306
|
+
if (!refreshToken) throw new OpenAiAuthError('OpenAI auth response is missing refresh_token.');
|
|
307
|
+
let claims: IOpenAiTokenClaims;
|
|
308
|
+
try { claims = parseOpenAiTokenClaims(idToken ?? accessToken); }
|
|
309
|
+
catch { throw new OpenAiAuthError('OpenAI auth returned an invalid token.'); }
|
|
310
|
+
return { accessToken, refreshToken, ...(idToken ? { idToken } : {}), claims };
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/** Starts a device sign-in (`POST {issuer}/api/accounts/deviceauth/usercode`). */
|
|
314
|
+
export const requestOpenAiDeviceCode = async (options: IOpenAiAuthOptions = {}): Promise<IOpenAiDeviceCode> => {
|
|
315
|
+
const response = await postJson(`${OPENAI_AUTH_ISSUER}/api/accounts/deviceauth/usercode`,
|
|
316
|
+
{ client_id: OPENAI_AUTH_CLIENT_ID }, options) as Record<string, unknown>;
|
|
317
|
+
return {
|
|
318
|
+
verificationUrl: `${OPENAI_AUTH_ISSUER}/codex/device`,
|
|
319
|
+
userCode: asString(response.user_code ?? response.usercode, 'user_code'),
|
|
320
|
+
deviceAuthId: asString(response.device_auth_id, 'device_auth_id'),
|
|
321
|
+
intervalSeconds: asIntervalSeconds(response.interval),
|
|
322
|
+
};
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Polls until the person approved the sign-in (`POST {issuer}/api/accounts/deviceauth/token`): 403 and 404 mean
|
|
327
|
+
* pending, as in Codex, and the poll gives up after at most fifteen minutes.
|
|
328
|
+
*/
|
|
329
|
+
export const pollOpenAiDeviceCode = async (deviceCode: IOpenAiDeviceCode,
|
|
330
|
+
options: IOpenAiDeviceCodePollOptions = {}): Promise<IOpenAiDeviceAuthorization> => {
|
|
331
|
+
const pollUrl = `${OPENAI_AUTH_ISSUER}/api/accounts/deviceauth/token`;
|
|
332
|
+
const timeoutMs = options.timeoutMs ?? DEVICE_CODE_TIMEOUT_MS;
|
|
333
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > DEVICE_CODE_TIMEOUT_MS) {
|
|
334
|
+
throw new OpenAiAuthError('OpenAI device-code timeout is invalid.');
|
|
335
|
+
}
|
|
336
|
+
if (utf8ByteLength(deviceCode.deviceAuthId) > MAX_IDENTIFIER_BYTES
|
|
337
|
+
|| utf8ByteLength(deviceCode.userCode) > MAX_IDENTIFIER_BYTES
|
|
338
|
+
|| !Number.isSafeInteger(deviceCode.intervalSeconds) || deviceCode.intervalSeconds < 1
|
|
339
|
+
|| deviceCode.intervalSeconds > 300) {
|
|
340
|
+
throw new OpenAiAuthError('OpenAI device-code input is invalid.');
|
|
341
|
+
}
|
|
342
|
+
const startedAt = Date.now();
|
|
343
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
344
|
+
const remainingBeforeRequest = timeoutMs - (Date.now() - startedAt);
|
|
345
|
+
const pollResult = await runFetch(pollUrl, {
|
|
346
|
+
method: 'POST',
|
|
347
|
+
headers: { 'Content-Type': 'application/json' },
|
|
348
|
+
body: JSON.stringify({ device_auth_id: deviceCode.deviceAuthId, user_code: deviceCode.userCode }),
|
|
349
|
+
}, options, async (response, requestOptions) => {
|
|
350
|
+
if (response.ok) {
|
|
351
|
+
return { status: 'complete' as const,
|
|
352
|
+
body: await readJson(response, `POST ${pollUrl}`, requestOptions) as Record<string, unknown> };
|
|
353
|
+
}
|
|
354
|
+
await readResponseText(response, requestOptions);
|
|
355
|
+
if (response.status !== 403 && response.status !== 404) {
|
|
356
|
+
throw new OpenAiAuthError(`OpenAI device-code polling failed with status ${response.status}.`,
|
|
357
|
+
{ status: response.status });
|
|
358
|
+
}
|
|
359
|
+
return { status: 'pending' as const };
|
|
360
|
+
}, Math.max(remainingBeforeRequest, 1));
|
|
361
|
+
if (pollResult.status === 'complete') {
|
|
362
|
+
return {
|
|
363
|
+
authorizationCode: asString(pollResult.body.authorization_code, 'authorization_code', MAX_AUTHORIZATION_CODE_BYTES),
|
|
364
|
+
codeChallenge: asString(pollResult.body.code_challenge, 'code_challenge'),
|
|
365
|
+
codeVerifier: asString(pollResult.body.code_verifier, 'code_verifier'),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
const remaining = timeoutMs - (Date.now() - startedAt);
|
|
369
|
+
await sleepWithAbort(Math.min(deviceCode.intervalSeconds * 1000, Math.max(remaining, 0)), options);
|
|
370
|
+
}
|
|
371
|
+
throw new OpenAiAuthError('OpenAI device-code login timed out.');
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/** Exchanges an approved device sign-in for tokens (`POST {issuer}/oauth/token`, PKCE, form-encoded). */
|
|
375
|
+
export const exchangeOpenAiDeviceAuthorization = async (authorization: IOpenAiDeviceAuthorization,
|
|
376
|
+
options: IOpenAiAuthOptions = {}): Promise<IOpenAiTokenSet> => {
|
|
377
|
+
const response = await postForm(`${OPENAI_AUTH_ISSUER}/oauth/token`, new URLSearchParams({
|
|
378
|
+
grant_type: 'authorization_code',
|
|
379
|
+
code: authorization.authorizationCode,
|
|
380
|
+
redirect_uri: `${OPENAI_AUTH_ISSUER}/deviceauth/callback`,
|
|
381
|
+
client_id: OPENAI_AUTH_CLIENT_ID,
|
|
382
|
+
code_verifier: authorization.codeVerifier,
|
|
383
|
+
}), options) as ITokenResponse;
|
|
384
|
+
return createTokenSet(response);
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Sends one refresh-token grant (`POST {issuer}/oauth/token`, JSON). A response without a new refresh token keeps
|
|
389
|
+
* the one that was sent, as Codex does.
|
|
390
|
+
*/
|
|
391
|
+
export const refreshOpenAiTokens = async (tokens: IOpenAiTokenSet,
|
|
392
|
+
options: IOpenAiAuthOptions = {}): Promise<IOpenAiTokenSet> => {
|
|
393
|
+
const response = await postJson(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
|
|
394
|
+
client_id: OPENAI_AUTH_CLIENT_ID,
|
|
395
|
+
grant_type: 'refresh_token',
|
|
396
|
+
refresh_token: tokens.refreshToken,
|
|
397
|
+
}, options) as ITokenResponse;
|
|
398
|
+
return createTokenSet({
|
|
399
|
+
id_token: response.id_token ?? tokens.idToken,
|
|
400
|
+
access_token: response.access_token ?? tokens.accessToken,
|
|
401
|
+
refresh_token: response.refresh_token ?? tokens.refreshToken,
|
|
402
|
+
}, tokens);
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Revokes a refresh token (`POST {issuer}/oauth/revoke`, JSON), as Codex does when it logs out: the grant then
|
|
407
|
+
* refreshes nothing anywhere. It resolves only once the service accepted the revocation.
|
|
408
|
+
*/
|
|
409
|
+
export const revokeOpenAiRefreshToken = async (refreshToken: string, options: IOpenAiAuthOptions = {}): Promise<void> => {
|
|
410
|
+
if (typeof refreshToken !== 'string' || !refreshToken || utf8ByteLength(refreshToken) > MAX_TOKEN_BYTES) {
|
|
411
|
+
throw new OpenAiAuthError('OpenAI revocation input is invalid.', { requestOutcome: 'notSent' });
|
|
412
|
+
}
|
|
413
|
+
const url = `${OPENAI_AUTH_ISSUER}/oauth/revoke`;
|
|
414
|
+
await runFetch(url, {
|
|
415
|
+
method: 'POST',
|
|
416
|
+
headers: { 'Content-Type': 'application/json' },
|
|
417
|
+
body: JSON.stringify({ token: refreshToken, token_type_hint: 'refresh_token', client_id: OPENAI_AUTH_CLIENT_ID }),
|
|
418
|
+
}, options, async (response, requestOptions) => {
|
|
419
|
+
await readResponseText(response, requestOptions);
|
|
420
|
+
if (!response.ok) throw new OpenAiAuthError(`POST ${url} failed with status ${response.status}.`, { status: response.status });
|
|
421
|
+
}, REVOKE_TIMEOUT_MS);
|
|
422
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// OpenAI protocol portions are adapted in TypeScript from OpenAI Codex (Apache-2.0) and modified by Task Venture Capital GmbH; see third-party-notices.md.
|
|
2
|
+
|
|
3
|
+
import * as plugins from './plugins.js';
|
|
4
|
+
import { commitinfo } from './00_commitinfo_data.js';
|
|
5
|
+
import type { IOpenAiAccountRateLimits, IOpenAiRateLimitDetails, IOpenAiRateLimitWindow } from './interfaces.openai.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The one reader of the ChatGPT backend's account routes, and of `wham/usage` in particular (Codex
|
|
9
|
+
* `codex-rs/backend-client`: `client.rs`, `models/rate_limit_status_payload.rs`). The authority's usage reading,
|
|
10
|
+
* the import's access-token proof and the legacy `limits` view all read an account through here.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
14
|
+
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
15
|
+
const MAX_ADDITIONAL_RATE_LIMITS = 128;
|
|
16
|
+
const MAX_TEXT_BYTES = 256;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Why a backend read failed, never with the response body:
|
|
20
|
+
* - `unauthorized`: the access token was rejected (401); renew it or sign in again.
|
|
21
|
+
* - `rate_limited`: the service refused for too many requests (429); `retryAfter` is its raw header.
|
|
22
|
+
* - `forbidden`: access denied (403). `challenge`: a Cloudflare verification challenge answered instead.
|
|
23
|
+
* - `http`: another error status. `protocol`: an answer too large or not what this route returns.
|
|
24
|
+
* - `network`: the request did not complete. `aborted` / `timeout`: the caller's signal or the time limit.
|
|
25
|
+
*/
|
|
26
|
+
export type TOpenAiBackendFailure = 'unauthorized' | 'rate_limited' | 'forbidden' | 'challenge' | 'http'
|
|
27
|
+
| 'protocol' | 'network' | 'aborted' | 'timeout';
|
|
28
|
+
|
|
29
|
+
export class OpenAiBackendError extends Error {
|
|
30
|
+
constructor(public readonly reason: TOpenAiBackendFailure, public readonly status?: number,
|
|
31
|
+
public readonly retryAfter: string | null = null) {
|
|
32
|
+
super(`ChatGPT backend request failed (${reason}${status === undefined ? '' : ` ${status}`}).`);
|
|
33
|
+
this.name = 'OpenAiBackendError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Access to read one ChatGPT account. A legacy login may carry no workspace; its reads send none. */
|
|
38
|
+
export interface IOpenAiReadAccess {
|
|
39
|
+
accessToken: string;
|
|
40
|
+
accountId?: string;
|
|
41
|
+
isFedrampAccount: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface IOpenAiBackendReadOptions {
|
|
45
|
+
fetch?: typeof fetch;
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
timeoutMs?: number;
|
|
48
|
+
/** The `originator` header; defaults to `authswitch`. */
|
|
49
|
+
originator?: string;
|
|
50
|
+
/** Extra request headers a route requires. */
|
|
51
|
+
headers?: Record<string, string>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
55
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
56
|
+
|
|
57
|
+
/** One bounded GET of `https://chatgpt.com/backend-api/<path>`, answered as a JSON object or an `OpenAiBackendError`. */
|
|
58
|
+
export const readChatGptBackend = async (path: string, access: IOpenAiReadAccess,
|
|
59
|
+
options: IOpenAiBackendReadOptions = {}): Promise<Record<string, unknown>> => {
|
|
60
|
+
// A referenced timer, unlike `AbortSignal.timeout`, so the read settles within its limit even when the
|
|
61
|
+
// transport ignores its signal and holds nothing else open.
|
|
62
|
+
const timeout = new AbortController();
|
|
63
|
+
const timer = setTimeout(() => timeout.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
64
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeout.signal]) : timeout.signal;
|
|
65
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
66
|
+
const failure = () => new OpenAiBackendError(timeout.signal.aborted ? 'timeout' : signal.aborted ? 'aborted' : 'network');
|
|
67
|
+
// The signal settles the read even when a transport ignores it.
|
|
68
|
+
let onAbort!: () => void;
|
|
69
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
70
|
+
onAbort = () => reject(failure());
|
|
71
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
72
|
+
if (signal.aborted) onAbort();
|
|
73
|
+
});
|
|
74
|
+
aborted.catch(() => undefined);
|
|
75
|
+
try {
|
|
76
|
+
return await Promise.race([readBody(fetcher, path, access, options, signal, failure), aborted]);
|
|
77
|
+
} finally {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
signal.removeEventListener('abort', onAbort);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const readBody = async (fetcher: typeof fetch, path: string, access: IOpenAiReadAccess,
|
|
84
|
+
options: IOpenAiBackendReadOptions, signal: AbortSignal, failure: () => OpenAiBackendError,
|
|
85
|
+
): Promise<Record<string, unknown>> => {
|
|
86
|
+
if (signal.aborted) throw failure();
|
|
87
|
+
let response: Response;
|
|
88
|
+
try {
|
|
89
|
+
response = await fetcher(`https://chatgpt.com/backend-api/${path}`, {
|
|
90
|
+
method: 'GET', redirect: 'error', signal,
|
|
91
|
+
headers: { ...plugins.flexOpenAi.createOpenAiChatGptAccessHeaders(access, options.originator ?? 'authswitch'),
|
|
92
|
+
'User-Agent': `authswitch/${commitinfo.version}`, Accept: 'application/json', ...options.headers },
|
|
93
|
+
});
|
|
94
|
+
} catch {
|
|
95
|
+
throw failure();
|
|
96
|
+
}
|
|
97
|
+
if (signal.aborted) {
|
|
98
|
+
await response.body?.cancel().catch(() => undefined);
|
|
99
|
+
throw failure();
|
|
100
|
+
}
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
await response.body?.cancel().catch(() => undefined);
|
|
103
|
+
if (response.headers.get('cf-mitigated') === 'challenge') throw new OpenAiBackendError('challenge', response.status);
|
|
104
|
+
if (response.status === 401) throw new OpenAiBackendError('unauthorized', 401);
|
|
105
|
+
if (response.status === 429) throw new OpenAiBackendError('rate_limited', 429, response.headers.get('retry-after'));
|
|
106
|
+
if (response.status === 403) throw new OpenAiBackendError('forbidden', 403);
|
|
107
|
+
throw new OpenAiBackendError('http', response.status);
|
|
108
|
+
}
|
|
109
|
+
const declared = response.headers.get('content-length');
|
|
110
|
+
if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > MAX_RESPONSE_BYTES)) {
|
|
111
|
+
await response.body?.cancel().catch(() => undefined);
|
|
112
|
+
throw new OpenAiBackendError('protocol', response.status);
|
|
113
|
+
}
|
|
114
|
+
const reader = response.body?.getReader();
|
|
115
|
+
if (!reader) throw new OpenAiBackendError('protocol', response.status);
|
|
116
|
+
const chunks: Uint8Array[] = [];
|
|
117
|
+
let size = 0;
|
|
118
|
+
const cancel = (): void => { void reader.cancel().catch(() => undefined); };
|
|
119
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
120
|
+
try {
|
|
121
|
+
while (true) {
|
|
122
|
+
const part = await reader.read();
|
|
123
|
+
if (part.done) break;
|
|
124
|
+
size += part.value.byteLength;
|
|
125
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
126
|
+
await reader.cancel().catch(() => undefined);
|
|
127
|
+
throw new OpenAiBackendError('protocol', response.status);
|
|
128
|
+
}
|
|
129
|
+
chunks.push(part.value);
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error instanceof OpenAiBackendError) throw error;
|
|
133
|
+
throw failure();
|
|
134
|
+
} finally {
|
|
135
|
+
signal.removeEventListener('abort', cancel);
|
|
136
|
+
reader.releaseLock();
|
|
137
|
+
}
|
|
138
|
+
if (signal.aborted) throw failure();
|
|
139
|
+
let body: unknown;
|
|
140
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
|
141
|
+
catch { throw new OpenAiBackendError('protocol', response.status); }
|
|
142
|
+
if (!isRecord(body)) throw new OpenAiBackendError('protocol', response.status);
|
|
143
|
+
return body;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const invalid = (): never => { throw new OpenAiBackendError('protocol'); };
|
|
147
|
+
|
|
148
|
+
const boundedInteger = (value: unknown, minimum: number, maximum = Number.MAX_SAFE_INTEGER): number =>
|
|
149
|
+
Number.isSafeInteger(value) && (value as number) >= minimum && (value as number) <= maximum ? value as number : invalid();
|
|
150
|
+
|
|
151
|
+
const boundedText = (value: unknown): string =>
|
|
152
|
+
typeof value === 'string' && value.length > 0 && Buffer.byteLength(value, 'utf8') <= MAX_TEXT_BYTES ? value : invalid();
|
|
153
|
+
|
|
154
|
+
const present = (value: unknown): boolean => value !== null && value !== undefined;
|
|
155
|
+
|
|
156
|
+
const projectWindow = (value: unknown): IOpenAiRateLimitWindow => {
|
|
157
|
+
if (!isRecord(value)) return invalid();
|
|
158
|
+
return {
|
|
159
|
+
usedPercent: boundedInteger(value.used_percent, 0, 100),
|
|
160
|
+
limitWindowSeconds: boundedInteger(value.limit_window_seconds, 1),
|
|
161
|
+
resetAfterSeconds: boundedInteger(value.reset_after_seconds, 0),
|
|
162
|
+
resetsAt: boundedInteger(value.reset_at, 0),
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const projectDetails = (value: unknown): IOpenAiRateLimitDetails => {
|
|
167
|
+
if (!isRecord(value) || typeof value.allowed !== 'boolean' || typeof value.limit_reached !== 'boolean') return invalid();
|
|
168
|
+
return {
|
|
169
|
+
allowed: value.allowed,
|
|
170
|
+
limitReached: value.limit_reached,
|
|
171
|
+
...(present(value.primary_window) ? { primaryWindow: projectWindow(value.primary_window) } : {}),
|
|
172
|
+
...(present(value.secondary_window) ? { secondaryWindow: projectWindow(value.secondary_window) } : {}),
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** Credits and spend control, which only the account views show; the stored reading keeps the limits alone. */
|
|
177
|
+
export interface IOpenAiAccountUsage {
|
|
178
|
+
/** The workspace the service answered for, when it names one. */
|
|
179
|
+
accountId: string | null;
|
|
180
|
+
rateLimits: IOpenAiAccountRateLimits;
|
|
181
|
+
credits?: { unlimited: boolean; hasCredits: boolean; balance?: string };
|
|
182
|
+
spendControl?: { reached: boolean; individualLimit?: { used: string; limit: string; remaining: string; resetsAt: number } };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Projects one `wham/usage` answer, refusing it whole when any member it carries is not the documented shape. */
|
|
186
|
+
export const projectOpenAiAccountUsage = (value: unknown, observedAt: string): IOpenAiAccountUsage => {
|
|
187
|
+
if (!isRecord(value)) return invalid();
|
|
188
|
+
const additional = value.additional_rate_limits ?? [];
|
|
189
|
+
if (!Array.isArray(additional) || additional.length > MAX_ADDITIONAL_RATE_LIMITS) return invalid();
|
|
190
|
+
let credits: IOpenAiAccountUsage['credits'];
|
|
191
|
+
if (present(value.credits)) {
|
|
192
|
+
const raw = value.credits;
|
|
193
|
+
if (!isRecord(raw) || typeof raw.unlimited !== 'boolean' || typeof raw.has_credits !== 'boolean') return invalid();
|
|
194
|
+
credits = { unlimited: raw.unlimited, hasCredits: raw.has_credits,
|
|
195
|
+
...(present(raw.balance) ? { balance: boundedText(raw.balance) } : {}) };
|
|
196
|
+
}
|
|
197
|
+
let spendControl: IOpenAiAccountUsage['spendControl'];
|
|
198
|
+
if (present(value.spend_control)) {
|
|
199
|
+
const raw = value.spend_control;
|
|
200
|
+
if (!isRecord(raw) || typeof raw.reached !== 'boolean') return invalid();
|
|
201
|
+
let individualLimit: NonNullable<IOpenAiAccountUsage['spendControl']>['individualLimit'];
|
|
202
|
+
if (present(raw.individual_limit)) {
|
|
203
|
+
const limit = raw.individual_limit;
|
|
204
|
+
if (!isRecord(limit)) return invalid();
|
|
205
|
+
individualLimit = { used: boundedText(limit.used), limit: boundedText(limit.limit),
|
|
206
|
+
remaining: boundedText(limit.remaining), resetsAt: boundedInteger(limit.reset_at, 0) };
|
|
207
|
+
}
|
|
208
|
+
spendControl = { reached: raw.reached, ...(individualLimit ? { individualLimit } : {}) };
|
|
209
|
+
}
|
|
210
|
+
const reachedType = value.rate_limit_reached_type;
|
|
211
|
+
if (present(reachedType) && !isRecord(reachedType)) return invalid();
|
|
212
|
+
const resetCredits = value.rate_limit_reset_credits;
|
|
213
|
+
if (present(resetCredits) && !isRecord(resetCredits)) return invalid();
|
|
214
|
+
return {
|
|
215
|
+
accountId: present(value.account_id) ? boundedText(value.account_id) : null,
|
|
216
|
+
rateLimits: {
|
|
217
|
+
providerId: 'openai',
|
|
218
|
+
plan: boundedText(value.plan_type),
|
|
219
|
+
observedAt,
|
|
220
|
+
...(present(value.rate_limit) ? { rateLimit: projectDetails(value.rate_limit) } : {}),
|
|
221
|
+
additionalRateLimits: additional.map(raw => {
|
|
222
|
+
if (!isRecord(raw)) return invalid();
|
|
223
|
+
return {
|
|
224
|
+
limitName: boundedText(raw.limit_name),
|
|
225
|
+
meteredFeature: boundedText(raw.metered_feature),
|
|
226
|
+
...(present(raw.rate_limit) ? { rateLimit: projectDetails(raw.rate_limit) } : {}),
|
|
227
|
+
};
|
|
228
|
+
}),
|
|
229
|
+
...(isRecord(reachedType) ? { rateLimitReachedType: boundedText(reachedType.type) } : {}),
|
|
230
|
+
...(isRecord(resetCredits)
|
|
231
|
+
? { rateLimitResetCreditsAvailableCount: boundedInteger(resetCredits.available_count, 0) } : {}),
|
|
232
|
+
},
|
|
233
|
+
...(credits ? { credits } : {}),
|
|
234
|
+
...(spendControl ? { spendControl } : {}),
|
|
235
|
+
};
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/** Reads and projects one account's `wham/usage`. An answer for another workspace is refused. */
|
|
239
|
+
export const readOpenAiAccountUsage = async (access: IOpenAiReadAccess, options: IOpenAiBackendReadOptions & {
|
|
240
|
+
now?: () => Date } = {}): Promise<IOpenAiAccountUsage> => {
|
|
241
|
+
const body = await readChatGptBackend('wham/usage', access, options);
|
|
242
|
+
const usage = projectOpenAiAccountUsage(body, (options.now ?? (() => new Date()))().toISOString());
|
|
243
|
+
if (usage.accountId !== null && access.accountId !== undefined && usage.accountId !== access.accountId) {
|
|
244
|
+
throw new OpenAiBackendError('protocol');
|
|
245
|
+
}
|
|
246
|
+
return usage;
|
|
247
|
+
};
|
package/ts/plugins.ts
CHANGED
|
@@ -41,7 +41,5 @@ export { smartconsole, smartsecret, smartdaemon, typedrequest };
|
|
|
41
41
|
// @modelprofile.com modules
|
|
42
42
|
import * as flexModels from '@modelprofile.com/flexharness-models';
|
|
43
43
|
import * as flexOpenAi from '@modelprofile.com/flexharness-providers/openai';
|
|
44
|
-
import * as flexAuth from '@modelprofile.com/flexharness-providers/auth';
|
|
45
|
-
import * as flexAccounts from '@modelprofile.com/flexharness-providers/accounts';
|
|
46
44
|
import * as crossharness from '@modelprofile.com/mcp-crossharness';
|
|
47
|
-
export { flexModels, flexOpenAi,
|
|
45
|
+
export { flexModels, flexOpenAi, crossharness };
|