@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.
Files changed (81) 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.claudeauthority.d.ts +5 -0
  22. package/dist_ts/classes.claudeauthority.js +47 -4
  23. package/dist_ts/classes.claudecodelocks.d.ts +6 -0
  24. package/dist_ts/classes.claudecodelocks.js +30 -2
  25. package/dist_ts/classes.claudenative.d.ts +37 -2
  26. package/dist_ts/classes.claudenative.js +142 -13
  27. package/dist_ts/classes.cli.js +3 -5
  28. package/dist_ts/classes.codexpreuse.d.ts +14 -3
  29. package/dist_ts/classes.codexpreuse.js +127 -44
  30. package/dist_ts/classes.codexstatus.js +44 -95
  31. package/dist_ts/classes.login.d.ts +20 -10
  32. package/dist_ts/classes.login.js +14 -13
  33. package/dist_ts/classes.openaiprovider.d.ts +53 -0
  34. package/dist_ts/classes.openaiprovider.js +340 -0
  35. package/dist_ts/classes.opencodeharness.js +3 -2
  36. package/dist_ts/classes.service.js +3 -5
  37. package/dist_ts/index.d.ts +2 -0
  38. package/dist_ts/index.js +3 -1
  39. package/dist_ts/interfaces.openai.d.ts +118 -0
  40. package/dist_ts/interfaces.openai.js +3 -0
  41. package/dist_ts/openaiauth.d.ts +76 -0
  42. package/dist_ts/openaiauth.js +371 -0
  43. package/dist_ts/openaiusage.d.ts +59 -0
  44. package/dist_ts/openaiusage.js +222 -0
  45. package/dist_ts/plugins.d.ts +1 -3
  46. package/dist_ts/plugins.js +2 -4
  47. package/dist_ts/preuse.d.ts +50 -0
  48. package/dist_ts/preuse.js +63 -4
  49. package/dist_ts/ts_migration/legacysources/shared.js +4 -3
  50. package/openai-codex-license.txt +201 -0
  51. package/package.json +6 -4
  52. package/readme.md +102 -15
  53. package/third-party-notices.md +29 -0
  54. package/ts/00_commitinfo_data.ts +1 -1
  55. package/ts/authority-contract.ts +56 -8
  56. package/ts/classes.authoritybroker.ts +52 -13
  57. package/ts/classes.authoritycli.ts +48 -6
  58. package/ts/classes.authorityclient.ts +11 -3
  59. package/ts/classes.authoritydaemon.ts +7 -4
  60. package/ts/classes.authoritydatabase.ts +14 -6
  61. package/ts/classes.authorityimport.ts +12 -12
  62. package/ts/classes.authoritymodels.ts +42 -14
  63. package/ts/classes.authoritypreuse.ts +30 -17
  64. package/ts/classes.authorityusage.ts +8 -9
  65. package/ts/classes.claudeauthority.ts +37 -3
  66. package/ts/classes.claudecodelocks.ts +31 -1
  67. package/ts/classes.claudenative.ts +161 -14
  68. package/ts/classes.cli.ts +3 -3
  69. package/ts/classes.codexpreuse.ts +130 -40
  70. package/ts/classes.codexstatus.ts +40 -73
  71. package/ts/classes.login.ts +34 -21
  72. package/ts/classes.openaiprovider.ts +365 -0
  73. package/ts/classes.opencodeharness.ts +3 -2
  74. package/ts/classes.service.ts +3 -4
  75. package/ts/index.ts +2 -0
  76. package/ts/interfaces.openai.ts +142 -0
  77. package/ts/openaiauth.ts +422 -0
  78. package/ts/openaiusage.ts +247 -0
  79. package/ts/plugins.ts +1 -3
  80. package/ts/preuse.ts +88 -3
  81. package/ts/ts_migration/legacysources/shared.ts +4 -3
@@ -1,7 +1,44 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type { ICodexIdentity } from './interfaces.js';
3
3
  import type { IHarnessPreuseOptions, IHarnessPreuseResult } from './interfaces.harness.js';
4
- import { PreuseError, validatePreuseOptions } from './preuse.js';
4
+ import { PreuseError, PreuseFailureError, PreuseLimitError, preuseMayHaveConsumed, validatePreuseOptions,
5
+ type IPreuseLimit } from './preuse.js';
6
+ import { parseOpenAiTokenClaims } from './openaiauth.js';
7
+ import { OpenAiBackendError, readOpenAiAccountUsage, type IOpenAiAccountUsage } from './openaiusage.js';
8
+ import { usageWindowPeriod } from './accounts.js';
9
+ import type { IOpenAiAccess } from './interfaces.openai.js';
10
+
11
+ const PREUSE_TIMEOUT_MS = 180_000;
12
+
13
+ /**
14
+ * The limit the account's own usage reading says is reached, or null. Every window at its limit must reset
15
+ * before a prompt can pass, so the one that resets last is named.
16
+ */
17
+ const reachedLimit = (usage: IOpenAiAccountUsage): IPreuseLimit | null => {
18
+ const limit = usage.rateLimits.rateLimit;
19
+ if (!limit || (limit.allowed && !limit.limitReached)) return null;
20
+ const exhausted = [limit.primaryWindow, limit.secondaryWindow]
21
+ .filter(window => window !== undefined && window.usedPercent >= 100)
22
+ .sort((left, right) => right!.resetsAt - left!.resetsAt)[0];
23
+ return { kind: 'usage_limit', window: exhausted ? usageWindowPeriod(exhausted.limitWindowSeconds) : null,
24
+ resetsAt: exhausted ? new Date(exhausted.resetsAt * 1000).toISOString() : null };
25
+ };
26
+
27
+ /** A FlexHarness limit window label (`7d`, `5h`, `90m`) as a window name; a slot name says nothing about length. */
28
+ const windowName = (label: string | undefined): string | null => {
29
+ const match = /^([1-9][0-9]{0,5})([mhd])$/.exec(label ?? '');
30
+ return match ? usageWindowPeriod(Number(match[1]) * { m: 60, h: 3600, d: 86400 }[match[2] as 'm' | 'h' | 'd']) : null;
31
+ };
32
+
33
+ /** The first error in a cause chain that matches, so a wrapped failure is still told by its own class. */
34
+ const inChain = <T>(error: unknown, match: (value: unknown) => value is T): T | undefined => {
35
+ let current = error;
36
+ for (let depth = 0; depth < 8 && current !== undefined && current !== null; depth++) {
37
+ if (match(current)) return current;
38
+ current = current instanceof Error ? current.cause : undefined;
39
+ }
40
+ return undefined;
41
+ };
5
42
 
6
43
  export interface ICodexPreuseLifecycle {
7
44
  /** Persist the may-have-started marker before the inference API is invoked. */
@@ -16,67 +53,65 @@ export class CodexPreuse {
16
53
  validatePreuseOptions(optionsArg);
17
54
  if (identityArg.kind !== 'chatgpt') throw new PreuseError('Codex preuse requires a ChatGPT subscription login; API-key logins have no subscription reset window.');
18
55
  if (!identityArg.accountId) throw new PreuseError('This login has no account ID for preuse.');
19
- let credentials: plugins.flexAuth.IOpenAiChatGptAuthCredentials;
20
- let oauth: plugins.flexAccounts.IOpenAiChatGptOAuthCredential;
56
+ let access: IOpenAiAccess;
21
57
  try {
22
58
  const tokens = JSON.parse(rawArg).tokens;
23
- oauth = { kind: 'chatgptOAuth', providerId: 'openai', accessToken: tokens.access_token, refreshToken: tokens.refresh_token, idToken: tokens.id_token };
24
- credentials = plugins.flexAccounts.toOpenAiChatGptAuthCredentials(oauth);
25
- if (credentials.accountId !== identityArg.accountId) throw new Error('Identity mismatch');
59
+ const claims = parseOpenAiTokenClaims(tokens.id_token);
60
+ if (typeof tokens.access_token !== 'string' || !tokens.access_token
61
+ || claims.chatgptAccountId !== identityArg.accountId) throw new Error('Identity mismatch');
62
+ access = { accessToken: tokens.access_token, accountId: claims.chatgptAccountId,
63
+ isFedrampAccount: claims.chatgptAccountIsFedramp };
26
64
  } catch {
27
65
  throw new PreuseError('No complete, matching Codex login is available for preuse. Log in again with Codex and save the account.');
28
66
  }
29
- return this.runCredential(oauth, credentials, optionsArg);
67
+ return this.runWithAccess(access, optionsArg);
30
68
  }
31
69
 
32
70
  /** Authority-owned access grants contain no refresh token and are never serialized by this helper. */
33
- public async runAccess(credentialArg: plugins.flexAccounts.IOpenAiChatGptAccessCredential,
34
- optionsArg: IHarnessPreuseOptions, lifecycleArg: ICodexPreuseLifecycle): Promise<IHarnessPreuseResult> {
71
+ public async runAccess(accessArg: IOpenAiAccess, optionsArg: IHarnessPreuseOptions,
72
+ lifecycleArg: ICodexPreuseLifecycle): Promise<IHarnessPreuseResult> {
35
73
  validatePreuseOptions(optionsArg);
36
- if (!credentialArg || credentialArg.kind !== 'chatgptAccess' || credentialArg.providerId !== 'openai'
37
- || typeof credentialArg.accessToken !== 'string' || !credentialArg.accessToken
38
- || typeof credentialArg.accountId !== 'string' || !credentialArg.accountId
39
- || typeof credentialArg.isFedrampAccount !== 'boolean') {
74
+ if (!accessArg || typeof accessArg.accessToken !== 'string' || !accessArg.accessToken
75
+ || typeof accessArg.accountId !== 'string' || !accessArg.accountId
76
+ || typeof accessArg.isFedrampAccount !== 'boolean') {
40
77
  throw new PreuseError('No complete, matching Codex login is available for preuse. Log in again with Codex and save the account.');
41
78
  }
42
- return this.runCredential(credentialArg, {
43
- accessToken: credentialArg.accessToken, accountId: credentialArg.accountId,
44
- isFedrampAccount: credentialArg.isFedrampAccount,
45
- }, optionsArg, lifecycleArg);
79
+ return this.runWithAccess({ accessToken: accessArg.accessToken, accountId: accessArg.accountId,
80
+ isFedrampAccount: accessArg.isFedrampAccount }, optionsArg, lifecycleArg);
46
81
  }
47
82
 
48
- private async runCredential(readCredentialArg: plugins.flexAccounts.TOpenAiChatGptReadCredential,
49
- credentialsArg: plugins.flexAuth.IOpenAiChatGptAuthCredentials, optionsArg: IHarnessPreuseOptions,
83
+ private async runWithAccess(accessArg: IOpenAiAccess, optionsArg: IHarnessPreuseOptions,
50
84
  lifecycleArg?: ICodexPreuseLifecycle): Promise<IHarnessPreuseResult> {
51
- const signal = optionsArg.signal
52
- ? AbortSignal.any([optionsArg.signal, AbortSignal.timeout(180_000)])
53
- : AbortSignal.timeout(180_000);
85
+ const timeout = AbortSignal.timeout(PREUSE_TIMEOUT_MS);
86
+ const signal = optionsArg.signal ? AbortSignal.any([optionsArg.signal, timeout]) : timeout;
87
+ await this.refuseReachedLimit(accessArg, signal);
54
88
  let model = optionsArg.model;
55
89
  let reasoningEffort: string | undefined;
56
90
  if (!model) {
57
- const adapter = new plugins.flexAccounts.OpenAiProviderAdapter({ fetch: this.fetcher });
58
91
  try {
59
- const catalog = await adapter.listModels(readCredentialArg, { signal, timeoutMs: 10_000 });
60
- if (!catalog.success) throw new PreuseError(catalog.errorCode === 'CREDENTIAL_REFRESH_REQUIRED'
61
- ? 'The saved login has expired. Log in again with Codex and save it before preuse.'
62
- : 'Could not read the account model catalog. No preuse prompt was sent.');
63
- const selected = catalog.models.find(item => item.isDefault && item.inputModalities.includes('text'));
92
+ const catalog = await plugins.flexOpenAi.listOpenAiChatGptModels(accessArg,
93
+ { signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]), fetch: this.fetcher });
94
+ const selected = catalog.find(item => item.isDefault && item.inputModalities.includes('text'));
64
95
  if (!selected) throw new PreuseError('The account catalog has no default text model. Specify --model explicitly.');
65
96
  model = selected.wireModel;
66
97
  reasoningEffort = ['none', 'minimal', 'low'].find(effort => selected.reasoningEfforts.some(item => item.effort === effort)) ?? selected.defaultReasoningEffort;
67
98
  } catch (error) {
68
99
  if (error instanceof PreuseError) throw error;
69
- throw new PreuseError('Could not read the account model catalog. No preuse prompt was sent.');
70
- } finally { await adapter.dispose(); }
100
+ throw new PreuseError(error instanceof plugins.flexOpenAi.OpenAiChatGptAuthError && error.status === 401
101
+ ? 'The saved login has expired. Log in again with Codex and save it before preuse.'
102
+ : 'Could not read the account model catalog. No preuse prompt was sent.');
103
+ }
71
104
  }
72
- let requestStarted = false;
105
+ let phase: 'connecting' | 'marking' | 'sent' = 'connecting';
106
+ // The provider has begun to answer once the stream's first step starts; a refusal before that consumed nothing.
107
+ let generationStarted = false;
73
108
  try {
74
109
  signal.throwIfAborted();
75
- const connection = plugins.flexAuth.createOpenAiChatGptModelConnection(credentialsArg);
76
- connection.settings.fetch = this.fetcher;
110
+ const connection = plugins.flexOpenAi.createOpenAiChatGptAccessModelConnection(accessArg, { fetch: this.fetcher });
77
111
  const languageModel = plugins.flexOpenAi.createOpenAiModelProvider().getModel({ provider: 'openai', model, connection });
112
+ phase = 'marking';
78
113
  await lifecycleArg?.beforeRequest(model);
79
- requestStarted = true;
114
+ phase = 'sent';
80
115
  const stream = plugins.flexModels.streamText({
81
116
  model: languageModel,
82
117
  instructions: 'Answer the user prompt with plain text. Do not use tools.',
@@ -89,16 +124,71 @@ export class CodexPreuse {
89
124
  });
90
125
  // Drain the stream; generated prose is neither printed nor persisted.
91
126
  for await (const part of stream.fullStream) {
92
- if (part.type === 'error' || part.type === 'abort') throw new Error('Inference did not complete');
127
+ if (part.type === 'start-step') generationStarted = true;
128
+ if (part.type === 'error') throw part.error;
129
+ if (part.type === 'abort') throw signal.reason;
93
130
  }
94
131
  const [finishReason, usage] = await Promise.all([stream.finishReason, stream.totalUsage]);
95
- if (finishReason !== 'stop' && finishReason !== 'length') throw new Error('Inference did not complete');
132
+ if (finishReason !== 'stop' && finishReason !== 'length') {
133
+ throw new PreuseError(`Preuse did not complete: the answer ended with finish reason ${/^[a-z-]{1,32}$/.test(finishReason) ? finishReason : 'unknown'}. ${preuseMayHaveConsumed}`, true);
134
+ }
96
135
  const count = (value: number | undefined) => Number.isSafeInteger(value) && value! >= 0 ? value : undefined;
97
136
  return { model, inputTokens: count(usage.inputTokens), outputTokens: count(usage.outputTokens), totalTokens: count(usage.totalTokens) };
98
- } catch {
99
- throw new PreuseError(requestStarted
100
- ? 'Preuse did not complete. Tokens may already have been consumed; no automatic retry was made. Check the account status before trying again.'
101
- : 'Preuse was cancelled before the prompt was sent.', requestStarted);
137
+ } catch (error) {
138
+ if (error instanceof PreuseError) throw error;
139
+ throw this.failure(error, phase, generationStarted, optionsArg.signal, timeout);
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Reads the account's usage before anything is sent, and refuses when a limit is reached: a prompt then only
145
+ * meets the provider's refusal. A reading that fails decides nothing -- the prompt is sent, and a limit the
146
+ * provider states instead is reported by name -- except a rejected token, which no prompt would pass either.
147
+ */
148
+ private async refuseReachedLimit(accessArg: IOpenAiAccess, signalArg: AbortSignal): Promise<void> {
149
+ let usage: IOpenAiAccountUsage;
150
+ try {
151
+ usage = await readOpenAiAccountUsage(accessArg, { fetch: this.fetcher, signal: signalArg });
152
+ } catch (error) {
153
+ if (signalArg.aborted) throw new PreuseError('Preuse was cancelled before the prompt was sent.');
154
+ if (error instanceof OpenAiBackendError && error.reason === 'unauthorized') {
155
+ throw new PreuseError('The saved login has expired. Log in again with Codex and save it before preuse.');
156
+ }
157
+ return;
158
+ }
159
+ const limit = reachedLimit(usage);
160
+ if (limit) throw new PreuseLimitError(limit, 'usage', false, Date.now());
161
+ }
162
+
163
+ /**
164
+ * Names why a started preuse failed, without the provider's text: a limit, a rejected token, a deadline, a cancellation,
165
+ * or the cause FlexHarness describes (an HTTP status, a stream error and its code, a failed connection).
166
+ */
167
+ private failure(errorArg: unknown, phaseArg: 'connecting' | 'marking' | 'sent', generationStartedArg: boolean,
168
+ callerSignalArg: AbortSignal | undefined, timeoutArg: AbortSignal): PreuseError {
169
+ if (phaseArg !== 'sent') {
170
+ return new PreuseError(callerSignalArg?.aborted ? 'Preuse was cancelled before the prompt was sent.'
171
+ : timeoutArg.aborted ? `Preuse stopped before the prompt was sent: ${PREUSE_TIMEOUT_MS / 1000} seconds passed.`
172
+ : phaseArg === 'marking' ? 'Preuse stopped before the prompt was sent: its request could not be recorded as started.'
173
+ : 'Preuse stopped before the prompt was sent: the model connection could not be prepared.');
174
+ }
175
+ if (callerSignalArg?.aborted) return new PreuseError(`Preuse was cancelled after the prompt was sent. ${preuseMayHaveConsumed}`, true);
176
+ if (timeoutArg.aborted) {
177
+ return new PreuseError(`Preuse did not complete: no answer within ${PREUSE_TIMEOUT_MS / 1000} seconds. ${preuseMayHaveConsumed}`, true);
178
+ }
179
+ const described = plugins.flexModels.describeModelFailure(errorArg);
180
+ if (described.kind === 'limit' && described.limit) {
181
+ return new PreuseLimitError({ kind: described.limit.kind, window: windowName(described.limit.window),
182
+ resetsAt: described.limit.resetsAt === undefined ? null : new Date(described.limit.resetsAt).toISOString() },
183
+ 'provider', generationStartedArg, Date.now());
184
+ }
185
+ const auth = inChain(errorArg, (value): value is InstanceType<typeof plugins.flexOpenAi.OpenAiChatGptAuthError> =>
186
+ value instanceof plugins.flexOpenAi.OpenAiChatGptAuthError);
187
+ if (auth?.status === 401 && !generationStartedArg) {
188
+ return new PreuseError('The provider rejected the access token before answering; no tokens were consumed. '
189
+ + 'Log in again with Codex and save the account before preuse.', true);
102
190
  }
191
+ return new PreuseFailureError({ kind: described.kind === 'limit' ? 'unknown' : described.kind,
192
+ status: described.status ?? null, providerCode: described.providerCode ?? null });
103
193
  }
104
194
  }
@@ -1,6 +1,7 @@
1
1
  import type { ICodexIdentity } from './interfaces.js';
2
2
  import type { IHarnessAccountStatus, IHarnessStatusOptions, IHarnessStatusSummary } from './interfaces.harness.js';
3
- import { commitinfo } from './00_commitinfo_data.js';
3
+ import { OpenAiBackendError, projectOpenAiAccountUsage, readChatGptBackend } from './openaiusage.js';
4
+ import type { IOpenAiRateLimitDetails } from './interfaces.openai.js';
4
5
  import { usageWindowPeriod } from './accounts.js';
5
6
  import { latestRetryAt, retryAtFrom } from './ratelimit.js';
6
7
 
@@ -23,7 +24,6 @@ const boolean = (valueArg: unknown): boolean => {
23
24
  if (typeof valueArg !== 'boolean') throw new Error('Unexpected boolean status field.');
24
25
  return valueArg;
25
26
  };
26
- const timestamp = (secondsArg: unknown): string => new Date(numeric(secondsArg) * 1000).toISOString();
27
27
  const count = (valueArg: unknown): number => {
28
28
  const value = numeric(valueArg);
29
29
  if (!Number.isSafeInteger(value)) throw new Error('Invalid status count.');
@@ -106,43 +106,26 @@ export class CodexAccountStatus {
106
106
  }
107
107
 
108
108
  private async get(pathArg: string, tokenArg: string, accountIdArg: string, cancelArg?: AbortSignal): Promise<Record<string, unknown>> {
109
- const timeout = AbortSignal.timeout(10_000);
110
- const signal = cancelArg ? AbortSignal.any([cancelArg, timeout]) : timeout;
109
+ const billing = pathArg === 'accounts/check/v4-2023-04-27';
111
110
  try {
112
- const response = await this.fetcher(`https://chatgpt.com/backend-api/${pathArg}`, {
113
- method: 'GET', redirect: 'error', signal,
114
- headers: { Authorization: `Bearer ${tokenArg}`, 'ChatGPT-Account-ID': accountIdArg,
115
- 'User-Agent': `authswitch/${commitinfo.version}`, Accept: 'application/json',
116
- ...(pathArg === 'accounts/check/v4-2023-04-27' ? { originator: 'Codex Desktop', 'OAI-Language': 'en', 'User-Agent': billingUserAgent } : {}) },
111
+ return await readChatGptBackend(pathArg, { accessToken: tokenArg, accountId: accountIdArg, isFedrampAccount: false }, {
112
+ fetch: this.fetcher, signal: cancelArg,
113
+ ...(billing ? { originator: 'Codex Desktop', headers: { 'OAI-Language': 'en', 'User-Agent': billingUserAgent } } : {}),
117
114
  });
118
- if (!response.ok) {
119
- await response.body?.cancel();
120
- if (response.headers.get('cf-mitigated') === 'challenge') throw new StatusRequestError('Blocked by a service verification challenge (Cloudflare); this lookup is unavailable to the current HTTP client.');
121
- if (response.status === 429) throw new StatusRateLimitError(retryAtFrom(response.headers.get('retry-after'), this.now()));
122
- if (response.status === 401) throw new StatusRequestError('Login expired or was rejected. Log in again with the account’s harness, then save the account.');
123
- if (response.status === 403) throw new StatusRequestError('Access denied by the service (HTTP 403).');
124
- throw new StatusRequestError(`Service returned HTTP ${response.status}.`);
125
- }
126
- const reader = response.body?.getReader();
127
- if (!reader) throw new StatusRequestError('The service returned an empty response.');
128
- const chunks: Uint8Array[] = [];
129
- let size = 0;
130
- try {
131
- while (true) {
132
- const part = await reader.read();
133
- if (part.done) break;
134
- size += part.value.byteLength;
135
- if (size > 1024 * 1024) { await reader.cancel(); throw new StatusRequestError('The status response is too large.'); }
136
- chunks.push(part.value);
137
- }
138
- } finally { reader.releaseLock(); }
139
- try { return record(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
140
- catch { throw new StatusRequestError('The service returned invalid status data.'); }
141
115
  } catch (errorArg) {
142
116
  // Never surface remote bodies, request headers, token strings or arbitrary fetch errors.
143
- if (signal.aborted) throw new Error(timeout.aborted ? 'The status request timed out.' : 'The status request was cancelled.');
144
- if (errorArg instanceof StatusRequestError) throw errorArg;
145
- throw new Error('The status service could not be reached.');
117
+ if (!(errorArg instanceof OpenAiBackendError)) throw new Error('The status service could not be reached.');
118
+ switch (errorArg.reason) {
119
+ case 'challenge': throw new StatusRequestError('Blocked by a service verification challenge (Cloudflare); this lookup is unavailable to the current HTTP client.');
120
+ case 'rate_limited': throw new StatusRateLimitError(retryAtFrom(errorArg.retryAfter, this.now()));
121
+ case 'unauthorized': throw new StatusRequestError('Login expired or was rejected. Log in again with the account’s harness, then save the account.');
122
+ case 'forbidden': throw new StatusRequestError('Access denied by the service (HTTP 403).');
123
+ case 'http': throw new StatusRequestError(`Service returned HTTP ${errorArg.status}.`);
124
+ case 'protocol': throw new StatusRequestError('The service returned invalid or oversized status data.');
125
+ case 'timeout': throw new Error('The status request timed out.');
126
+ case 'aborted': throw new Error('The status request was cancelled.');
127
+ case 'network': throw new Error('The status service could not be reached.');
128
+ }
146
129
  }
147
130
  }
148
131
 
@@ -178,27 +161,22 @@ export class CodexAccountStatus {
178
161
  }
179
162
 
180
163
  private limits(bodyArg: Record<string, unknown>, accountIdArg: string): Pick<IHarnessAccountStatus, 'facts' | 'summary'> {
181
- if (bodyArg.account_id != null && bodyArg.account_id !== accountIdArg) throw new Error('Account mismatch.');
182
- const plan = text(bodyArg.plan_type);
164
+ const usage = projectOpenAiAccountUsage(bodyArg, new Date(this.now()).toISOString());
165
+ if (usage.accountId !== null && usage.accountId !== accountIdArg) throw new Error('Account mismatch.');
166
+ const plan = usage.rateLimits.plan;
183
167
  const facts: IHarnessAccountStatus['facts'] = [{ section: 'Subscription', summaryKey: 'subscription', label: 'Subscription plan', value: `${plan} (live)` }];
184
168
  const usageWindows: NonNullable<IHarnessStatusSummary['usageWindows']> = [];
185
169
  const summary: IHarnessStatusSummary = { subscription: { plan, source: 'live' }, usageWindows };
186
- const appendLimit = (nameArg: string, valueArg: unknown, scopeArg: 'account' | 'feature') => {
187
- if (valueArg == null) return;
188
- const limit = record(valueArg);
170
+ const appendLimit = (nameArg: string, limitArg: IOpenAiRateLimitDetails | undefined, scopeArg: 'account' | 'feature') => {
171
+ if (!limitArg) return;
189
172
  // The service's own reading of the same windows, so a view that shows the windows need not repeat it.
190
- facts.push({ section: 'Limits & credits', summaryKey: 'usageWindows', label: `${nameArg} usage allowed`, value: boolean(limit.allowed) ? 'yes' : 'no' });
191
- facts.push({ section: 'Limits & credits', summaryKey: 'usageWindows', label: `${nameArg} limit reached`, value: boolean(limit.limit_reached) ? 'yes' : 'no' });
173
+ facts.push({ section: 'Limits & credits', summaryKey: 'usageWindows', label: `${nameArg} usage allowed`, value: limitArg.allowed ? 'yes' : 'no' });
174
+ facts.push({ section: 'Limits & credits', summaryKey: 'usageWindows', label: `${nameArg} limit reached`, value: limitArg.limitReached ? 'yes' : 'no' });
192
175
  // Both slots are read before either is named: only a second window of the same length makes the
193
176
  // vendor's slot worth showing, and that is decided from the pair.
194
- const windows = (['primary_window', 'secondary_window'] as const).flatMap((slotArg) => {
195
- if (limit[slotArg] == null) return [];
196
- const window = record(limit[slotArg]);
197
- return [{
198
- slot: slotArg === 'primary_window' ? 'primary' : 'secondary',
199
- usedPercent: numeric(window.used_percent), durationSeconds: numeric(window.limit_window_seconds), resetAt: timestamp(window.reset_at),
200
- }];
201
- });
177
+ const windows = ([['primary', limitArg.primaryWindow], ['secondary', limitArg.secondaryWindow]] as const)
178
+ .flatMap(([slot, window]) => window ? [{ slot, usedPercent: window.usedPercent,
179
+ durationSeconds: window.limitWindowSeconds, resetAt: new Date(window.resetsAt * 1000).toISOString() }] : []);
202
180
  for (const window of windows) {
203
181
  // Named by its length, as Codex names its own windows: a slot carries no length, and which
204
182
  // window the service puts in it varies by plan and by which limit is currently binding.
@@ -208,31 +186,20 @@ export class CodexAccountStatus {
208
186
  facts.push({ section: 'Usage', summaryKey: 'usageWindows', label, value: `${window.usedPercent}% used, ${Math.max(0, 100 - window.usedPercent)}% remaining; resets ${window.resetAt}` });
209
187
  }
210
188
  };
211
- appendLimit('Codex', bodyArg.rate_limit, 'account');
212
- if (bodyArg.additional_rate_limits != null) {
213
- if (!Array.isArray(bodyArg.additional_rate_limits)) throw new Error('Invalid additional limits.');
214
- for (const value of bodyArg.additional_rate_limits) {
215
- const limit = record(value);
216
- appendLimit(text(limit.limit_name), limit.rate_limit, 'feature');
217
- }
218
- }
219
- if (bodyArg.credits != null) {
220
- const credits = record(bodyArg.credits);
221
- const unlimited = boolean(credits.unlimited);
222
- const hasCredits = boolean(credits.has_credits);
223
- facts.push({ section: 'Limits & credits', label: 'Credits', value: unlimited ? 'unlimited' : credits.balance != null ? text(credits.balance) : hasCredits ? 'available (balance not returned)' : 'none available' });
189
+ appendLimit('Codex', usage.rateLimits.rateLimit, 'account');
190
+ for (const limit of usage.rateLimits.additionalRateLimits) appendLimit(limit.limitName, limit.rateLimit, 'feature');
191
+ if (usage.credits) {
192
+ const credits = usage.credits;
193
+ facts.push({ section: 'Limits & credits', label: 'Credits', value: credits.unlimited ? 'unlimited' : credits.balance !== undefined ? credits.balance : credits.hasCredits ? 'available (balance not returned)' : 'none available' });
224
194
  }
225
- if (bodyArg.spend_control != null) {
226
- const spend = record(bodyArg.spend_control);
227
- facts.push({ section: 'Limits & credits', label: 'Spend limit reached', value: boolean(spend.reached) ? 'yes' : 'no' });
228
- if (spend.individual_limit != null) {
229
- const limit = record(spend.individual_limit);
230
- facts.push({ section: 'Limits & credits', label: 'Individual spend', value: `${text(limit.used)} used of ${text(limit.limit)}; ${text(limit.remaining)} remaining; resets ${timestamp(limit.reset_at)}` });
231
- }
195
+ if (usage.spendControl) {
196
+ facts.push({ section: 'Limits & credits', label: 'Spend limit reached', value: usage.spendControl.reached ? 'yes' : 'no' });
197
+ const limit = usage.spendControl.individualLimit;
198
+ if (limit) facts.push({ section: 'Limits & credits', label: 'Individual spend', value: `${limit.used} used of ${limit.limit}; ${limit.remaining} remaining; resets ${new Date(limit.resetsAt * 1000).toISOString()}` });
232
199
  }
233
- if (bodyArg.rate_limit_reached_type != null) facts.push({ section: 'Limits & credits', label: 'Limit state', value: text(record(bodyArg.rate_limit_reached_type).type) });
234
- if (bodyArg.rate_limit_reset_credits != null) {
235
- summary.resets = { available: count(record(bodyArg.rate_limit_reset_credits).available_count) };
200
+ if (usage.rateLimits.rateLimitReachedType !== undefined) facts.push({ section: 'Limits & credits', label: 'Limit state', value: usage.rateLimits.rateLimitReachedType });
201
+ if (usage.rateLimits.rateLimitResetCreditsAvailableCount !== undefined) {
202
+ summary.resets = { available: usage.rateLimits.rateLimitResetCreditsAvailableCount };
236
203
  facts.push({ section: 'Earned resets', summaryKey: 'resets', label: 'Available earned resets', value: String(summary.resets.available) });
237
204
  }
238
205
  else facts.push({ section: 'Earned resets', summaryKey: 'resets', label: 'Available earned resets', value: 'Not returned by the limits service' });
@@ -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
  };