@bitkyc08/opencodex 2.1.8 → 2.1.10

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.
@@ -0,0 +1 @@
1
+ :root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;--bg:var(--lightningcss-light,#f6f7f9)var(--lightningcss-dark,#0c0d11);--rail:var(--lightningcss-light,#fff)var(--lightningcss-dark,#101218);--surface:var(--lightningcss-light,#fff)var(--lightningcss-dark,#15171d);--raised:var(--lightningcss-light,#f1f2f5)var(--lightningcss-dark,#1c1f27);--raised-hover:var(--lightningcss-light,#e8eaee)var(--lightningcss-dark,#242833);--border:var(--lightningcss-light,#e2e4e9)var(--lightningcss-dark,#2a2e39);--border-soft:var(--lightningcss-light,#ededf1)var(--lightningcss-dark,#20242d);--hover:var(--lightningcss-light,#11131c09)var(--lightningcss-dark,#ffffff06);--text:var(--lightningcss-light,#16181d)var(--lightningcss-dark,#edeef2);--muted:var(--lightningcss-light,#5b6270)var(--lightningcss-dark,#a3a9b5);--faint:var(--lightningcss-light,#868d9b)var(--lightningcss-dark,#6b7280);--accent:var(--lightningcss-light,#4f46e5)var(--lightningcss-dark,#6366f1);--accent-hover:var(--lightningcss-light,#4338ca)var(--lightningcss-dark,#818cf8);--accent-ink:#fff;--accent-soft:var(--lightningcss-light,#4f46e51a)var(--lightningcss-dark,#6366f129);--accent-ring:var(--lightningcss-light,#4f46e566)var(--lightningcss-dark,#6366f180);--green:var(--lightningcss-light,#047857)var(--lightningcss-dark,#34d399);--green-soft:var(--lightningcss-light,#0596691a)var(--lightningcss-dark,#34d39921);--red:var(--lightningcss-light,#b91c1c)var(--lightningcss-dark,#f87171);--red-soft:var(--lightningcss-light,#b91c1c17)var(--lightningcss-dark,#f8717121);--amber:var(--lightningcss-light,#b45309)var(--lightningcss-dark,#fbbf24);--amber-soft:var(--lightningcss-light,#b453091a)var(--lightningcss-dark,#fbbf2421);--radius:8px;--radius-sm:5px;--radius-xs:4px;--font:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, "Helvetica Neue", sans-serif;--mono:ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;--shadow:0 1px 2px var(--lightningcss-light,#1018280f)var(--lightningcss-dark,#00000080), 0 10px 28px var(--lightningcss-light,#10182812)var(--lightningcss-dark,#0000004d);--shadow-sm:0 1px 2px var(--lightningcss-light,#1018280f)var(--lightningcss-dark,#0006)}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}}:root[data-theme=light]{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light}:root[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}html,body,#root{height:100%}body{background:var(--bg);color:var(--text);font-family:var(--font);-webkit-font-smoothing:antialiased;text-rendering:optimizelegibility;margin:0;font-size:14px;line-height:1.5}a{color:var(--accent-hover);text-decoration:none}a:hover{text-decoration:underline}code,.mono{font-family:var(--mono);font-size:.92em}h1,h2,h3,h4{letter-spacing:-.01em;margin:0;font-weight:650}::selection{background:var(--accent-soft)}input[type=checkbox],input[type=radio]{accent-color:var(--accent)}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:var(--border);border:2px solid var(--bg);border-radius:99px}::-webkit-scrollbar-thumb:hover{background:var(--faint)}:focus-visible{outline:2px solid var(--accent-ring);outline-offset:2px;border-radius:4px}.app{grid-template-columns:232px 1fr;min-height:100dvh;display:grid}.sidebar{border-right:1px solid var(--border);background:var(--rail);flex-direction:column;align-self:start;gap:4px;height:100dvh;padding:18px 14px;display:flex;position:sticky;top:0}.brand{align-items:center;gap:10px;padding:6px 8px 14px;display:flex}.brand-logo{background:var(--text);flex-shrink:0;width:26px;height:26px;-webkit-mask:url(/logo.png) 50%/contain no-repeat;mask:url(/logo.png) 50%/contain no-repeat}.brand .name{letter-spacing:-.02em;font-size:15px;font-weight:700;line-height:26px}.brand .ver{font-family:var(--mono);color:var(--muted);background:var(--raised);border:1px solid var(--border);border-radius:99px;align-self:center;padding:2px 6px;font-size:10px;line-height:1}.nav-item{border-radius:var(--radius-sm);text-align:left;cursor:pointer;width:100%;color:var(--muted);font:inherit;background:0 0;border:none;align-items:center;gap:10px;padding:8px 10px;font-size:13.5px;font-weight:500;transition:background .12s,color .12s;display:flex}.nav-item:hover{background:var(--raised);color:var(--text)}.nav-item.active{background:var(--accent-soft);color:var(--text)}.nav-item svg{width:17px;height:17px;color:var(--faint);flex-shrink:0}.nav-item.active svg{color:var(--accent)}.sidebar-foot{flex-direction:column;gap:2px;margin-top:auto;padding-top:12px;display:flex}.sidebar-link{color:var(--muted);border-radius:var(--radius-sm);align-items:center;gap:9px;padding:8px 10px;font-size:13px;display:flex}.sidebar-link:hover{background:var(--raised);color:var(--text);text-decoration:none}.sidebar-link svg{width:16px;height:16px}.theme-toggle{text-align:left;cursor:pointer;width:100%;color:var(--muted);font:inherit;border-radius:var(--radius-sm);background:0 0;border:none;align-items:center;gap:9px;padding:8px 10px;font-size:13px;transition:background .12s,color .12s;display:flex}.theme-toggle:hover{background:var(--raised);color:var(--text)}.theme-toggle svg{flex-shrink:0;width:16px;height:16px}.theme-toggle .mode{text-transform:capitalize}.stop-toggle{color:var(--red)}.stop-toggle:hover{background:var(--red-soft);color:var(--red)}.stop-toggle:disabled{opacity:.5;cursor:default}.main{min-width:0}.main-inner{max-width:980px;margin:0 auto;padding:32px 36px 64px}.page-head{justify-content:space-between;align-items:center;gap:16px;margin-bottom:6px;display:flex}.page-head h2{font-size:19px}.page-sub{color:var(--muted);max-width:70ch;margin:4px 0 22px;font-size:13.5px}.page-sub b{color:var(--text);font-weight:600}.btn{border-radius:var(--radius-sm);font:inherit;cursor:pointer;white-space:nowrap;border:1px solid #0000;justify-content:center;align-items:center;gap:7px;padding:7px 14px;font-size:13px;font-weight:550;transition:background .12s,border-color .12s,opacity .12s;display:inline-flex}.btn svg{width:15px;height:15px}.btn:disabled{opacity:.55;cursor:default}.btn-primary{background:var(--accent);color:var(--accent-ink)}.btn-primary:hover:not(:disabled){background:var(--accent-hover)}.btn-ghost{background:var(--raised);color:var(--text);border-color:var(--border)}.btn-ghost:hover:not(:disabled){background:var(--raised-hover)}.btn-danger{color:var(--red);background:0 0;border-color:#f871714d}.btn-danger:hover:not(:disabled){background:var(--red-soft)}.btn-sm{border-radius:var(--radius-xs);padding:4px 9px;font-size:12px}.btn-icon{color:var(--muted);border-radius:var(--radius-xs);padding:5px;transition:background .12s,color .12s}.btn-icon:hover{background:var(--raised-hover);color:var(--text)}.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);min-width:0}.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:18px}.panel-accent{background:linear-gradient(180deg, var(--accent-soft), transparent 120%), var(--surface);border-color:#7c5cff47}.stat-row{grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:28px;display:grid}.stat{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;transition:border-color .12s}.stat:hover{border-color:var(--accent-ring)}.stat .label{color:var(--muted);text-transform:uppercase;letter-spacing:.05em;align-items:center;gap:6px;margin-bottom:9px;font-size:11px;font-weight:600;display:flex}.stat .label svg{width:14px;height:14px}.stat .value{letter-spacing:-.02em;font-size:24px;font-weight:700;line-height:1.1}.stat .value.mono{font-family:var(--mono);font-size:19px}.model-group-head{color:var(--muted);text-transform:uppercase;letter-spacing:.04em;align-items:baseline;gap:8px;margin:0 0 8px;font-size:12px;font-weight:600;display:flex}.model-group-head .count{font-family:var(--mono);text-transform:none;letter-spacing:0;color:var(--faint);font-weight:500}.model-grid{grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px;display:grid}.model-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px 12px;transition:border-color .12s,background .12s}.model-card:hover{border-color:var(--accent-ring);background:var(--hover)}.model-card .id{font-family:var(--mono);letter-spacing:-.01em;color:var(--text);font-size:13px;font-weight:600}.badge{font-size:11px;font-weight:600;font-family:var(--mono);letter-spacing:.01em;border-radius:99px;align-items:center;gap:5px;padding:2px 8px;display:inline-flex}.badge-accent{background:var(--accent-soft);color:var(--accent-hover)}.badge-green{background:var(--green-soft);color:var(--green)}.badge-amber{background:var(--amber-soft);color:var(--amber)}.badge-muted{background:var(--raised);color:var(--muted);border:1px solid var(--border)}.dot{border-radius:50%;flex-shrink:0;width:7px;height:7px}.dot-green{background:var(--green);box-shadow:0 0 0 3px var(--green-soft)}.dot-red{background:var(--red);box-shadow:0 0 0 3px var(--red-soft)}.tbl{border-collapse:collapse;width:100%;font-size:13px}.tbl thead th{text-align:left;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--border);padding:9px 12px;font-size:11.5px;font-weight:600}.tbl tbody td{border-bottom:1px solid var(--border-soft);padding:10px 12px}.tbl tbody tr:last-child td{border-bottom:none}.tbl tbody tr:hover td{background:var(--hover)}.tbl .num{text-align:right;font-family:var(--mono)}.tbl-wrap{border:1px solid var(--border);border-radius:var(--radius);overflow-x:auto}.input,textarea.input{border-radius:var(--radius-sm);background:var(--raised);border:1px solid var(--border);width:100%;color:var(--text);font:inherit;padding:8px 11px;font-size:13px;transition:border-color .12s}.input::placeholder{color:var(--faint)}.input:focus{border-color:var(--accent);outline:none}textarea.input{resize:vertical;font-family:var(--mono);line-height:1.55}.field-label{color:var(--muted);margin-bottom:5px;font-size:12px;font-weight:500;display:block}select.input{appearance:none}.select-sm{border-radius:var(--radius-sm);background:var(--raised);border:1px solid var(--border);color:var(--text);font:inherit;cursor:pointer;padding:5px 8px;font-size:13px;transition:border-color .12s}.select-sm:focus{border-color:var(--accent);outline:none}.select-sm:disabled{opacity:.5;cursor:default}.switch{cursor:pointer;background:var(--lightningcss-light,#c5c9d2)var(--lightningcss-dark,#3a3f4b);border:none;border-radius:99px;flex-shrink:0;width:34px;height:19px;padding:0;transition:background .15s;position:relative}.switch.on{background:var(--accent)}.switch:disabled{opacity:.6;cursor:default}.switch .knob{background:#fff;border-radius:50%;width:15px;height:15px;transition:left .15s;position:absolute;top:2px;left:2px;box-shadow:0 1px 2px #1018284d}.switch.on .knob{left:17px}.muted{color:var(--muted)}.faint{color:var(--faint)}.row{align-items:center;gap:10px;display:flex}.spread{justify-content:space-between;align-items:center;gap:12px;display:flex}.stack{flex-direction:column;display:flex}.chip{font-family:var(--mono);background:var(--raised);border:1px solid var(--border);border-radius:var(--radius-xs);color:var(--text);padding:1px 7px;font-size:12px}.empty{text-align:center;border:1px dashed var(--border);border-radius:var(--radius);color:var(--muted);padding:56px 20px}.empty svg{width:30px;height:30px;color:var(--faint);margin-bottom:12px}.empty .title{color:var(--text);margin-bottom:6px;font-weight:600}.notice{border-radius:var(--radius-sm);align-items:center;gap:8px;margin-bottom:14px;padding:9px 12px;font-size:13px;display:flex}.notice svg{flex-shrink:0;width:15px;height:15px}.notice-ok{background:var(--green-soft);color:var(--green)}.notice-err{background:var(--red-soft);color:var(--red)}.h-section{color:var(--text);align-items:center;gap:8px;margin:30px 0 12px;font-size:13px;font-weight:600;display:flex}.h-section .count{color:var(--muted);font-weight:500;font-family:var(--mono);font-size:12px}.spin{border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;width:14px;height:14px;animation:.7s linear infinite spin;display:inline-block}@keyframes spin{to{transform:rotate(360deg)}}@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}.modal-overlay{-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);z-index:50;background:var(--lightningcss-light,#11131c73)var(--lightningcss-dark,#0009);justify-content:center;align-items:flex-start;padding:8vh 16px;display:flex;position:fixed;inset:0}.modal-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);width:100%;max-width:520px;box-shadow:var(--shadow);max-height:84vh;padding:20px;overflow-y:auto}.modal-head{justify-content:space-between;align-items:center;margin-bottom:16px;display:flex}.modal-head h3{font-size:16px}.card-head{flex-wrap:wrap;align-items:center;gap:8px;min-width:0;padding:14px 16px 6px;display:flex}.card-head strong{overflow-wrap:anywhere;min-width:0}.card-sub{color:var(--muted);overflow-wrap:anywhere;min-width:0;padding:0 16px 10px;font-size:12px}.card-active{border-color:var(--accent-ring)}.card-right{color:var(--faint);align-items:center;gap:4px;margin-left:auto;font-size:11px;display:flex}.btn-icon-danger.card-right{appearance:none;color:var(--red);cursor:pointer;background:0 0;border:1px solid #0000;justify-content:center}.btn-icon-danger.card-right:hover{background:var(--red-soft);border-color:var(--red-soft);color:var(--red)}.card-row{justify-content:space-between;align-items:center;padding:14px 16px;display:flex}.badge-primary{background:var(--accent-soft);color:var(--accent-hover)}.dot-blue{background:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}.dot-muted{background:var(--muted)}.dot-amber{background:var(--amber);box-shadow:0 0 0 3px var(--amber-soft)}.section-sep{align-items:center;gap:10px;margin:20px 0 12px;display:flex}.section-label{color:var(--muted);text-transform:uppercase;letter-spacing:.04em;white-space:nowrap;font-size:12px;font-weight:600}.sep-line{background:var(--border);flex:1;height:1px}.quota-compact{gap:4px;padding:2px 16px 12px;display:grid}.quota-row{grid-template-columns:minmax(34px,max-content) 34px minmax(34px,44px) minmax(38px,42px) minmax(58px,1fr) minmax(30px,max-content);align-items:center;gap:8px;min-width:0;display:grid}.quota-label{color:var(--muted);font-size:11px;font-weight:600}.quota-val{font-size:11px;font-family:var(--mono);color:var(--text);text-align:right}.quota-reset-label{text-overflow:ellipsis;white-space:nowrap;color:var(--faint);font-size:11px;overflow:hidden}.quota-reset-day,.quota-reset-time{color:var(--muted);white-space:nowrap;font-size:11px}.quota-reset-time{font-family:var(--mono)}.bar{background:var(--raised);border-radius:3px;min-width:0;height:5px;overflow:hidden}.bar-fill{border-radius:3px;height:100%;transition:width .3s}.bar-green{background:var(--green)}.bar-amber{background:linear-gradient(90deg, var(--green), var(--amber))}.toggle{background:var(--raised);border:1px solid var(--border);cursor:pointer;border-radius:10px;flex-shrink:0;width:36px;height:20px;transition:background .15s;position:relative}.toggle.on{background:var(--accent);border-color:var(--accent)}.toggle-knob{background:#fff;border-radius:50%;width:16px;height:16px;transition:left .15s;position:absolute;top:1px;left:1px}.toggle.on .toggle-knob{left:17px}.notice-warn{border-radius:var(--radius-sm);background:var(--amber-soft);color:var(--amber);align-items:center;gap:6px;margin-bottom:12px;padding:8px 10px;font-size:12px;display:flex}.notice-warn svg{flex-shrink:0;width:14px;height:14px}.modal-desc{color:var(--muted);margin-bottom:14px;font-size:13px}.modal-actions{gap:8px;margin-top:16px;display:flex}.modal-actions .btn{flex:1}.setup-guide{border:1px solid var(--border);border-radius:var(--radius-sm);margin-bottom:4px;padding:8px 12px;font-size:13px}.setup-guide summary{cursor:pointer;color:var(--accent-hover);font-weight:500}.setup-guide summary:hover{text-decoration:underline}.setup-guide a{color:var(--accent-hover)}.list-row{text-align:left;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--raised);cursor:pointer;width:100%;color:var(--text);font:inherit;justify-content:space-between;align-items:center;gap:10px;padding:11px 13px;transition:background .12s,border-color .12s;display:flex}.list-row:hover{background:var(--raised-hover);border-color:var(--accent-ring)}.list-row .title{font-size:14px;font-weight:600}.list-row .sub{color:var(--muted);margin-top:2px;font-size:12px}.prov-card{justify-content:space-between;align-items:flex-start;gap:12px;padding:15px 16px;display:flex}.link-btn{color:var(--accent-hover);font:inherit;cursor:pointer;background:0 0;border:none;padding:6px 2px;font-size:13px;text-decoration:underline}@media (width<=760px){.app{grid-template-columns:1fr}.sidebar{z-index:20;border-right:none;border-bottom:1px solid var(--border);background:var(--rail);flex-flow:wrap;align-items:center;gap:0;min-width:0;height:auto;padding:0 10px;position:sticky;top:0}.brand{flex:auto;order:1;width:auto;padding:10px 4px}.sidebar-foot{flex-direction:row;flex:none;order:2;gap:4px;margin:0;padding:0}.sidebar-foot .sidebar-link{display:none}.theme-toggle{justify-content:center;min-width:44px;min-height:44px;padding:8px}.theme-toggle .mode{display:none}.sidebar nav{overscroll-behavior-x:contain;border-top:1px solid var(--border-soft);scrollbar-width:none;flex-direction:row;flex:100%;order:3;gap:2px;min-width:0;margin:0;padding:4px 0 8px;display:flex;overflow-x:auto}.sidebar nav::-webkit-scrollbar{display:none}.nav-item{white-space:nowrap;width:auto;min-height:44px;padding:9px 14px;font-size:14px}.main-inner{padding:22px 18px 48px}.stat-row{grid-template-columns:repeat(2,minmax(0,1fr))}.tbl{min-width:460px}}
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-DalshCSi.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-dCS-lwCM.css">
19
+ <script type="module" crossorigin src="/assets/index-3RZw8J9v.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-xJdeQjzZ.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.1.8",
3
+ "version": "2.1.10",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -22,6 +22,7 @@
22
22
  "start": "bun run src/cli.ts start",
23
23
  "test": "bun test tests",
24
24
  "typecheck": "bun x tsc --noEmit",
25
+ "privacy:scan": "bun scripts/privacy-scan.ts",
25
26
  "generate:jawcode-metadata": "bun scripts/generate-jawcode-metadata.ts",
26
27
  "build:gui": "cd gui && bun install && bun run build",
27
28
  "prepublishOnly": "bun run typecheck && bun run build:gui",
@@ -63,6 +63,19 @@ function usageFromAnthropic(usage: Record<string, number> | undefined): OcxUsage
63
63
  };
64
64
  }
65
65
 
66
+ function mergeAnthropicUsage(
67
+ base: Record<string, number> | undefined,
68
+ next: Record<string, number> | undefined,
69
+ ): Record<string, number> | undefined {
70
+ if (!next) return base;
71
+ if (!base) return { ...next };
72
+ const merged = { ...base };
73
+ for (const [k, v] of Object.entries(next)) {
74
+ merged[k] = (merged[k] ?? 0) + v;
75
+ }
76
+ return merged;
77
+ }
78
+
66
79
  function buildToolNameTransforms(provider: OcxProviderConfig): { toWire: (name: string) => string; fromWire: (name: string) => string } {
67
80
  if (provider.authMode === "oauth") {
68
81
  return { toWire: applyClaudeToolPrefix, fromWire: stripClaudeToolPrefix };
@@ -269,6 +282,14 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
269
282
  let currentBlockType = "";
270
283
  let currentToolCallId = "";
271
284
  let currentToolCallName = "";
285
+ let pendingUsage: Record<string, number> | undefined;
286
+ let emittedDone = false;
287
+
288
+ const emitDone = function* (): Generator<AdapterEvent> {
289
+ if (emittedDone) return;
290
+ emittedDone = true;
291
+ yield { type: "done", usage: usageFromAnthropic(pendingUsage) };
292
+ };
272
293
 
273
294
  try {
274
295
  while (true) {
@@ -298,6 +319,11 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
298
319
  }
299
320
 
300
321
  switch (currentEventType || data.type) {
322
+ case "message_start": {
323
+ const message = data.message as { usage?: Record<string, number> } | undefined;
324
+ pendingUsage = mergeAnthropicUsage(pendingUsage, message?.usage);
325
+ break;
326
+ }
301
327
  case "content_block_start": {
302
328
  const block = data.content_block as { type: string; id?: string; name?: string } | undefined;
303
329
  if (!block) break;
@@ -331,15 +357,11 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
331
357
  }
332
358
  case "message_delta": {
333
359
  const usage = data.usage as Record<string, number> | undefined;
334
- if (usage) {
335
- yield {
336
- type: "done",
337
- usage: usageFromAnthropic(usage),
338
- };
339
- }
360
+ pendingUsage = mergeAnthropicUsage(pendingUsage, usage);
340
361
  break;
341
362
  }
342
363
  case "message_stop": {
364
+ yield* emitDone();
343
365
  break;
344
366
  }
345
367
  case "error": {
@@ -351,6 +373,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
351
373
  currentEventType = "";
352
374
  }
353
375
  }
376
+ if (pendingUsage && !emittedDone) yield* emitDone();
354
377
  } finally {
355
378
  reader.releaseLock();
356
379
  }
@@ -55,10 +55,22 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
55
55
  // OAuth passthrough: ChatGPT backend path is `${baseUrl}/responses` (no /v1).
56
56
  url = `${provider.baseUrl}/responses`;
57
57
  if (provider.headers) Object.assign(headers, provider.headers); // static headers first…
58
+ const runtimeProvider = provider as {
59
+ _codexAccountOverride?: { accessToken: string; chatgptAccountId: string };
60
+ _codexAccountRequired?: boolean;
61
+ };
62
+ if (runtimeProvider._codexAccountRequired && !runtimeProvider._codexAccountOverride) {
63
+ throw new Error("Codex pool account auth is required but unavailable");
64
+ }
58
65
  for (const h of FORWARD_HEADERS) {
59
66
  const v = incoming?.headers.get(h);
60
67
  if (v) headers[h] = v; // …so forwarded auth always wins.
61
68
  }
69
+ const override = runtimeProvider._codexAccountOverride;
70
+ if (override) {
71
+ headers["authorization"] = `Bearer ${override.accessToken}`;
72
+ headers["chatgpt-account-id"] = override.chatgptAccountId;
73
+ }
62
74
  } else {
63
75
  const base = provider.baseUrl.replace(/\/v1\/?$/, "");
64
76
  url = `${base}/v1/responses`;
package/src/bridge.ts CHANGED
@@ -35,6 +35,8 @@ interface OutputItem {
35
35
  [key: string]: unknown;
36
36
  }
37
37
 
38
+ export type ResponsesTerminalStatus = "completed" | "failed" | "incomplete";
39
+
38
40
  export function bridgeToResponsesSSE(
39
41
  events: AsyncIterable<AdapterEvent>,
40
42
  modelId: string,
@@ -43,7 +45,12 @@ export function bridgeToResponsesSSE(
43
45
  toolSearchToolNames?: Set<string>,
44
46
  onCancel?: () => void,
45
47
  heartbeatMs = 2_000,
46
- options?: { responseId?: string; stallTimeoutSec?: number; hideThinkingSummary?: boolean },
48
+ options?: {
49
+ responseId?: string;
50
+ stallTimeoutSec?: number;
51
+ hideThinkingSummary?: boolean;
52
+ onTerminal?: (status: ResponsesTerminalStatus) => void;
53
+ },
47
54
  ): ReadableStream<Uint8Array> {
48
55
  // Freeform/custom tools (apply_patch) carry their body in `input`; the model is given a
49
56
  // function with `{input:string}`, so unwrap it here when relaying back as a custom_tool_call.
@@ -62,6 +69,13 @@ export function bridgeToResponsesSSE(
62
69
  // never enqueue again and never throw a second time inside start() — the RC2 double-throw that
63
70
  // otherwise surfaced as proxy-side stream noise on every client disconnect.
64
71
  let closed = false;
72
+ let clientCancelled = false;
73
+ let terminalReported = false;
74
+ const reportTerminal = (status: ResponsesTerminalStatus) => {
75
+ if (terminalReported || clientCancelled || closed) return;
76
+ terminalReported = true;
77
+ options?.onTerminal?.(status);
78
+ };
65
79
  // RC3 keep-alive: Codex's idle timer is timeout(idle_timeout, stream.next()) over an
66
80
  // eventsource_stream; ANY received event re-arms it, while an unknown type is ignored
67
81
  // (responses.rs `_ => Ok(None)`). We emit a real, parser-ignored `response.heartbeat` only during
@@ -120,6 +134,7 @@ export function bridgeToResponsesSSE(
120
134
  incomplete_details: { reason: "upstream_stall_timeout" },
121
135
  },
122
136
  });
137
+ reportTerminal("incomplete");
123
138
  terminated = true;
124
139
  closed = true;
125
140
  clearInterval(beat!);
@@ -341,6 +356,7 @@ export function bridgeToResponsesSSE(
341
356
  emit("response.completed", {
342
357
  response: { ...responseSnapshot("completed", finishedItems), usage: responsesUsage(event.usage) },
343
358
  });
359
+ reportTerminal("completed");
344
360
  terminated = true;
345
361
  break;
346
362
  }
@@ -356,6 +372,7 @@ export function bridgeToResponsesSSE(
356
372
  last_error: responseError(502, "upstream_error", event.message),
357
373
  },
358
374
  });
375
+ reportTerminal("failed");
359
376
  terminated = true;
360
377
  break;
361
378
  }
@@ -369,6 +386,7 @@ export function bridgeToResponsesSSE(
369
386
  last_error: responseError(500, "proxy_error", err instanceof Error ? err.message : String(err)),
370
387
  },
371
388
  });
389
+ reportTerminal("failed");
372
390
  terminated = true;
373
391
  }
374
392
 
@@ -388,6 +406,7 @@ export function bridgeToResponsesSSE(
388
406
  incomplete_details: { reason: "adapter_eof" },
389
407
  },
390
408
  });
409
+ reportTerminal("incomplete");
391
410
  }
392
411
 
393
412
  emitDone();
@@ -400,6 +419,7 @@ export function bridgeToResponsesSSE(
400
419
  cancel() {
401
420
  // Client (Codex) disconnected. Stop emitting and let the caller abort the upstream fetch so a
402
421
  // cancelled turn does not leak the upstream stream or keep draining tokens (RC2).
422
+ clientCancelled = true;
403
423
  closed = true;
404
424
  if (beat) clearInterval(beat);
405
425
  onCancel?.();
@@ -0,0 +1,34 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import type { CodexAccount } from "./types";
3
+
4
+ export const CODEX_ACCOUNT_LOG_LABEL_RE = /^p[a-f0-9]{6}$/;
5
+
6
+ export function createCodexAccountLogLabel(existingLabels: Iterable<string | undefined | null> = []): string {
7
+ const used = new Set([...existingLabels].filter((value): value is string => !!value));
8
+ for (let i = 0; i < 16; i++) {
9
+ const label = `p${randomBytes(3).toString("hex")}`;
10
+ if (!used.has(label)) return label;
11
+ }
12
+ return `p${randomBytes(6).toString("hex").slice(0, 6)}`;
13
+ }
14
+
15
+ export function fallbackCodexAccountLogLabel(accountId: string): string {
16
+ return `p${createHash("sha256").update(accountId).digest("hex").slice(0, 6)}`;
17
+ }
18
+
19
+ export function codexAccountLogLabel(account: CodexAccount): string {
20
+ return CODEX_ACCOUNT_LOG_LABEL_RE.test(account.logLabel ?? "")
21
+ ? account.logLabel!
22
+ : fallbackCodexAccountLogLabel(account.id);
23
+ }
24
+
25
+ export function withCodexAccountLogLabel(
26
+ account: Omit<CodexAccount, "logLabel"> & Partial<Pick<CodexAccount, "logLabel">>,
27
+ existingAccounts: readonly CodexAccount[],
28
+ ): CodexAccount {
29
+ if (account.logLabel && CODEX_ACCOUNT_LOG_LABEL_RE.test(account.logLabel)) return account as CodexAccount;
30
+ return {
31
+ ...account,
32
+ logLabel: createCodexAccountLogLabel(existingAccounts.map(existing => existing.logLabel)),
33
+ };
34
+ }
@@ -0,0 +1,21 @@
1
+ import { removeCodexAccountCredential } from "./codex-account-store";
2
+ import { clearAccountNeedsReauth } from "./codex-account-runtime-state";
3
+ import { clearAccountQuota } from "./codex-quota";
4
+ import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./codex-routing";
5
+ import { invalidateCodexWebSocketsForAccount } from "./codex-websocket-registry";
6
+ import type { OcxConfig } from "./types";
7
+
8
+ export function purgeCodexAccountRuntimeState(accountId: string): void {
9
+ clearAccountNeedsReauth(accountId);
10
+ clearAccountQuota(accountId);
11
+ clearThreadAccountMapForAccount(accountId);
12
+ clearCodexUpstreamHealthForAccount(accountId);
13
+ }
14
+
15
+ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): void {
16
+ removeCodexAccountCredential(accountId);
17
+ runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []).filter(account => account.id !== accountId);
18
+ if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined;
19
+ purgeCodexAccountRuntimeState(accountId);
20
+ invalidateCodexWebSocketsForAccount(accountId);
21
+ }
@@ -0,0 +1,13 @@
1
+ const reauthAccounts = new Set<string>();
2
+
3
+ export function markAccountNeedsReauth(id: string): void {
4
+ reauthAccounts.add(id);
5
+ }
6
+
7
+ export function isAccountNeedsReauth(id: string): boolean {
8
+ return reauthAccounts.has(id);
9
+ }
10
+
11
+ export function clearAccountNeedsReauth(id: string): void {
12
+ reauthAccounts.delete(id);
13
+ }
@@ -0,0 +1,355 @@
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, existsSync, readFileSync, mkdirSync, openSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { getConfigDir, atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "./config";
5
+ import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "./types";
6
+
7
+ type LegacyCodexAccountStore = Record<string, CodexAccountCredentials>;
8
+ type CodexAccountStore = Record<string, CodexAccountCredentialRecord>;
9
+ type RawCodexAccountStore = Record<string, CodexAccountCredentials | CodexAccountCredentialRecord>;
10
+
11
+ const REFRESH_SKEW_MS = 60_000;
12
+ const REFRESH_LOCK_STALE_MS = 60_000;
13
+ const REFRESH_LOCK_WAIT_MS = REFRESH_LOCK_STALE_MS + 5_000;
14
+ const REFRESH_LOCK_POLL_MS = 50;
15
+
16
+ function codexAccountsPath(): string {
17
+ return join(getConfigDir(), "codex-accounts.json");
18
+ }
19
+
20
+ export function loadCodexAccountStore(): LegacyCodexAccountStore {
21
+ const records = loadCodexAccountRecordStore();
22
+ const credentials: LegacyCodexAccountStore = {};
23
+ for (const [id, record] of Object.entries(records)) {
24
+ if (record.deletedAt == null && record.credential) credentials[id] = record.credential;
25
+ }
26
+ return credentials;
27
+ }
28
+
29
+ function isObject(value: unknown): value is Record<string, unknown> {
30
+ return !!value && typeof value === "object" && !Array.isArray(value);
31
+ }
32
+
33
+ function isCredential(value: unknown): value is CodexAccountCredentials {
34
+ return isObject(value)
35
+ && typeof value.accessToken === "string"
36
+ && typeof value.refreshToken === "string"
37
+ && typeof value.expiresAt === "number"
38
+ && typeof value.chatgptAccountId === "string";
39
+ }
40
+
41
+ function isCredentialRecord(value: unknown): value is CodexAccountCredentialRecord {
42
+ return isObject(value)
43
+ && typeof value.generation === "number"
44
+ && (value.credential === undefined || isCredential(value.credential))
45
+ && (value.refreshGrantFingerprint === undefined || typeof value.refreshGrantFingerprint === "string")
46
+ && (value.deletedAt === undefined || typeof value.deletedAt === "number")
47
+ && (value.replacedAt === undefined || typeof value.replacedAt === "number");
48
+ }
49
+
50
+ export function refreshGrantFingerprintForToken(refreshToken: string): string {
51
+ return createHash("sha256").update(`codex-refresh-grant:${refreshToken}`).digest("hex");
52
+ }
53
+
54
+ function recordGrantFingerprint(record: CodexAccountCredentialRecord): string | undefined {
55
+ return record.refreshGrantFingerprint ?? (
56
+ record.credential ? refreshGrantFingerprintForToken(record.credential.refreshToken) : undefined
57
+ );
58
+ }
59
+
60
+ function normalizeRecord(value: CodexAccountCredentials | CodexAccountCredentialRecord | undefined): CodexAccountCredentialRecord | undefined {
61
+ if (!value) return undefined;
62
+ if (isCredentialRecord(value)) {
63
+ const refreshGrantFingerprint = recordGrantFingerprint(value);
64
+ return refreshGrantFingerprint ? { ...value, refreshGrantFingerprint } : value;
65
+ }
66
+ if (isCredential(value)) {
67
+ return {
68
+ credential: value,
69
+ generation: 0,
70
+ refreshGrantFingerprint: refreshGrantFingerprintForToken(value.refreshToken),
71
+ };
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ function loadCodexAccountRecordStore(): CodexAccountStore {
77
+ const path = codexAccountsPath();
78
+ hardenConfigDir();
79
+ hardenExistingSecret(path);
80
+ if (!existsSync(path)) return {};
81
+ try {
82
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as RawCodexAccountStore;
83
+ const normalized: CodexAccountStore = {};
84
+ for (const [id, value] of Object.entries(raw)) {
85
+ const record = normalizeRecord(value);
86
+ if (record) normalized[id] = record;
87
+ }
88
+ return normalized;
89
+ } catch {
90
+ return {};
91
+ }
92
+ }
93
+
94
+ function persist(store: CodexAccountStore): void {
95
+ const dir = getConfigDir();
96
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
97
+ atomicWriteFile(codexAccountsPath(), JSON.stringify(store, null, 2) + "\n");
98
+ }
99
+
100
+ export function getCodexAccountCredential(id: string): CodexAccountCredentials | null {
101
+ const record = readCodexAccountRecord(id);
102
+ if (!record || record.deletedAt != null) return null;
103
+ return record.credential ?? null;
104
+ }
105
+
106
+ export function saveCodexAccountCredential(id: string, cred: CodexAccountCredentials): void {
107
+ const store = loadCodexAccountRecordStore();
108
+ const current = store[id];
109
+ const refreshGrantFingerprint = current?.credential?.refreshToken === cred.refreshToken
110
+ ? current.refreshGrantFingerprint ?? refreshGrantFingerprintForToken(cred.refreshToken)
111
+ : refreshGrantFingerprintForToken(cred.refreshToken);
112
+ store[id] = {
113
+ credential: cred,
114
+ generation: (current?.generation ?? 0) + 1,
115
+ refreshGrantFingerprint,
116
+ replacedAt: current ? Date.now() : undefined,
117
+ };
118
+ persist(store);
119
+ }
120
+
121
+ export function removeCodexAccountCredential(id: string): void {
122
+ tombstoneCodexAccount(id);
123
+ }
124
+
125
+ export function listCodexAccountIds(): string[] {
126
+ return Object.keys(loadCodexAccountStore());
127
+ }
128
+
129
+ export function readCodexAccountRecord(id: string): CodexAccountCredentialRecord | null {
130
+ return loadCodexAccountRecordStore()[id] ?? null;
131
+ }
132
+
133
+ export function isCodexAccountGenerationLive(id: string, generation: number): boolean {
134
+ const record = readCodexAccountRecord(id);
135
+ return !!record?.credential && record.deletedAt == null && record.generation === generation;
136
+ }
137
+
138
+ export function saveCodexAccountCredentialIfGeneration(
139
+ id: string,
140
+ generation: number,
141
+ cred: CodexAccountCredentials,
142
+ ): boolean {
143
+ const store = loadCodexAccountRecordStore();
144
+ const current = store[id];
145
+ if (!current || current.generation !== generation || current.deletedAt != null || !current.credential) {
146
+ return false;
147
+ }
148
+ store[id] = {
149
+ credential: cred,
150
+ generation: generation + 1,
151
+ refreshGrantFingerprint: current.refreshGrantFingerprint ?? refreshGrantFingerprintForToken(current.credential.refreshToken),
152
+ replacedAt: current.replacedAt,
153
+ };
154
+ persist(store);
155
+ return true;
156
+ }
157
+
158
+ export function tombstoneCodexAccount(id: string): number {
159
+ const store = loadCodexAccountRecordStore();
160
+ const current = store[id];
161
+ const generation = (current?.generation ?? 0) + 1;
162
+ store[id] = { generation, deletedAt: Date.now() };
163
+ persist(store);
164
+ return generation;
165
+ }
166
+
167
+ const CHATGPT_TOKEN_URL = "https://auth.openai.com/oauth/token";
168
+ const CHATGPT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
169
+
170
+ export class TokenRefreshError extends Error {
171
+ reason: "expired" | "revoked" | "unknown";
172
+ constructor(reason: "expired" | "revoked" | "unknown", message: string) {
173
+ super(message);
174
+ this.name = "TokenRefreshError";
175
+ this.reason = reason;
176
+ }
177
+ }
178
+
179
+ export class CodexCredentialGenerationConflictError extends Error {
180
+ constructor(message = "Codex account changed during refresh") {
181
+ super(message);
182
+ this.name = "CodexCredentialGenerationConflictError";
183
+ }
184
+ }
185
+
186
+ export class CodexCredentialRefreshLockTimeoutError extends Error {
187
+ constructor(message = "Timed out waiting for Codex account refresh lock") {
188
+ super(message);
189
+ this.name = "CodexCredentialRefreshLockTimeoutError";
190
+ }
191
+ }
192
+
193
+ type CodexTokenResult = { accessToken: string; chatgptAccountId: string; generation: number };
194
+ const refreshLocks = new Map<string, Promise<CodexTokenResult>>();
195
+
196
+ function codexRefreshLockPath(lockKey: string): string {
197
+ const digest = createHash("sha256").update(lockKey).digest("hex").slice(0, 32);
198
+ return join(getConfigDir(), `codex-refresh-${digest}.lock`);
199
+ }
200
+
201
+ function sleep(ms: number): Promise<void> {
202
+ return new Promise(resolve => setTimeout(resolve, ms));
203
+ }
204
+
205
+ function errCode(err: unknown): string | undefined {
206
+ return err && typeof err === "object" && "code" in err ? String((err as { code?: unknown }).code) : undefined;
207
+ }
208
+
209
+ function isRefreshLockStale(path: string): boolean {
210
+ try {
211
+ hardenExistingSecret(path);
212
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as { acquiredAt?: unknown };
213
+ return typeof parsed.acquiredAt !== "number" || Date.now() - parsed.acquiredAt > REFRESH_LOCK_STALE_MS;
214
+ } catch {
215
+ return true;
216
+ }
217
+ }
218
+
219
+ async function withCodexRefreshFileLock<T>(lockKey: string, fn: () => Promise<T>): Promise<T> {
220
+ hardenConfigDir();
221
+ const dir = getConfigDir();
222
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
223
+
224
+ const path = codexRefreshLockPath(lockKey);
225
+ const deadline = Date.now() + REFRESH_LOCK_WAIT_MS;
226
+ let fd: number | null = null;
227
+ while (fd == null) {
228
+ try {
229
+ fd = openSync(path, "wx", 0o600);
230
+ writeFileSync(fd, JSON.stringify({ acquiredAt: Date.now(), pid: process.pid }) + "\n");
231
+ break;
232
+ } catch (err) {
233
+ if (errCode(err) !== "EEXIST") throw err;
234
+ if (isRefreshLockStale(path)) {
235
+ try {
236
+ unlinkSync(path);
237
+ } catch (unlinkErr) {
238
+ if (errCode(unlinkErr) !== "ENOENT") throw unlinkErr;
239
+ }
240
+ continue;
241
+ }
242
+ if (Date.now() >= deadline) throw new CodexCredentialRefreshLockTimeoutError();
243
+ await sleep(REFRESH_LOCK_POLL_MS);
244
+ }
245
+ }
246
+
247
+ try {
248
+ return await fn();
249
+ } finally {
250
+ if (fd != null) closeSync(fd);
251
+ try {
252
+ unlinkSync(path);
253
+ } catch (err) {
254
+ if (errCode(err) !== "ENOENT") throw err;
255
+ }
256
+ }
257
+ }
258
+
259
+ function findFreshCredentialForGrant(
260
+ refreshGrantFingerprint: string,
261
+ excludeId: string,
262
+ ): CodexAccountCredentials | null {
263
+ const now = Date.now();
264
+ const records = loadCodexAccountRecordStore();
265
+ for (const [candidateId, candidate] of Object.entries(records)) {
266
+ if (candidateId === excludeId || candidate.deletedAt != null || !candidate.credential) continue;
267
+ if (recordGrantFingerprint(candidate) !== refreshGrantFingerprint) continue;
268
+ if (candidate.credential.expiresAt > now + REFRESH_SKEW_MS) return candidate.credential;
269
+ }
270
+ return null;
271
+ }
272
+
273
+ export async function getValidCodexToken(id: string): Promise<CodexTokenResult> {
274
+ const record = readCodexAccountRecord(id);
275
+ const cred = record?.deletedAt == null ? record?.credential : undefined;
276
+ if (!record || !cred) throw new Error("Codex account credential is unavailable; reauthenticate the account.");
277
+ const refreshGrantFingerprint = recordGrantFingerprint(record);
278
+ if (!refreshGrantFingerprint) throw new Error("Codex account credential is unavailable; reauthenticate the account.");
279
+
280
+ if (cred.expiresAt > Date.now() + REFRESH_SKEW_MS) {
281
+ return { accessToken: cred.accessToken, chatgptAccountId: cred.chatgptAccountId, generation: record.generation };
282
+ }
283
+
284
+ const existing = refreshLocks.get(refreshGrantFingerprint);
285
+ if (existing) {
286
+ await existing;
287
+ return getValidCodexToken(id);
288
+ }
289
+
290
+ const refreshPromise = withCodexRefreshFileLock(refreshGrantFingerprint, async (): Promise<CodexTokenResult> => {
291
+ const lockedRecord = readCodexAccountRecord(id);
292
+ const lockedCred = lockedRecord?.deletedAt == null ? lockedRecord?.credential : undefined;
293
+ if (!lockedRecord || !lockedCred) throw new CodexCredentialGenerationConflictError();
294
+ const startGeneration = lockedRecord.generation;
295
+ const lockedRefreshGrantFingerprint = recordGrantFingerprint(lockedRecord);
296
+ if (lockedRefreshGrantFingerprint !== refreshGrantFingerprint) throw new CodexCredentialGenerationConflictError();
297
+ if (lockedCred.expiresAt > Date.now() + REFRESH_SKEW_MS) {
298
+ return {
299
+ accessToken: lockedCred.accessToken,
300
+ chatgptAccountId: lockedCred.chatgptAccountId,
301
+ generation: startGeneration,
302
+ };
303
+ }
304
+ const sameGrantFreshCredential = findFreshCredentialForGrant(refreshGrantFingerprint, id);
305
+ if (sameGrantFreshCredential) {
306
+ if (!saveCodexAccountCredentialIfGeneration(id, startGeneration, sameGrantFreshCredential)) {
307
+ throw new CodexCredentialGenerationConflictError();
308
+ }
309
+ return {
310
+ accessToken: sameGrantFreshCredential.accessToken,
311
+ chatgptAccountId: sameGrantFreshCredential.chatgptAccountId,
312
+ generation: startGeneration + 1,
313
+ };
314
+ }
315
+ const res = await fetch(CHATGPT_TOKEN_URL, {
316
+ method: "POST",
317
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
318
+ body: new URLSearchParams({
319
+ grant_type: "refresh_token",
320
+ client_id: CHATGPT_CLIENT_ID,
321
+ refresh_token: lockedCred.refreshToken,
322
+ }).toString(),
323
+ signal: AbortSignal.timeout(30_000),
324
+ });
325
+ if (!res.ok) {
326
+ const errText = await res.text().catch(() => "");
327
+ let errDesc: string;
328
+ try {
329
+ const parsed = JSON.parse(errText) as { error?: string; error_description?: string };
330
+ errDesc = [parsed.error, parsed.error_description].filter(Boolean).join(": ") || `HTTP ${res.status}`;
331
+ } catch { errDesc = `HTTP ${res.status}`; }
332
+ const reason = errDesc.includes("invalidated") || errDesc.includes("revoked") ? "revoked" as const
333
+ : errDesc.includes("expired") ? "expired" as const
334
+ : "unknown" as const;
335
+ throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`);
336
+ }
337
+ const data = (await res.json()) as { access_token: string; refresh_token?: string; expires_in: number };
338
+
339
+ const updated: CodexAccountCredentials = {
340
+ accessToken: data.access_token,
341
+ refreshToken: data.refresh_token ?? lockedCred.refreshToken,
342
+ expiresAt: Date.now() + data.expires_in * 1000,
343
+ chatgptAccountId: lockedCred.chatgptAccountId,
344
+ };
345
+ if (!saveCodexAccountCredentialIfGeneration(id, startGeneration, updated)) {
346
+ throw new CodexCredentialGenerationConflictError();
347
+ }
348
+ return { accessToken: updated.accessToken, chatgptAccountId: updated.chatgptAccountId, generation: startGeneration + 1 };
349
+ }).finally(() => {
350
+ refreshLocks.delete(refreshGrantFingerprint);
351
+ });
352
+
353
+ refreshLocks.set(refreshGrantFingerprint, refreshPromise);
354
+ return refreshPromise;
355
+ }