@pure01fx/dsh-openai-codex-auth 0.13.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.13.1
6
+
7
+ - Serves a concurrent or pool-saturated request over HTTP/SSE instead of failing the turn.
8
+ - Moves a session to HTTP/SSE after a 400-class WebSocket rejection, so one rejected request can no longer fail every later turn in that session.
9
+ - Logs the provider's own rejection code, status and message instead of discarding them.
10
+ - Keeps a Fast alias resolvable when the catalog could not be read and retries one empty catalog read, so a transient catalog outage no longer fails the whole provider group in the model picker; a catalog that answers without Fast still refuses it.
11
+ - Memoizes the managed credential per credential-file signature with one shared resolution, so the model picker's per-model resolutions no longer take a dozen concurrent writer-lock acquisitions on the credential file (measured 2.1 s for 14 concurrent readers against a 2 s lock deadline before this change).
12
+ - Adds `locale/en.json` and `locale/zh.json` so the DSH 0.2.0 plugin panel shows a display name and description instead of the package id.
13
+
5
14
  ## 0.13.0
6
15
 
7
16
  - Targets DSH 0.2.0-rc.2 with exact SDK peers, development dependencies and runtime helpers; retains the tool-message, shell and provenance migration below.
package/lib/catalog.js CHANGED
@@ -265,7 +265,8 @@ export class NativeCodexCatalog {
265
265
  if (error instanceof LlmError && error.code === 'ABORTED')
266
266
  throw error;
267
267
  if (!(error instanceof LlmError) || error.code !== 'MISSING_CREDENTIAL') {
268
- this.options.warn?.('native Codex credential is unavailable; model metadata is advisory');
268
+ const code = error instanceof LlmError ? error.code : 'UNKNOWN';
269
+ this.options.warn?.(`native Codex credential is unavailable (${code}); model metadata is advisory`);
269
270
  }
270
271
  return { models: [] };
271
272
  }
package/lib/index.d.ts CHANGED
@@ -132,6 +132,8 @@ export declare class OpenAICodexAuth extends Service {
132
132
  private usageAccountId;
133
133
  private responseUsage;
134
134
  private credentialAccountId;
135
+ private credentialMemo;
136
+ private credentialResolution;
135
137
  private usageError;
136
138
  private usageRefresh;
137
139
  private usageGeneration;
@@ -163,6 +165,15 @@ export declare class OpenAICodexAuth extends Service {
163
165
  private commitCredential;
164
166
  /** Return the current managed bearer token, refreshing and migrating it when needed. */
165
167
  bearerToken(signal?: AbortSignal): Promise<string | undefined>;
168
+ /**
169
+ * Resolve the managed credential for one native call, reusing a lock-free memo of
170
+ * the file it was read from. The model picker resolves one credential per listed
171
+ * model, so one shared resolution replaces a burst of lock acquisitions on the
172
+ * same credential file.
173
+ */
174
+ private cachedManagedCredential;
175
+ /** Resolve once for every concurrent caller; never memo a credential the file no longer matches. */
176
+ private resolveManagedCredential;
166
177
  private managedCredential;
167
178
  private externalNativeCredential;
168
179
  private resolveNativeCredential;
package/lib/index.js CHANGED
@@ -6,7 +6,7 @@ import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
6
6
  import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
7
7
  import { createServer } from 'node:http';
8
8
  import { createHash, randomBytes } from 'node:crypto';
9
- import { readFile, unlink } from 'node:fs/promises';
9
+ import { readFile, stat, unlink } from 'node:fs/promises';
10
10
  import { join, resolve } from 'node:path';
11
11
  import { INVALID_CREDENTIAL_CODE, LlmError } from '@deepseek-ai/dsh-llm';
12
12
  import { CODEX_PROVIDER, NATIVE_CODEX_PROVIDER, NativeCodexAdapter, } from './native-adapter.js';
@@ -256,6 +256,26 @@ async function readCredentialDocument(filename) {
256
256
  throw error;
257
257
  }
258
258
  }
259
+ /**
260
+ * Cheap lock-free identity of the credential file. Every writer replaces it
261
+ * atomically, so both parts change and a cached read cannot outlive its file.
262
+ */
263
+ async function credentialFileSignature(filename) {
264
+ try {
265
+ const info = await stat(filename);
266
+ // An atomic replacement renames a fresh file, so the inode changes with it.
267
+ return `${info.ino}:${info.mtimeMs}:${info.size}`;
268
+ }
269
+ catch (error) {
270
+ if (error.code === 'ENOENT')
271
+ return 'missing';
272
+ throw error;
273
+ }
274
+ }
275
+ /** Whether a managed credential still avoids the pre-refresh window `bearerToken` enforces. */
276
+ function managedCredentialUsable(credential, now = Date.now()) {
277
+ return credential.expires > now + TOKEN_REFRESH_PREEMPT_MS;
278
+ }
259
279
  function currentCredential(document) {
260
280
  if (document?.currentAccountId === null || document === undefined)
261
281
  return undefined;
@@ -722,6 +742,8 @@ export class OpenAICodexAuth extends Service {
722
742
  usageAccountId;
723
743
  responseUsage;
724
744
  credentialAccountId;
745
+ credentialMemo;
746
+ credentialResolution;
725
747
  usageError;
726
748
  usageRefresh;
727
749
  usageGeneration = 0;
@@ -1036,6 +1058,8 @@ export class OpenAICodexAuth extends Service {
1036
1058
  throw failure;
1037
1059
  }
1038
1060
  async commitDocument(document, signal) {
1061
+ // A write may publish or replace the managed account, so no read memo survives it.
1062
+ this.credentialMemo = undefined;
1039
1063
  const nextToken = currentCredential(document)?.access;
1040
1064
  const previous = await this.ctx.credentials.resolve(TOKEN_REF);
1041
1065
  throwIfCancelled(signal);
@@ -1143,6 +1167,37 @@ export class OpenAICodexAuth extends Service {
1143
1167
  return access;
1144
1168
  }
1145
1169
  }
1170
+ /**
1171
+ * Resolve the managed credential for one native call, reusing a lock-free memo of
1172
+ * the file it was read from. The model picker resolves one credential per listed
1173
+ * model, so one shared resolution replaces a burst of lock acquisitions on the
1174
+ * same credential file.
1175
+ */
1176
+ async cachedManagedCredential(signal) {
1177
+ const signature = await credentialFileSignature(this.filename);
1178
+ const memo = this.credentialMemo;
1179
+ if (memo !== undefined && memo.signature === signature && managedCredentialUsable(memo.credential)) {
1180
+ this.setCredentialAccount(memo.credential.accountId);
1181
+ return memo.credential;
1182
+ }
1183
+ const pending = this.credentialResolution ??= this.resolveManagedCredential(signature);
1184
+ return await waitForSignal(pending, signal);
1185
+ }
1186
+ /** Resolve once for every concurrent caller; never memo a credential the file no longer matches. */
1187
+ resolveManagedCredential(signature) {
1188
+ const resolution = this.managedCredential().then(async (credential) => {
1189
+ if (credential !== undefined && await credentialFileSignature(this.filename) === signature) {
1190
+ this.credentialMemo = { signature, credential };
1191
+ }
1192
+ return credential;
1193
+ });
1194
+ const settled = () => {
1195
+ if (this.credentialResolution === resolution)
1196
+ this.credentialResolution = undefined;
1197
+ };
1198
+ void resolution.then(settled, settled);
1199
+ return resolution;
1200
+ }
1146
1201
  async managedCredential(signal) {
1147
1202
  while (true) {
1148
1203
  const access = await this.bearerToken(signal);
@@ -1172,7 +1227,7 @@ export class OpenAICodexAuth extends Service {
1172
1227
  async resolveNativeCredential(signal) {
1173
1228
  try {
1174
1229
  throwIfCancelled(signal);
1175
- const managed = await this.managedCredential(signal);
1230
+ const managed = await this.cachedManagedCredential(signal);
1176
1231
  if (managed !== undefined) {
1177
1232
  throwIfCancelled(signal);
1178
1233
  return { accessToken: managed.access, accountId: managed.accountId };
@@ -44,6 +44,13 @@ export declare class NativeCodexAdapter extends LlmAdapter {
44
44
  private assertNotAborted;
45
45
  providerInfo(provider: string): LlmProviderInfo;
46
46
  providerRetryPolicy(provider: string): ResolvedRetryPolicy;
47
+ /**
48
+ * Read model metadata, retrying once when a fetch returned nothing. The catalog
49
+ * caches nothing on failure, so a momentary outage otherwise empties every model
50
+ * list it touches; metadata is advisory and one extra request is cheaper than a
51
+ * provider that disappears from the picker until the next attempt.
52
+ */
53
+ private catalogModels;
47
54
  listModels(provider: string): Promise<readonly LlmModelInfo[]>;
48
55
  resolveModel(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>;
49
56
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
@@ -189,9 +189,22 @@ export class NativeCodexAdapter extends LlmAdapter {
189
189
  this.assertProvider(provider);
190
190
  return NATIVE_RETRY_POLICY;
191
191
  }
192
+ /**
193
+ * Read model metadata, retrying once when a fetch returned nothing. The catalog
194
+ * caches nothing on failure, so a momentary outage otherwise empties every model
195
+ * list it touches; metadata is advisory and one extra request is cheaper than a
196
+ * provider that disappears from the picker until the next attempt.
197
+ */
198
+ async catalogModels(signal) {
199
+ const first = await this.catalog?.list(signal) ?? [];
200
+ if (first.length > 0)
201
+ return first;
202
+ this.assertNotAborted(signal);
203
+ return await this.catalog?.list(signal) ?? [];
204
+ }
192
205
  async listModels(provider) {
193
206
  this.assertProvider(provider);
194
- const models = await this.catalog?.list() ?? [];
207
+ const models = await this.catalogModels();
195
208
  const aliases = fastRoutes(models);
196
209
  return models
197
210
  .filter(model => model.visibility === 'list')
@@ -207,7 +220,7 @@ export class NativeCodexAdapter extends LlmAdapter {
207
220
  async resolveModel(provider, model, signal) {
208
221
  this.assertNotAborted(signal);
209
222
  this.assertProvider(provider);
210
- const models = await this.catalog?.list(signal) ?? [];
223
+ const models = await this.catalogModels(signal);
211
224
  this.assertNotAborted(signal);
212
225
  const exact = models.find(candidate => candidate.slug === model);
213
226
  if (exact !== undefined)
@@ -216,7 +229,14 @@ export class NativeCodexAdapter extends LlmAdapter {
216
229
  if (fast !== undefined)
217
230
  return resolvedModel(provider, fast, true);
218
231
  if (model.endsWith(CODEX_FAST_ALIAS_SUFFIX)) {
219
- throw new LlmError('native Codex Fast is not advertised for the selected model', 'FAST_UNSUPPORTED');
232
+ const base = model.slice(0, -CODEX_FAST_ALIAS_SUFFIX.length);
233
+ // Only a catalog that knows the base model can answer this negatively.
234
+ // Without any entry for it the metadata is missing rather than negative, and
235
+ // a caller that resolves every listed model (the picker) must not lose the
236
+ // whole provider group to an advisory fetch.
237
+ if (models.some(candidate => candidate.slug === base)) {
238
+ throw new LlmError('native Codex Fast is not advertised for the selected model', 'FAST_UNSUPPORTED');
239
+ }
220
240
  }
221
241
  return { provider, id: model, name: model };
222
242
  }
@@ -191,6 +191,22 @@ function eventFailure(event) {
191
191
  }
192
192
  return retryError('native Codex WebSocket request was rejected', 'INVALID_REQUEST', facts);
193
193
  }
194
+ /**
195
+ * One bounded log line naming the provider's own rejection, so an operator can
196
+ * see why the socket refused a request. The provider's free-form text stays in
197
+ * the log; it never becomes the provider-neutral error message.
198
+ */
199
+ function rejectionDiagnostic(event) {
200
+ const error = typeof event.error === 'object' && event.error !== null
201
+ ? event.error : event;
202
+ const code = typeof error.code === 'string' && error.code.length > 0 ? error.code : undefined;
203
+ const raw = typeof error.message === 'string' ? error.message : '';
204
+ const message = raw.replace(/\s+/g, ' ').trim().slice(0, 300);
205
+ const status = typeof event.status === 'number' ? ` status=${String(event.status)}` : '';
206
+ const detail = [code, message.length === 0 ? undefined : message]
207
+ .filter((part) => part !== undefined).join(': ');
208
+ return `native Codex WebSocket rejected the request${status}${detail.length === 0 ? '' : ` (${detail})`}`;
209
+ }
194
210
  /** Prefer WebSocket v2; once safe retries exhaust, keep that DSH session on HTTP. */
195
211
  export class NativeCodexWebSocketTransport {
196
212
  options;
@@ -399,8 +415,10 @@ export class NativeCodexWebSocketTransport {
399
415
  publishCodexRateLimits(accountId, parseCodexRateLimitHeaders(typeof rawEvent.headers === 'object' && rawEvent.headers !== null
400
416
  ? rawEvent.headers : undefined), this.options.onRateLimits, this.options.warn);
401
417
  const wrapped = eventFailure(rawEvent);
402
- if (wrapped !== undefined)
418
+ if (wrapped !== undefined) {
419
+ this.options.warn?.(rejectionDiagnostic(rawEvent));
403
420
  throw wrapped;
421
+ }
404
422
  if (entry.turnState === undefined) {
405
423
  const nextTurnState = codexResponseTurnState(event);
406
424
  if (nextTurnState !== undefined)
@@ -478,10 +496,16 @@ export class NativeCodexWebSocketTransport {
478
496
  throw failure('native Codex WebSocket transport was disposed', 'DISPOSED');
479
497
  const key = sessionKey(generation, prepared.routingId);
480
498
  const entry = this.entry(key);
481
- if (entry.busy)
482
- throw failure('native Codex WebSocket session already has an active request', 'CONCURRENT_REQUEST');
483
- if (this.active.size >= this.maxSessions) {
484
- throw failure('native Codex WebSocket active session limit was reached', 'WS_SESSION_LIMIT');
499
+ // A second request for the same DSH session (the session-title call racing
500
+ // the first step, for example) cannot share one socket chain, and a
501
+ // saturated pool has no socket to lend. Both still have a correct transport
502
+ // over HTTP/SSE, so neither may fail the turn.
503
+ if (entry.busy || this.active.size >= this.maxSessions) {
504
+ this.options.warn?.(entry.busy
505
+ ? 'native Codex WebSocket session already has an active request; serving this request over HTTP/SSE'
506
+ : 'native Codex WebSocket active session limit was reached; serving this request over HTTP/SSE');
507
+ yield* this.http.stream(activeGeneration, mode, telemetry);
508
+ return;
485
509
  }
486
510
  entry.busy = true;
487
511
  this.active.set(entry, lifecycle);
@@ -545,6 +569,16 @@ export class NativeCodexWebSocketTransport {
545
569
  throw failedStepRetry(failureValue);
546
570
  throw failureValue;
547
571
  }
572
+ // A rejected request is not a transient socket condition: retrying the
573
+ // socket repeats the rejection. The same payload has a correct
574
+ // transport over HTTP/SSE, so keep this DSH session on HTTP from now on
575
+ // instead of failing every later turn.
576
+ if (failureValue.code === 'INVALID_REQUEST') {
577
+ entry.disabled = true;
578
+ yield* this.http.stream(activeGeneration, fallbackMode(), telemetry);
579
+ requestCompleted = true;
580
+ return;
581
+ }
548
582
  // Credential resolution may need the network itself, so wait until it recovers.
549
583
  // Once a credential is available, however, a WebSocket-only outage must consume
550
584
  // the bounded reconnect budget and then fall back to HTTP/SSE instead of leaving
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "OpenAI Codex Auth",
4
+ "description": "Native ChatGPT Codex provider with device-code login, its own model catalog and usage reporting, wired into the DSH Web GUI from the same origin."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "OpenAI Codex 认证",
4
+ "description": "原生 ChatGPT Codex 提供方:设备码登录、自带的模型目录与用量统计,与 DSH Web GUI 同源集成。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pure01fx/dsh-openai-codex-auth",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Native ChatGPT Codex provider, device-code-first login, and same-origin Web integration for DeepSeek Harness",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -28,9 +28,11 @@
28
28
  "default": "./lib/index.js"
29
29
  },
30
30
  "./client": "./client.js",
31
+ "./locale/*.json": "./locale/*.json",
31
32
  "./package.json": "./package.json"
32
33
  },
33
34
  "files": [
35
+ "locale",
34
36
  "lib/index.js",
35
37
  "lib/index.d.ts",
36
38
  "lib/catalog.js",