@modelprofile.com/authswitch 9.2.0 → 10.0.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.
Files changed (72) hide show
  1. package/dist_ts/00_commitinfo_data.js +2 -2
  2. package/dist_ts/authority-contract.d.ts +52 -6
  3. package/dist_ts/authority-contract.js +1 -1
  4. package/dist_ts/classes.authoritybroker.d.ts +20 -4
  5. package/dist_ts/classes.authoritybroker.js +44 -8
  6. package/dist_ts/classes.authoritycli.d.ts +4 -1
  7. package/dist_ts/classes.authoritycli.js +42 -7
  8. package/dist_ts/classes.authorityclient.d.ts +9 -1
  9. package/dist_ts/classes.authorityclient.js +9 -3
  10. package/dist_ts/classes.authoritydaemon.js +8 -5
  11. package/dist_ts/classes.authoritydatabase.d.ts +6 -2
  12. package/dist_ts/classes.authoritydatabase.js +13 -6
  13. package/dist_ts/classes.authorityimport.d.ts +4 -4
  14. package/dist_ts/classes.authorityimport.js +8 -7
  15. package/dist_ts/classes.authoritymodels.d.ts +11 -3
  16. package/dist_ts/classes.authoritymodels.js +43 -13
  17. package/dist_ts/classes.authoritypreuse.d.ts +8 -2
  18. package/dist_ts/classes.authoritypreuse.js +27 -17
  19. package/dist_ts/classes.authorityusage.d.ts +4 -3
  20. package/dist_ts/classes.authorityusage.js +3 -5
  21. package/dist_ts/classes.cli.js +3 -5
  22. package/dist_ts/classes.codexpreuse.d.ts +14 -3
  23. package/dist_ts/classes.codexpreuse.js +127 -44
  24. package/dist_ts/classes.codexstatus.js +44 -95
  25. package/dist_ts/classes.login.d.ts +20 -10
  26. package/dist_ts/classes.login.js +14 -13
  27. package/dist_ts/classes.openaiprovider.d.ts +53 -0
  28. package/dist_ts/classes.openaiprovider.js +340 -0
  29. package/dist_ts/classes.opencodeharness.js +3 -2
  30. package/dist_ts/classes.service.js +3 -5
  31. package/dist_ts/index.d.ts +2 -0
  32. package/dist_ts/index.js +3 -1
  33. package/dist_ts/interfaces.openai.d.ts +118 -0
  34. package/dist_ts/interfaces.openai.js +3 -0
  35. package/dist_ts/openaiauth.d.ts +76 -0
  36. package/dist_ts/openaiauth.js +371 -0
  37. package/dist_ts/openaiusage.d.ts +59 -0
  38. package/dist_ts/openaiusage.js +222 -0
  39. package/dist_ts/plugins.d.ts +1 -3
  40. package/dist_ts/plugins.js +2 -4
  41. package/dist_ts/preuse.d.ts +50 -0
  42. package/dist_ts/preuse.js +63 -4
  43. package/dist_ts/ts_migration/legacysources/shared.js +4 -3
  44. package/openai-codex-license.txt +201 -0
  45. package/package.json +6 -4
  46. package/readme.md +86 -10
  47. package/third-party-notices.md +29 -0
  48. package/ts/00_commitinfo_data.ts +1 -1
  49. package/ts/authority-contract.ts +56 -8
  50. package/ts/classes.authoritybroker.ts +52 -13
  51. package/ts/classes.authoritycli.ts +48 -6
  52. package/ts/classes.authorityclient.ts +11 -3
  53. package/ts/classes.authoritydaemon.ts +7 -4
  54. package/ts/classes.authoritydatabase.ts +14 -6
  55. package/ts/classes.authorityimport.ts +12 -12
  56. package/ts/classes.authoritymodels.ts +42 -14
  57. package/ts/classes.authoritypreuse.ts +30 -17
  58. package/ts/classes.authorityusage.ts +8 -9
  59. package/ts/classes.cli.ts +3 -3
  60. package/ts/classes.codexpreuse.ts +130 -40
  61. package/ts/classes.codexstatus.ts +40 -73
  62. package/ts/classes.login.ts +34 -21
  63. package/ts/classes.openaiprovider.ts +365 -0
  64. package/ts/classes.opencodeharness.ts +3 -2
  65. package/ts/classes.service.ts +3 -4
  66. package/ts/index.ts +2 -0
  67. package/ts/interfaces.openai.ts +142 -0
  68. package/ts/openaiauth.ts +422 -0
  69. package/ts/openaiusage.ts +247 -0
  70. package/ts/plugins.ts +1 -3
  71. package/ts/preuse.ts +88 -3
  72. package/ts/ts_migration/legacysources/shared.ts +4 -3
@@ -1,13 +1,27 @@
1
- import * as plugins from './plugins.js';
1
+ import { AuthSwitchOpenAiProvider } from './classes.openaiprovider.js';
2
+ import { parseOpenAiTokenClaims } from './openaiauth.js';
2
3
  import type { IHarnessLoginHandle, IHarnessLoginOptions, IHarnessLoginResult } from './interfaces.harness.js';
4
+ import type { IOpenAiDevicePrompt, IOpenAiLoginHandle, IOpenAiLoginOptions, IOpenAiLoginResult,
5
+ IOpenAiOAuthCredential } from './interfaces.openai.js';
3
6
 
4
- export type TAuthLoginFlow = plugins.flexAccounts.TSmartAiProviderLoginFlow;
5
- export type TAuthLoginCredential = plugins.flexAccounts.TSmartAiProviderCredential;
6
- export type TAuthLoginPrompt = plugins.flexAccounts.TSmartAiProviderLoginPrompt;
7
- export interface IAuthLoginResult extends plugins.flexAccounts.ISmartAiProviderLoginResult {}
8
- export interface IAuthLoginHandle extends plugins.flexAccounts.ISmartAiProviderLoginHandle {}
9
- export interface IAuthLoginProvider extends Pick<plugins.flexAccounts.ISmartAiProviderAdapter, 'descriptor' | 'beginLogin' | 'dispose'> {}
10
- export interface IAuthLoginOptions extends plugins.flexAccounts.ISmartAiProviderLoginOptions {
7
+ /** The one login flow: a device sign-in, which needs no browser on the machine that signs in. */
8
+ export type TAuthLoginFlow = IOpenAiLoginOptions['flow'];
9
+ export type TAuthLoginCredential = IOpenAiOAuthCredential;
10
+ export type TAuthLoginPrompt = IOpenAiDevicePrompt;
11
+ export interface IAuthLoginResult extends IOpenAiLoginResult {}
12
+ export interface IAuthLoginHandle extends IOpenAiLoginHandle {}
13
+ /** A provider this login owner starts sign-ins with, named by its `providerId`. */
14
+ export interface IAuthLoginProviderDescriptor {
15
+ providerId: string;
16
+ displayName: string;
17
+ loginFlows: readonly TAuthLoginFlow[];
18
+ }
19
+ export interface IAuthLoginProvider {
20
+ readonly descriptor: IAuthLoginProviderDescriptor;
21
+ beginLogin(options: IOpenAiLoginOptions): Promise<IAuthLoginHandle>;
22
+ dispose(): Promise<void>;
23
+ }
24
+ export interface IAuthLoginOptions extends IOpenAiLoginOptions {
11
25
  providerId: string;
12
26
  }
13
27
  export interface IAuthSwitchLoginOptions {
@@ -23,13 +37,13 @@ export class AuthSwitchLogin {
23
37
  private closing?: Promise<void>;
24
38
  private readonly handles = new Set<IAuthLoginHandle>();
25
39
 
26
- constructor(providers: IAuthLoginProvider[] = [new plugins.flexAccounts.OpenAiProviderAdapter()], private readonly options: IAuthSwitchLoginOptions = {}) {
40
+ constructor(providers: IAuthLoginProvider[] = [new AuthSwitchOpenAiProvider()], private readonly options: IAuthSwitchLoginOptions = {}) {
27
41
  for (const provider of providers) {
28
42
  if (this.providers.has(provider.descriptor.providerId)) throw new Error('Duplicate login provider.');
29
43
  this.providers.set(provider.descriptor.providerId, provider);
30
44
  }
31
45
  }
32
- public listProviders(): plugins.flexAccounts.ISmartAiProviderDescriptor[] {
46
+ public listProviders(): IAuthLoginProviderDescriptor[] {
33
47
  return [...this.providers.values()].map(provider => ({ ...provider.descriptor, loginFlows: [...provider.descriptor.loginFlows] }));
34
48
  }
35
49
  public async beginLogin(options: IAuthLoginOptions): Promise<IAuthLoginHandle> {
@@ -70,7 +84,7 @@ export const beginSavedOpenAiLogin = async (
70
84
  fetcher?: typeof fetch,
71
85
  ): Promise<IHarnessLoginHandle> => {
72
86
  if (options.providerId !== 'openai' || options.flow !== 'device') throw new Error('This harness supports OpenAI device login.');
73
- const service = new AuthSwitchLogin([new plugins.flexAccounts.OpenAiProviderAdapter({ fetch: fetcher })]);
87
+ const service = new AuthSwitchLogin([new AuthSwitchOpenAiProvider({ fetch: fetcher })]);
74
88
  try {
75
89
  const handle = await service.beginLogin({ providerId: 'openai', flow: 'device' });
76
90
  const completion = handle.completion.then(save).catch((): IHarnessLoginResult => ({
@@ -81,23 +95,22 @@ export const beginSavedOpenAiLogin = async (
81
95
  };
82
96
 
83
97
  /** Convert a verified OpenAI login for native OpenCode without exposing it in public account DTOs. */
84
- export const openCodeCredentialFromLogin = (credential: plugins.flexAccounts.IOpenAiChatGptOAuthCredential): {
98
+ export const openCodeCredentialFromLogin = (credential: IOpenAiOAuthCredential): {
85
99
  type: 'oauth'; access: string; refresh: string; expires: number; accountId?: string;
86
100
  } => {
87
101
  try {
88
- const auth = plugins.flexAccounts.toOpenAiChatGptAuthCredentials(credential);
89
- const payload = credential.accessToken.split('.')[1];
102
+ if (credential.kind !== 'chatgptOAuth' || credential.providerId !== 'openai' || !credential.accessToken
103
+ || !credential.refreshToken || !credential.idToken) throw new Error();
104
+ const accountId = parseOpenAiTokenClaims(credential.idToken).chatgptAccountId;
90
105
  let expires = 0;
91
- if (payload && payload.length <= 48 * 1024) {
92
- try {
93
- const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { exp?: unknown };
94
- expires = typeof claims?.exp === 'number' ? claims.exp * 1000 : 0;
95
- } catch { /* Opaque access tokens are valid; native OpenCode refreshes an unknown expiry. */ }
96
- }
106
+ try {
107
+ const expiresAt = parseOpenAiTokenClaims(credential.accessToken).expiresAt;
108
+ expires = expiresAt === undefined ? 0 : Date.parse(expiresAt);
109
+ } catch { /* Opaque access tokens are valid; native OpenCode refreshes an unknown expiry. */ }
97
110
  return {
98
111
  type: 'oauth', access: credential.accessToken, refresh: credential.refreshToken,
99
112
  expires: Number.isSafeInteger(expires) && expires >= 0 ? expires : 0,
100
- ...(auth.accountId ? { accountId: auth.accountId } : {}),
113
+ ...(accountId ? { accountId } : {}),
101
114
  };
102
115
  } catch { throw new Error('OpenAI login credential is invalid.'); }
103
116
  };
@@ -0,0 +1,365 @@
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 {
5
+ exchangeOpenAiDeviceAuthorization, OpenAiAuthError, parseOpenAiTokenClaims, pollOpenAiDeviceCode,
6
+ refreshOpenAiTokens, requestOpenAiDeviceCode, revokeOpenAiRefreshToken, type IOpenAiTokenSet,
7
+ } from './openaiauth.js';
8
+ import { OpenAiBackendError, readOpenAiAccountUsage } from './openaiusage.js';
9
+ import type {
10
+ IOpenAiAccess, IOpenAiAccountSummary, IOpenAiDevicePrompt, IOpenAiLoginHandle, IOpenAiLoginOptions,
11
+ IOpenAiLoginResult, IOpenAiOAuthCredential, IOpenAiOperationOptions, IOpenAiProviderDescriptor,
12
+ TOpenAiAccountRateLimitsResult, TOpenAiLoginCancelStatus, TOpenAiRefreshResult,
13
+ } from './interfaces.openai.js';
14
+
15
+ const DEVICE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
16
+ const OPERATION_TIMEOUT_MS = 30_000;
17
+ const MAXIMUM_OPERATION_TIMEOUT_MS = 20 * 60 * 1000;
18
+ const MAXIMUM_HEADER_BYTES = 16 * 1024;
19
+
20
+ /**
21
+ * Why a provider operation failed, as a code a caller branches on:
22
+ * - `INVALID_ARGUMENT` and `CREDENTIAL_INVALID`: the caller's input.
23
+ * - `CLOSED`: the provider was disposed. `ABORTED` / `TIMEOUT`: the caller's signal or the time limit.
24
+ * - `LOGIN_FAILED` / `LOGIN_CANCELED`: a device sign-in did not produce a credential.
25
+ * - `REVOKE_FAILED`: the service did not confirm a revocation; the sign-in may still be valid.
26
+ */
27
+ export type TOpenAiProviderErrorCode = 'INVALID_ARGUMENT' | 'CREDENTIAL_INVALID' | 'CLOSED' | 'ABORTED' | 'TIMEOUT'
28
+ | 'LOGIN_FAILED' | 'LOGIN_CANCELED' | 'REVOKE_FAILED';
29
+
30
+ export class OpenAiProviderError extends Error {
31
+ constructor(public readonly code: TOpenAiProviderErrorCode) {
32
+ super(`OpenAI provider operation failed (${code}).`);
33
+ this.name = 'OpenAiProviderError';
34
+ }
35
+ }
36
+
37
+ export interface IOpenAiProviderOptions {
38
+ fetch?: typeof fetch;
39
+ now?: () => Date;
40
+ }
41
+
42
+ interface IOperation {
43
+ controller: AbortController;
44
+ signal: AbortSignal;
45
+ readonly timedOut: boolean;
46
+ cleanup(): void;
47
+ }
48
+
49
+ interface ISharedRefresh {
50
+ controller: AbortController;
51
+ promise: Promise<TOpenAiRefreshResult>;
52
+ waiters: number;
53
+ settled: boolean;
54
+ }
55
+
56
+ const createOperation = (options: IOpenAiOperationOptions, defaultTimeoutMs: number): IOperation => {
57
+ const timeoutMs = options.timeoutMs ?? defaultTimeoutMs;
58
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAXIMUM_OPERATION_TIMEOUT_MS) {
59
+ throw new OpenAiProviderError('INVALID_ARGUMENT');
60
+ }
61
+ const controller = new AbortController();
62
+ let timedOut = false;
63
+ const onExternalAbort = (): void => controller.abort();
64
+ options.signal?.addEventListener('abort', onExternalAbort, { once: true });
65
+ if (options.signal?.aborted) controller.abort();
66
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
67
+ return {
68
+ controller,
69
+ signal: controller.signal,
70
+ get timedOut() { return timedOut; },
71
+ cleanup: () => {
72
+ clearTimeout(timer);
73
+ options.signal?.removeEventListener('abort', onExternalAbort);
74
+ },
75
+ };
76
+ };
77
+
78
+ const failureCode = (operation: IOperation): 'ABORTED' | 'TIMEOUT' | null =>
79
+ operation.timedOut ? 'TIMEOUT' : operation.signal.aborted ? 'ABORTED' : null;
80
+
81
+ /** The token set a stored credential holds, with its ID-token claims; a malformed credential is refused. */
82
+ const tokensOf = (credential: IOpenAiOAuthCredential): IOpenAiTokenSet => {
83
+ if (credential === null || typeof credential !== 'object' || credential.kind !== 'chatgptOAuth'
84
+ || credential.providerId !== 'openai' || !credential.accessToken || !credential.refreshToken || !credential.idToken) {
85
+ throw new OpenAiProviderError('CREDENTIAL_INVALID');
86
+ }
87
+ try {
88
+ return { accessToken: credential.accessToken, refreshToken: credential.refreshToken, idToken: credential.idToken,
89
+ claims: parseOpenAiTokenClaims(credential.idToken) };
90
+ } catch { throw new OpenAiProviderError('CREDENTIAL_INVALID'); }
91
+ };
92
+
93
+ const credentialOf = (tokens: IOpenAiTokenSet, lastRefreshAt?: string): IOpenAiOAuthCredential => {
94
+ if (!tokens.idToken) throw new OpenAiProviderError('CREDENTIAL_INVALID');
95
+ return { kind: 'chatgptOAuth', providerId: 'openai', accessToken: tokens.accessToken,
96
+ refreshToken: tokens.refreshToken, idToken: tokens.idToken,
97
+ ...(lastRefreshAt === undefined ? {} : { lastRefreshAt }) };
98
+ };
99
+
100
+ const summaryOf = (credential: IOpenAiOAuthCredential): IOpenAiAccountSummary => {
101
+ const claims = tokensOf(credential).claims;
102
+ return {
103
+ providerId: 'openai', authKind: 'chatgptOAuth',
104
+ ...(claims.chatgptAccountId ? { accountId: claims.chatgptAccountId } : {}),
105
+ ...(claims.email ? { email: claims.email } : {}),
106
+ ...(claims.chatgptPlanType ? { plan: claims.chatgptPlanType } : {}),
107
+ };
108
+ };
109
+
110
+ const requireAccess = (access: IOpenAiAccess): IOpenAiAccess => {
111
+ if (access === null || typeof access !== 'object' || typeof access.accessToken !== 'string' || !access.accessToken
112
+ || Buffer.byteLength(access.accessToken, 'utf8') > MAXIMUM_HEADER_BYTES
113
+ || typeof access.accountId !== 'string' || !access.accountId
114
+ || Buffer.byteLength(access.accountId, 'utf8') > MAXIMUM_HEADER_BYTES
115
+ || typeof access.isFedrampAccount !== 'boolean') {
116
+ throw new OpenAiProviderError('CREDENTIAL_INVALID');
117
+ }
118
+ return { accessToken: access.accessToken, accountId: access.accountId, isFedrampAccount: access.isFedrampAccount };
119
+ };
120
+
121
+ class OpenAiLoginHandle implements IOpenAiLoginHandle {
122
+ public readonly completion: Promise<IOpenAiLoginResult>;
123
+ private closePromise?: Promise<void>;
124
+ private done = false;
125
+
126
+ constructor(public readonly prompt: IOpenAiDevicePrompt, completion: Promise<IOpenAiLoginResult>,
127
+ private readonly abortController: AbortController, private readonly cleanup: () => Promise<void>,
128
+ onDone: () => void) {
129
+ this.completion = completion.finally(async () => {
130
+ this.done = true;
131
+ try { await this.cleanup(); } finally { onDone(); }
132
+ });
133
+ void this.completion.catch(() => undefined);
134
+ }
135
+
136
+ public async cancel(): Promise<TOpenAiLoginCancelStatus> {
137
+ if (this.done || this.abortController.signal.aborted) return 'notFound';
138
+ this.abortController.abort();
139
+ return 'canceled';
140
+ }
141
+
142
+ public close(): Promise<void> {
143
+ this.closePromise ??= (async () => {
144
+ await this.cancel();
145
+ await this.completion.catch(() => undefined);
146
+ await this.cleanup();
147
+ })().catch(error => { this.closePromise = undefined; throw error; });
148
+ return this.closePromise;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * The authority's ChatGPT provider: device sign-in, one shared refresh per refresh token, revocation, the
154
+ * credential's claims and the account's usage limits. It holds no credential of its own; every call names the
155
+ * one it acts on.
156
+ */
157
+ export class AuthSwitchOpenAiProvider {
158
+ public readonly descriptor: IOpenAiProviderDescriptor = { providerId: 'openai', displayName: 'OpenAI', loginFlows: ['device'] };
159
+ private readonly fetcher: typeof fetch;
160
+ private readonly now: () => Date;
161
+ private readonly handles = new Set<OpenAiLoginHandle>();
162
+ private readonly operations = new Set<AbortController>();
163
+ private readonly refreshes = new Map<string, ISharedRefresh>();
164
+ private closed = false;
165
+ private disposePromise?: Promise<void>;
166
+
167
+ constructor(options: IOpenAiProviderOptions = {}) {
168
+ const fetcher = options.fetch ?? globalThis.fetch;
169
+ if (!fetcher) throw new OpenAiProviderError('INVALID_ARGUMENT');
170
+ this.fetcher = fetcher;
171
+ this.now = options.now ?? (() => new Date());
172
+ }
173
+
174
+ private requireOpen(): void {
175
+ if (this.closed) throw new OpenAiProviderError('CLOSED');
176
+ }
177
+
178
+ /** What a stored credential's claims say about its account. Throws `CREDENTIAL_INVALID` for a malformed one. */
179
+ public inspectCredential(credential: IOpenAiOAuthCredential): IOpenAiAccountSummary {
180
+ this.requireOpen();
181
+ return summaryOf(credential);
182
+ }
183
+
184
+ /** Starts a device sign-in; the handle's prompt is shown to the person, its completion carries the credential. */
185
+ public async beginLogin(options: IOpenAiLoginOptions): Promise<IOpenAiLoginHandle> {
186
+ this.requireOpen();
187
+ if (options?.flow !== 'device' || (options.timeoutMs ?? DEVICE_LOGIN_TIMEOUT_MS) > DEVICE_LOGIN_TIMEOUT_MS) {
188
+ throw new OpenAiProviderError('INVALID_ARGUMENT');
189
+ }
190
+ const operation = createOperation(options, DEVICE_LOGIN_TIMEOUT_MS);
191
+ const abortController = operation.controller;
192
+ this.operations.add(abortController);
193
+ const cleanup = async (): Promise<void> => {
194
+ this.operations.delete(abortController);
195
+ operation.cleanup();
196
+ };
197
+ const auth = { fetch: this.fetcher, signal: abortController.signal };
198
+ try {
199
+ const deviceCode = await requestOpenAiDeviceCode(auth);
200
+ const completion = pollOpenAiDeviceCode(deviceCode, { ...auth, timeoutMs: options.timeoutMs ?? DEVICE_LOGIN_TIMEOUT_MS })
201
+ .then(authorization => exchangeOpenAiDeviceAuthorization(authorization, auth))
202
+ .then(tokens => {
203
+ const credential = credentialOf(tokens);
204
+ return { credential, account: summaryOf(credential) };
205
+ });
206
+ this.requireOpen();
207
+ if (abortController.signal.aborted) throw new OpenAiProviderError('ABORTED');
208
+ const handle = new OpenAiLoginHandle(
209
+ { flow: 'device', verificationUrl: deviceCode.verificationUrl, userCode: deviceCode.userCode },
210
+ completion.catch((error: unknown) => {
211
+ if (operation.timedOut) throw new OpenAiProviderError('TIMEOUT');
212
+ if (options.signal?.aborted) throw new OpenAiProviderError('ABORTED');
213
+ if (abortController.signal.aborted) throw new OpenAiProviderError('LOGIN_CANCELED');
214
+ if (error instanceof OpenAiProviderError) throw error;
215
+ throw new OpenAiProviderError('LOGIN_FAILED');
216
+ }),
217
+ abortController, cleanup, () => this.handles.delete(handle));
218
+ this.handles.add(handle);
219
+ return handle;
220
+ } catch (error) {
221
+ abortController.abort();
222
+ await cleanup().catch(() => undefined);
223
+ if (operation.timedOut) throw new OpenAiProviderError('TIMEOUT');
224
+ if (options.signal?.aborted) throw new OpenAiProviderError('ABORTED');
225
+ if (error instanceof OpenAiProviderError) throw error;
226
+ throw new OpenAiProviderError('LOGIN_FAILED');
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Refreshes one credential. Concurrent callers of the same refresh token share one request, so a rotating grant
232
+ * is sent once; a caller that stops waiting leaves the shared request to the others, and the last one cancels it.
233
+ */
234
+ public async refreshCredential(credential: IOpenAiOAuthCredential,
235
+ options: IOpenAiOperationOptions = {}): Promise<TOpenAiRefreshResult> {
236
+ this.requireOpen();
237
+ tokensOf(credential);
238
+ const waitOperation = createOperation(options, OPERATION_TIMEOUT_MS);
239
+ if (waitOperation.signal.aborted) {
240
+ waitOperation.cleanup();
241
+ return { success: false, credential, errorCode: waitOperation.timedOut ? 'TIMEOUT' : 'ABORTED',
242
+ requestOutcome: 'notSent' };
243
+ }
244
+ let shared: ISharedRefresh | undefined;
245
+ let joined = false;
246
+ let onAbort: (() => void) | undefined;
247
+ try {
248
+ const key = plugins.crypto.createHash('sha256').update(credential.refreshToken, 'utf8').digest('base64url');
249
+ shared = this.refreshes.get(key);
250
+ if (!shared) {
251
+ const controller = new AbortController();
252
+ let created!: ISharedRefresh;
253
+ const promise = this.performRefresh(credential, controller.signal).finally(() => {
254
+ created.settled = true;
255
+ if (this.refreshes.get(key) === created) this.refreshes.delete(key);
256
+ });
257
+ created = { controller, promise, waiters: 0, settled: false };
258
+ this.refreshes.set(key, created);
259
+ shared = created;
260
+ }
261
+ shared.waiters++;
262
+ joined = true;
263
+ const aborted = new Promise<TOpenAiRefreshResult>(resolve => {
264
+ onAbort = () => resolve({ success: false, credential, errorCode: waitOperation.timedOut ? 'TIMEOUT' : 'ABORTED',
265
+ requestOutcome: 'outcomeUnknown' });
266
+ waitOperation.signal.addEventListener('abort', onAbort, { once: true });
267
+ });
268
+ return await Promise.race([shared.promise, aborted]);
269
+ } finally {
270
+ if (onAbort) waitOperation.signal.removeEventListener('abort', onAbort);
271
+ waitOperation.cleanup();
272
+ if (shared && joined) shared.waiters--;
273
+ if (shared && joined && shared.waiters === 0 && !shared.settled) shared.controller.abort();
274
+ }
275
+ }
276
+
277
+ private async performRefresh(credential: IOpenAiOAuthCredential, signal: AbortSignal): Promise<TOpenAiRefreshResult> {
278
+ const input = tokensOf(credential);
279
+ const operation = createOperation({ signal }, OPERATION_TIMEOUT_MS);
280
+ this.operations.add(operation.controller);
281
+ try {
282
+ const refreshed = await refreshOpenAiTokens(input, { fetch: this.fetcher, signal: operation.signal });
283
+ // A refresh that returned the same tokens, or no ID token, proves nothing about the grant it used.
284
+ if ((refreshed.accessToken === input.accessToken && refreshed.refreshToken === input.refreshToken)
285
+ || !refreshed.idToken) {
286
+ return { success: false, credential, errorCode: 'REFRESH_FAILED', requestOutcome: 'outcomeUnknown' };
287
+ }
288
+ const previous = credential.lastRefreshAt === undefined ? 0 : Date.parse(credential.lastRefreshAt);
289
+ const now = this.now().getTime();
290
+ const lastRefreshAt = new Date(Math.max(now, Number.isFinite(previous) ? previous + 1 : now)).toISOString();
291
+ const next = credentialOf(refreshed, lastRefreshAt);
292
+ return { success: true, credential: next, account: summaryOf(next) };
293
+ } catch (error) {
294
+ return {
295
+ success: false, credential,
296
+ errorCode: failureCode(operation) ?? 'REFRESH_FAILED',
297
+ requestOutcome: error instanceof OpenAiAuthError ? error.requestOutcome ?? 'outcomeUnknown' : 'outcomeUnknown',
298
+ ...(error instanceof OpenAiAuthError && error.status !== undefined ? { responseStatus: error.status } : {}),
299
+ ...(error instanceof OpenAiAuthError && error.retryAfterMs !== undefined ? { retryAfterMs: error.retryAfterMs } : {}),
300
+ };
301
+ } finally {
302
+ this.operations.delete(operation.controller);
303
+ operation.cleanup();
304
+ }
305
+ }
306
+
307
+ /** Reads the account's usage limits with access alone; a rejected token asks the caller to renew it. */
308
+ public async getAccountRateLimits(access: IOpenAiAccess,
309
+ options: IOpenAiOperationOptions = {}): Promise<TOpenAiAccountRateLimitsResult> {
310
+ this.requireOpen();
311
+ const read = requireAccess(access);
312
+ const operation = createOperation(options, OPERATION_TIMEOUT_MS);
313
+ this.operations.add(operation.controller);
314
+ try {
315
+ const usage = await readOpenAiAccountUsage(read, { fetch: this.fetcher, signal: operation.signal,
316
+ timeoutMs: MAXIMUM_OPERATION_TIMEOUT_MS, now: this.now });
317
+ return { success: true, rateLimits: usage.rateLimits };
318
+ } catch (error) {
319
+ if (error instanceof OpenAiBackendError && error.reason === 'unauthorized') {
320
+ return { success: false, errorCode: 'CREDENTIAL_REFRESH_REQUIRED' };
321
+ }
322
+ return { success: false, errorCode: failureCode(operation) ?? 'ACCOUNT_RATE_LIMITS_FAILED' };
323
+ } finally {
324
+ this.operations.delete(operation.controller);
325
+ operation.cleanup();
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Revokes a credential's refresh token at the service, so that sign-in refreshes nothing anywhere afterwards.
331
+ * Throws `REVOKE_FAILED`, `TIMEOUT` or `ABORTED` when the service did not confirm it.
332
+ */
333
+ public async revokeCredential(credential: IOpenAiOAuthCredential, options: IOpenAiOperationOptions = {}): Promise<void> {
334
+ this.requireOpen();
335
+ const tokens = tokensOf(credential);
336
+ const operation = createOperation(options, OPERATION_TIMEOUT_MS);
337
+ this.operations.add(operation.controller);
338
+ try {
339
+ await revokeOpenAiRefreshToken(tokens.refreshToken, { fetch: this.fetcher, signal: operation.signal });
340
+ } catch {
341
+ throw new OpenAiProviderError(failureCode(operation) ?? 'REVOKE_FAILED');
342
+ } finally {
343
+ this.operations.delete(operation.controller);
344
+ operation.cleanup();
345
+ }
346
+ }
347
+
348
+ /** Cancels every sign-in and request in flight and waits for them; the provider cannot be used afterwards. */
349
+ public dispose(): Promise<void> {
350
+ this.disposePromise ??= (async () => {
351
+ this.closed = true;
352
+ for (const operation of this.operations) operation.abort();
353
+ const results = await Promise.allSettled([...this.handles].map(handle => handle.close()));
354
+ const deadline = Date.now() + 5_000;
355
+ while (this.operations.size > 0 && Date.now() < deadline) {
356
+ await new Promise<void>(resolve => setTimeout(resolve, 10));
357
+ }
358
+ if (results.some(result => result.status === 'rejected') || this.operations.size > 0) {
359
+ throw new OpenAiProviderError('CLOSED');
360
+ }
361
+ this.handles.clear();
362
+ })().catch(error => { this.disposePromise = undefined; throw error; });
363
+ return this.disposePromise;
364
+ }
365
+ }
@@ -1,4 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
+ import { parseOpenAiTokenClaims, type IOpenAiTokenClaims } from './openaiauth.js';
2
3
  import { FileHarness, type IFileAccount, type IFileHarnessSnapshot } from './classes.fileharness.js';
3
4
  import { CredentialStore, credentialHash, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw } from './classes.credentialstore.js';
4
5
  import { HarnessProcesses } from './classes.harnessprocesses.js';
@@ -80,8 +81,8 @@ export class OpenCodeHarness extends FileHarness {
80
81
  }
81
82
  private openAiInfo(entry: Record<string, unknown>): { accountId?: string; email?: string; plan?: string } {
82
83
  if (entry.type !== 'oauth') return {};
83
- let claims: plugins.flexAuth.IOpenAiChatGptTokenInfo | undefined;
84
- try { claims = plugins.flexAuth.parseOpenAiChatGptTokenInfo(credentialText(entry.access)!); } catch { /* Opaque tokens can still be preserved. */ }
84
+ let claims: IOpenAiTokenClaims | undefined;
85
+ try { claims = parseOpenAiTokenClaims(credentialText(entry.access)!); } catch { /* Opaque tokens can still be preserved. */ }
85
86
  const accountId = credentialText(entry.accountId) ?? claims?.chatgptAccountId;
86
87
  if (entry.accountId && claims?.chatgptAccountId && entry.accountId !== claims.chatgptAccountId) throw new Error('OpenCode OpenAI account ID does not match the credential.');
87
88
  return { accountId, email: claims?.email?.toLowerCase(), plan: claims?.chatgptPlanType };
@@ -41,9 +41,8 @@ const publicOutcome = (outcome: IHarnessOutcome): IHarnessOutcome => ({ lines: [
41
41
  const publicResult = (result: TAuthSwitchCoordinationResult): TAuthSwitchCoordinationResult => result.status === 'complete'
42
42
  ? { status: 'complete', outcome: publicOutcome(result.outcome) }
43
43
  : result.status === 'busy' ? { status: 'busy', message: result.message } : { status: 'unavailable' };
44
- const publicPrompt = (prompt: TAuthLoginPrompt): TAuthLoginPrompt => prompt.flow === 'device'
45
- ? { flow: 'device', verificationUrl: prompt.verificationUrl, userCode: prompt.userCode }
46
- : { flow: 'browser', authUrl: prompt.authUrl };
44
+ const publicPrompt = (prompt: TAuthLoginPrompt): TAuthLoginPrompt =>
45
+ ({ flow: 'device', verificationUrl: prompt.verificationUrl, userCode: prompt.userCode });
47
46
 
48
47
  export const assertAuthSwitchServiceRequest: (value: unknown) => asserts value is TAuthSwitchServiceRequest = value => {
49
48
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid authswitch request.');
@@ -63,7 +62,7 @@ export const assertAuthSwitchServiceRequest: (value: unknown) => asserts value i
63
62
  } else if ((request.harnessId !== undefined || request.action === 'login')
64
63
  && (typeof request.harnessId !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(request.harnessId))) throw new Error('Invalid harness ID.');
65
64
  if (request.action === 'login' && (typeof request.providerId !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(request.providerId)
66
- || !['device', 'browser'].includes(String(request.flow)))) throw new Error('Invalid login provider or flow.');
65
+ || request.flow !== 'device')) throw new Error('Invalid login provider or flow.');
67
66
  };
68
67
 
69
68
  /** Hosted account manager. Operations outlive HTTP connections and are retained briefly for polling. */
package/ts/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export * from './interfaces.js';
2
2
  export * from './interfaces.harness.js';
3
3
  export * from './interfaces.list.js';
4
+ export * from './interfaces.openai.js';
5
+ export * from './classes.openaiprovider.js';
4
6
  export * from './classes.codexharness.js';
5
7
  export * from './classes.opencodeharness.js';
6
8
  export * from './classes.claudecodeharness.js';
@@ -0,0 +1,142 @@
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
+ /**
4
+ * The ChatGPT account types authswitch owns: the sign-in it runs, the credential it holds and refreshes, and the
5
+ * credential-free readings it takes. Inference with an access token is FlexHarness's `/openai`, not this.
6
+ */
7
+
8
+ /** Whether a failed request reached the provider: `notSent` never did, `outcomeUnknown` may have. */
9
+ export type TOpenAiRequestOutcome = 'notSent' | 'outcomeUnknown';
10
+
11
+ /**
12
+ * A ChatGPT sign-in with its rotating refresh token. This exact shape is what the authority seals, so a stored
13
+ * credential keeps reading back across releases.
14
+ */
15
+ export interface IOpenAiOAuthCredential {
16
+ kind: 'chatgptOAuth';
17
+ providerId: 'openai';
18
+ accessToken: string;
19
+ refreshToken: string;
20
+ idToken: string;
21
+ /** When this credential was last refreshed; strictly increasing across refreshes. */
22
+ lastRefreshAt?: string;
23
+ }
24
+
25
+ /** Access to one ChatGPT workspace, with the workspace and residency its issuer states. */
26
+ export interface IOpenAiAccess {
27
+ accessToken: string;
28
+ accountId: string;
29
+ isFedrampAccount: boolean;
30
+ }
31
+
32
+ /** What a sign-in's claims say about its account; no token. */
33
+ export interface IOpenAiAccountSummary {
34
+ providerId: 'openai';
35
+ authKind: 'chatgptOAuth';
36
+ accountId?: string;
37
+ email?: string;
38
+ plan?: string;
39
+ }
40
+
41
+ export interface IOpenAiProviderDescriptor {
42
+ providerId: 'openai';
43
+ displayName: string;
44
+ loginFlows: readonly ['device'];
45
+ }
46
+
47
+ /** What the person signing in does: open the URL and enter the code. */
48
+ export interface IOpenAiDevicePrompt {
49
+ flow: 'device';
50
+ verificationUrl: string;
51
+ userCode: string;
52
+ }
53
+
54
+ export interface IOpenAiLoginResult {
55
+ credential: IOpenAiOAuthCredential;
56
+ account: IOpenAiAccountSummary;
57
+ }
58
+
59
+ export type TOpenAiLoginCancelStatus = 'canceled' | 'notFound';
60
+
61
+ /** One device sign-in in flight. `completion` settles once the person approved it, or it failed or was cancelled. */
62
+ export interface IOpenAiLoginHandle {
63
+ readonly prompt: IOpenAiDevicePrompt;
64
+ readonly completion: Promise<IOpenAiLoginResult>;
65
+ cancel(): Promise<TOpenAiLoginCancelStatus>;
66
+ close(): Promise<void>;
67
+ }
68
+
69
+ export interface IOpenAiOperationOptions {
70
+ signal?: AbortSignal;
71
+ timeoutMs?: number;
72
+ }
73
+
74
+ export interface IOpenAiLoginOptions extends IOpenAiOperationOptions {
75
+ flow: 'device';
76
+ }
77
+
78
+ export type TOpenAiOperationErrorCode = 'ABORTED' | 'TIMEOUT';
79
+
80
+ export interface IOpenAiRefreshSuccess {
81
+ success: true;
82
+ credential: IOpenAiOAuthCredential;
83
+ account: IOpenAiAccountSummary;
84
+ }
85
+
86
+ export interface IOpenAiRefreshFailure {
87
+ success: false;
88
+ credential: IOpenAiOAuthCredential;
89
+ errorCode: TOpenAiOperationErrorCode | 'REFRESH_FAILED';
90
+ /** Never replay a rotating grant when this is `outcomeUnknown`. */
91
+ requestOutcome: TOpenAiRequestOutcome;
92
+ /** The response status, when an HTTP response was observed. */
93
+ responseStatus?: number;
94
+ /** Bounded Retry-After delay from the observed response. */
95
+ retryAfterMs?: number;
96
+ }
97
+
98
+ export type TOpenAiRefreshResult = IOpenAiRefreshSuccess | IOpenAiRefreshFailure;
99
+
100
+ export interface IOpenAiRateLimitWindow {
101
+ usedPercent: number;
102
+ limitWindowSeconds: number;
103
+ resetAfterSeconds: number;
104
+ /** Unix timestamp in seconds. */
105
+ resetsAt: number;
106
+ }
107
+
108
+ export interface IOpenAiRateLimitDetails {
109
+ allowed: boolean;
110
+ limitReached: boolean;
111
+ primaryWindow?: IOpenAiRateLimitWindow;
112
+ secondaryWindow?: IOpenAiRateLimitWindow;
113
+ }
114
+
115
+ export interface IOpenAiAdditionalRateLimit {
116
+ limitName: string;
117
+ meteredFeature: string;
118
+ rateLimit?: IOpenAiRateLimitDetails;
119
+ }
120
+
121
+ /** An account's usage limits as `wham/usage` reports them, credential-free; the authority stores this shape. */
122
+ export interface IOpenAiAccountRateLimits {
123
+ providerId: 'openai';
124
+ plan: string;
125
+ observedAt: string;
126
+ rateLimit?: IOpenAiRateLimitDetails;
127
+ additionalRateLimits: IOpenAiAdditionalRateLimit[];
128
+ rateLimitReachedType?: string;
129
+ rateLimitResetCreditsAvailableCount?: number;
130
+ }
131
+
132
+ export interface IOpenAiAccountRateLimitsSuccess {
133
+ success: true;
134
+ rateLimits: IOpenAiAccountRateLimits;
135
+ }
136
+
137
+ export interface IOpenAiAccountRateLimitsFailure {
138
+ success: false;
139
+ errorCode: TOpenAiOperationErrorCode | 'CREDENTIAL_REFRESH_REQUIRED' | 'ACCOUNT_RATE_LIMITS_FAILED';
140
+ }
141
+
142
+ export type TOpenAiAccountRateLimitsResult = IOpenAiAccountRateLimitsSuccess | IOpenAiAccountRateLimitsFailure;