@bitkyc08/opencodex 2.7.27 → 2.7.28

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.
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-Vcr0pzdO.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-BnrJO9Wz.css">
19
+ <script type="module" crossorigin src="/assets/index-TZysP4q4.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-DyBPh28A.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.7.27",
3
+ "version": "2.7.28",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -46,7 +46,8 @@
46
46
  "prepublishOnly": "bun run typecheck && bun run build:gui",
47
47
  "release": "bun scripts/release.ts",
48
48
  "release:watch": "bun scripts/release.ts watch",
49
- "prepush": "bun run typecheck && bun run test && bun run privacy:scan && bun run doctor:gui:if-changed",
49
+ "prepush": "bun run typecheck && bun run lint:gui && bun run test && bun run privacy:scan && bun run doctor:gui:if-changed",
50
+ "lint:gui": "cd gui && bun run lint",
50
51
  "doctor:gui": "cd gui && bun run doctor",
51
52
  "doctor:gui:full": "cd gui && bun run doctor:full",
52
53
  "doctor:gui:if-changed": "bun scripts/doctor-gui-if-changed.ts",
@@ -12,16 +12,24 @@ export const ANTIGRAVITY_REQUEST_UA = process.env.GOOGLE_ANTIGRAVITY_USER_AGENT
12
12
 
13
13
  /**
14
14
  * Whether a stored `OcxToolCall.thoughtSignature` is a REAL upstream Gemini signature versus a
15
- * synthetic Responses item id (`fc_...`, `call_...`, `rs_...`, etc) that the bridge/parser stashes
16
- * on the field. Only real signatures may be forwarded to Gemini/Antigravity — sending a synthetic
17
- * id as `thoughtSignature` breaks multi-turn reasoning continuity (upstream rejects it). Real
18
- * signatures are opaque base64-ish blobs with no Responses-id prefix.
15
+ * foreign id that must not be forwarded to Gemini/Antigravity.
16
+ *
17
+ * Foreign ids that have 400'd Antigravity (`TYPE_BYTES` / Base64 decoding failed) include:
18
+ * - Responses/bridge item ids: `fc_...`, `ctc_...` (custom_tool_call), `tsc_...` (tool_search_call),
19
+ * `call_...`, `rs_...`, …
20
+ * - Anthropic tool-use ids: `toolu_...`
21
+ *
22
+ * Only real signatures may be forwarded — sending a foreign id as `thoughtSignature` breaks
23
+ * multi-turn reasoning continuity. Real signatures are opaque base64-ish blobs with no
24
+ * Responses/Anthropic id prefix.
25
+ *
26
+ * Note: do NOT reject a bare `sig_` / `sig-` prefix — existing Gemini replay fixtures and some
27
+ * upstream blobs use that shape; a deny-list entry for `sig` would drop valid continuity tokens.
19
28
  */
20
29
  export function isLikelyRealThoughtSignature(sig: string | undefined): boolean {
21
30
  if (typeof sig !== "string" || sig.length < 16) return false;
22
- // Reject synthetic Responses/tool-call ids in both `_` and `-` separated spellings
23
- // (e.g. `fc_...`, `call_...`, `function-call-...`, `tool-call-...`).
24
- if (/^(fc|call|msg|rs|resp|reasoning|item|ws|tool|func|function)[-_]/i.test(sig)) return false;
31
+ // Reject synthetic Responses/tool-call ids and Anthropic tool-use ids (`_` or `-` separators).
32
+ if (/^(fc|ctc|tsc|call|msg|rs|resp|reasoning|item|ws|toolu|tool|func|function)[-_]/i.test(sig)) return false;
25
33
  // Real Gemini thought signatures are opaque base64/base64url blobs: only [A-Za-z0-9+/_=-].
26
34
  // Anything containing other characters (or whitespace) is not a real signature.
27
35
  return /^[A-Za-z0-9+/_=-]+$/.test(sig);
package/src/bridge.ts CHANGED
@@ -2,6 +2,7 @@ import type { AdapterEvent, OcxUsage } from "./types";
2
2
  import { adapterFailureFromMessage, classifyError, type OcxErrorPayload } from "./lib/errors";
3
3
  import { encodeCompactionSummary } from "./responses/compaction";
4
4
  import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
5
+ import { resolveStallTimeoutSec } from "./stall-timeout";
5
6
  import { usageDisplayTotalTokens } from "./usage/totals";
6
7
 
7
8
  function uuid(): string {
@@ -81,6 +82,8 @@ export function bridgeToResponsesSSE(
81
82
  * response.completed — codex-rs collect_compaction_output requires exactly one.
82
83
  */
83
84
  compaction?: boolean;
85
+ /** One-shot: first non-empty text/thinking/raw-reasoning delta observed (WP4 TTFT). */
86
+ onFirstOutput?: () => void;
84
87
  onTerminal?: (status: ResponsesTerminalStatus) => void;
85
88
  onCompletedResponse?: (response: Record<string, unknown>) => void;
86
89
  },
@@ -181,7 +184,7 @@ export function bridgeToResponsesSSE(
181
184
  // whenever a real event was emitted since the last tick, so it only fires on a genuine stall.
182
185
  const heartbeatFrame = encoder.encode('event: response.heartbeat\ndata: {"type":"response.heartbeat"}\n\n');
183
186
  let stallTicks = 0;
184
- const stallSec = Math.max(1, options?.stallTimeoutSec ?? 90);
187
+ const stallSec = resolveStallTimeoutSec(options?.stallTimeoutSec);
185
188
  const maxStallTicks = Math.ceil((stallSec * 1000) / heartbeatMs);
186
189
  beat = setInterval(() => {
187
190
  if (closed) return;
@@ -401,6 +404,20 @@ export function bridgeToResponsesSSE(
401
404
  // we synthesize response.completed below, so Codex never hits the parser's
402
405
  // "stream closed before response.completed" (responses.rs) -> ApiError::Stream.
403
406
  let terminated = false;
407
+ let firstOutputReported = false;
408
+ const reportFirstOutput = (event: AdapterEvent): void => {
409
+ if (firstOutputReported) return;
410
+ const nonEmpty = event.type === "text_delta"
411
+ ? event.text.length > 0
412
+ : event.type === "thinking_delta"
413
+ ? event.thinking.length > 0
414
+ : event.type === "reasoning_raw_delta"
415
+ ? event.text.length > 0
416
+ : false;
417
+ if (!nonEmpty) return;
418
+ firstOutputReported = true;
419
+ try { options?.onFirstOutput?.(); } catch { /* metrics must not break the stream */ }
420
+ };
404
421
  let macrotaskFired = true;
405
422
  let macrotaskTimer: ReturnType<typeof setTimeout> | undefined;
406
423
 
@@ -416,6 +433,7 @@ export function bridgeToResponsesSSE(
416
433
  macrotaskTimer = setTimeout(() => { macrotaskFired = true; macrotaskTimer = undefined; }, 0);
417
434
  activity = true;
418
435
  stallTicks = 0;
436
+ reportFirstOutput(event);
419
437
  // Compaction turns emit ONLY the synthetic compaction item + response.completed. The
420
438
  // summary text is accumulated silently: emitting it as a normal assistant message would
421
439
  // duplicate the summary if this response is ever replayed via previous_response_id
@@ -124,7 +124,7 @@ function handleList(args: string[]): void {
124
124
  // provider add
125
125
  // ---------------------------------------------------------------------------
126
126
 
127
- const ADD_USAGE = "Usage: ocx provider add <name> [--adapter <adapter>] [--base-url <url>] [--api-key <key>] [--default-model <model>] [--set-default] [--force] [--json] [--sync]";
127
+ const ADD_USAGE = "Usage: ocx provider add <name> [--adapter <adapter>] [--base-url <url>] [--api-key <key>] [--default-model <model>] [--allow-private-network] [--set-default] [--force] [--json] [--sync]";
128
128
 
129
129
  async function handleAdd(args: string[]): Promise<void> {
130
130
  const name = args[0];
@@ -138,12 +138,13 @@ async function handleAdd(args: string[]): Promise<void> {
138
138
  process.exit(1);
139
139
  }
140
140
 
141
- const restArgs = args.slice(1);
142
- const force = consumeFlag(restArgs, "--force");
143
- const setDefault = consumeFlag(restArgs, "--set-default");
144
- const wantsJson = consumeFlag(restArgs, "--json");
145
- const wantsSync = consumeFlag(restArgs, "--sync");
146
- const apiKey = consumeFlagValue(restArgs, "--api-key");
141
+ const restArgs = args.slice(1);
142
+ const force = consumeFlag(restArgs, "--force");
143
+ const setDefault = consumeFlag(restArgs, "--set-default");
144
+ const wantsJson = consumeFlag(restArgs, "--json");
145
+ const wantsSync = consumeFlag(restArgs, "--sync");
146
+ const allowPrivateNetwork = consumeFlag(restArgs, "--allow-private-network");
147
+ const apiKey = consumeFlagValue(restArgs, "--api-key");
147
148
  const adapter = consumeFlagValue(restArgs, "--adapter");
148
149
  const baseUrl = consumeFlagValue(restArgs, "--base-url");
149
150
  const defaultModel = consumeFlagValue(restArgs, "--default-model");
@@ -187,8 +188,9 @@ async function handleAdd(args: string[]): Promise<void> {
187
188
  };
188
189
  }
189
190
 
190
- config.providers[name] = provConfig;
191
- if (setDefault) config.defaultProvider = name;
191
+ config.providers[name] = provConfig;
192
+ if (allowPrivateNetwork) provConfig.allowPrivateNetwork = true;
193
+ if (setDefault) config.defaultProvider = name;
192
194
 
193
195
  validateAndSave(config);
194
196
 
@@ -564,16 +564,24 @@ export async function handleCodexAuthAPI(
564
564
  }
565
565
 
566
566
  if (url.pathname === "/api/codex-auth/login" && req.method === "POST") {
567
- const body = (await req.json().catch(() => ({}))) as { id?: string };
567
+ const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean };
568
568
  const requestedAccountId = body.id?.trim();
569
+ const reauth = body.reauth === true;
569
570
  if (requestedAccountId && !ACCOUNT_ID_RE.test(requestedAccountId)) {
570
571
  return jsonResponse({ error: "Invalid account id format" }, 400);
571
572
  }
572
573
  const accountId = requestedAccountId || `chatgpt-${Date.now()}`;
573
574
  const runtimeConfig = getRuntimeConfig(config);
574
- if ((runtimeConfig.codexAccounts ?? []).some(a => a.id === accountId) || getCodexAccountCredential(accountId)) {
575
+ const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === accountId) || Boolean(getCodexAccountCredential(accountId));
576
+ if (exists && !reauth) {
575
577
  return jsonResponse({ error: `Account id already exists: ${accountId}` }, 400);
576
578
  }
579
+ if (reauth) {
580
+ if (!requestedAccountId) return jsonResponse({ error: "id required for reauth" }, 400);
581
+ if (!configuredPoolAccount(runtimeConfig, accountId)) {
582
+ return jsonResponse({ error: "Unknown pool account for reauth" }, 404);
583
+ }
584
+ }
577
585
  const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
578
586
  try {
579
587
  const { startLoginFlow, getLoginStatus } = await import("../oauth");
@@ -622,8 +630,49 @@ export async function handleCodexAuthAPI(
622
630
  quota = parseUsageQuota(data);
623
631
  }
624
632
  } catch { /* wham fetch is non-blocking */ }
633
+ // Reauth must refresh the same ChatGPT identity already bound to this pool slot.
634
+ // Otherwise a different login would silently overwrite credentials under a trusted id.
635
+ if (reauth) {
636
+ const existingCred = getCodexAccountCredential(accountId);
637
+ const poolAccount = configuredPoolAccount(getRuntimeConfig(config), accountId);
638
+ const expectedChatgptId = existingCred?.chatgptAccountId?.trim();
639
+ const expectedEmail = poolAccount?.email?.trim().toLowerCase();
640
+ const gotEmail = email.trim().toLowerCase();
641
+ if (expectedChatgptId) {
642
+ if (expectedChatgptId !== oauthAccountId) {
643
+ codexAuthLoginState.set(flowId, {
644
+ status: "error",
645
+ error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.",
646
+ doneAt: Date.now(),
647
+ });
648
+ completed = true;
649
+ break;
650
+ }
651
+ } else if (expectedEmail) {
652
+ if (!gotEmail || gotEmail !== expectedEmail) {
653
+ codexAuthLoginState.set(flowId, {
654
+ status: "error",
655
+ error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.",
656
+ doneAt: Date.now(),
657
+ });
658
+ completed = true;
659
+ break;
660
+ }
661
+ } else {
662
+ // No chatgptAccountId and no pool email — refuse silent identity replacement
663
+ // (including empty credential slots that still have a pool row).
664
+ codexAuthLoginState.set(flowId, {
665
+ status: "error",
666
+ error: "Cannot verify account identity for reauth. Remove this account and add it again.",
667
+ doneAt: Date.now(),
668
+ });
669
+ completed = true;
670
+ break;
671
+ }
672
+ }
673
+
625
674
  // 1.2: Duplicate check is scoped by personal vs workspace plan bucket.
626
- const collision = checkAccountIdCollision(oauthAccountId, email, plan);
675
+ const collision = checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
627
676
  if (collision.collision) {
628
677
  codexAuthLoginState.set(flowId, {
629
678
  status: "error", error: collision.reason, doneAt: Date.now(),
@@ -665,7 +714,18 @@ export async function handleCodexAuthAPI(
665
714
 
666
715
  const latestConfig = getRuntimeConfig(config);
667
716
  const accounts = latestConfig.codexAccounts ?? [];
668
- if (!accounts.find(a => a.id === accountId)) {
717
+ const existingIdx = accounts.findIndex(a => a.id === accountId);
718
+ if (existingIdx >= 0) {
719
+ // Keep the pool id stable; refresh display metadata after a successful login/reauth.
720
+ accounts[existingIdx] = withCodexAccountLogLabel({
721
+ ...accounts[existingIdx],
722
+ email,
723
+ plan,
724
+ isMain: false,
725
+ }, accounts);
726
+ latestConfig.codexAccounts = accounts;
727
+ saveRuntimeConfig(config, latestConfig);
728
+ } else {
669
729
  accounts.push(withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts));
670
730
  latestConfig.codexAccounts = accounts;
671
731
  saveRuntimeConfig(config, latestConfig);
@@ -714,9 +774,12 @@ export async function handleCodexAuthAPI(
714
774
  if (url.pathname === "/api/codex-auth/login-status" && req.method === "GET") {
715
775
  const flowId = url.searchParams.get("flowId");
716
776
  const accountId = url.searchParams.get("accountId")?.trim();
777
+ // Reauth always has a pre-existing credential; never treat "credential exists" as success
778
+ // when the flow map entry is gone (would false-complete on lost/expired flow state).
779
+ const reauthStatus = url.searchParams.get("reauth") === "1";
717
780
  if (flowId) {
718
781
  const st = codexAuthLoginState.get(flowId);
719
- if (!st && accountId && getCodexAccountCredential(accountId)) {
782
+ if (!st && accountId && !reauthStatus && getCodexAccountCredential(accountId)) {
720
783
  return jsonResponse({ status: "done", accountId });
721
784
  }
722
785
  return jsonResponse(st ? { ...st, email: maskEmail(st.email) ?? undefined } : { status: "expired" });
@@ -44,10 +44,12 @@ export function checkAccountIdCollision(
44
44
  chatgptAccountId: string,
45
45
  email?: string | null,
46
46
  plan?: string | null,
47
+ excludeAccountId?: string | null,
47
48
  ): { collision: true; reason: string } | { collision: false } {
48
49
  const candidateEmail = normalizedEmail(email);
49
50
  const candidateWorkspace = isWorkspacePlan(plan);
50
51
  for (const account of loadConfig().codexAccounts ?? []) {
52
+ if (excludeAccountId && account.id === excludeAccountId) continue;
51
53
  if (account.isMain) continue;
52
54
  if (isWorkspacePlan(account.plan) !== candidateWorkspace) continue;
53
55
  const cred = getCodexAccountCredential(account.id);