@bitkyc08/opencodex 2.48.0 → 2.50.0

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 (141) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +11 -5
  3. package/SPONSORS.md +1 -1
  4. package/assets/sponsors/orcarouter.png +0 -0
  5. package/assets/sponsors/packycode.png +0 -0
  6. package/gui/dist/assets/index-BoBRSehJ.css +1 -0
  7. package/gui/dist/assets/index-C39tnjXO.js +115 -0
  8. package/gui/dist/index.html +2 -2
  9. package/gui/dist/provider-icons/packycode.svg +19 -0
  10. package/gui/dist/provider-icons/qoder.svg +5 -0
  11. package/package.json +5 -3
  12. package/src/adapters/anthropic.ts +31 -16
  13. package/src/adapters/codebuddy/adapter.ts +85 -0
  14. package/src/adapters/codebuddy/profiles.ts +52 -0
  15. package/src/adapters/coding-agent/profile.ts +100 -0
  16. package/src/adapters/coding-agent/protocol.ts +463 -0
  17. package/src/adapters/coding-agent/turn.ts +353 -0
  18. package/src/adapters/google.ts +15 -11
  19. package/src/adapters/mimo-free.ts +3 -0
  20. package/src/adapters/openai-chat.ts +2 -2
  21. package/src/adapters/openai-responses.ts +18 -11
  22. package/src/adapters/qoder/adapter.ts +70 -0
  23. package/src/adapters/qoder/live-models.ts +89 -0
  24. package/src/adapters/qoder/profiles.ts +36 -0
  25. package/src/adapters/registry.ts +12 -0
  26. package/src/adapters/responses-tool-schema.ts +113 -8
  27. package/src/claude/inbound.ts +17 -5
  28. package/src/cli/account-api.ts +18 -3
  29. package/src/cli/account-auth.ts +8 -1
  30. package/src/cli/account-extended.ts +2 -1
  31. package/src/cli/account.ts +1 -0
  32. package/src/cli/capabilities.ts +15 -1
  33. package/src/cli/dispatch.ts +2 -0
  34. package/src/cli/doctor.ts +40 -0
  35. package/src/cli/effort.ts +24 -8
  36. package/src/cli/help.ts +2 -0
  37. package/src/cli/index.ts +29 -2
  38. package/src/cli/models-runtime.ts +8 -3
  39. package/src/cli/observe.ts +13 -3
  40. package/src/cli/provider-runtime.ts +2 -1
  41. package/src/cli/registry.ts +2 -2
  42. package/src/cli/system-command.ts +10 -3
  43. package/src/cli/usage-report.ts +9 -5
  44. package/src/clients/config-export/zcode.ts +24 -0
  45. package/src/codex/account-lifecycle.ts +35 -2
  46. package/src/codex/account-runtime-state.ts +6 -1
  47. package/src/codex/account-store.ts +72 -9
  48. package/src/codex/account-usability.ts +3 -2
  49. package/src/codex/auth-api.ts +113 -26
  50. package/src/codex/auth-collision.ts +12 -2
  51. package/src/codex/auth-context.ts +96 -7
  52. package/src/codex/catalog/parsing.ts +23 -0
  53. package/src/codex/catalog/provider-fetch.ts +144 -11
  54. package/src/codex/catalog/sync.ts +14 -0
  55. package/src/codex/inject.ts +128 -30
  56. package/src/codex/internal/catalog-writer.ts +3 -0
  57. package/src/codex/journal.ts +61 -12
  58. package/src/codex/model-cache.ts +11 -4
  59. package/src/codex/native-profile-startup.ts +72 -5
  60. package/src/codex/native-profile-store.ts +2 -2
  61. package/src/codex/ocx-compaction-history.ts +226 -0
  62. package/src/codex/project-config-warnings.ts +3 -1
  63. package/src/codex/quota-auto-refresh.ts +6 -1
  64. package/src/codex/quota.ts +71 -15
  65. package/src/codex/reserve-availability.ts +21 -5
  66. package/src/codex/runtime.ts +45 -1
  67. package/src/codex/sync.ts +5 -0
  68. package/src/combos/index.ts +2 -0
  69. package/src/combos/resolve.ts +52 -0
  70. package/src/config.ts +59 -0
  71. package/src/generated/compatibility-version.json +178 -114
  72. package/src/images/loop.ts +1 -0
  73. package/src/images/xai-video-client.ts +2 -0
  74. package/src/integrations/registry.ts +1 -0
  75. package/src/lib/errors.ts +8 -0
  76. package/src/lib/privacy.ts +25 -0
  77. package/src/lib/process-control.ts +52 -8
  78. package/src/lib/upstream-retry.ts +1 -0
  79. package/src/oauth/chatgpt.ts +83 -0
  80. package/src/oauth/health.ts +47 -12
  81. package/src/oauth/index.ts +46 -8
  82. package/src/oauth/token-guardian.ts +32 -6
  83. package/src/oauth/xai.ts +151 -8
  84. package/src/providers/api-key-selection-capture.ts +10 -0
  85. package/src/providers/api-key-selection.ts +2 -7
  86. package/src/providers/caller-authorization.ts +36 -0
  87. package/src/providers/codebuddy-models.ts +184 -0
  88. package/src/providers/derive.ts +5 -0
  89. package/src/providers/free-directory.ts +26 -2
  90. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  91. package/src/providers/openai-sidecar.ts +35 -11
  92. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  93. package/src/providers/qoder-models.ts +25 -0
  94. package/src/providers/quota.ts +15 -0
  95. package/src/providers/registry.ts +140 -1
  96. package/src/responses/compaction.ts +4 -0
  97. package/src/responses/task-input.ts +21 -1
  98. package/src/router.ts +1 -1
  99. package/src/server/auth-cors.ts +6 -0
  100. package/src/server/chat-completions.ts +30 -13
  101. package/src/server/chat-native.ts +10 -1
  102. package/src/server/claude-messages.ts +17 -7
  103. package/src/server/images.ts +3 -2
  104. package/src/server/index.ts +25 -2
  105. package/src/server/management/account-selection-stream.ts +13 -4
  106. package/src/server/management/config-routes.ts +24 -5
  107. package/src/server/management/logs-usage-routes.ts +5 -1
  108. package/src/server/management/model-rows.ts +16 -1
  109. package/src/server/management/native-integration-routes.ts +2 -1
  110. package/src/server/management/oauth-account-routes.ts +6 -2
  111. package/src/server/management/provider-routes.ts +33 -2
  112. package/src/server/management/request-history-routes.ts +4 -2
  113. package/src/server/management/route-registry.ts +5 -4
  114. package/src/server/management/shared.ts +66 -3
  115. package/src/server/management-api.ts +15 -1
  116. package/src/server/port-reclaim.ts +11 -26
  117. package/src/server/request-decompress.ts +91 -3
  118. package/src/server/request-log.ts +16 -0
  119. package/src/server/responses/codex-ws-wire.ts +1 -1
  120. package/src/server/responses/collaboration.ts +4 -9
  121. package/src/server/responses/compact.ts +8 -2
  122. package/src/server/responses/context-overflow.ts +11 -0
  123. package/src/server/responses/core.ts +285 -57
  124. package/src/server/responses/fetch-helpers.ts +18 -7
  125. package/src/server/responses/policy-fallback.ts +18 -2
  126. package/src/server/search.ts +2 -2
  127. package/src/service.ts +128 -9
  128. package/src/storage/cleanup.ts +77 -45
  129. package/src/types/accounts.ts +18 -0
  130. package/src/types/config.ts +43 -1
  131. package/src/types/provider.ts +56 -0
  132. package/src/types.ts +4 -0
  133. package/src/usage/log.ts +24 -0
  134. package/src/vision/anthropic-describe.ts +1 -0
  135. package/src/web-search/anthropic-executor.ts +1 -0
  136. package/src/web-search/loop.ts +1 -0
  137. package/src/web-search/ollama-executor.ts +127 -0
  138. package/src/web-search/passthrough-bridge.ts +761 -0
  139. package/src/web-search/progress-stream.ts +4 -0
  140. package/gui/dist/assets/index-B5r7LNHN.js +0 -115
  141. package/gui/dist/assets/index-D5SiRo8X.css +0 -1
@@ -0,0 +1,36 @@
1
+ import { clearCodingAgentBinaryCache, resolveProfileByBaseUrl, type CodingAgentProviderProfile } from "../coding-agent/profile";
2
+
3
+ /** Official Qoder CLI profile. Region variants are separate profiles and credentials. */
4
+ export interface QoderProfile extends CodingAgentProviderProfile {
5
+ family: "qoder";
6
+ }
7
+
8
+ export const QODER_GLOBAL_PROFILE: QoderProfile = {
9
+ providerId: "qoder",
10
+ family: "qoder",
11
+ region: "global",
12
+ label: "Qoder",
13
+ canonicalBaseUrl: "https://qoder.com",
14
+ binaryCandidates: ["qoder", "qodercli"],
15
+ tokenEnv: "QODER_PERSONAL_ACCESS_TOKEN",
16
+ installHint: "npm install -g @qoder-ai/qodercli",
17
+ documentationUrl: "https://docs.qoder.com/cli/authentication",
18
+ };
19
+
20
+ export const QODER_CN_PROFILE: QoderProfile = {
21
+ providerId: "qoder-cn",
22
+ family: "qoder",
23
+ region: "cn",
24
+ label: "Qoder CN",
25
+ canonicalBaseUrl: "https://qoder.cn",
26
+ binaryCandidates: ["qodercn", "qoderclicn"],
27
+ tokenEnv: "QODERCN_PERSONAL_ACCESS_TOKEN",
28
+ installHint: "npm install -g @qodercn-ai/qoderclicn",
29
+ documentationUrl: "https://docs.qoder.cn/en/cli/authentication",
30
+ };
31
+
32
+ export const QODER_PROFILES: readonly QoderProfile[] = [QODER_GLOBAL_PROFILE, QODER_CN_PROFILE];
33
+ export function resolveQoderProfile(baseUrl: string | undefined): QoderProfile | undefined {
34
+ return resolveProfileByBaseUrl(QODER_PROFILES, baseUrl) as QoderProfile | undefined;
35
+ }
36
+ export const clearQoderBinaryCache = clearCodingAgentBinaryCache;
@@ -2,6 +2,8 @@ import { createAnthropicAdapter } from "./anthropic";
2
2
  import { createAzureAdapter } from "./azure";
3
3
  import type { ProviderAdapter } from "./base";
4
4
  import { withClinePassDeepSeekV4ToolReplayCompatibility } from "./cline-pass-deepseek-v4-tool-replay";
5
+ import { createCodeBuddyAdapter } from "./codebuddy/adapter";
6
+ import { createQoderAdapter } from "./qoder/adapter";
5
7
  import { createCommandCodeAdapter } from "./command-code";
6
8
  import { createCursorAdapter } from "./cursor";
7
9
  import { createGoogleAdapter } from "./google";
@@ -20,6 +22,7 @@ export interface AdapterFactoryContext {
20
22
  }
21
23
 
22
24
  export type AdapterWire =
25
+ | "codebuddy"
23
26
  | "command-code"
24
27
  | "openai-chat"
25
28
  | "ollama-native"
@@ -53,6 +56,11 @@ type InheritedAdapterDefinition = {
53
56
  type AdapterDefinition = DirectAdapterDefinition | InheritedAdapterDefinition;
54
57
 
55
58
  export const ADAPTER_REGISTRY = {
59
+ codebuddy: {
60
+ wire: "codebuddy",
61
+ mutation: "codex-owned",
62
+ create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createCodeBuddyAdapter(provider),
63
+ },
56
64
  "command-code": {
57
65
  wire: "command-code",
58
66
  mutation: "codex-owned",
@@ -108,6 +116,10 @@ export const ADAPTER_REGISTRY = {
108
116
  contractParent: "openai-chat",
109
117
  create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createMimoFreeAdapter(provider),
110
118
  },
119
+ qoder: {
120
+ contractParent: "codebuddy",
121
+ create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createQoderAdapter(provider),
122
+ },
111
123
  } as const satisfies Record<string, AdapterDefinition>;
112
124
 
113
125
  export type AdapterId = keyof typeof ADAPTER_REGISTRY;
@@ -1,8 +1,7 @@
1
- // Codex multi-agent v2 stamps a Responses-only `encrypted: true` marker on
2
- // collaboration tool schemas (openai/codex 5f4d06ef; issue #85). It is an
3
- // annotation for the ChatGPT backend only, so translated provider schemas must
4
- // drop it without removing properties or definitions literally named `encrypted`.
5
- const ENCRYPTED_MARKER_NAME_BAG_KEYS = new Set([
1
+ // Keys whose *children's names* are caller-chosen rather than schema keywords, and keys whose
2
+ // values are literal payloads rather than schemas. Shared by both strippers below: each one has
3
+ // to tell "the keyword `x`" apart from "a property someone named `x`".
4
+ const SCHEMA_NAME_BAG_KEYS = new Set([
6
5
  "properties",
7
6
  "patternProperties",
8
7
  "$defs",
@@ -11,9 +10,21 @@ const ENCRYPTED_MARKER_NAME_BAG_KEYS = new Set([
11
10
  "dependentSchemas",
12
11
  "dependentRequired",
13
12
  ]);
14
- const ENCRYPTED_MARKER_LITERAL_VALUE_KEYS = new Set(["const", "default", "enum", "examples"]);
13
+ const SCHEMA_LITERAL_VALUE_KEYS = new Set(["const", "default", "enum", "examples"]);
14
+
15
+ // These subtrees depend on property evaluation, polarity, branch selection or references.
16
+ // Preserve their complete argument contract rather than guessing whether a local relaxation
17
+ // remains a relaxation in the containing schema. The destination reports unsupported regexes.
18
+ const PRESERVED_PATTERN_SUBTREES = new Set([
19
+ "patternProperties", "not", "oneOf", "if", "contains", "$defs", "definitions",
20
+ ]);
15
21
 
16
22
  /**
23
+ * Codex multi-agent v2 stamps a Responses-only `encrypted: true` marker on collaboration tool
24
+ * schemas (openai/codex 5f4d06ef; issue #85). It is an annotation for the ChatGPT backend only,
25
+ * so translated provider schemas must drop it without removing properties or definitions
26
+ * literally named `encrypted`.
27
+ *
17
28
  * The schema is caller-supplied, so its nesting depth is attacker-influenced. Native recursion
18
29
  * would turn a deep schema into a stack overflow that takes down the request path, so this walks
19
30
  * an explicit stack instead: depth costs heap, which is bounded and recoverable.
@@ -52,11 +63,11 @@ export function stripResponsesOnlyEncryptedMarker(node: unknown, inNameBag = fal
52
63
  // Inside a name bag every key is a caller-chosen name, so `encrypted` here is data.
53
64
  stack.push({ node: value, inNameBag: false, assign: v => { out[key] = v; } });
54
65
  } else if (key !== "encrypted") {
55
- if (ENCRYPTED_MARKER_LITERAL_VALUE_KEYS.has(key)) {
66
+ if (SCHEMA_LITERAL_VALUE_KEYS.has(key)) {
56
67
  // Literal payloads are values, not schemas: an `encrypted` key inside them is data.
57
68
  out[key] = value;
58
69
  } else {
59
- const childInNameBag = ENCRYPTED_MARKER_NAME_BAG_KEYS.has(key);
70
+ const childInNameBag = SCHEMA_NAME_BAG_KEYS.has(key);
60
71
  stack.push({ node: value, inNameBag: childInNameBag, assign: v => { out[key] = v; } });
61
72
  }
62
73
  }
@@ -65,3 +76,97 @@ export function stripResponsesOnlyEncryptedMarker(node: unknown, inNameBag = fal
65
76
 
66
77
  return result;
67
78
  }
79
+
80
+ /**
81
+ * `\p{…}` is an escape only when the backslash introducing it is itself unescaped: in `\\p{2}`
82
+ * the pair is a literal backslash and the `p{2}` that follows is an ordinary quantified `p`,
83
+ * which Python compiles fine. Scanning for the raw substring would misread that as a property
84
+ * escape and discard a working pattern.
85
+ */
86
+ function usesUnicodePropertyEscape(pattern: string): boolean {
87
+ for (let i = 0; i < pattern.length; i++) {
88
+ if (pattern[i] !== "\\") continue;
89
+ const next = pattern[i + 1];
90
+ if (next === "\\") {
91
+ i++;
92
+ continue;
93
+ }
94
+ if ((next === "p" || next === "P") && pattern[i + 2] === "{") return true;
95
+ }
96
+ return false;
97
+ }
98
+
99
+ /**
100
+ * Remove unsupported Unicode property escapes from scalar `pattern` constraints in ordinary
101
+ * positive schema positions. This keeps built-in Artifact tools usable on Python-re backends;
102
+ * the omitted constraint is not enforced by this proxy and tools must validate their inputs.
103
+ *
104
+ * Regex-keyed objects are preserved. Removing a matcher can lose evaluated-property annotations
105
+ * needed by an ancestor's unevaluatedProperties, even when the local object appears open.
106
+ * Negation, exclusive alternatives, conditions, contains and reusable definitions are also
107
+ * preserved: loosening a nested constraint can instead reject an input in those contexts.
108
+ * Unsupported patterns there remain the destination's validation responsibility.
109
+ *
110
+ * Returns `node` itself when nothing was dropped. Uses an explicit stack for caller-controlled
111
+ * nesting depth; the separate Responses-only encrypted-marker normalization is unchanged.
112
+ */
113
+ export function stripUnicodePropertyPatterns(node: unknown, inNameBag = false): unknown {
114
+ type Assign = (value: unknown) => void;
115
+ interface Frame { node: unknown; inNameBag: boolean; assign: Assign }
116
+
117
+ let result: unknown;
118
+ let dropped = 0;
119
+ const stack: Frame[] = [{ node, inNameBag, assign: value => { result = value; } }];
120
+
121
+ while (stack.length > 0) {
122
+ const frame = stack.pop()!;
123
+ const current = frame.node;
124
+
125
+ if (Array.isArray(current)) {
126
+ const out: unknown[] = new Array(current.length);
127
+ frame.assign(out);
128
+ // Array items are schemas in their own right, never a name bag.
129
+ for (let i = current.length - 1; i >= 0; i--) {
130
+ stack.push({ node: current[i], inNameBag: false, assign: value => { out[i] = value; } });
131
+ }
132
+ continue;
133
+ }
134
+ if (!current || typeof current !== "object") {
135
+ frame.assign(current);
136
+ continue;
137
+ }
138
+
139
+ // A schema name may be `__proto__`; a null-prototype record keeps it as data.
140
+ const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
141
+ frame.assign(out);
142
+
143
+ for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
144
+ if (frame.inNameBag) {
145
+ // Inside a name bag every key is a caller-chosen name, so `pattern` here is a property
146
+ // name; its value is still a schema and is walked as one.
147
+ stack.push({ node: value, inNameBag: false, assign: v => { out[key] = v; } });
148
+ continue;
149
+ }
150
+ if (PRESERVED_PATTERN_SUBTREES.has(key)) {
151
+ out[key] = value;
152
+ continue;
153
+ }
154
+ if (key === "pattern" && typeof value === "string" && usesUnicodePropertyEscape(value)) {
155
+ dropped++;
156
+ continue;
157
+ }
158
+ if (SCHEMA_LITERAL_VALUE_KEYS.has(key)) {
159
+ // Literal payloads are values, not schemas: a `pattern` key inside them is data.
160
+ out[key] = value;
161
+ continue;
162
+ }
163
+ stack.push({
164
+ node: value,
165
+ inNameBag: SCHEMA_NAME_BAG_KEYS.has(key),
166
+ assign: v => { out[key] = v; },
167
+ });
168
+ }
169
+ }
170
+
171
+ return dropped === 0 ? node : result;
172
+ }
@@ -150,10 +150,17 @@ function blockedSkillCallIds(messages: readonly unknown[], blocked: readonly str
150
150
 
151
151
  /**
152
152
  * Claude Code (observed 2026-07-11, real CLI smoke) sends `role:"system"` entries in
153
- * `messages` despite the published API having no system role. Map them to Responses
154
- * instructions text: the native ChatGPT backend rejects system message items in
155
- * `input` ("System messages are not allowed", verified live), so folding into
156
- * `instructions` is the only shape that works on every route.
153
+ * `messages` despite the published API having no system role. They are emitted as
154
+ * chronological `role:"developer"` input items, which keeps the timeline intact and
155
+ * leaves `instructions` owned solely by the top-level Anthropic `system` field.
156
+ *
157
+ * The original mapping folded them into `instructions` because the native ChatGPT
158
+ * backend rejects `role:"system"` items in `input` ("System messages are not allowed",
159
+ * verified live). That constraint is real and still respected — but it only rules out
160
+ * `system`, not `developer`, which every Responses route accepts. Folding meant each
161
+ * mid-conversation reminder mutated the prompt head, invalidating the upstream KV
162
+ * prefix and rotating the Desktop `prompt_cache_key` fallback below on every turn
163
+ * (#4148).
157
164
  */
158
165
  function systemMessageText(content: unknown): string {
159
166
  if (typeof content === "string") return content;
@@ -333,7 +340,12 @@ function translateAnthropicRequest(raw: unknown, cc: OcxClaudeCodeConfig | undef
333
340
  else if (msg.role === "assistant") assistantMessageToItems(msg.content, input, budget);
334
341
  else if (msg.role === "system") {
335
342
  const text = systemMessageText(msg.content);
336
- if (text.length > 0) systemParts.push(text);
343
+ // Keep it where the client put it. `developer` is first-class in the Responses
344
+ // schema and survives parseRequest as a chronological message, where `system`
345
+ // would be re-hoisted back onto the system prompt and defeat the point.
346
+ if (text.length > 0) {
347
+ input.push({ type: "message", role: "developer", content: [{ type: "input_text", text }] });
348
+ }
337
349
  }
338
350
  else throw new AnthropicRequestError(`unsupported message role: ${String(msg.role)}`);
339
351
  }
@@ -18,10 +18,16 @@ export interface AccountRow {
18
18
  id: string;
19
19
  label?: string;
20
20
  email?: string;
21
- plan?: string;
21
+ /**
22
+ * Subscription tier. `null` means this version looked and the provider did not report one;
23
+ * an absent key means the row's surface does not carry a tier at all (#3777).
24
+ */
25
+ plan?: string | null;
22
26
  masked?: string;
23
27
  active: boolean;
24
28
  needsReauth?: boolean;
29
+ /** Registered credential that is still excluded from routing until validation completes. */
30
+ validationPending?: boolean;
25
31
  /** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */
26
32
  priority?: number;
27
33
  quota?: CodexQuotaDto | null;
@@ -237,6 +243,7 @@ interface CodexAccountDto {
237
243
  plan?: string;
238
244
  isMain?: boolean;
239
245
  needsReauth?: boolean;
246
+ health?: { reason?: string };
240
247
  priority?: number;
241
248
  quota?: CodexQuotaDto | null;
242
249
  quotaRefresh?: unknown;
@@ -263,10 +270,12 @@ export async function fetchCodexRows(
263
270
  baseUrl: string,
264
271
  forceRefresh = false,
265
272
  includeQuota = forceRefresh,
273
+ options: { refreshAction?: boolean } = {},
266
274
  ): Promise<FamilyRows> {
267
- const accountsPath = `/api/codex-auth/accounts${forceRefresh ? "?refresh=1" : ""}`;
275
+ const refreshAction = options.refreshAction === true;
276
+ const accountsPath = `/api/codex-auth/accounts${refreshAction ? "/refresh" : forceRefresh ? "?refresh=1" : ""}`;
268
277
  const [accountsRes, activeRes] = await Promise.all([
269
- apiJson(deps, baseUrl, "GET", accountsPath),
278
+ apiJson(deps, baseUrl, refreshAction ? "POST" : "GET", accountsPath),
270
279
  apiJson(deps, baseUrl, "GET", "/api/codex-auth/active"),
271
280
  ]);
272
281
  if (accountsRes.status !== 0 && accountsRes.status !== 200) {
@@ -300,6 +309,7 @@ export async function fetchCodexRows(
300
309
  plan: a.plan,
301
310
  active: a.id === activeId,
302
311
  needsReauth: a.needsReauth,
312
+ ...(a.health?.reason === "validation_pending" ? { validationPending: true } : {}),
303
313
  priority: typeof a.priority === "number" ? a.priority : 0,
304
314
  paused: a.paused === true,
305
315
  ...(includeQuota ? {
@@ -316,6 +326,8 @@ interface OAuthAccountDto {
316
326
  email?: string;
317
327
  active?: boolean;
318
328
  needsReauth?: boolean;
329
+ /** Always sent by the management route; explicitly `null` when the tier is unknown. */
330
+ plan?: string | null;
319
331
  quota?: CodexQuotaDto | null;
320
332
  quotaUnavailable?: boolean;
321
333
  }
@@ -346,6 +358,9 @@ async function fetchOAuthRows(
346
358
  email: a.email,
347
359
  active: a.active ?? a.id === activeId,
348
360
  needsReauth: a.needsReauth,
361
+ // Forward the server's answer verbatim, including `null`. Collapsing null to "absent" here
362
+ // would destroy the one distinction this field exists to make.
363
+ plan: a.plan ?? null,
349
364
  ...(a.quota !== undefined ? { quota: a.quota } : {}),
350
365
  ...(a.quotaUnavailable !== undefined ? { quotaUnavailable: a.quotaUnavailable } : {}),
351
366
  }));
@@ -154,7 +154,14 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
154
154
  {}, deps,
155
155
  );
156
156
  if (state.status === "done") {
157
- printData({ ...state, modelSelection: modelSelectionNextSteps("openai") }, wantsJson, [`Logged in${state.email ? ` as ${String(state.email)}` : ""}.`, ...modelSelectionGuidance("openai")]);
157
+ if (state.validationPending === true) {
158
+ printData({ ...state, recoveryCommand: "ocx gui", recoveryAction: "After quota recovers, click Refresh quotas in the dashboard Codex account pool." }, wantsJson, [
159
+ "Account registered; validation pending (routing disabled).",
160
+ "After quota recovers, open 'ocx gui' and click Refresh quotas to complete validation.",
161
+ ]);
162
+ } else {
163
+ printData({ ...state, modelSelection: modelSelectionNextSteps("openai") }, wantsJson, [`Logged in${state.email ? ` as ${String(state.email)}` : ""}.`, ...modelSelectionGuidance("openai")]);
164
+ }
158
165
  if (!wantsJson) warnIfCodexCatalogRefreshPending(state);
159
166
  return;
160
167
  }
@@ -264,6 +264,7 @@ function refreshLine(row: FamilyRows["rows"][number]): string {
264
264
  const quotaText = row.quota ? quotaParts(row.quota).join(" ") : "";
265
265
  parts.push(quotaText.length > 0 ? quotaText : "quota: unknown");
266
266
  if (row.needsReauth) parts.push("needs-reauth");
267
+ if (row.validationPending) parts.push("validation-pending (routing disabled; open 'ocx gui' and click Refresh quotas after recovery)");
267
268
  return parts.filter(Boolean).join(" ");
268
269
  }
269
270
 
@@ -339,7 +340,7 @@ export async function cmdRefresh(args: string[], deps: AccountDeps): Promise<num
339
340
  } else console.log(`no quota report available for ${name}`);
340
341
  return 0;
341
342
  }
342
- const result = await fetchCodexRows(deps, baseUrl, true);
343
+ const result = await fetchCodexRows(deps, baseUrl, true, true, { refreshAction: true });
343
344
  const failed = familyFailure(result, `failed to refresh ${name}`);
344
345
  if (failed !== null) return failed;
345
346
  if (wantsJson) console.log(JSON.stringify({ accounts: result.rows }, null, 2));
@@ -99,6 +99,7 @@ function statusText(row: AccountRow): string {
99
99
  if (row.paused) parts.push("paused");
100
100
  if (row.active) parts.push(row.type === "codex" ? "selected" : "active");
101
101
  if (row.needsReauth) parts.push("needs-reauth");
102
+ if (row.validationPending) parts.push("validation-pending");
102
103
  return parts.join(" ");
103
104
  }
104
105
 
@@ -219,6 +219,18 @@ export const CAPABILITIES: readonly Capability[] = [
219
219
  "`--quota` shows cached Codex windows (including 5h); `--refresh` bypasses the server TTL.",
220
220
  ],
221
221
  },
222
+ {
223
+ command: ["account", "refresh"],
224
+ summary: "Refresh account quotas without model validation; pending Codex accounts require dashboard consent.",
225
+ routes: [
226
+ { method: "POST", path: "/api/codex-auth/accounts/refresh" },
227
+ { method: "GET", path: "/api/provider-quotas" },
228
+ ],
229
+ flags: [{ name: "--json", value: "boolean", summary: "Emit the refresh result as JSON." }],
230
+ mutates: true,
231
+ json: "payload",
232
+ details: ["CLI/admin-token refreshes only observe usage. After quota recovery, a human must click Refresh quotas in the dashboard to authorize model validation. Do not mint a GUI session to work around this consent boundary."],
233
+ },
222
234
  {
223
235
  command: ["usage"],
224
236
  summary: "Token and estimated-cost report over a time range.",
@@ -307,12 +319,13 @@ export const CAPABILITIES: readonly Capability[] = [
307
319
  },
308
320
  {
309
321
  command: ["logs"],
310
- summary: "Recent request log rows, filterable by provider, model, conversation, and status.",
322
+ summary: "Recent request log rows, filterable by provider, model, conversation, account, and status.",
311
323
  routes: [{ method: "GET", path: "/api/logs" }],
312
324
  flags: [
313
325
  { name: "--provider", value: "string", summary: "Restrict to one provider, matching failover attempts too." },
314
326
  { name: "--model", value: "string", summary: "Restrict to one model id, matching failover attempts too." },
315
327
  { name: "--conversation", value: "string", summary: "Restrict to one conversation id (`--conversationId` is accepted too)." },
328
+ { name: "--account", value: "string", summary: "Restrict to one account log label (`main`, `p<hex6>`, `o<hex6>`), matching failover attempts too." },
316
329
  { name: "--status", value: "string", summary: "An exact code (429) or a class (5xx)." },
317
330
  { name: "--limit", value: "number", summary: "Row cap; defaults to 200." },
318
331
  { name: "--follow", value: "boolean", summary: "Poll for new rows; add --jsonl to emit JSONL." },
@@ -324,6 +337,7 @@ export const CAPABILITIES: readonly Capability[] = [
324
337
  details: [
325
338
  "`--provider` and `--model` both match a failover attempt, so a request is findable by what actually served it, not only by what was asked for.",
326
339
  "Rows print `conv=<id>` when the entry carries one, so a conversation filter can be told apart from an empty result.",
340
+ "Rows print `acct=<label>` when the account is known, so an `--account` filter can be told apart from an empty result.",
327
341
  "`--follow` deduplicates by row id and cannot be combined with `--json`.",
328
342
  ],
329
343
  },
@@ -25,6 +25,7 @@ import { afterCatalogWriteHandleAppServers } from "../codex/app-server-processes
25
25
  import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
26
26
  import { isJsonOption, takeFlag } from "./runtime-api";
27
27
  import type { ClientConnectionState } from "../client/state";
28
+ import { OCX_NATIVE_REPLAY_RECOVERY_NOTE } from "../responses/compaction";
28
29
 
29
30
  export interface CliDispatchDeps {
30
31
  args: string[];
@@ -199,6 +200,7 @@ const commandRunners: Record<string, CommandRunner> = {
199
200
  }
200
201
  if (r.success) {
201
202
  console.log("Codex integration is OFF and plain `codex` now runs natively. Switch back with: ocx restore back");
203
+ console.log(`Note: ${OCX_NATIVE_REPLAY_RECOVERY_NOTE}`);
202
204
  } else {
203
205
  console.error("Plain `codex` was not fully restored. Inspect $CODEX_HOME/config.toml before using native Codex.");
204
206
  }
package/src/cli/doctor.ts CHANGED
@@ -45,6 +45,9 @@ import {
45
45
  probeCodexCoordinatorNamespace,
46
46
  resolveEffectiveUserIdentity,
47
47
  } from "../codex/user-identity";
48
+ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers-destination";
49
+ import type { OcxProviderConfig } from "../types/provider";
50
+ import { routedProviderConfig } from "../router";
48
51
  import { collectProjectCodexConfigWarnings, formatProjectCodexConfigWarningsForDoctor } from "../codex/project-config-warnings";
49
52
  import {
50
53
  collectLegacyCodexConfigKeyDiagnostics,
@@ -1000,6 +1003,41 @@ export function proxyDownRestartHint(input: {
1000
1003
  return `The ocx proxy is not running. ${uncleanExit}Codex/Claude clients pinned to 127.0.0.1:${input.port} fail with errors like "error sending request for url (http://127.0.0.1:${input.port}/v1/responses)". ${restart}`;
1001
1004
  }
1002
1005
 
1006
+ /** Explain the expected channel and latency trade-off for native ChatGPT routing. */
1007
+ export function chatgptPublicEndpointHint(
1008
+ providers: Record<string, unknown> | undefined,
1009
+ ): string | null {
1010
+ const openai = providers?.openai;
1011
+ if (!openai || typeof openai !== "object") {
1012
+ return null;
1013
+ }
1014
+ // A disabled row never routes, so it must not be described as the route in use.
1015
+ const configured = openai as OcxProviderConfig;
1016
+ if (configured.disabled === true) {
1017
+ return null;
1018
+ }
1019
+ // Classify the destination the router resolves, not the raw config text. Two things follow
1020
+ // from the registry entry for the built-in `openai` id: a row that omits `authMode` still
1021
+ // forwards, and a row carrying some other `baseUrl` has it discarded in favour of the
1022
+ // canonical ChatGPT endpoint. Both keep using the public endpoint, so both want this hint;
1023
+ // reading the raw row would have suppressed the first and misjudged the second.
1024
+ //
1025
+ // `routedProviderConfig` throws for an unresolved URL only when the registry entry allows a
1026
+ // baseUrl override, which this entry does not, so no input reaches that path today. The guard
1027
+ // is here because doctor is read-only diagnostics: a later registry change must not turn a
1028
+ // diagnostic into a crash.
1029
+ let routed: OcxProviderConfig;
1030
+ try {
1031
+ routed = routedProviderConfig("openai", configured);
1032
+ } catch {
1033
+ return null;
1034
+ }
1035
+ if (!isCanonicalOpenAiForwardProvider(routed)) {
1036
+ return null;
1037
+ }
1038
+ return "ChatGPT-family requests use the public ChatGPT endpoint through this proxy, in both Pool and Direct modes. Eligible streaming turns dial the ChatGPT websocket transport (the same responses_websockets lane Codex CLI defaults to) and fall back to SSE over HTTP when a turn is not eligible - an unsupported Bun runtime, an oversized create frame, or a proxy route that cannot carry the socket - and local provider pacing can hold a request before it is dispatched at all. This hint classifies configuration only and measures nothing, so upstream queueing is one possible contributor to a slow first output: compare actual transport, pacing, network, and provider observations before concluding. service_tier=priority is a request preference: this backend can echo service_tier \"default\" even on turns it scheduled as priority (#2558), so the echoed response tier in request logs stays an observation with confirmation \"assumed\" and cannot confirm or deny the granted tier.";
1039
+ }
1040
+
1003
1041
  export async function runDoctor(args: string[] = []): Promise<void> {
1004
1042
  if (args.includes("--fix-codex-runtime")) {
1005
1043
  const resolved = resolveCodexRuntime();
@@ -1330,6 +1368,8 @@ export async function runDoctor(args: string[] = []): Promise<void> {
1330
1368
 
1331
1369
  // Hints, not fixes.
1332
1370
  const hints: string[] = [];
1371
+ const chatgptHint = chatgptPublicEndpointHint(doctorConfig.providers);
1372
+ if (chatgptHint) hints.push(chatgptHint);
1333
1373
  const proxyDown = proxyDownRestartHint({
1334
1374
  proxyRunning: Boolean(live),
1335
1375
  port: live?.port ?? doctorConfig.port ?? 10100,
package/src/cli/effort.ts CHANGED
@@ -2,6 +2,7 @@ import { loadConfig, saveConfig } from "../config";
2
2
  import {
3
3
  CODEX_REASONING_LEVELS,
4
4
  configuredReasoningEfforts,
5
+ isCodexReasoningEffort,
5
6
  isDeclaredReasoningEffort,
6
7
  mapReasoningEffort,
7
8
  reasoningEffortMapFor,
@@ -21,7 +22,7 @@ import {
21
22
 
22
23
  export const EFFORT_USAGE = `Usage:
23
24
  ocx effort [status] [--json]
24
- ocx effort <low|medium|high|xhigh|max|ultra|none|minimal|-> [--json]
25
+ ocx effort <low|medium|high|xhigh|max|ultra|-> [--json]
25
26
  ocx effort set [--main <level|->] [--subagent <level|->] [--injection <level|->] [--json]
26
27
  ocx effort clear [--json]
27
28
  ocx effort model <provider/model|model> [--json]
@@ -33,13 +34,18 @@ function clearable(value: string | undefined): string | null | undefined {
33
34
  return value === "-" ? null : value;
34
35
  }
35
36
 
36
- function validateEffortLevel(level: string | null | undefined, label: string): string | null | undefined {
37
+ function validateEffortLevel(
38
+ level: string | null | undefined,
39
+ label: string,
40
+ kind: "cap" | "injection",
41
+ ): string | null | undefined {
37
42
  if (level === undefined || level === null) return level;
38
43
  const trimmed = level.trim();
39
44
  if (trimmed === "-" || trimmed === "") return null;
40
- if (!isDeclaredReasoningEffort(trimmed)) {
45
+ const valid = kind === "cap" ? isCodexReasoningEffort(trimmed) : isDeclaredReasoningEffort(trimmed);
46
+ if (!valid) {
41
47
  throw new CliUsageError(
42
- `unknown reasoning effort "${trimmed}" for ${label} (allowed: ${CODEX_REASONING_LEVELS.map(l => l.effort).join(", ")}, none, minimal, -)`,
48
+ `unknown reasoning effort "${trimmed}" for ${label} (allowed: ${CODEX_REASONING_LEVELS.map(l => l.effort).join(", ")}${kind === "injection" ? ", none, minimal" : ""}, -)`,
43
49
  EFFORT_USAGE,
44
50
  );
45
51
  }
@@ -114,6 +120,15 @@ async function status(wantsJson: boolean, deps: RuntimeApiDeps): Promise<void> {
114
120
  data = getOfflineStatus();
115
121
  }
116
122
 
123
+ // Report the stored/runtime value exactly as the enforcement layer evaluates it.
124
+ // An ignored subagent field does not disable a valid main cap on that child.
125
+ const warnings = ([ ["effortCap", "--main"], ["subagentEffortCap", "--subagent"] ] as const)
126
+ .flatMap(([key, flag]) => {
127
+ const value = data[key];
128
+ if (value === null || isCodexReasoningEffort(value)) return [];
129
+ return [`${key}=${JSON.stringify(value)} is invalid and is not applied. Use: ocx effort set ${flag} <${CODEX_REASONING_LEVELS.map(l => l.effort).join("|")}|->.`];
130
+ });
131
+
117
132
  const lines = [
118
133
  `Reasoning effort status (${data.source === "runtime" ? "live proxy" : "offline config"}):`,
119
134
  ` Main agent effort cap: ${data.effortCap ?? "(unset — no cap)"}`,
@@ -122,9 +137,10 @@ async function status(wantsJson: boolean, deps: RuntimeApiDeps): Promise<void> {
122
137
  "",
123
138
  "Supported Codex reasoning effort ladder:",
124
139
  ...CODEX_REASONING_LEVELS.map(l => ` - ${l.effort.padEnd(8)} ${l.description}`),
140
+ ...(warnings.length ? ["", "Warnings:", ...warnings.map(warning => ` ${warning}`)] : []),
125
141
  ];
126
142
 
127
- printData(data, wantsJson, lines);
143
+ printData({ ...data, warnings }, wantsJson, lines);
128
144
  }
129
145
 
130
146
  async function setEffort(
@@ -136,9 +152,9 @@ async function setEffort(
136
152
  wantsJson: boolean,
137
153
  deps: RuntimeApiDeps,
138
154
  ): Promise<void> {
139
- const validatedMain = validateEffortLevel(options.main, "--main");
140
- const validatedSubagent = validateEffortLevel(options.subagent, "--subagent");
141
- const validatedInjection = validateEffortLevel(options.injection, "--injection");
155
+ const validatedMain = validateEffortLevel(options.main, "--main", "cap");
156
+ const validatedSubagent = validateEffortLevel(options.subagent, "--subagent", "cap");
157
+ const validatedInjection = validateEffortLevel(options.injection, "--injection", "injection");
142
158
 
143
159
  if (validatedMain === undefined && validatedSubagent === undefined && validatedInjection === undefined) {
144
160
  throw new CliUsageError("at least one effort option (--main, --subagent, or --injection) is required", EFFORT_USAGE);
package/src/cli/help.ts CHANGED
@@ -33,6 +33,8 @@ Usage:
33
33
  ocx restore back Re-point codex at the running proxy (undo restore)
34
34
  ocx recover-history --legacy-openai --yes
35
35
  Force all user-message opencodex rows to OpenAI (legacy recovery)
36
+ ocx recover-history --ocx-compaction <thread-id> --yes
37
+ Back up and make one ocx1-compacted thread replayable by native Codex
36
38
  ocx uninstall Remove service/shim/config and restore native Codex (alias: remove)
37
39
  ocx service [sub] Run as a background service (default: install/update/start)
38
40
  ocx codex-shim <sub> Auto-start proxy when \`codex\` launches (install|status|uninstall|remove)
package/src/cli/index.ts CHANGED
@@ -1428,9 +1428,13 @@ async function handleStatus() {
1428
1428
  }
1429
1429
  }
1430
1430
  const { collectOAuthHealthEntriesForCli, oauthLoginSummary } = await import("../oauth");
1431
+ const { emailMaskingEnabled } = await import("../lib/privacy");
1431
1432
  const { formatOAuthHealthForStatus } = await import("./status-oauth");
1432
1433
  console.log(` OAuth logins:`);
1433
- for (const e of oauthLoginSummary()) {
1434
+ // The operator's own `privacy.maskEmails` decision applies to the CLI too: `ocx status` is not
1435
+ // the dashboard, but it reads the same stored addresses, and a flag that only moved one of the
1436
+ // two would leave the operator unable to tell which surface they had configured.
1437
+ for (const e of oauthLoginSummary(emailMaskingEnabled(loadConfig()))) {
1434
1438
  console.log(` ${e.provider.padEnd(10)} ${e.loggedIn ? `✓ logged in${e.email ? ` (${e.email})` : ""}` : "✗ not logged in"}`);
1435
1439
  }
1436
1440
  const oauthHealthBlock = formatOAuthHealthForStatus(await collectOAuthHealthEntriesForCli());
@@ -1442,8 +1446,31 @@ async function handleStatus() {
1442
1446
  }
1443
1447
 
1444
1448
  async function handleRecoverHistory() {
1449
+ if (args[1] === "--ocx-compaction") {
1450
+ const threadId = args[2];
1451
+ if (args.length !== 4 || !threadId || args[3] !== "--yes") {
1452
+ console.error("Usage: ocx recover-history --ocx-compaction <thread-id> --yes");
1453
+ console.error("This rewrites one rollout after saving a private byte-for-byte backup. Close that Codex thread before retrying.");
1454
+ process.exit(1);
1455
+ }
1456
+ console.error("WARNING: this converts OpenCodeX-owned ocx1 compaction state into a plain summary for native Codex replay.");
1457
+ try {
1458
+ const { recoverOcxCompactionHistory } = await import("../codex/ocx-compaction-history");
1459
+ const result = recoverOcxCompactionHistory({ threadId });
1460
+ if (result.replaced === 0) {
1461
+ console.log(`Thread ${threadId} has no repairable ocx1 compaction history; no files changed.`);
1462
+ return;
1463
+ }
1464
+ console.log(`Recovered ${result.replaced} ocx1 compaction item(s) in thread ${threadId}.`);
1465
+ console.log(`Backup: ${result.backupPath}`);
1466
+ return;
1467
+ } catch (error) {
1468
+ console.error(`Recovery failed: ${error instanceof Error ? error.message : String(error)}`);
1469
+ process.exit(1);
1470
+ }
1471
+ }
1445
1472
  if (args[1] !== "--legacy-openai") {
1446
- console.error("Usage: ocx recover-history --legacy-openai --yes");
1473
+ console.error("Usage: ocx recover-history (--legacy-openai | --ocx-compaction <thread-id>) --yes");
1447
1474
  console.error("This force-relabels every user-message opencodex row to OpenAI, including legitimate dedicated-provider history. Back up first and use it only for pre-backup legacy recovery.");
1448
1475
  process.exit(1);
1449
1476
  }