@bitkyc08/opencodex 2.11.0 → 2.11.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.
Files changed (92) hide show
  1. package/gui/dist/assets/{index-Bk-PN-70.css → index-BA1dgo4q.css} +1 -1
  2. package/gui/dist/assets/index-DDZpgzKk.js +70 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +6 -4
  5. package/src/adapters/anthropic.ts +10 -2
  6. package/src/adapters/cursor/native-exec-common.ts +6 -2
  7. package/src/adapters/google-antigravity-replay.ts +3 -2
  8. package/src/adapters/google-antigravity-wire.ts +38 -6
  9. package/src/adapters/google.ts +57 -10
  10. package/src/adapters/openai-chat.ts +19 -2
  11. package/src/claude/model-info.ts +1 -1
  12. package/src/cli/account-auth.ts +3 -1
  13. package/src/cli/account-catalog-refresh.ts +14 -0
  14. package/src/cli/account-extended.ts +15 -1
  15. package/src/cli/codex-shim-readiness.ts +69 -0
  16. package/src/cli/combo.ts +5 -0
  17. package/src/cli/config-command.ts +33 -4
  18. package/src/cli/doctor.ts +21 -0
  19. package/src/cli/export-command.ts +6 -6
  20. package/src/cli/help.ts +6 -6
  21. package/src/cli/index.ts +22 -5
  22. package/src/cli/models-runtime.ts +13 -1
  23. package/src/cli/provider.ts +7 -0
  24. package/src/clients/config-export.ts +67 -5
  25. package/src/codex/account-lifecycle.ts +99 -10
  26. package/src/codex/auth-api.ts +222 -34
  27. package/src/codex/catalog/account-models.ts +9 -4
  28. package/src/codex/catalog/aggregation.ts +41 -8
  29. package/src/codex/catalog/bundled.ts +54 -22
  30. package/src/codex/catalog/effort.ts +47 -20
  31. package/src/codex/catalog/kinds.ts +2 -0
  32. package/src/codex/catalog/metadata.ts +70 -18
  33. package/src/codex/catalog/native-models.ts +7 -0
  34. package/src/codex/catalog/parsing.ts +29 -11
  35. package/src/codex/catalog/provider-fetch.ts +335 -50
  36. package/src/codex/catalog/sync.ts +588 -126
  37. package/src/codex/catalog-refresh-status.ts +87 -0
  38. package/src/codex/catalog-write-serialization.ts +2 -1
  39. package/src/codex/catalog.ts +4 -3
  40. package/src/codex/convergence-types.ts +1 -1
  41. package/src/codex/convergence.ts +190 -52
  42. package/src/codex/custom-model-catalog-migration.ts +176 -0
  43. package/src/codex/features.ts +79 -1
  44. package/src/codex/history-job.ts +81 -1
  45. package/src/codex/history-lock.ts +2 -1
  46. package/src/codex/history-provider.ts +4 -3
  47. package/src/codex/inject.ts +56 -12
  48. package/src/codex/model-cache.ts +50 -10
  49. package/src/codex/transition-state.ts +10 -2
  50. package/src/codex/user-identity.ts +110 -2
  51. package/src/combos/index.ts +3 -0
  52. package/src/combos/types.ts +75 -9
  53. package/src/config.ts +26 -12
  54. package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +12 -10
  55. package/src/integrations/registry.ts +7 -0
  56. package/src/integrations/writer.ts +1 -1
  57. package/src/lib/pinned-http.ts +40 -9
  58. package/src/lib/process-control.ts +4 -1
  59. package/src/lib/provider-outbound.ts +42 -9
  60. package/src/oauth/cursor.ts +25 -5
  61. package/src/oauth/index.ts +59 -45
  62. package/src/providers/antigravity-models.ts +115 -3
  63. package/src/providers/context-cap.ts +13 -5
  64. package/src/providers/model-discovery-limits.ts +16 -0
  65. package/src/providers/model-discovery.ts +14 -15
  66. package/src/providers/provider-id-rewrite.ts +29 -0
  67. package/src/providers/quota.ts +93 -19
  68. package/src/providers/registry.ts +16 -5
  69. package/src/providers/slug-codec.ts +13 -6
  70. package/src/reasoning-effort.ts +22 -0
  71. package/src/router.ts +0 -20
  72. package/src/routing/history/indexer.ts +54 -39
  73. package/src/routing/quota.ts +77 -56
  74. package/src/server/index.ts +32 -7
  75. package/src/server/management/combo-routes.ts +43 -19
  76. package/src/server/management/config-routes.ts +115 -20
  77. package/src/server/management/model-routes.ts +10 -7
  78. package/src/server/management/model-rows.ts +6 -2
  79. package/src/server/management/oauth-account-routes.ts +12 -0
  80. package/src/server/management/provider-routes.ts +114 -40
  81. package/src/server/management/routing-profile-routes.ts +0 -22
  82. package/src/server/management-api.ts +7 -25
  83. package/src/server/responses/core.ts +3 -3
  84. package/src/server/responses/policy-fallback.ts +152 -0
  85. package/src/server/responses.ts +3 -2
  86. package/src/types.ts +15 -2
  87. package/src/usage/cost.ts +0 -0
  88. package/src/vision/describe.ts +3 -1
  89. package/src/vision/index.ts +17 -6
  90. package/src/vision/reasoning.ts +55 -0
  91. package/src/web-search/parse.ts +15 -3
  92. package/gui/dist/assets/index-BynIEIV-.js +0 -70
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BynIEIV-.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-Bk-PN-70.css">
19
+ <script type="module" crossorigin src="/assets/index-DDZpgzKk.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-BA1dgo4q.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.11.0",
3
+ "version": "2.11.1",
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",
@@ -42,7 +42,7 @@
42
42
  "typecheck": "bun x tsc --noEmit",
43
43
  "audit:high": "bun audit --audit-level=high && cd gui && bun audit --audit-level=high",
44
44
  "privacy:scan": "bun scripts/privacy-scan.ts",
45
- "generate:jawcode-metadata": "bun scripts/generate-jawcode-metadata.ts",
45
+ "generate:model-metadata": "bun scripts/generate-model-metadata.ts",
46
46
  "build:gui": "cd gui && bun install --frozen-lockfile && bun run build && cd .. && bun run prepare:package",
47
47
  "prepare:package": "bun scripts/prepare-package.ts",
48
48
  "prepack": "bun run prepare:package",
@@ -59,7 +59,7 @@
59
59
  "setup:hooks": "bun scripts/setup-hooks.ts"
60
60
  },
61
61
  "dependencies": {
62
- "@bufbuild/protobuf": "^2.12.0",
62
+ "@bufbuild/protobuf": "^2.13.0",
63
63
  "@modelcontextprotocol/sdk": "^1.30.0",
64
64
  "@napi-rs/keyring": "1.3.0",
65
65
  "bun": "1.3.14",
@@ -67,10 +67,12 @@
67
67
  },
68
68
  "devDependencies": {
69
69
  "@types/bun": "1.3.14",
70
- "typescript": "5.9.3"
70
+ "typescript": "7.0.2"
71
71
  },
72
72
  "overrides": {
73
+ "@hono/node-server": "2.1.0",
73
74
  "fast-uri": "^3.1.5",
75
+ "hono": "4.13.1",
74
76
  "ip-address": "^10.4.0"
75
77
  },
76
78
  "keywords": [
@@ -984,13 +984,21 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
984
984
  const payload = record.data.trim();
985
985
  if (!payload) continue;
986
986
 
987
- let data: Record<string, unknown>;
987
+ let parsed: unknown;
988
988
  try {
989
- data = JSON.parse(payload) as Record<string, unknown>;
989
+ parsed = JSON.parse(payload);
990
990
  } catch {
991
991
  debugDroppedFrame("anthropic", payload);
992
992
  continue;
993
993
  }
994
+ // `JSON.parse("null")` returns null instead of throwing, so the catch above cannot cover
995
+ // it and the `data.type` read below crashed the stream. Drop a non-record frame the same
996
+ // way an unparseable one is dropped, so the message_stop check still governs the outcome.
997
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
998
+ debugDroppedFrame("anthropic", payload);
999
+ continue;
1000
+ }
1001
+ const data = parsed as Record<string, unknown>;
994
1002
 
995
1003
  switch (record.event || data.type) {
996
1004
  case "message_start": {
@@ -15,14 +15,18 @@ export function clientBytes(message: Parameters<typeof create<typeof AgentClient
15
15
  return toBinary(AgentClientMessageSchema, create(AgentClientMessageSchema, message));
16
16
  }
17
17
 
18
- export function execBytes(execMsg: ExecServerMessage, messageCase: ExecClientMessage["message"]["case"], value: unknown): Uint8Array {
18
+ export function execBytes<K extends ExecClientMessage["message"]["case"]>(
19
+ execMsg: ExecServerMessage,
20
+ messageCase: K,
21
+ value: Extract<ExecClientMessage["message"], { case: K }>["value"],
22
+ ): Uint8Array {
19
23
  return clientBytes({
20
24
  message: {
21
25
  case: "execClientMessage",
22
26
  value: create(ExecClientMessageSchema, {
23
27
  id: execMsg.id,
24
28
  execId: execMsg.execId,
25
- message: { case: messageCase, value: value as never },
29
+ message: { case: messageCase, value } as ExecClientMessage["message"],
26
30
  }),
27
31
  },
28
32
  });
@@ -2,14 +2,15 @@ import { createHash } from "node:crypto";
2
2
  import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
3
3
 
4
4
  /**
5
- * Antigravity (Cloud Code Assist) thoughtSignature reasoning-replay cache.
5
+ * Google-family thoughtSignature reasoning-replay cache.
6
6
  *
7
7
  * Gemini-3 interleaved thinking is stateless upstream: each model content part carries a
8
8
  * `thoughtSignature` that MUST be echoed back on the matching part in the next request, or the
9
9
  * upstream rejects the turn (HTTP 400). We observe signatures on the response stream, cache them
10
10
  * per `model + session`, and re-inject them into the outgoing `request.contents` on the next turn.
11
11
  *
12
- * Mirrors CLIProxyAPI `internal/runtime/executor/antigravity_reasoning_replay.go`. Gemini-only;
12
+ * Mirrors CLIProxyAPI `internal/runtime/executor/antigravity_reasoning_replay.go` and is also used
13
+ * by Vertex with a transport/project/location-prefixed model identity. Gemini-only;
13
14
  * Claude-on-Antigravity uses inline signature sanitization instead (see google-antigravity-wire).
14
15
  */
15
16
 
@@ -51,18 +51,50 @@ function firstUserText(parsed: OcxParsedRequest): string | undefined {
51
51
  * 0x7FFFFFFFFFFFFFFF, prefixed with "-". Falls back to a random "-<digits>" id when there is no text.
52
52
  */
53
53
  export function antigravitySessionId(parsed: OcxParsedRequest): string {
54
- // Anchored on the first user message text: this is the one value that stays STABLE across every
55
- // turn of a conversation, which the replay cache requires (it observes signatures on turn N's
56
- // response and re-injects them on turn N+1's request, so both turns must map to the same id).
57
- // Cross-conversation collisions (two threads opening with identical text) are made harmless by
58
- // the replay cache keying signatures on functionCall identity (name+args), not on this id alone.
59
- const text = firstUserText(parsed);
54
+ // The id must be IDENTICAL on turn N and turn N+1: the replay cache observes thought signatures
55
+ // on turn N's response and re-injects them on turn N+1's request, so a changing id loses the
56
+ // association entirely. (A *shared* id does not signatures are keyed on functionCall identity,
57
+ // name+args, so cross-conversation collisions stay harmless. Instability is the failure mode.)
58
+ //
59
+ // First-user text does not guarantee that. It is stable only while the first user message
60
+ // survives verbatim in what this adapter sees, and Codex compacts, summarises, and trims long
61
+ // histories — at which point the anchor changes mid-conversation (#1295's sibling, #1297).
62
+ //
63
+ // `_clientThreadId` is Codex's own thread identity from `x-codex-parent-thread-id`. It survives
64
+ // compaction because it does not depend on message content, which is the property first-user
65
+ // text fails to provide. Deliberately NOT `promptCacheKey`: that is arbitrary Responses input
66
+ // and is explicitly shared across conversations for some clients
67
+ // (`adapters/cursor/request-builder.ts`), so it identifies a cache cohort, not a conversation.
68
+ //
69
+ // What is NOT claimed: that Google treats this id as anything but opaque, or that upstream
70
+ // signatures are not session-bound. Only the local association is verified here.
71
+ //
72
+ // Clients that send no thread header keep the text anchor and its instability; this is a scoped
73
+ // repair, not a universal one.
74
+ const text = clientThreadAnchor(parsed) ?? firstUserText(parsed);
60
75
  if (!text) return `-${Math.floor(Math.random() * 9e18).toString()}`;
61
76
  const digest = createHash("sha256").update(text, "utf8").digest();
62
77
  const masked = digest.readBigUInt64BE(0) & 0x7fffffffffffffffn;
63
78
  return `-${masked.toString()}`;
64
79
  }
65
80
 
81
+ /**
82
+ * Codex's stable client thread id, prefixed before hashing so it does not collide with a first
83
+ * user message equal to the bare thread id.
84
+ *
85
+ * This is a prefix, not full domain separation: a textless-anchor request whose first message is
86
+ * literally `codex-thread:<id>` still hashes the same preimage. Tagging BOTH anchor classes would
87
+ * separate them completely, but it would also change every existing text-derived id — a
88
+ * Google-visible wire value for live conversations — which is a larger blast radius than the
89
+ * collision it removes. A collision is harmless here anyway: the replay cache keys signatures on
90
+ * functionCall identity (name+args), so a shared id does not misattribute them. Instability, not
91
+ * collision, is the failure mode this function exists to prevent.
92
+ */
93
+ function clientThreadAnchor(parsed: OcxParsedRequest): string | undefined {
94
+ const threadId = parsed._clientThreadId?.trim();
95
+ return threadId ? `codex-thread:${threadId}` : undefined;
96
+ }
97
+
66
98
  /** A Gemini content part as it appears in an Antigravity request body. */
67
99
  interface GeminiPart {
68
100
  thought?: boolean;
@@ -51,6 +51,13 @@ function resolveVertexApiKey(optKey?: string): string | undefined {
51
51
  return realKey || process.env.GOOGLE_CLOUD_API_KEY;
52
52
  }
53
53
 
54
+ /** Prefer Codex's stable opaque thread key; retain the existing deterministic fallback for clients
55
+ * that omit it. The replay store hashes this value and never retains the raw session identifier. */
56
+ function vertexReplaySessionId(parsed: OcxParsedRequest): string {
57
+ const promptCacheKey = parsed.options.promptCacheKey?.trim();
58
+ return promptCacheKey || antigravitySessionId(parsed);
59
+ }
60
+
54
61
  /**
55
62
  * Stable tool-call id for the Gemini wire `functionCall.id` / `functionResponse.id` fields.
56
63
  *
@@ -302,6 +309,11 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
302
309
  // can stash the CCA model/session for parseStream's reasoning-replay observation.
303
310
  let antigravityModel: string | undefined;
304
311
  let antigravitySession: string | undefined;
312
+ // Vertex returns the same opaque Gemini thought signatures as CCA, but its replay namespace
313
+ // must stay transport-scoped: a signature minted by one Google backend must never be sent to
314
+ // another merely because the public model id and first prompt happen to match.
315
+ let vertexReplayModel: string | undefined;
316
+ let vertexReplaySession: string | undefined;
305
317
  let restoreGoogleToolName = (name: string): string => name;
306
318
  return {
307
319
  name: "google",
@@ -436,6 +448,20 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
436
448
  if (provider.googleMode === "vertex") {
437
449
  const compiled = compileGoogleWireBody(body);
438
450
  restoreGoogleToolName = compiled.restoreToolName;
451
+ const vertexProject = provider.project || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || "api-key";
452
+ const vertexLocation = provider.location || process.env.GOOGLE_CLOUD_LOCATION || "global";
453
+ vertexReplayModel = `vertex:${vertexProject}:${vertexLocation}:${parsed.modelId}`;
454
+ vertexReplaySession = vertexReplaySessionId(parsed);
455
+ // Compile names before replay so the cache matches the exact provider-visible
456
+ // functionCall identity. This is the same bounded TTL/LRU store used by CCA, with the
457
+ // transport prefix above preventing cross-backend signature reuse (#1254).
458
+ if (Array.isArray((compiled.body as { contents?: unknown[] }).contents)) {
459
+ applyAntigravityReplay(
460
+ vertexReplayModel,
461
+ vertexReplaySession,
462
+ (compiled.body as { contents: unknown[] }).contents,
463
+ );
464
+ }
439
465
  // Vertex AI: project/location endpoint with GCP ADC, or x-goog-api-key fast path.
440
466
  const apiKey = resolveVertexApiKey(provider.apiKey);
441
467
  if (apiKey) {
@@ -497,13 +523,23 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
497
523
  }
498
524
  let emittedContentEvent = false;
499
525
 
500
- let chunk: Record<string, unknown>;
526
+ let parsed: unknown;
501
527
  try {
502
- chunk = JSON.parse(payload);
528
+ parsed = JSON.parse(payload);
503
529
  } catch {
504
530
  yield { type: "error", message: "malformed upstream SSE data frame" };
505
531
  return "terminate";
506
532
  }
533
+ // `JSON.parse("null")` returns null rather than throwing, so the catch above cannot cover
534
+ // it and the `chunk.error` read below crashed the stream (see openai-chat.ts). Skip such a
535
+ // frame rather than terminating, for the reason given there: it is padding between real
536
+ // frames, not a broken stream. Deliberately returns BEFORE `sawAnyFrame`, so a stream made
537
+ // only of non-record frames still fails the terminal-signal check below instead of
538
+ // completing empty. An unparseable frame stays terminal.
539
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
540
+ return "continue";
541
+ }
542
+ const chunk = parsed as Record<string, unknown>;
507
543
  sawAnyFrame = true;
508
544
 
509
545
  // Inline provider error inside a 200 stream → terminal error (see openai-chat.ts).
@@ -511,9 +547,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
511
547
  const err = chunk.error as { message?: string } | undefined;
512
548
  // Clear-on-invalid: a signature rejection means our replayed thoughtSignatures are stale.
513
549
  // Drop the cache entry so the next turn starts clean instead of re-injecting a bad sig.
514
- if (provider.googleMode === "cloud-code-assist" && antigravityModel && antigravitySession
550
+ const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel;
551
+ const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession;
552
+ if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex")
553
+ && replayModel && replaySession
515
554
  && /signature|invalid_argument|invalid argument/i.test(err?.message ?? "")) {
516
- clearAntigravityReplay(antigravityModel, antigravitySession);
555
+ clearAntigravityReplay(replayModel, replaySession);
517
556
  }
518
557
  yield { type: "error", message: err?.message ?? "upstream error" };
519
558
  return "terminate";
@@ -547,9 +586,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
547
586
  }
548
587
 
549
588
  const parts = candidates[0].content?.parts as { text?: string; functionCall?: { name: string; args: unknown } }[] | undefined;
550
- // Antigravity reasoning-replay: record thoughtSignatures from the model parts for the next turn.
551
- if (provider.googleMode === "cloud-code-assist" && parts && antigravityModel && antigravitySession) {
552
- observeAntigravityReplay(antigravityModel, antigravitySession, parts as unknown[]);
589
+ // Record Gemini thought signatures for the next stateless tool-result turn. Vertex and
590
+ // Antigravity use separate model namespaces so opaque provider state cannot cross routes.
591
+ const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel;
592
+ const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession;
593
+ if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex")
594
+ && parts && replayModel && replaySession) {
595
+ observeAntigravityReplay(replayModel, replaySession, parts as unknown[]);
553
596
  }
554
597
  if (parts) {
555
598
  for (const part of parts) {
@@ -764,9 +807,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
764
807
  let toolCallsStarted = 0;
765
808
  const imageBudget = createImageBudget();
766
809
  if (candidates?.[0]?.content?.parts) {
767
- // Non-streaming CCA: observe thoughtSignatures for the next turn, same as the stream path.
768
- if (provider.googleMode === "cloud-code-assist" && antigravityModel && antigravitySession) {
769
- observeAntigravityReplay(antigravityModel, antigravitySession, candidates[0].content.parts as unknown[]);
810
+ // Non-streaming Google-family response: observe thought signatures for the next turn,
811
+ // using the same transport-scoped namespace as the streaming path.
812
+ const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel;
813
+ const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession;
814
+ if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex")
815
+ && replayModel && replaySession) {
816
+ observeAntigravityReplay(replayModel, replaySession, candidates[0].content.parts as unknown[]);
770
817
  }
771
818
  for (const part of candidates[0].content.parts) {
772
819
  if (part.text) events.push({ type: "text_delta", text: part.text });
@@ -951,6 +951,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
951
951
  const rawPayload = sseFieldValue(line, "data");
952
952
  if (rawPayload === null) return "continue";
953
953
  const payload = rawPayload.trim();
954
+ // A bare `data:` line carries nothing (heartbeat-style keep-alive on some gateways);
955
+ // it is not a malformed frame, just nothing to parse.
956
+ if (payload.length === 0) return "continue";
954
957
  if (payload === "[DONE]") {
955
958
  yield* flushToolCalls();
956
959
  const stopReason = stopReasonFor(finishReason);
@@ -958,13 +961,27 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
958
961
  return "terminate";
959
962
  }
960
963
 
961
- let chunk: Record<string, unknown>;
964
+ let parsed: unknown;
962
965
  try {
963
- chunk = JSON.parse(payload) as Record<string, unknown>;
966
+ parsed = JSON.parse(payload);
964
967
  } catch {
965
968
  yield { type: "error", message: "malformed upstream SSE data frame" };
966
969
  return "terminate";
967
970
  }
971
+ // Validate the shape instead of asserting it. `JSON.parse` yields a value, not necessarily
972
+ // an object — `JSON.parse("null")` returns null without throwing, so the catch above never
973
+ // sees it and the `chunk.error` read below crashed the stream mid-flight.
974
+ //
975
+ // Skip rather than terminate: `data: null` is emitted as a benign padding frame BETWEEN
976
+ // content deltas by real OpenAI-compatible routes (issue #1219), so failing here would
977
+ // discard the finish_reason chunk and [DONE] still in flight and turn a healthy response
978
+ // into a failed turn. Skipping cannot mask a genuinely broken stream — a stream carrying
979
+ // only such frames still sets neither finishReason nor sawUserFacingOutput and so trips
980
+ // the EOF truncation guard below. An unparseable frame stays terminal.
981
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
982
+ return "continue";
983
+ }
984
+ const chunk = parsed as Record<string, unknown>;
968
985
 
969
986
  // A 200/OK chat-completions stream may carry an inline provider error envelope
970
987
  // instead of a clean [DONE]. Surface it as a terminal error so the bridge emits a
@@ -134,7 +134,7 @@ export function buildAnthropicModelInfos(
134
134
  const id = idStyle === "readable" ? claudeCodeNativeAlias(slug) : aliasForRoute("native", slug);
135
135
  if (seen.has(id)) continue;
136
136
  seen.add(id);
137
- const info = modelInfo(id, `${slug} (native)`, nativeEffectiveLadder(slug), true);
137
+ const info = modelInfo(id, `${slug} (native)`, nativeEffectiveLadder(slug), true, nativeOpenAiContextWindow(slug));
138
138
  out.push(info);
139
139
  push1mVariant(info, nativeOpenAiContextWindow(slug));
140
140
  }
@@ -1,3 +1,5 @@
1
+ import { writeSync } from "node:fs";
2
+ import { warnIfCodexCatalogRefreshPending } from "./account-catalog-refresh";
1
3
  import {
2
4
  CliUsageError,
3
5
  printData,
@@ -11,7 +13,6 @@ import {
11
13
  type CliStdin,
12
14
  type RuntimeApiDeps,
13
15
  } from "./runtime-api";
14
- import { writeSync } from "node:fs";
15
16
 
16
17
  /**
17
18
  * Write the whole block to fd 1 synchronously (#1007). `console.log` can
@@ -127,6 +128,7 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
127
128
  );
128
129
  if (state.status === "done") {
129
130
  printData(state, wantsJson, [`Logged in${state.email ? ` as ${String(state.email)}` : ""}.`]);
131
+ if (!wantsJson) warnIfCodexCatalogRefreshPending(state);
130
132
  return;
131
133
  }
132
134
  if (state.status === "error" || state.status === "expired") {
@@ -0,0 +1,14 @@
1
+ export const CODEX_CATALOG_REFRESH_PENDING_WARNING =
2
+ "Warning: the account change was saved, but the Codex model catalog refresh is pending. Run 'ocx sync' to retry.";
3
+
4
+ /** Read only the public completion flag from an account mutation response. */
5
+ export function codexCatalogRefreshPending(value: unknown): boolean {
6
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
7
+ const descriptor = Object.getOwnPropertyDescriptor(value, "catalogRefreshPending");
8
+ return descriptor !== undefined && "value" in descriptor && descriptor.value === true;
9
+ }
10
+
11
+ /** Print fixed recovery guidance without reflecting server or account details. */
12
+ export function warnIfCodexCatalogRefreshPending(value: unknown): void {
13
+ if (codexCatalogRefreshPending(value)) console.error(CODEX_CATALOG_REFRESH_PENDING_WARNING);
14
+ }
@@ -17,6 +17,10 @@ import {
17
17
  type AccountDeps, type AccountStdin, type FamilyRows,
18
18
  type ProviderQuotaDto, type ProviderQuotaReportDto,
19
19
  } from "./account-api";
20
+ import {
21
+ codexCatalogRefreshPending,
22
+ warnIfCodexCatalogRefreshPending,
23
+ } from "./account-catalog-refresh";
20
24
 
21
25
  const MAIN_ID = "__main__";
22
26
  const AUTO_NOTE = "auto (no pin — lowest-usage account is selected per request)";
@@ -241,18 +245,28 @@ export async function cmdRemove(args: string[], deps: AccountDeps): Promise<numb
241
245
  const response = await apiJson(deps, baseUrl, "DELETE", deletePath(classified.type, name, id));
242
246
  if (response.status === 0) return fail("Proxy not reachable. Start it with 'ocx start' or 'ocx ensure'.");
243
247
  if (response.status !== 200) return fail(errorText(response.json, `failed to remove ${requestedId}`));
248
+ const catalogRefreshPending = classified.type === "codex"
249
+ && codexCatalogRefreshPending(response.json);
244
250
  const after = await fetchRows(deps, baseUrl, name, classified.type);
245
251
  if (after.networkDown || after.errorJson) {
246
252
  const detail = after.networkDown ? "proxy not reachable" : typeof after.errorJson?.error === "string" ? after.errorJson.error : "unknown error";
247
253
  return fail(`post-delete verification failed; delete may have succeeded: ${detail}`);
248
254
  }
249
255
  const removedActive = before.activeId === id;
250
- const result = { ok: true, provider: name, id, removedActive, promotedActiveId: after.activeId };
256
+ const result = {
257
+ ok: true,
258
+ provider: name,
259
+ id,
260
+ removedActive,
261
+ promotedActiveId: after.activeId,
262
+ ...(classified.type === "codex" ? { catalogRefreshPending } : {}),
263
+ };
251
264
  if (wantsJson) console.log(JSON.stringify(result, null, 2));
252
265
  else if (classified.type === "codex" && removedActive && after.activeId === null) console.log(`openai: ${AUTO_NOTE}`);
253
266
  else if (classified.type === "oauth") console.log(after.rows.length ? `${name}: active account is now ${after.activeId}` : `${name}: no accounts remaining`);
254
267
  else if (classified.type === "api-key") console.log(after.rows.length ? `${name}: active key is now ${after.activeId}` : `${name}: no keys remaining`);
255
268
  else console.log(`${name}: removed account ${requestedId}`);
269
+ if (!wantsJson && catalogRefreshPending) warnIfCodexCatalogRefreshPending(response.json);
256
270
  return 0;
257
271
  }
258
272
 
@@ -0,0 +1,69 @@
1
+ import {
2
+ currentExternalCodexModelProvider,
3
+ getCodexRoutingKind,
4
+ type CodexRoutingKind,
5
+ } from "../codex/inject";
6
+ import { loadConfig, resolveEnvValue } from "../config";
7
+
8
+ const PROXY_ENV_KEYS = [
9
+ "HTTP_PROXY",
10
+ "HTTPS_PROXY",
11
+ "ALL_PROXY",
12
+ "http_proxy",
13
+ "https_proxy",
14
+ "all_proxy",
15
+ ] as const;
16
+
17
+ export interface CodexShimReadinessInputs {
18
+ routingKind: CodexRoutingKind;
19
+ externalProvider: string | null;
20
+ processProxyEnvPresent: boolean;
21
+ configuredProxyResolved: boolean;
22
+ }
23
+
24
+ function externalProviderLabel(provider: string | null): string {
25
+ return provider
26
+ ? `external model_provider ${JSON.stringify(provider)}`
27
+ : "the active Codex route";
28
+ }
29
+
30
+ export function codexShimReadinessWarnings(
31
+ inputs: CodexShimReadinessInputs,
32
+ ): string[] {
33
+ const warnings: string[] = [];
34
+ const provider = externalProviderLabel(inputs.externalProvider);
35
+
36
+ if (inputs.routingKind === "unknown") {
37
+ warnings.push(
38
+ inputs.externalProvider
39
+ ? `Codex still selects ${provider}. The shim can start OpenCodex, but it does not redirect that provider; point it at the live OpenCodex /v1 endpoint with wire_api = "responses", or switch to the built-in openai provider and run 'ocx sync'.`
40
+ : "Codex routing could not be verified. The shim can start OpenCodex, but it may not redirect Codex; run 'ocx doctor' before relying on autostart.",
41
+ );
42
+ } else if (inputs.routingKind === "custom-local") {
43
+ warnings.push(
44
+ `Codex uses ${provider} through a user-owned local gateway. The shim can start OpenCodex, but OpenCodex does not own that route; run 'ocx doctor' to verify its lifecycle.`,
45
+ );
46
+ } else if (inputs.routingKind === "custom-remote") {
47
+ warnings.push(
48
+ `Codex uses ${provider} through a remote gateway. The shim only starts a local OpenCodex proxy and will not affect those requests.`,
49
+ );
50
+ }
51
+
52
+ if (inputs.processProxyEnvPresent && !inputs.configuredProxyResolved) {
53
+ warnings.push(
54
+ "Proxy environment variables are present only in this process while config.proxy is unset or unresolved. Codex launchers and background services may not inherit them; persist config.proxy before relying on autostart.",
55
+ );
56
+ }
57
+
58
+ return warnings;
59
+ }
60
+
61
+ export function collectCodexShimReadinessWarnings(): string[] {
62
+ const config = loadConfig();
63
+ return codexShimReadinessWarnings({
64
+ routingKind: getCodexRoutingKind(),
65
+ externalProvider: currentExternalCodexModelProvider(),
66
+ processProxyEnvPresent: PROXY_ENV_KEYS.some(key => Boolean(process.env[key]?.trim())),
67
+ configuredProxyResolved: Boolean(resolveEnvValue(config.proxy)?.trim()),
68
+ });
69
+ }
package/src/cli/combo.ts CHANGED
@@ -16,6 +16,7 @@ const USAGE = `Usage:
16
16
  ocx combo set <id> --targets <provider/model[:weight],...>
17
17
  [--strategy <failover|round-robin>] [--sticky <1-100>]
18
18
  [--effort <low|medium|high|xhigh|max|ultra|->] [--alias <name|->]
19
+ [--native-alias] [--display-name <label|->]
19
20
  [--rename-from <id>] [--json]
20
21
  ocx combo remove <id> --yes [--json]`;
21
22
 
@@ -77,6 +78,8 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<void> {
77
78
  if (stickyLimit > 100) throw new CliUsageError("--sticky must be <= 100", USAGE);
78
79
  const effort = takeOption(args, "--effort");
79
80
  const alias = takeOption(args, "--alias");
81
+ const nativeAlias = takeFlag(args, "--native-alias");
82
+ const displayName = takeOption(args, "--display-name");
80
83
  const renameFrom = takeOption(args, "--rename-from");
81
84
  rejectArgs(args, USAGE);
82
85
  const combo: Record<string, unknown> = {
@@ -86,6 +89,8 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<void> {
86
89
  };
87
90
  if (effort !== undefined) combo.defaultEffort = effort === "-" ? null : effort;
88
91
  if (alias !== undefined) combo.alias = alias === "-" ? "" : alias;
92
+ if (nativeAlias) combo.nativeAlias = true;
93
+ if (displayName !== undefined) combo.displayName = displayName === "-" ? "" : displayName;
89
94
  const result = await runtimeRequest("/api/combos", {
90
95
  method: "PUT",
91
96
  body: JSON.stringify({ id, combo, ...(renameFrom ? { renameFrom } : {}) }),
@@ -1,7 +1,9 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { clearCodexAccountPin } from "../codex/account-priority";
3
3
  import { getConfigPath, readConfigDiagnostics, saveConfig, validateConfigCandidate } from "../config";
4
+ import { VISION_REASONING_EFFORTS, isVisionReasoningEffort } from "../reasoning-effort";
4
5
  import type { OcxConfig } from "../types";
6
+ import { normalizeVisionReasoningForModel } from "../vision/reasoning";
5
7
  import { CliUsageError, printData, rejectArgs, runCliAction, takeFlag } from "./runtime-api";
6
8
 
7
9
  const USAGE = `Usage:
@@ -65,10 +67,36 @@ function loadInput(path: string): unknown {
65
67
  catch { throw new CliUsageError(`invalid JSON in ${path}`); }
66
68
  }
67
69
 
70
+ function visionReasoningError(value: unknown): string | null {
71
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
72
+ const vision = (value as Record<string, unknown>).visionSidecar;
73
+ if (!vision || typeof vision !== "object" || Array.isArray(vision)) return null;
74
+ const reasoning = (vision as Record<string, unknown>).reasoning;
75
+ if (reasoning === undefined || isVisionReasoningEffort(reasoning)) return null;
76
+ return `schema_invalid: visionSidecar.reasoning: must be one of ${VISION_REASONING_EFFORTS.join(", ")}`;
77
+ }
78
+
79
+ function validateCandidate(value: unknown): ReturnType<typeof validateConfigCandidate> {
80
+ const error = visionReasoningError(value);
81
+ return error ? { ok: false, error } : validateConfigCandidate(value);
82
+ }
83
+
84
+ function normalizeVisionConfig(config: OcxConfig): OcxConfig {
85
+ const vision = config.visionSidecar;
86
+ if (!vision || vision.reasoning === undefined) return config;
87
+ // Keep CLI import/set semantics aligned with the execution path: an omitted or blank model means
88
+ // the bounded OpenAI vision default, gpt-5.4-mini, not the Dashboard's web-search default.
89
+ const model = vision.model || "gpt-5.4-mini";
90
+ const normalized = normalizeVisionReasoningForModel(model, vision.reasoning);
91
+ if (normalized === undefined) delete vision.reasoning;
92
+ else vision.reasoning = normalized;
93
+ return config;
94
+ }
95
+
68
96
  function validate(value: unknown): OcxConfig {
69
- const result = validateConfigCandidate(value);
97
+ const result = validateCandidate(value);
70
98
  if (!result.ok) throw new CliUsageError(result.error);
71
- return result.config;
99
+ return normalizeVisionConfig(result.config);
72
100
  }
73
101
 
74
102
  export async function handleConfigCommand(argv: string[]): Promise<number> {
@@ -119,9 +147,10 @@ export async function handleConfigCommand(argv: string[]): Promise<number> {
119
147
  if (action === "validate") {
120
148
  const path = args.shift();
121
149
  rejectArgs(args, USAGE);
122
- const result = path ? validateConfigCandidate(loadInput(path)) : (() => {
150
+ const result = path ? validateCandidate(loadInput(path)) : (() => {
123
151
  const diagnostics = readConfigDiagnostics();
124
- return diagnostics.error ? { ok: false as const, error: diagnostics.error } : { ok: true as const, config: diagnostics.config };
152
+ if (diagnostics.error) return { ok: false as const, error: diagnostics.error };
153
+ return validateCandidate(diagnostics.config);
125
154
  })();
126
155
  printData(result.ok ? { ok: true, source: path ?? getConfigPath() } : result, wantsJson,
127
156
  [result.ok ? "Config is valid." : `Config is invalid: ${result.error}`]);
package/src/cli/doctor.ts CHANGED
@@ -25,6 +25,11 @@ import { NativeProfileError } from "../codex/native-profile-types";
25
25
  import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWindowsCodexHomes, wslAutomountRoot, type CodexHomeDeps } from "../codex/home";
26
26
  import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
27
27
  import { countPendingOpencodexHistory } from "../codex/history-provider";
28
+ import {
29
+ CodexUserIdentityRefusal,
30
+ probeCodexCoordinatorNamespace,
31
+ resolveEffectiveUserIdentity,
32
+ } from "../codex/user-identity";
28
33
  import { collectProjectCodexConfigWarnings, formatProjectCodexConfigWarningsForDoctor } from "../codex/project-config-warnings";
29
34
  import { collectStartupHealth, startupHealthSummary } from "../codex/autostart-health";
30
35
  import {
@@ -902,6 +907,22 @@ export async function runDoctor(args: string[] = []): Promise<void> {
902
907
  // Codex app until the one-time migration lands. Read-only probe (readonly sqlite, 100ms
903
908
  // busy timeout) — reports state, never mutates.
904
909
  console.log("\nCodex history migration");
910
+ // The history failure messages point here; make the visit worthwhile by
911
+ // probing the coordinator namespace the locks live in. The probe exercises
912
+ // identity, runtime-root, and permission checks without taking any lock or
913
+ // creating anything (a doctor run must observe, not initialize).
914
+ try {
915
+ const identity = resolveEffectiveUserIdentity();
916
+ const probe = probeCodexCoordinatorNamespace(identity);
917
+ if (probe.status === "missing") {
918
+ console.log(" ok history coordinator namespace not created yet (no history operation has run)");
919
+ } else {
920
+ console.log(" ok history coordinator namespace resolves");
921
+ }
922
+ } catch (cause) {
923
+ const reason = cause instanceof CodexUserIdentityRefusal ? cause.message : String(cause);
924
+ console.log(` -- history coordinator namespace refused: ${reason}`);
925
+ }
905
926
  const pending = countPendingOpencodexHistory();
906
927
  if (pending.failed) {
907
928
  console.log(" -- state DB locked or unreadable (Codex app open?) — migration state unknown");