@pure01fx/dsh-openai-codex-auth 0.5.0 → 0.6.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/lib/index.js CHANGED
@@ -8,6 +8,15 @@ import { createServer } from 'node:http';
8
8
  import { createHash, randomBytes } from 'node:crypto';
9
9
  import { readFile, unlink } from 'node:fs/promises';
10
10
  import { join, resolve } from 'node:path';
11
+ import { INVALID_CREDENTIAL_CODE, LlmError } from '@deepseek-ai/dsh-llm';
12
+ import { CODEX_PROVIDER, NATIVE_CODEX_PROVIDER, NativeCodexAdapter, } from './native-adapter.js';
13
+ export { CODEX_PROVIDER, NATIVE_CODEX_PROVIDER } from './native-adapter.js';
14
+ import { NativeCodexCatalog } from './catalog.js';
15
+ import { NativeCodexHttpTransport } from './native-http.js';
16
+ import { NativeCodexWebSocketTransport } from './native-websocket.js';
17
+ import { mergeDirectUsage, normalizeUsage } from './usage.js';
18
+ export { normalizeUsage } from './usage.js';
19
+ export { CODEX_CLIENT_VERSION, TRACKED_CODEX_COMMIT, TRACKED_CODEX_RELEASE, TRACKED_CODEX_REPOSITORY, } from './upstream.js';
11
20
  const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
12
21
  const AUTH_BASE_URL = 'https://auth.openai.com';
13
22
  const AUTHORIZE_URL = `${AUTH_BASE_URL}/oauth/authorize`;
@@ -24,6 +33,7 @@ const BROWSER_LOGIN_TIMEOUT_MS = 10 * 60_000;
24
33
  const DEFAULT_DEVICE_INTERVAL_SECONDS = 5;
25
34
  const MIN_DEVICE_INTERVAL_MS = 1_000;
26
35
  const SLOW_DOWN_INCREMENT_MS = 5_000;
36
+ const TOKEN_REFRESH_PREEMPT_MS = 5 * 60_000;
27
37
  const DEFAULT_FILENAME = 'openai-codex-auth.json';
28
38
  const TOKEN_REF = credentialRef('DSH_OPENAI_CODEX_TOKEN');
29
39
  const USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
@@ -36,28 +46,99 @@ class LoginConflictError extends Error {
36
46
  }
37
47
  class CredentialNotWritableError extends Error {
38
48
  }
49
+ const PERMANENT_REFRESH_CODES = new Set([
50
+ 'refresh_token_expired',
51
+ 'refresh_token_reused',
52
+ 'refresh_token_invalidated',
53
+ 'invalid_grant',
54
+ ]);
55
+ class OAuthEndpointError extends Error {
56
+ status;
57
+ oauthCode;
58
+ constructor(message, status, oauthCode) {
59
+ super(message);
60
+ this.status = status;
61
+ this.oauthCode = oauthCode;
62
+ this.name = 'OAuthEndpointError';
63
+ }
64
+ }
39
65
  function base64Url(value) {
40
66
  return value.toString('base64url');
41
67
  }
42
68
  function messageOf(error) {
43
69
  return error instanceof Error ? error.message : String(error);
44
70
  }
45
- function accountId(access) {
46
- const parts = access.split('.');
71
+ function throwIfCancelled(signal) {
72
+ if (!signal?.aborted)
73
+ return;
74
+ if (signal.reason instanceof LlmError)
75
+ throw signal.reason;
76
+ throw new Error('OpenAI login cancelled');
77
+ }
78
+ function jwtPayload(token, label) {
79
+ const parts = token.split('.');
47
80
  if (parts.length !== 3)
48
- throw new Error('OpenAI returned an invalid access token');
49
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
50
- const id = payload['https://api.openai.com/auth']?.chatgpt_account_id;
81
+ throw new Error(`OpenAI returned an invalid ${label}`);
82
+ try {
83
+ const value = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
84
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
85
+ throw new Error();
86
+ return value;
87
+ }
88
+ catch {
89
+ throw new Error(`OpenAI returned an invalid ${label}`);
90
+ }
91
+ }
92
+ function chatGptAccountId(token, label) {
93
+ const auth = jwtPayload(token, label)['https://api.openai.com/auth'];
94
+ const id = auth !== null && typeof auth === 'object'
95
+ ? auth.chatgpt_account_id
96
+ : undefined;
51
97
  if (typeof id !== 'string' || id.length === 0)
52
- throw new Error('OpenAI token has no ChatGPT account id');
98
+ throw new Error(`OpenAI ${label} has no ChatGPT account id`);
53
99
  return id;
54
100
  }
101
+ function accessTokenExpiry(access) {
102
+ const exp = jwtPayload(access, 'access token').exp;
103
+ return typeof exp === 'number' && Number.isFinite(exp) && exp > 0 ? exp * 1_000 : undefined;
104
+ }
105
+ function parseTokenResponse(value, previous) {
106
+ if (value === null || typeof value.access_token !== 'string' || value.access_token.length === 0) {
107
+ throw new Error('OpenAI token response has no access token');
108
+ }
109
+ const refresh = value.refresh_token === undefined ? previous?.refresh : value.refresh_token;
110
+ if (typeof refresh !== 'string' || refresh.length === 0) {
111
+ throw new Error('OpenAI token response has no refresh token');
112
+ }
113
+ const expires = typeof value.expires_in === 'number' && Number.isFinite(value.expires_in) && value.expires_in > 0
114
+ ? Date.now() + value.expires_in * 1_000
115
+ : accessTokenExpiry(value.access_token);
116
+ if (expires === undefined)
117
+ throw new Error('OpenAI token response has no usable expiry');
118
+ const idToken = value.id_token;
119
+ if (idToken !== undefined && (typeof idToken !== 'string' || idToken.length === 0)) {
120
+ throw new Error('OpenAI token response has an invalid ID token');
121
+ }
122
+ const resolvedAccountId = idToken === undefined
123
+ ? previous?.accountId
124
+ : chatGptAccountId(idToken, 'ID token');
125
+ if (resolvedAccountId === undefined)
126
+ throw new Error('OpenAI token response has no ID token');
127
+ return { access: value.access_token, refresh, expires, accountId: resolvedAccountId };
128
+ }
129
+ function isPermanentRefreshError(error) {
130
+ return error instanceof OAuthEndpointError
131
+ && (error.status === 401
132
+ || (error.oauthCode !== undefined && PERMANENT_REFRESH_CODES.has(error.oauthCode.toLowerCase())));
133
+ }
55
134
  function parseCredential(text, filename) {
56
135
  const value = JSON.parse(text);
57
136
  const credential = value.credential;
58
137
  if (value.version !== 1 || credential === undefined
59
- || typeof credential.access !== 'string' || typeof credential.refresh !== 'string'
60
- || typeof credential.expires !== 'number' || typeof credential.accountId !== 'string') {
138
+ || typeof credential.access !== 'string' || credential.access.length === 0
139
+ || typeof credential.refresh !== 'string' || credential.refresh.length === 0
140
+ || typeof credential.expires !== 'number' || !Number.isFinite(credential.expires)
141
+ || typeof credential.accountId !== 'string' || credential.accountId.length === 0) {
61
142
  throw new Error(`openai-codex-auth: invalid credential document ${filename}`);
62
143
  }
63
144
  return credential;
@@ -119,14 +200,11 @@ function parseManualAuthorizationInput(input) {
119
200
  }
120
201
  return { code: text };
121
202
  }
122
- async function tokenRequest(body, signal) {
203
+ async function tokenRequest(init, previous, signal) {
123
204
  let response;
124
205
  try {
125
206
  response = await fetch(TOKEN_URL, {
126
- method: 'POST',
127
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
128
- body,
129
- ...signal === undefined ? {} : { signal },
207
+ method: 'POST', redirect: 'error', ...init, ...signal === undefined ? {} : { signal },
130
208
  });
131
209
  }
132
210
  catch (error) {
@@ -136,65 +214,25 @@ async function tokenRequest(body, signal) {
136
214
  }
137
215
  if (!response.ok) {
138
216
  const text = await responseText(response);
139
- throw new Error(`OpenAI token request failed (HTTP ${response.status})${text ? `: ${text}` : ''}`);
217
+ throw new OAuthEndpointError(`OpenAI token request failed (HTTP ${response.status})${text ? `: ${text}` : ''}`, response.status, errorCodeFromText(text));
140
218
  }
141
- const value = await response.json();
142
- if (value === null || typeof value.access_token !== 'string' || typeof value.refresh_token !== 'string'
143
- || typeof value.expires_in !== 'number')
144
- throw new Error('OpenAI token response is incomplete');
145
- return {
146
- access: value.access_token,
147
- refresh: value.refresh_token,
148
- expires: Date.now() + value.expires_in * 1000,
149
- accountId: accountId(value.access_token),
150
- };
151
- }
152
- function optionalNumber(value) {
153
- return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
219
+ return parseTokenResponse(await response.json(), previous);
154
220
  }
155
- function usageWindow(value) {
156
- if (value === null || typeof value !== 'object')
157
- return undefined;
158
- const row = value;
159
- const usedPercent = optionalNumber(row.used_percent ?? row.usedPercent);
160
- if (usedPercent === undefined)
161
- return undefined;
162
- const windowSeconds = optionalNumber(row.limit_window_seconds ?? row.windowDurationSecs);
163
- const resetAt = optionalNumber(row.reset_at ?? row.resetsAt);
164
- return {
165
- usedPercent: Math.max(0, Math.min(100, usedPercent)),
166
- ...windowSeconds === undefined ? {} : { windowSeconds },
167
- ...resetAt === undefined ? {} : { resetAt },
168
- };
221
+ function exchangeToken(body, signal) {
222
+ return tokenRequest({
223
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
224
+ body,
225
+ }, undefined, signal);
169
226
  }
170
- /** Reduce the OpenAI response to the stable fields displayed by the Web card. */
171
- export function normalizeUsage(value) {
172
- const root = value !== null && typeof value === 'object' ? value : {};
173
- const limits = root.rate_limit !== null && typeof root.rate_limit === 'object'
174
- ? root.rate_limit
175
- : root.rateLimits !== null && typeof root.rateLimits === 'object'
176
- ? root.rateLimits
177
- : {};
178
- const credits = root.rate_limit_reset_credits !== null && typeof root.rate_limit_reset_credits === 'object'
179
- ? root.rate_limit_reset_credits
180
- : undefined;
181
- const planType = typeof root.plan_type === 'string'
182
- ? root.plan_type
183
- : typeof root.planType === 'string' ? root.planType : undefined;
184
- const primary = usageWindow(limits.primary_window ?? limits.primary);
185
- const secondary = usageWindow(limits.secondary_window ?? limits.secondary);
186
- const limitReached = typeof limits.limit_reached === 'boolean'
187
- ? limits.limit_reached
188
- : typeof limits.limitReached === 'boolean' ? limits.limitReached : undefined;
189
- const resetCredits = optionalNumber(credits?.available_count ?? credits?.availableCount);
190
- return {
191
- ...planType === undefined ? {} : { planType },
192
- ...primary === undefined ? {} : { primary },
193
- ...secondary === undefined ? {} : { secondary },
194
- ...limitReached === undefined ? {} : { limitReached },
195
- ...resetCredits === undefined ? {} : { resetCredits },
196
- fetchedAt: Date.now(),
197
- };
227
+ function refreshToken(current, signal) {
228
+ return tokenRequest({
229
+ headers: { 'content-type': 'application/json' },
230
+ body: JSON.stringify({
231
+ client_id: CLIENT_ID,
232
+ grant_type: 'refresh_token',
233
+ refresh_token: current.refresh,
234
+ }),
235
+ }, current, signal);
198
236
  }
199
237
  function header(request, name) {
200
238
  const value = request.headers[name];
@@ -361,6 +399,7 @@ async function startDeviceAuthorization(signal) {
361
399
  try {
362
400
  response = await fetch(DEVICE_USER_CODE_URL, {
363
401
  method: 'POST',
402
+ redirect: 'error',
364
403
  headers: { 'content-type': 'application/json' },
365
404
  body: JSON.stringify({ client_id: CLIENT_ID }),
366
405
  ...signal === undefined ? {} : { signal },
@@ -407,6 +446,7 @@ function errorCodeFromText(text) {
407
446
  const code = error.code;
408
447
  return typeof code === 'string' ? code : undefined;
409
448
  }
449
+ return typeof value.code === 'string' ? value.code : undefined;
410
450
  }
411
451
  catch {
412
452
  return undefined;
@@ -467,6 +507,7 @@ async function pollDeviceAuthorization(device, signal) {
467
507
  try {
468
508
  response = await fetch(DEVICE_TOKEN_URL, {
469
509
  method: 'POST',
510
+ redirect: 'error',
470
511
  headers: { 'content-type': 'application/json' },
471
512
  body: JSON.stringify({ device_auth_id: device.deviceAuthId, user_code: device.userCode }),
472
513
  ...signal === undefined ? {} : { signal },
@@ -491,7 +532,47 @@ async function pollDeviceAuthorization(device, signal) {
491
532
  }
492
533
  throw new Error('OpenAI device-code login timed out');
493
534
  }
535
+ function resolveCodexRouteStatus(config, llm) {
536
+ let providers = [];
537
+ try {
538
+ providers = llm?.listProviders() ?? [];
539
+ }
540
+ catch {
541
+ // The auth/status surface must remain available while the LLM tree is settling.
542
+ }
543
+ const production = providers.find(provider => provider.id === CODEX_PROVIDER);
544
+ const compatibilityActive = providers.some(provider => provider.id === NATIVE_CODEX_PROVIDER);
545
+ if (config.nativeAdapter) {
546
+ return {
547
+ provider: CODEX_PROVIDER,
548
+ owner: 'native',
549
+ active: production !== undefined,
550
+ ...production === undefined ? {} : { registeredName: production.name },
551
+ transport: config.nativeWebSocket ? 'websocket-v2' : 'http-sse',
552
+ compatibilityRoute: {
553
+ configured: config.nativeCompatibilityRoute,
554
+ active: compatibilityActive,
555
+ },
556
+ };
557
+ }
558
+ if (production !== undefined) {
559
+ return {
560
+ provider: CODEX_PROVIDER,
561
+ owner: 'external',
562
+ active: true,
563
+ registeredName: production.name,
564
+ compatibilityRoute: { configured: false, active: compatibilityActive },
565
+ };
566
+ }
567
+ return {
568
+ provider: CODEX_PROVIDER,
569
+ owner: 'unregistered',
570
+ active: false,
571
+ compatibilityRoute: { configured: false, active: compatibilityActive },
572
+ };
573
+ }
494
574
  export const internals = {
575
+ parseTokenResponse,
495
576
  parseAuthority,
496
577
  isLoopbackHostname,
497
578
  isTrustedHost,
@@ -501,17 +582,31 @@ export const internals = {
501
582
  startDeviceAuthorization,
502
583
  parseDevicePollResponse,
503
584
  pollDeviceAuthorization,
585
+ resolveCodexRouteStatus,
504
586
  };
505
587
  /** DSH service providing device-code/browser login, logout, and automatically refreshed bearer tokens. */
506
588
  export class OpenAICodexAuth extends Service {
507
- static Config = z.object({ path: z.string(), dshHome: z.string() });
589
+ static Config = z.object({
590
+ path: z.string(),
591
+ dshHome: z.string(),
592
+ nativeAdapter: z.boolean().default(true),
593
+ nativeCompatibilityRoute: z.boolean().default(false),
594
+ nativeWebSocket: z.boolean().default(true),
595
+ });
508
596
  static inject = ['credentials', 'webServer', 'webRuntime'];
509
597
  filename;
598
+ routeConfig;
510
599
  csrf = base64Url(randomBytes(24));
511
600
  usageCache;
601
+ usageAccountId;
602
+ responseUsage;
603
+ credentialAccountId;
512
604
  usageError;
513
605
  usageRefresh;
514
606
  usageGeneration = 0;
607
+ directUsageSequence = 0;
608
+ directUsageAccountId;
609
+ usageHasDirectDefault = false;
515
610
  codexTurns = new Map();
516
611
  loginFlow;
517
612
  startingDevice;
@@ -520,11 +615,50 @@ export class OpenAICodexAuth extends Service {
520
615
  constructor(ctx, config) {
521
616
  super(ctx, 'openaiCodexAuth');
522
617
  this.filename = resolve(config.path ?? join(resolveDshHome(config.dshHome), DEFAULT_FILENAME));
618
+ this.routeConfig = {
619
+ nativeAdapter: config.nativeAdapter ?? true,
620
+ nativeCompatibilityRoute: config.nativeCompatibilityRoute ?? false,
621
+ nativeWebSocket: config.nativeWebSocket ?? true,
622
+ };
623
+ if (this.routeConfig.nativeAdapter) {
624
+ const catalog = new NativeCodexCatalog({
625
+ resolveCredential: signal => this.resolveNativeCredential(signal),
626
+ warn: message => { ctx.logger.warn(message); },
627
+ });
628
+ const transportOptions = {
629
+ resolveCredential: signal => this.resolveNativeCredential(signal),
630
+ recoverCredential: (previous, signal) => this.recoverNativeCredential(previous, signal),
631
+ readImage: async (attachment, signal) => {
632
+ const store = ctx.get('attachments');
633
+ if (store === undefined) {
634
+ throw new LlmError('native Codex image input requires the attachment service', 'UNSUPPORTED');
635
+ }
636
+ return store.readImage(attachment, signal);
637
+ },
638
+ onRateLimits: observation => {
639
+ this.acceptRateLimits(observation.accountId, observation.updates);
640
+ },
641
+ onResponseUsage: observation => {
642
+ this.acceptResponseUsage(observation);
643
+ },
644
+ warn: message => { ctx.logger.warn(message); },
645
+ };
646
+ const transport = this.routeConfig.nativeWebSocket
647
+ ? new NativeCodexWebSocketTransport(transportOptions)
648
+ : new NativeCodexHttpTransport(transportOptions);
649
+ if (transport instanceof NativeCodexWebSocketTransport) {
650
+ ctx.effect(() => () => { transport.dispose(); }, 'openai-codex-auth: dispose native WebSockets');
651
+ }
652
+ ctx.inject(['llm'], (llmCtx) => {
653
+ llmCtx.llm.registerAdapter([
654
+ CODEX_PROVIDER,
655
+ ...(this.routeConfig.nativeCompatibilityRoute ? [NATIVE_CODEX_PROVIDER] : []),
656
+ ], new NativeCodexAdapter(catalog, transport));
657
+ });
658
+ }
523
659
  ctx.effect(async () => {
524
660
  try {
525
- const token = await this.bearerToken();
526
- if (token !== undefined)
527
- await this.storeCredentialToken(token);
661
+ await this.bearerToken();
528
662
  }
529
663
  catch (error) {
530
664
  this.lastLoginError = messageOf(error);
@@ -534,12 +668,16 @@ export class OpenAICodexAuth extends Service {
534
668
  }, 'openai-codex-auth: bootstrap credential');
535
669
  ctx.on('agent/request', async ({ agent, turn }, next) => {
536
670
  const request = await next();
537
- if (request.provider === 'openai-codex')
671
+ if (request.provider === CODEX_PROVIDER || request.provider === NATIVE_CODEX_PROVIDER) {
538
672
  this.markCodexTurn(String(agent.id), turn);
673
+ }
539
674
  return request;
540
675
  }, { global: true, prepend: true });
541
676
  ctx.on('session/event', (session, event) => {
542
- if (event.type !== 'turn/end' || !this.consumeCodexTurn(String(session.id), event.data.turn))
677
+ if (event.type !== 'turn/end')
678
+ return;
679
+ const turn = this.consumeCodexTurn(String(session.id), event.data.turn);
680
+ if (turn === undefined || turn.receivedDirectUsage)
543
681
  return;
544
682
  void this.refreshUsage(true);
545
683
  }, { global: true });
@@ -549,7 +687,6 @@ export class OpenAICodexAuth extends Service {
549
687
  ctx.effect(() => {
550
688
  const timer = setInterval(() => {
551
689
  void this.bearerToken()
552
- .then(token => token === undefined ? undefined : this.storeCredentialToken(token))
553
690
  .catch((error) => { this.usageError = messageOf(error); });
554
691
  }, 60_000);
555
692
  return () => { clearInterval(timer); };
@@ -596,18 +733,72 @@ export class OpenAICodexAuth extends Service {
596
733
  };
597
734
  }, 'openai-codex-auth: Web routes');
598
735
  }
736
+ setCredentialAccount(accountId) {
737
+ if (this.credentialAccountId !== accountId) {
738
+ this.directUsageAccountId = undefined;
739
+ this.usageHasDirectDefault = false;
740
+ this.responseUsage = undefined;
741
+ }
742
+ this.credentialAccountId = accountId;
743
+ }
744
+ acceptResponseUsage(observation) {
745
+ if (this.credentialAccountId !== undefined
746
+ && observation.accountId !== this.credentialAccountId)
747
+ return;
748
+ this.responseUsage = {
749
+ accountId: observation.accountId,
750
+ amount: observation.metadata.amount,
751
+ observedAt: Date.now(),
752
+ };
753
+ }
754
+ acceptRateLimits(accountId, updates) {
755
+ if (this.credentialAccountId !== undefined && accountId !== this.credentialAccountId)
756
+ return;
757
+ const accepted = updates.slice(0, 32);
758
+ if (accepted.length === 0)
759
+ return;
760
+ const defaultUpdate = accepted.find(candidate => candidate.limitId === 'codex');
761
+ const hasDefaultQuota = defaultUpdate !== undefined
762
+ && (defaultUpdate.primary !== undefined || defaultUpdate.secondary !== undefined);
763
+ const hasData = accepted.some(update => update.primary !== undefined
764
+ || update.secondary !== undefined || update.limitReached !== undefined
765
+ || update.planType !== undefined || update.credits !== undefined);
766
+ if (!hasData)
767
+ return;
768
+ const sameUsageAccount = this.usageAccountId === accountId;
769
+ if (!sameUsageAccount)
770
+ this.usageHasDirectDefault = false;
771
+ if (hasDefaultQuota)
772
+ this.usageGeneration += 1;
773
+ this.usageCache = mergeDirectUsage(sameUsageAccount ? this.usageCache : undefined, accepted);
774
+ this.usageAccountId = accountId;
775
+ this.usageError = undefined;
776
+ if (hasDefaultQuota) {
777
+ this.directUsageSequence += 1;
778
+ this.directUsageAccountId = accountId;
779
+ this.usageHasDirectDefault = true;
780
+ }
781
+ }
599
782
  markCodexTurn(sessionId, turn) {
600
- const turns = this.codexTurns.get(sessionId) ?? new Set();
601
- turns.add(turn);
783
+ const turns = this.codexTurns.get(sessionId) ?? new Map();
784
+ turns.set(turn, this.directUsageSequence);
602
785
  this.codexTurns.set(sessionId, turns);
603
786
  }
604
787
  consumeCodexTurn(sessionId, turn) {
605
788
  const turns = this.codexTurns.get(sessionId);
606
- if (turns === undefined || !turns.delete(turn))
607
- return false;
789
+ const directUsageAtStart = turns?.get(turn);
790
+ if (turns === undefined || directUsageAtStart === undefined)
791
+ return undefined;
792
+ turns.delete(turn);
608
793
  if (turns.size === 0)
609
794
  this.codexTurns.delete(sessionId);
610
- return true;
795
+ return {
796
+ receivedDirectUsage: this.directUsageSequence > directUsageAtStart
797
+ && this.usageHasDirectDefault
798
+ && this.directUsageAccountId !== undefined
799
+ && this.directUsageAccountId === this.credentialAccountId
800
+ && this.directUsageAccountId === this.usageAccountId,
801
+ };
611
802
  }
612
803
  async performUsageRefresh(generation) {
613
804
  try {
@@ -615,6 +806,8 @@ export class OpenAICodexAuth extends Service {
615
806
  if (credential === undefined) {
616
807
  if (this.usageGeneration === generation) {
617
808
  this.usageCache = undefined;
809
+ this.usageAccountId = undefined;
810
+ this.usageHasDirectDefault = false;
618
811
  this.usageError = undefined;
619
812
  }
620
813
  return;
@@ -622,6 +815,8 @@ export class OpenAICodexAuth extends Service {
622
815
  const usage = await this.fetchUsage(credential);
623
816
  if (this.usageGeneration === generation) {
624
817
  this.usageCache = usage;
818
+ this.usageAccountId = credential.accountId;
819
+ this.usageHasDirectDefault = false;
625
820
  this.usageError = undefined;
626
821
  }
627
822
  }
@@ -640,16 +835,20 @@ export class OpenAICodexAuth extends Service {
640
835
  const cycle = {
641
836
  promise: Promise.resolve(),
642
837
  queued: false,
838
+ generation: undefined,
643
839
  };
644
840
  this.usageRefresh = cycle;
645
841
  cycle.promise = (async () => {
646
842
  try {
647
843
  do {
648
844
  cycle.queued = false;
649
- await this.performUsageRefresh(this.usageGeneration);
845
+ cycle.generation = this.usageGeneration;
846
+ await this.performUsageRefresh(cycle.generation);
847
+ cycle.generation = undefined;
650
848
  } while (cycle.queued);
651
849
  }
652
850
  finally {
851
+ cycle.generation = undefined;
653
852
  if (this.usageRefresh === cycle)
654
853
  this.usageRefresh = undefined;
655
854
  }
@@ -662,52 +861,230 @@ export class OpenAICodexAuth extends Service {
662
861
  throw new CredentialNotWritableError(`DSH_OPENAI_CODEX_TOKEN is supplied by read-only source ${info.source ?? 'unknown'}; remove that override before logging in`);
663
862
  }
664
863
  }
665
- async storeCredentialToken(token) {
666
- await this.assertCredentialWritable();
864
+ async publishCredentialToken(token, signal) {
865
+ const resolved = await this.ctx.credentials.resolve(TOKEN_REF);
866
+ throwIfCancelled(signal);
867
+ if (resolved?.value === token)
868
+ return;
869
+ const info = await this.ctx.credentials.describe(TOKEN_REF);
870
+ throwIfCancelled(signal);
871
+ if (!info.writable) {
872
+ throw new CredentialNotWritableError(`DSH_OPENAI_CODEX_TOKEN is supplied by read-only source ${info.source ?? 'unknown'} and does not match the managed OpenAI credential`);
873
+ }
667
874
  await this.ctx.credentials.set(TOKEN_REF, token);
668
875
  }
669
- /** Return a valid bearer token, refreshing and persisting it when near expiry. */
876
+ async restorePublishedCredential(previous) {
877
+ if (previous === undefined)
878
+ await this.ctx.credentials.unset(TOKEN_REF);
879
+ else
880
+ await this.ctx.credentials.set(TOKEN_REF, previous.value);
881
+ }
882
+ publicationChangedError(failure) {
883
+ return new AggregateError([failure, new Error('DSH_OPENAI_CODEX_TOKEN changed concurrently; rollback was skipped')], 'OpenAI credential publication failed after its DSH credential authority changed');
884
+ }
885
+ async failAfterPublicationRollback(previous, expectedCurrent, failure) {
886
+ let current;
887
+ try {
888
+ current = await this.ctx.credentials.resolve(TOKEN_REF);
889
+ }
890
+ catch (resolveError) {
891
+ throw new AggregateError([failure, resolveError], 'OpenAI credential publication failed and rollback safety could not be verified');
892
+ }
893
+ if (current?.value !== expectedCurrent)
894
+ throw this.publicationChangedError(failure);
895
+ try {
896
+ await this.restorePublishedCredential(previous);
897
+ }
898
+ catch (rollbackError) {
899
+ throw new AggregateError([failure, rollbackError], 'OpenAI credential publication failed and its prior DSH credential could not be restored');
900
+ }
901
+ throw failure;
902
+ }
903
+ async commitCredential(credential) {
904
+ const previous = await this.ctx.credentials.resolve(TOKEN_REF);
905
+ try {
906
+ await this.ctx.credentials.set(TOKEN_REF, credential.access);
907
+ }
908
+ catch (error) {
909
+ let current;
910
+ try {
911
+ current = await this.ctx.credentials.resolve(TOKEN_REF);
912
+ }
913
+ catch {
914
+ return this.failAfterPublicationRollback(previous, credential.access, error);
915
+ }
916
+ if (current?.value === credential.access) {
917
+ return this.failAfterPublicationRollback(previous, credential.access, error);
918
+ }
919
+ if (current?.value !== previous?.value)
920
+ throw this.publicationChangedError(error);
921
+ throw error;
922
+ }
923
+ try {
924
+ await this.write(credential);
925
+ }
926
+ catch (error) {
927
+ return this.failAfterPublicationRollback(previous, credential.access, error);
928
+ }
929
+ }
930
+ async resolveManagedCredentialLocked(signal) {
931
+ throwIfCancelled(signal);
932
+ const current = await readCredential(this.filename);
933
+ throwIfCancelled(signal);
934
+ if (current === undefined) {
935
+ this.setCredentialAccount(undefined);
936
+ return undefined;
937
+ }
938
+ if (current.expires > Date.now() + TOKEN_REFRESH_PREEMPT_MS) {
939
+ await this.publishCredentialToken(current.access, signal);
940
+ this.setCredentialAccount(current.accountId);
941
+ return current;
942
+ }
943
+ await this.assertCredentialWritable();
944
+ let next;
945
+ try {
946
+ next = await refreshToken(current, signal);
947
+ }
948
+ catch (error) {
949
+ throwIfCancelled(signal);
950
+ if (!isPermanentRefreshError(error) && current.expires > Date.now()) {
951
+ await this.publishCredentialToken(current.access, signal);
952
+ this.setCredentialAccount(current.accountId);
953
+ return current;
954
+ }
955
+ throw error;
956
+ }
957
+ throwIfCancelled(signal);
958
+ await this.commitCredential(next);
959
+ this.setCredentialAccount(next.accountId);
960
+ return next;
961
+ }
962
+ /** Return a valid managed bearer token, refreshing and persisting it when near expiry. */
670
963
  async bearerToken(signal) {
964
+ throwIfCancelled(signal);
671
965
  return withFileLock(this.filename, async () => {
672
- const current = await readCredential(this.filename);
673
- if (current === undefined)
674
- return undefined;
675
- if (current.expires > Date.now() + 60_000)
676
- return current.access;
677
- await this.assertCredentialWritable();
678
- const next = await tokenRequest(new URLSearchParams({
679
- grant_type: 'refresh_token', refresh_token: current.refresh, client_id: CLIENT_ID,
680
- }), signal);
681
- if (signal?.aborted)
682
- throw new Error('OpenAI login cancelled');
683
- await this.write(next);
684
- if (signal?.aborted)
685
- throw new Error('OpenAI login cancelled');
686
- await this.ctx.credentials.set(TOKEN_REF, next.access);
687
- return next.access;
966
+ const credential = await this.resolveManagedCredentialLocked(signal);
967
+ return credential?.access;
688
968
  });
689
969
  }
970
+ externalNativeCredential(accessToken) {
971
+ let accountId;
972
+ let expires;
973
+ try {
974
+ accountId = chatGptAccountId(accessToken, 'access token');
975
+ expires = accessTokenExpiry(accessToken);
976
+ }
977
+ catch (error) {
978
+ throw new LlmError('native Codex credential is not a valid ChatGPT access token', INVALID_CREDENTIAL_CODE, { cause: error });
979
+ }
980
+ if (expires !== undefined && expires <= Date.now()) {
981
+ throw new LlmError('native Codex ChatGPT access token has expired', INVALID_CREDENTIAL_CODE);
982
+ }
983
+ return { accessToken, accountId };
984
+ }
985
+ async resolveNativeCredential(signal) {
986
+ try {
987
+ throwIfCancelled(signal);
988
+ return await withFileLock(this.filename, async () => {
989
+ throwIfCancelled(signal);
990
+ const managed = await this.resolveManagedCredentialLocked(signal);
991
+ if (managed !== undefined) {
992
+ throwIfCancelled(signal);
993
+ return { accessToken: managed.access, accountId: managed.accountId };
994
+ }
995
+ const external = await this.ctx.credentials.resolve(TOKEN_REF);
996
+ throwIfCancelled(signal);
997
+ if (external === undefined) {
998
+ throw new LlmError('native Codex credential is not configured', 'MISSING_CREDENTIAL');
999
+ }
1000
+ const credential = this.externalNativeCredential(external.value);
1001
+ throwIfCancelled(signal);
1002
+ this.setCredentialAccount(credential.accountId);
1003
+ return credential;
1004
+ });
1005
+ }
1006
+ catch (error) {
1007
+ if (error instanceof LlmError)
1008
+ throw error;
1009
+ if (signal?.aborted) {
1010
+ throw new LlmError('native Codex credential resolution was aborted', 'ABORTED', { cause: error });
1011
+ }
1012
+ throw new LlmError('native Codex managed credential could not be resolved', INVALID_CREDENTIAL_CODE, { cause: error });
1013
+ }
1014
+ }
1015
+ nativeRecoveryError(error) {
1016
+ const options = {
1017
+ cause: error,
1018
+ ...(error instanceof OAuthEndpointError ? { status: error.status } : {}),
1019
+ };
1020
+ if (error instanceof CredentialNotWritableError || isPermanentRefreshError(error)) {
1021
+ return new LlmError('native Codex managed credential cannot be refreshed', INVALID_CREDENTIAL_CODE, options);
1022
+ }
1023
+ return new LlmError('native Codex managed credential recovery failed', 'AUTH', options);
1024
+ }
1025
+ async recoverNativeCredential(previous, signal) {
1026
+ try {
1027
+ throwIfCancelled(signal);
1028
+ return await withFileLock(this.filename, async () => {
1029
+ throwIfCancelled(signal);
1030
+ const current = await readCredential(this.filename);
1031
+ throwIfCancelled(signal);
1032
+ if (current === undefined) {
1033
+ const external = await this.ctx.credentials.resolve(TOKEN_REF);
1034
+ throwIfCancelled(signal);
1035
+ if (external === undefined)
1036
+ return false;
1037
+ const credential = this.externalNativeCredential(external.value);
1038
+ throwIfCancelled(signal);
1039
+ this.setCredentialAccount(credential.accountId);
1040
+ return credential.accessToken !== previous.accessToken
1041
+ || credential.accountId !== previous.accountId;
1042
+ }
1043
+ if (current.access !== previous.accessToken || current.accountId !== previous.accountId) {
1044
+ await this.publishCredentialToken(current.access, signal);
1045
+ throwIfCancelled(signal);
1046
+ this.setCredentialAccount(current.accountId);
1047
+ return true;
1048
+ }
1049
+ await this.assertCredentialWritable();
1050
+ throwIfCancelled(signal);
1051
+ const next = await refreshToken(current, signal);
1052
+ throwIfCancelled(signal);
1053
+ await this.commitCredential(next);
1054
+ throwIfCancelled(signal);
1055
+ this.setCredentialAccount(next.accountId);
1056
+ return next.access !== previous.accessToken || next.accountId !== previous.accountId;
1057
+ });
1058
+ }
1059
+ catch (error) {
1060
+ if (error instanceof LlmError)
1061
+ throw error;
1062
+ if (signal?.aborted) {
1063
+ throw new LlmError('native Codex credential recovery was aborted', 'ABORTED');
1064
+ }
1065
+ throw this.nativeRecoveryError(error);
1066
+ }
1067
+ }
690
1068
  async finishCredential(credential, signal) {
691
1069
  await this.assertCredentialWritable();
692
- if (signal.aborted)
693
- throw new Error('OpenAI login cancelled');
1070
+ throwIfCancelled(signal);
694
1071
  await withFileLock(this.filename, async () => {
695
- if (signal.aborted)
696
- throw new Error('OpenAI login cancelled');
697
- await this.write(credential);
1072
+ throwIfCancelled(signal);
1073
+ await this.commitCredential(credential);
1074
+ this.setCredentialAccount(credential.accountId);
1075
+ this.usageGeneration += 1;
1076
+ if (this.usageRefresh !== undefined)
1077
+ this.usageRefresh.queued = true;
1078
+ this.usageCache = undefined;
1079
+ this.usageAccountId = undefined;
1080
+ this.directUsageAccountId = undefined;
1081
+ this.usageHasDirectDefault = false;
1082
+ this.usageError = undefined;
698
1083
  });
699
- this.usageGeneration += 1;
700
- if (this.usageRefresh !== undefined)
701
- this.usageRefresh.queued = true;
702
- this.usageCache = undefined;
703
- this.usageError = undefined;
704
- if (signal.aborted)
705
- throw new Error('OpenAI login cancelled');
706
- await this.ctx.credentials.set(TOKEN_REF, credential.access);
707
1084
  this.lastLoginError = undefined;
708
1085
  }
709
1086
  async finishAuthorizationCode(code, verifier, redirectUri, signal) {
710
- const credential = await tokenRequest(new URLSearchParams({
1087
+ const credential = await exchangeToken(new URLSearchParams({
711
1088
  grant_type: 'authorization_code', client_id: CLIENT_ID, code,
712
1089
  code_verifier: verifier, redirect_uri: redirectUri,
713
1090
  }), signal);
@@ -857,7 +1234,7 @@ export class OpenAICodexAuth extends Service {
857
1234
  const url = new URL(AUTHORIZE_URL);
858
1235
  for (const [key, value] of Object.entries({
859
1236
  response_type: 'code', client_id: CLIENT_ID, redirect_uri: redirectUri,
860
- scope: 'openid profile email offline_access', code_challenge: challenge,
1237
+ scope: 'openid profile email offline_access api.connectors.read api.connectors.invoke', code_challenge: challenge,
861
1238
  code_challenge_method: 'S256', state, id_token_add_organizations: 'true',
862
1239
  codex_cli_simplified_flow: 'true', originator: 'deepseek-harness',
863
1240
  }))
@@ -922,30 +1299,44 @@ export class OpenAICodexAuth extends Service {
922
1299
  async logout() {
923
1300
  await this.cancelLogin(true);
924
1301
  await withFileLock(this.filename, async () => {
1302
+ const previous = await this.ctx.credentials.resolve(TOKEN_REF);
1303
+ try {
1304
+ await this.ctx.credentials.unset(TOKEN_REF);
1305
+ }
1306
+ catch (error) {
1307
+ let current;
1308
+ try {
1309
+ current = await this.ctx.credentials.resolve(TOKEN_REF);
1310
+ }
1311
+ catch {
1312
+ return this.failAfterPublicationRollback(previous, undefined, error);
1313
+ }
1314
+ if (current === undefined) {
1315
+ return this.failAfterPublicationRollback(previous, undefined, error);
1316
+ }
1317
+ if (current.value !== previous?.value)
1318
+ throw this.publicationChangedError(error);
1319
+ throw error;
1320
+ }
925
1321
  try {
926
1322
  await unlink(this.filename);
927
1323
  }
928
1324
  catch (error) {
929
- if (error.code !== 'ENOENT')
930
- throw error;
1325
+ if (error.code !== 'ENOENT') {
1326
+ return this.failAfterPublicationRollback(previous, undefined, error);
1327
+ }
931
1328
  }
932
1329
  });
933
- let unsetError;
934
- try {
935
- await this.ctx.credentials.unset(TOKEN_REF);
936
- }
937
- catch (error) {
938
- unsetError = error;
939
- }
1330
+ this.setCredentialAccount(undefined);
940
1331
  this.usageGeneration += 1;
941
1332
  if (this.usageRefresh !== undefined)
942
1333
  this.usageRefresh.queued = false;
943
1334
  this.usageCache = undefined;
1335
+ this.usageAccountId = undefined;
1336
+ this.directUsageAccountId = undefined;
1337
+ this.usageHasDirectDefault = false;
944
1338
  this.usageError = undefined;
945
1339
  this.lastLoginError = undefined;
946
- if (unsetError !== undefined) {
947
- throw new Error(`Local OpenAI credential was removed, but DSH_OPENAI_CODEX_TOKEN could not be unset: ${messageOf(unsetError)}`);
948
- }
949
1340
  }
950
1341
  async status(refresh, callbackUrl) {
951
1342
  let credential;
@@ -966,7 +1357,13 @@ export class OpenAICodexAuth extends Service {
966
1357
  }
967
1358
  if (refresh)
968
1359
  await this.refreshUsage(true);
969
- else if (this.usageRefresh !== undefined)
1360
+ else if (this.usageRefresh !== undefined
1361
+ && !(this.usageRefresh.generation !== undefined
1362
+ && this.usageRefresh.generation !== this.usageGeneration
1363
+ && this.usageCache?.source === 'response'
1364
+ && this.usageHasDirectDefault
1365
+ && this.usageAccountId === credential.accountId
1366
+ && this.directUsageAccountId === credential.accountId))
970
1367
  await this.usageRefresh.promise;
971
1368
  }
972
1369
  const flow = this.loginFlow;
@@ -980,6 +1377,7 @@ export class OpenAICodexAuth extends Service {
980
1377
  ? { authorizationUrl: flow.url, probeUrl: flow.probeUrl, expiresAt: flow.expiresAt }
981
1378
  : undefined;
982
1379
  return {
1380
+ route: resolveCodexRouteStatus(this.routeConfig, this.ctx.get('llm')),
983
1381
  loggedIn: credential !== undefined,
984
1382
  loginPending: startingMethod !== undefined || flow !== undefined,
985
1383
  ...startingMethod !== undefined ? { loginMethod: startingMethod } : flow === undefined ? {} : { loginMethod: flow.kind },
@@ -991,7 +1389,13 @@ export class OpenAICodexAuth extends Service {
991
1389
  ...credential === undefined ? {} : {
992
1390
  accountId: credential.accountId,
993
1391
  expiresAt: credential.expires,
994
- ...this.usageCache === undefined ? {} : { usage: this.usageCache },
1392
+ ...this.usageCache === undefined || this.usageAccountId !== credential.accountId
1393
+ ? {} : { usage: this.usageCache },
1394
+ ...this.responseUsage === undefined || this.responseUsage.accountId !== credential.accountId
1395
+ ? {} : { responseUsage: {
1396
+ amount: this.responseUsage.amount,
1397
+ observedAt: this.responseUsage.observedAt,
1398
+ } },
995
1399
  ...this.usageError === undefined ? {} : { usageError: this.usageError },
996
1400
  },
997
1401
  csrf: this.csrf,
@@ -1002,6 +1406,7 @@ export class OpenAICodexAuth extends Service {
1002
1406
  if (access === undefined)
1003
1407
  throw new Error('OpenAI login is missing');
1004
1408
  const response = await fetch(USAGE_URL, {
1409
+ redirect: 'error',
1005
1410
  headers: {
1006
1411
  accept: 'application/json',
1007
1412
  authorization: `Bearer ${access}`,