@pure01fx/dsh-openai-codex-auth 0.12.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,21 @@
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
+
14
+ ## 0.13.0
15
+
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.
17
+ - Merges GPT-6.1 catalog routing, shared Retry-After handling, assistant phases and deterministic developer instructions from the latest source.
18
+ - Target build/typecheck and all 517 regression tests pass on DSH 0.2.0-rc.2. No live OAuth/model requests were made; collection-level Web validation is recorded separately.
19
+
5
20
  ## 0.12.0
6
21
 
7
22
  ### Migration
@@ -22,6 +37,15 @@
22
37
  - Developer messages: preserved in Responses API requests
23
38
  - Cloud context: requires toolCallId === source.callId and !isError
24
39
 
40
+ - Shares Retry-After parsing across HTTP, SSE, and WebSocket errors, including nested JSON headers and HTTP dates; preserves server deadlines across retries and HTTP fallback without truncating advice to local backoff limits.
41
+
42
+ - Preserves commentary, partial_answer, and final_answer phases through response events, durable replay, and WebSocket continuation matching; legacy phase-less replay remains readable.
43
+
44
+ - Moves Standard base instructions into the same deterministic developer-message prefix used by Lite, preserving each protocol’s tool and reasoning controls; changed instructions invalidate WebSocket prefix reuse.
45
+
46
+ - Requests the live model catalog as Codex 0.162.0 to discover GPT-6.1 Sol; verified that 0.155.0 omits it for the same account despite the bundled 0.153.0 minimum. Adds its exact Lite fallback and dynamic metadata/routing regressions without fabricating catalog availability.
47
+
48
+
25
49
  ## 0.11.2
26
50
 
27
51
  - Requests the live model catalog as Codex 0.155.0 so version-gated GPT-6 Sol can be advertised; preserves dynamic account-specific discovery and routes exact Sol requests through Responses Lite when catalog metadata is unavailable. Fast remains catalog-authorized.
@@ -1,5 +1,44 @@
1
1
  # Codex 云端能力兼容性跟踪
2
2
 
3
+ ## 重试、消息阶段与 Standard 指令对齐(Unreleased)
4
+
5
+ 本节记录对 `ea27864f` 的三项增量适配,取代下方 Retry-After、phase 和 Standard 指令位置的历史待办;其余工作仍独立跟踪。
6
+
7
+ - Standard 基础指令改为 input 首部的 developer message,和 Lite 共用稳定 UUID 与 `model.base_instructions` metadata 构造。Standard 顶层 tools、并行工具与 reasoning 参数保留各自协议;Lite 的工具前缀及目录模板/DSH 指令组合保持独立。回归验证相同指令保持 WS 前缀复用,改变指令会触发完整请求,HTTP 历史、图片、工具输出和加密 reasoning 顺序不变。
8
+ - 消息 phase 已接收并保留 commentary / partial_answer / final_answer,通过 v1 描述符的可选字段持久化再回放,并让 WS 复用比较使用同一有效 phase。added-only 的 phase 保留到 done,done 的显式 phase 优先;流中的 null/缺失值表示未提供,非法枚举拒绝。测试覆盖 JSON 保存/恢复、分叉和重启的请求构造、真实传输类的增量匹配与 phase 改变后完整重发。缺少 phase 的旧会话可读;旧插件的严格字段校验无法读取含 phase 的新描述符,降级时需考虑这一限制。
9
+ - Retry-After 共用秒数/HTTP-date 解析,支持 JSON 数字值,嵌套错误 headers 优先;保留单次收到建议的单调时钟截止时间,不用本地退避上限截短服务端等待。HTTP 读取错误正文及其超时不会重置或丢弃等待,WS 最后一次失败后切换 HTTP 也遵守剩余等待。长等待分段使用 Node timer,仍可取消。宿主 `LlmError` 只公开正数 duration,无法跨宿主持久化表达单调时钟 deadline;本插件默认 normal 重试策略在宿主等待超上限时交还错误,不会提前重试。宿主另行配置的 always 策略可能忽略长等待,不属于本次插件内改动。
10
+
11
+ 验证:`npm run build`、`npm test`(27 文件 / 511 项)、`npm run typecheck`、`git diff --check`、`npm pack --dry-run --ignore-scripts` 均通过;所有生成模块与新鲜编译一致,新 retry-after 模块已加入打包清单。独立审查发现的非法日期归一化问题已修复,并加入内层非法日期回退外层有效等待的回归。本轮为源码、模拟传输与 loopback 验证,未执行线上推理、发布、替换 hu profile 或重启 GUI。
12
+
13
+ ## GPT-6.1 Sol 增量对齐(Unreleased,2026-10-08)
14
+
15
+ `../codex` 已 fast-forward 到 `ea27864f99f0b086cec2f9f0251b7190fb9844f1`(本次获取的 origin/main),工作树干净。此轮针对模型发现与 Lite 路由,不宣称完整运行时对齐,原 Provider/cloud 跟踪提交保持不变。
16
+
17
+ ### 已确认的缺失原因与修复
18
+
19
+ 对同一当前账号执行只读线上目录请求(未调用推理、未记录凭据):
20
+
21
+ | client_version | originator | HTTP | 返回目录 |
22
+ | --- | --- | --- | --- |
23
+ | 0.153.4 | dsh | 200 | 不含 gpt-6-sol / gpt-6.1-sol |
24
+ | 0.155.0 | dsh | 200 | 含 gpt-6-sol,不含 gpt-6.1-sol |
25
+ | 0.162.0 | dsh | 200 | 含 gpt-6.1-sol,visibility=list |
26
+ | 0.162.0 | codex_cli_rs | 200 | 同样含 gpt-6.1-sol |
27
+
28
+ 上游 `models-manager/models.json` 将 6.1 Sol 的 minimal_client_version 标为 0.153.0,但它不等于线上实际发现门槛。请求版本升至本次已验证的 0.162.0(上游已有 rust-v0.162.0-alpha.20;目录发送去预发布后缀的整版本)。不更改 originator,不将静态模型强行加入账号目录。
29
+
30
+ 补充精确 `gpt-6.1-sol` 的 Lite 后备,目录存在时仍以 metadata 为准。新增精简上游 fixture,验证动态发现、六档 reasoning、默认 low / multi-agent xhigh、Fast 授权、metadata 优先及未知近似名称不误用 Lite。
31
+
32
+ 当前 3080 GUI 的 hu profile 安装的是插件 0.11.1,请求版本 0.153.4。本轮只修改并构建仓库,未替换 profile 安装或重启当前会话;仓库修改不会自动改变正在运行的 GUI。
33
+
34
+ 验证:`npm run build`、`npm test`(26 文件 / 469 项)、`npm run typecheck`、`git diff --check` 全部通过;生成产物一致性测试通过。使用新构建的 NativeCodexCatalog + NativeCodexAdapter 查询当前账号,已返回 `gpt-6.1-sol` 与 `gpt-6.1-sol-fast`,六档推理配置及默认 low 解析成功。本轮未执行模型推理,因此此结果验证发现与元数据,不代表线上推理端到端验收。
35
+
36
+ ### 独立后续兼容性工作
37
+
38
+ - Retry-After:新上游从 response.failed.response.error.headers 读取等待提示,WebSocket 优先嵌套 headers,支持数字 JSON 值和 HTTP 日期;插件仍遗漏部分嵌套提示并将等待上限压到 120 秒。建议单独对齐共享解析及 deadline 传递,避免早于服务端建议重试。依据 `codex-api/src/sse/responses_error.rs`、`endpoint/responses_websocket.rs`、`http-client/src/retry_after.rs`。这不是 6.1 模型目录缺失原因。
39
+ - 数字自定义 reasoning effort、Standard instructions 移入 developer input、partial_answer phase 回放保真分别存在增量差异,应单独设计与验证。6.1 现有命名 effort 和 Lite 路径不依赖这些改动。
40
+
41
+
3
42
  ## 已实现:Sol 与错误协议增量适配(0.11.2)
4
43
 
5
44
  参考上游 `c44deff7b1083e9660ac55d02122481f1cdf139b`,在既有 0.11.1 工作树上完成以下必要改动。此节取代下方历史审查中相应的“待适配”结论;不代表完整上游运行时对齐。
package/README.md CHANGED
@@ -17,9 +17,9 @@
17
17
 
18
18
  ## 快速开始
19
19
 
20
- 版本 `0.12.0` 明确适配 DeepSeek Harness `0.1.7-rc.2`,SDK peers/runtime helpers 精确锁定该版本;不声明兼容旧 SDK。旧 DSH `0.1.5-rc.1` profile 应保留原插件 `0.11.2`,升级时在独立 profile 安装新版插件并保留原 profile/锁文件以便回滚。无需迁移账号文件或浏览器隐藏列表;`dsh.engines` 仅作兼容性说明,不当作宿主硬门禁。
20
+ 版本 `0.13.0` 明确适配 DeepSeek Harness `0.2.0-rc.2`,SDK peers/runtime helpers 精确锁定该版本;不声明兼容旧 SDK。旧 DSH `0.1.5-rc.1` profile 应保留原插件 `0.11.2`,升级时在独立 profile 安装新版插件并保留原 profile/锁文件以便回滚。无需迁移账号文件或浏览器隐藏列表;`dsh.engines` 仅作兼容性说明,不当作宿主硬门禁。
21
21
 
22
- 本版本通过真实目标 LLM/tools 服务与本地 HTTP/SSE/WebSocket 回放、错误/取消回归;模型隐藏覆盖真实目录/catalog/store 的投影更新、重连和重新加载。验证命令为 `pnpm typecheck && pnpm build && pnpm test`;当前版本未执行线上 OAuth/付费模型验收。
22
+ 回归范围包含真实目标 LLM/tools 服务、本地 HTTP/SSE/WebSocket 回放、错误/取消,以及模型隐藏在真实目录/catalog/store 的投影更新、重连和重新加载。验证命令为 `pnpm typecheck && pnpm build && pnpm test`;0.13.0 候选版已通过目标版本构建、类型检查和 517 项测试;当前未执行线上 OAuth/付费模型验收。
23
23
 
24
24
  `0.11.0` 移除了插件额外设置的请求、响应、SSE/WebSocket、图片和重放状态大小上限,以及相应的 transport 选项(含 `maxRequestBodyBytes`);旧配置中的该字段应删除。搜索结果完整保留,WebSocket 继续优先复用增量上下文。服务端限制、协议校验及 DSH 文件/附件策略仍生效。
25
25
 
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>;
@@ -57,7 +57,8 @@ function responsesLiteMode(model, requestedModel = model?.slug) {
57
57
  if (model !== undefined && !model.useResponsesLite)
58
58
  return {};
59
59
  // Never downgrade known Lite-only slugs to Standard Responses during catalog outages.
60
- if (model === undefined && requestedModel !== 'gpt-6-astra' && requestedModel !== 'gpt-6-sol')
60
+ if (model === undefined && requestedModel !== 'gpt-6-astra'
61
+ && requestedModel !== 'gpt-6-sol' && requestedModel !== 'gpt-6.1-sol')
61
62
  return {};
62
63
  return {
63
64
  responsesLite: {
@@ -188,9 +189,22 @@ export class NativeCodexAdapter extends LlmAdapter {
188
189
  this.assertProvider(provider);
189
190
  return NATIVE_RETRY_POLICY;
190
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
+ }
191
205
  async listModels(provider) {
192
206
  this.assertProvider(provider);
193
- const models = await this.catalog?.list() ?? [];
207
+ const models = await this.catalogModels();
194
208
  const aliases = fastRoutes(models);
195
209
  return models
196
210
  .filter(model => model.visibility === 'list')
@@ -206,7 +220,7 @@ export class NativeCodexAdapter extends LlmAdapter {
206
220
  async resolveModel(provider, model, signal) {
207
221
  this.assertNotAborted(signal);
208
222
  this.assertProvider(provider);
209
- const models = await this.catalog?.list(signal) ?? [];
223
+ const models = await this.catalogModels(signal);
210
224
  this.assertNotAborted(signal);
211
225
  const exact = models.find(candidate => candidate.slug === model);
212
226
  if (exact !== undefined)
@@ -215,7 +229,14 @@ export class NativeCodexAdapter extends LlmAdapter {
215
229
  if (fast !== undefined)
216
230
  return resolvedModel(provider, fast, true);
217
231
  if (model.endsWith(CODEX_FAST_ALIAS_SUFFIX)) {
218
- 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
+ }
219
240
  }
220
241
  return { provider, id: model, name: model };
221
242
  }
@@ -4,6 +4,7 @@ import { networkTelemetry, trackNetwork, observeNetworkOutput } from './network-
4
4
  import { EMPTY_RESPONSE_CODE, LlmError, ProviderRequestId, attributionHeaders, } from '@deepseek-ai/dsh-llm';
5
5
  import { nativeCodexAuthorityHash } from './catalog.js';
6
6
  import { nativeCodexEndpoint } from './endpoint.js';
7
+ import { retryAfterMs, retryError, remainingRetryDelay, sleepRetry, inheritRetryDeadline } from './retry-after.js';
7
8
  import { NATIVE_CODEX_CONNECTION_FAILED_CODE, NATIVE_CODEX_STREAM_INTERRUPTED_CODE, isNativeCodexConnectionFailure, } from './native-adapter.js';
8
9
  import { boundedCodexTurnState, codexRequestBody, codexResponseTurnState, streamResponses, responsesFailure, } from './responses.js';
9
10
  import { hasNativeCodexReplayKind } from './replay.js';
@@ -19,7 +20,7 @@ const INITIAL_CONNECTION_RETRY_DELAY_MS = 5_000;
19
20
  const MAX_CONNECTION_RETRY_DELAY_MS = 60_000;
20
21
  const MAX_ERROR_BODY_BYTES = 64 * 1024;
21
22
  function aborted(message = 'native Codex request was aborted') {
22
- return new LlmError(message, 'ABORTED');
23
+ return retryError(message, 'ABORTED');
23
24
  }
24
25
  function throwIfAborted(signal) {
25
26
  if (!signal?.aborted)
@@ -29,7 +30,7 @@ function throwIfAborted(signal) {
29
30
  throw aborted();
30
31
  }
31
32
  function fixedFailure(message, code, options) {
32
- return new LlmError(message, code, options);
33
+ return retryError(message, code, options);
33
34
  }
34
35
  function safePositiveInteger(value, fallback, name) {
35
36
  const resolved = value ?? fallback;
@@ -52,24 +53,6 @@ function stablePromptCacheKey(sessionId) {
52
53
  const hex = bytes.toString('hex');
53
54
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
54
55
  }
55
- function sleep(delayMs, signal) {
56
- throwIfAborted(signal);
57
- return new Promise((resolve, reject) => {
58
- const timer = setTimeout(done, delayMs);
59
- const abort = () => {
60
- clearTimeout(timer);
61
- signal?.removeEventListener('abort', abort);
62
- const reason = signal?.reason;
63
- reject(reason instanceof LlmError
64
- ? reason : aborted('native Codex retry wait was aborted'));
65
- };
66
- function done() {
67
- signal?.removeEventListener('abort', abort);
68
- resolve();
69
- }
70
- signal?.addEventListener('abort', abort, { once: true });
71
- });
72
- }
73
56
  function attemptWatchdog(parent, requestMs, idleMs) {
74
57
  const controller = new AbortController();
75
58
  let timeout;
@@ -252,18 +235,8 @@ async function boundedError(response, signal, telemetry) {
252
235
  return {};
253
236
  }
254
237
  }
255
- function retryAfterMs(response, maxDelayMs) {
256
- const raw = response.headers.get('retry-after');
257
- if (raw === null)
258
- return undefined;
259
- const seconds = Number(raw);
260
- const value = Number.isFinite(seconds)
261
- ? seconds * 1_000
262
- : Date.parse(raw) - Date.now();
263
- return Number.isFinite(value) && value > 0 ? Math.min(value, maxDelayMs) : undefined;
264
- }
265
- function errorFacts(response, maxDelayMs) {
266
- const providerRetryAfterMs = retryAfterMs(response, maxDelayMs);
238
+ function errorFacts(response) {
239
+ const providerRetryAfterMs = retryAfterMs(response.headers.get('retry-after'));
267
240
  const requestId = response.headers.get('x-request-id');
268
241
  return {
269
242
  status: response.status,
@@ -273,37 +246,43 @@ function errorFacts(response, maxDelayMs) {
273
246
  : { requestId: ProviderRequestId(requestId) },
274
247
  };
275
248
  }
276
- async function httpFailure(response, maxDelayMs, signal, telemetry) {
249
+ async function httpFailure(response, signal, telemetry) {
250
+ const facts = errorFacts(response);
251
+ const advice = retryError('native Codex HTTP retry advice', 'SERVER', facts);
252
+ const fail = (message, code, options) => inheritRetryDeadline(fixedFailure(message, code, options), advice);
277
253
  const detail = await boundedError(response, signal, telemetry);
278
- const facts = errorFacts(response, maxDelayMs);
279
254
  const classified = responsesFailure(detail.code, detail.message);
280
255
  if (response.status === 401 || response.status === 403) {
281
- return fixedFailure('native Codex rejected the configured credential', 'AUTH', facts);
256
+ return fail('native Codex rejected the configured credential', 'AUTH', facts);
282
257
  }
283
258
  if (classified.code !== 'SERVER') {
284
259
  const delay = facts?.providerRetryAfterMs ?? classified.failure.providerRetryAfterMs;
285
- return fixedFailure(classified.message, classified.code, {
260
+ return fail(classified.message, classified.code, {
286
261
  ...facts,
287
- ...(delay === undefined ? {} : { providerRetryAfterMs: Math.min(delay, maxDelayMs) }),
262
+ ...(delay === undefined ? {} : { providerRetryAfterMs: delay }),
288
263
  });
289
264
  }
290
265
  if (response.status === 429) {
291
- return fixedFailure('native Codex request was rate limited', 'RATE_LIMIT', facts);
266
+ return fail('native Codex request was rate limited', 'RATE_LIMIT', facts);
292
267
  }
293
268
  if (response.status === 408 || response.status === 504) {
294
- return fixedFailure('native Codex request timed out', 'TIMEOUT', facts);
269
+ return fail('native Codex request timed out', 'TIMEOUT', facts);
295
270
  }
296
271
  if (response.status >= 500) {
297
- return fixedFailure('native Codex server request failed', 'SERVER', facts);
272
+ return fail('native Codex server request failed', 'SERVER', facts);
298
273
  }
299
- return fixedFailure('native Codex request was rejected', 'INVALID_REQUEST', facts);
274
+ return fail('native Codex request was rejected', 'INVALID_REQUEST', facts);
300
275
  }
301
276
  function mappedFailure(error, watchdog, parent) {
302
277
  if (parent?.aborted) {
303
278
  return parent.reason instanceof LlmError ? parent.reason : aborted();
304
279
  }
305
280
  if (watchdog.timedOut())
306
- return fixedFailure('native Codex request timed out', 'TIMEOUT');
281
+ return fixedFailure('native Codex request timed out', 'TIMEOUT', {
282
+ cause: error,
283
+ ...(error instanceof LlmError && error.failure.providerRetryAfterMs !== undefined
284
+ ? { providerRetryAfterMs: error.failure.providerRetryAfterMs } : {}),
285
+ });
307
286
  if (error instanceof LlmError)
308
287
  return error;
309
288
  return fixedFailure('native Codex transport failed', 'TRANSPORT', { cause: error });
@@ -331,8 +310,7 @@ function retryable(error) {
331
310
  }
332
311
  function failedStepRetry(error) {
333
312
  const failure = error.failure;
334
- const providerRetryAfterMs = failure.providerRetryAfterMs === undefined
335
- ? undefined : Math.min(failure.providerRetryAfterMs, DEFAULT_MAX_RETRY_DELAY_MS);
313
+ const providerRetryAfterMs = failure.providerRetryAfterMs;
336
314
  return fixedFailure(`native Codex response stream was interrupted: ${error.message}`, NATIVE_CODEX_STREAM_INTERRUPTED_CODE, {
337
315
  cause: error,
338
316
  ...(failure.status === undefined ? {} : { status: failure.status }),
@@ -362,21 +340,21 @@ export class NativeCodexHttpTransport {
362
340
  }
363
341
  retryDelay(retry, providerDelay) {
364
342
  if (providerDelay !== undefined)
365
- return Math.min(providerDelay, this.maxDelayMs);
343
+ return providerDelay;
366
344
  const exponential = Math.min(this.initialDelayMs * (2 ** retry), this.maxDelayMs);
367
345
  const random = this.options.random?.() ?? Math.random();
368
346
  const jitter = 0.9 + Math.max(0, Math.min(1, random)) * 0.2;
369
347
  return Math.max(1, Math.round(exponential * jitter));
370
348
  }
371
349
  async wait(retry, error, signal, telemetry) {
372
- const delay = this.retryDelay(retry, error.failure.providerRetryAfterMs);
350
+ const delay = this.retryDelay(retry, remainingRetryDelay(error));
373
351
  telemetry.retry(delay);
374
- await (this.options.sleep ?? sleep)(delay, signal);
352
+ await (this.options.sleep ?? sleepRetry)(delay, signal);
375
353
  }
376
354
  async waitForConnection(delayMs, signal, telemetry) {
377
355
  telemetry.retry(delayMs, 'connection');
378
356
  this.options.warn?.(`native Codex network is unavailable; reconnecting in ${delayMs}ms`);
379
- await (this.options.sleep ?? sleep)(delayMs, signal);
357
+ await (this.options.sleep ?? sleepRetry)(delayMs, signal);
380
358
  }
381
359
  endpointUrl() { return this.endpoint; }
382
360
  async prepare(generation, mode = {}) {
@@ -527,7 +505,7 @@ export class NativeCodexHttpTransport {
527
505
  telemetry.retry(0);
528
506
  continue;
529
507
  }
530
- throw fixedFailure('native Codex rejected the configured credential', 'AUTH', errorFacts(response, this.maxDelayMs));
508
+ throw fixedFailure('native Codex rejected the configured credential', 'AUTH', errorFacts(response));
531
509
  }
532
510
  catch (error) {
533
511
  const connection = connectionFailure(error, watchdog, generation.signal);
@@ -542,7 +520,7 @@ export class NativeCodexHttpTransport {
542
520
  }
543
521
  }
544
522
  if (!response.ok) {
545
- let failure = await httpFailure(response, this.maxDelayMs, watchdog.signal, telemetry);
523
+ let failure = await httpFailure(response, watchdog.signal, telemetry);
546
524
  if (generation.signal?.aborted || watchdog.timedOut()) {
547
525
  failure = mappedFailure(failure, watchdog, generation.signal);
548
526
  }
@@ -3,11 +3,12 @@ import { LlmError, ProviderRequestId } from '@deepseek-ai/dsh-llm';
3
3
  import { HttpsProxyAgent } from 'https-proxy-agent';
4
4
  import { getProxyForUrl } from 'proxy-from-env';
5
5
  import { nativeCodexEndpoint } from './endpoint.js';
6
+ import { retryAfterMs, retryError } from './retry-after.js';
6
7
  import { NATIVE_CODEX_CONNECTION_FAILED_CODE, isNativeCodexConnectionFailure, } from './native-adapter.js';
7
8
  import WebSocket from 'ws';
8
9
  const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
9
10
  function failure(message, code, cause) {
10
- return new LlmError(message, code, cause === undefined ? undefined : { cause });
11
+ return retryError(message, code, cause === undefined ? undefined : { cause });
11
12
  }
12
13
  function abortFailure(signal) {
13
14
  return signal?.reason instanceof LlmError
@@ -17,9 +18,7 @@ function abortFailure(signal) {
17
18
  function handshakeFacts(status, headers) {
18
19
  const first = (value) => Array.isArray(value) ? value[0] : value;
19
20
  const requestId = first(headers['x-request-id']);
20
- const retryAfter = Number(first(headers['retry-after']));
21
- const providerRetryAfterMs = Number.isFinite(retryAfter) && retryAfter > 0
22
- ? Math.min(retryAfter * 1_000, 120_000) : undefined;
21
+ const providerRetryAfterMs = retryAfterMs(first(headers['retry-after']));
23
22
  return {
24
23
  ...(status === undefined ? {} : { status }),
25
24
  ...(providerRetryAfterMs === undefined ? {} : { providerRetryAfterMs }),
@@ -202,7 +201,7 @@ export class NodeNativeCodexWebSocketFactory {
202
201
  ? 'WS_RETRYABLE'
203
202
  : response.statusCode !== undefined && response.statusCode >= 400
204
203
  ? 'INVALID_REQUEST' : 'WS_HANDSHAKE';
205
- reject(new LlmError(`native Codex WebSocket handshake failed (HTTP ${response.statusCode ?? 0})`, code, handshakeFacts(response.statusCode, response.headers)));
204
+ reject(retryError(`native Codex WebSocket handshake failed (HTTP ${response.statusCode ?? 0})`, code, handshakeFacts(response.statusCode, response.headers)));
206
205
  });
207
206
  socket.on('open', () => {
208
207
  options.signal?.removeEventListener('abort', abort);