@yansigit/opencodex 2.31.3 → 2.33.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 (176) hide show
  1. package/README.md +2 -2
  2. package/bin/ocx.mjs +99 -70
  3. package/gui/dist/assets/index-DKLr4LTE.js +102 -0
  4. package/gui/dist/assets/index-DrSQdTRd.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +6 -5
  7. package/src/adapters/anthropic.ts +25 -13
  8. package/src/adapters/azure.ts +20 -4
  9. package/src/adapters/base.ts +5 -1
  10. package/src/adapters/command-code.ts +42 -10
  11. package/src/adapters/cursor/live-models.ts +8 -0
  12. package/src/adapters/cursor/live-transport.ts +1 -1
  13. package/src/adapters/cursor/native-exec-desktop.ts +16 -0
  14. package/src/adapters/cursor/protobuf-events.ts +158 -7
  15. package/src/adapters/cursor/protobuf-request.ts +33 -15
  16. package/src/adapters/cursor/request-builder.ts +4 -3
  17. package/src/adapters/cursor/tool-definitions.ts +27 -1
  18. package/src/adapters/cursor/types.ts +4 -3
  19. package/src/adapters/cursor.ts +9 -0
  20. package/src/adapters/google-antigravity-replay.ts +2 -2
  21. package/src/adapters/google-antigravity-wire.ts +7 -0
  22. package/src/adapters/google-errors.ts +6 -2
  23. package/src/adapters/google-http.ts +30 -7
  24. package/src/adapters/google-truncation.ts +5 -0
  25. package/src/adapters/google-wire-compiler.ts +38 -6
  26. package/src/adapters/google.ts +154 -33
  27. package/src/adapters/kiro-tools.ts +20 -9
  28. package/src/adapters/kiro.ts +0 -3
  29. package/src/adapters/openai-chat.ts +9 -0
  30. package/src/adapters/openai-responses.ts +4 -1
  31. package/src/adapters/tool-catalog-nudge.ts +1 -1
  32. package/src/adapters/xai-web-search.ts +7 -2
  33. package/src/bridge.ts +133 -24
  34. package/src/claude/context-windows.ts +16 -9
  35. package/src/cli/dispatch.ts +50 -2
  36. package/src/cli/doctor.ts +26 -13
  37. package/src/cli/help.ts +4 -3
  38. package/src/cli/index.ts +20 -6
  39. package/src/cli/models.ts +13 -3
  40. package/src/cli/observe.ts +20 -5
  41. package/src/cli/provider.ts +8 -1
  42. package/src/cli/registry.ts +7 -5
  43. package/src/cli/status.ts +25 -1
  44. package/src/cli/system-restart-client.ts +1 -1
  45. package/src/cli/usage-report.ts +134 -0
  46. package/src/codex/app-server-processes.ts +3 -1
  47. package/src/codex/auth-api.ts +4 -2
  48. package/src/codex/autostart-health.ts +16 -0
  49. package/src/codex/catalog/aggregation.ts +12 -0
  50. package/src/codex/catalog/effort.ts +42 -12
  51. package/src/codex/catalog/metadata.ts +27 -1
  52. package/src/codex/catalog/model-metadata.ts +593 -0
  53. package/src/codex/catalog/parsing.ts +71 -27
  54. package/src/codex/catalog/provider-fetch.ts +189 -33
  55. package/src/codex/catalog/sync.ts +6 -5
  56. package/src/codex/convergence.ts +5 -0
  57. package/src/codex/desktop-app-restart.ts +342 -0
  58. package/src/codex/history-job.ts +32 -3
  59. package/src/codex/history-manifest.ts +112 -0
  60. package/src/codex/history-migration-guardian.ts +5 -5
  61. package/src/codex/history-provider.ts +825 -247
  62. package/src/codex/history-worker.ts +8 -5
  63. package/src/codex/inject.ts +49 -21
  64. package/src/codex/injected-marker.ts +1 -1
  65. package/src/codex/internal/history-writer.ts +4 -3
  66. package/src/codex/native-profile-startup.ts +157 -27
  67. package/src/codex/native-residue.ts +26 -33
  68. package/src/codex/shim.ts +56 -3
  69. package/src/combos/failover.ts +27 -0
  70. package/src/compatibility/index.ts +26 -0
  71. package/src/compatibility/manifest.ts +253 -0
  72. package/src/compatibility/openai-responses.ts +81 -0
  73. package/src/config/atomic-write.ts +219 -0
  74. package/src/config/paths.ts +40 -0
  75. package/src/config/process-state.ts +308 -0
  76. package/src/config/provider-validation.ts +214 -0
  77. package/src/config.ts +153 -814
  78. package/src/generated/compatibility-version.json +232 -148
  79. package/src/images/loop.ts +37 -6
  80. package/src/images/plan.ts +5 -4
  81. package/src/integrations/ownership-policy.ts +141 -0
  82. package/src/integrations/ownership.ts +10 -0
  83. package/src/integrations/state.ts +44 -5
  84. package/src/integrations/writer.ts +6 -0
  85. package/src/lib/azure-identity.ts +154 -0
  86. package/src/lib/bounded-body.ts +14 -2
  87. package/src/lib/debug.ts +42 -0
  88. package/src/lib/errors.ts +14 -0
  89. package/src/lib/process-control.ts +2 -1
  90. package/src/lib/provider-outbound.ts +45 -33
  91. package/src/lib/provider-tls-profile.ts +309 -0
  92. package/src/lib/proxy-env.ts +49 -0
  93. package/src/lib/redact.ts +10 -1
  94. package/src/lib/state-store-registrations.ts +2 -0
  95. package/src/lib/tool-argument-integers.ts +56 -5
  96. package/src/oauth/antigravity-routing.ts +282 -236
  97. package/src/oauth/callback-server.ts +22 -2
  98. package/src/oauth/command-code.ts +5 -16
  99. package/src/oauth/google-antigravity.ts +42 -5
  100. package/src/oauth/health.ts +1 -1
  101. package/src/oauth/index.ts +15 -3
  102. package/src/oauth/kimi.ts +9 -1
  103. package/src/oauth/open-browser-choice.ts +26 -0
  104. package/src/oauth/store.ts +6 -0
  105. package/src/providers/antigravity-quota.ts +3 -1
  106. package/src/providers/api-keys.ts +2 -1
  107. package/src/providers/auto-compact-budget.ts +65 -0
  108. package/src/providers/derive.ts +4 -0
  109. package/src/providers/key-failover.ts +5 -1
  110. package/src/providers/openai-tiers.ts +5 -0
  111. package/src/providers/provider-id-rewrite.ts +1 -0
  112. package/src/providers/quota.ts +59 -13
  113. package/src/providers/registry.ts +4 -2
  114. package/src/providers/request-pacing.ts +33 -6
  115. package/src/providers/xai-transport.ts +21 -0
  116. package/src/reasoning-effort.ts +19 -2
  117. package/src/responses/apply-patch-envelope.ts +63 -0
  118. package/src/responses/custom-tool-compat.ts +132 -38
  119. package/src/responses/google-provider-options.ts +36 -0
  120. package/src/responses/namespace-tool-compat.ts +84 -4
  121. package/src/responses/parser.ts +14 -2
  122. package/src/responses/provider-opaque-metadata.ts +3 -3
  123. package/src/responses/reasoning-replay-cache.ts +81 -3
  124. package/src/responses/schema.ts +37 -0
  125. package/src/responses/state.ts +94 -4
  126. package/src/router.ts +8 -2
  127. package/src/server/auth-cors.ts +37 -7
  128. package/src/server/images.ts +19 -35
  129. package/src/server/index.ts +102 -21
  130. package/src/server/local-management-read-client.ts +1 -1
  131. package/src/server/local-provider-reload-client.ts +1 -1
  132. package/src/server/management/agent-settings-routes.ts +206 -16
  133. package/src/server/management/combo-routes.ts +6 -0
  134. package/src/server/management/config-routes.ts +35 -6
  135. package/src/server/management/context.ts +1 -1
  136. package/src/server/management/logs-usage-routes.ts +27 -6
  137. package/src/server/management/model-routes.ts +8 -4
  138. package/src/server/management/model-rows.ts +4 -0
  139. package/src/server/management/native-integration-routes.ts +2 -1
  140. package/src/server/management/oauth-account-routes.ts +25 -4
  141. package/src/server/management/provider-capability-config.ts +1 -1
  142. package/src/server/management/provider-routes.ts +113 -15
  143. package/src/server/management/routing-profile-routes.ts +3 -0
  144. package/src/server/management/system-restart.ts +1 -1
  145. package/src/server/port-reclaim.ts +1 -1
  146. package/src/server/proxy-liveness.ts +2 -1
  147. package/src/server/request-log-conversation.ts +30 -0
  148. package/src/server/request-log.ts +21 -0
  149. package/src/server/responses/agent-task-recovery.ts +1 -1
  150. package/src/server/responses/codex-auth-error.ts +55 -0
  151. package/src/server/responses/combo-stream-preflight.ts +171 -0
  152. package/src/server/responses/compact.ts +36 -22
  153. package/src/server/responses/core.ts +584 -247
  154. package/src/server/responses/empty-completion-guard.ts +35 -6
  155. package/src/server/responses/fetch-helpers.ts +20 -102
  156. package/src/server/responses/v2-native-parent-override.ts +59 -0
  157. package/src/server/responses/ws-upstream.ts +75 -2
  158. package/src/server/responses-custom-tool-repair.ts +41 -5
  159. package/src/server/responses-undeclared-tool-guard.ts +241 -18
  160. package/src/service.ts +9 -5
  161. package/src/types/config.ts +16 -1
  162. package/src/types/provider.ts +16 -0
  163. package/src/types/request.ts +34 -1
  164. package/src/types/tools.ts +114 -11
  165. package/src/types.ts +7 -1
  166. package/src/update/index.ts +5 -4
  167. package/src/update/job.ts +3 -1
  168. package/src/update/transactional-install.mjs +8 -1
  169. package/src/usage/log.ts +16 -8
  170. package/src/usage/summary.ts +201 -8
  171. package/src/vision/describe.ts +18 -13
  172. package/src/web-search/executor.ts +10 -3
  173. package/src/web-search/gemini-executor.ts +6 -4
  174. package/src/web-search/loop.ts +42 -6
  175. package/gui/dist/assets/index-CGoDO3uO.css +0 -1
  176. package/gui/dist/assets/index-Cxt5fZMP.js +0 -102
@@ -10,6 +10,8 @@ import {
10
10
  takeOption,
11
11
  type RuntimeApiDeps,
12
12
  } from "./runtime-api";
13
+ import { formatUsageReport } from "./usage-report";
14
+ import { USAGE_RANGES, USAGE_SURFACES } from "../usage/summary";
13
15
 
14
16
  const USAGE = `Usage:
15
17
  ocx observe logs [--provider <name>] [--model <id>] [--status <code>]
@@ -17,7 +19,8 @@ const USAGE = `Usage:
17
19
  ocx logs explain <request-id> [--json]
18
20
  ocx logs rebuild-index
19
21
  ocx logs index-status
20
- ocx observe usage [--range <7d|30d|all>] [--surface <all|codex|claude|grok>] [--json]
22
+ ocx observe usage [--range <today|1d|7d|30d|all>] [--surface <all|codex|claude|grok>]
23
+ [--provider <name>] [--model <id>] [--json]
21
24
  ocx observe storage [codex-logs [status|protect|unprotect|repair|compact] [--mode <compat|quiet>]] [--json]
22
25
  ocx observe memory [--json]
23
26
  ocx observe debug [--json]
@@ -131,11 +134,23 @@ async function usage(argv: string[], deps: RuntimeApiDeps): Promise<void> {
131
134
  const wantsJson = takeFlag(args, "--json");
132
135
  const range = takeOption(args, "--range") ?? "30d";
133
136
  const surface = takeOption(args, "--surface") ?? "all";
134
- if (!["7d", "30d", "all"].includes(range)) throw new CliUsageError("--range must be 7d, 30d, or all", USAGE);
135
- if (!["all", "codex", "claude", "grok"].includes(surface)) throw new CliUsageError("--surface must be all, codex, claude, or grok", USAGE);
137
+ const provider = takeOption(args, "--provider");
138
+ const model = takeOption(args, "--model");
139
+ // `1d` is accepted here as well as server-side so the CLI does not reject an
140
+ // alias the API would have understood.
141
+ const ranges = [...USAGE_RANGES, "1d"];
142
+ if (!ranges.includes(range)) throw new CliUsageError(`--range must be one of ${USAGE_RANGES.join(", ")} (1d aliases today)`, USAGE);
143
+ if (!USAGE_SURFACES.includes(surface as (typeof USAGE_SURFACES)[number])) {
144
+ throw new CliUsageError(`--surface must be one of ${USAGE_SURFACES.join(", ")}`, USAGE);
145
+ }
136
146
  rejectArgs(args, USAGE);
137
- const result = await runtimeRequest(`/api/usage${query({ range, surface })}`, {}, deps);
138
- printData(result, wantsJson, summaryLines(result));
147
+ const result = await runtimeRequest(`/api/usage${query({ range, surface, provider, model })}`, {}, deps);
148
+ // Built only when it will be printed: JavaScript evaluates arguments before
149
+ // the call, so passing formatUsageReport(...) inline would run the human
150
+ // renderer during --json and let its assumptions affect a path that is meant
151
+ // to bypass it entirely.
152
+ if (wantsJson) printData(result, true);
153
+ else printData(result, false, formatUsageReport(result as Parameters<typeof formatUsageReport>[0]));
139
154
  }
140
155
 
141
156
  async function simple(path: string, argv: string[], deps: RuntimeApiDeps): Promise<void> {
@@ -8,7 +8,8 @@
8
8
  * show <name> Show provider config details (secrets masked)
9
9
  * set-default <name> Change the default provider
10
10
  */
11
- import { apiKeyTransportConfigError, hasOwnProvider, isValidProviderName, loadConfig, sanitizeModelCostsForDisplay, saveConfig } from "../config";
11
+ import { hasOwnProvider, isValidProviderName, loadConfig, sanitizeModelCostsForDisplay, saveConfig } from "../config";
12
+ import { apiKeyTransportConfigError } from "../config/provider-validation";
12
13
  import { hasHelpFlag } from "./help";
13
14
  import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../providers/registry";
14
15
  import { providerConfigSeed } from "../providers/derive";
@@ -359,6 +360,12 @@ function handleShow(args: string[]): void {
359
360
  ...(prov.modelCosts !== undefined ? { modelCosts: sanitizeModelCostsForDisplay(prov.modelCosts) } : {}),
360
361
  ...(prov.apiKey ? { apiKey: maskSecret(prov.apiKey) } : {}),
361
362
  ...(prov.apiKeyPool ? { apiKeyPool: prov.apiKeyPool.map(e => ({ ...e, key: maskSecret(e.key) })) } : {}),
363
+ ...(prov.azureCredential ? {
364
+ azureCredential: {
365
+ type: prov.azureCredential.type,
366
+ hasManagedIdentityClientId: Boolean(prov.azureCredential.managedIdentityClientId?.trim()),
367
+ },
368
+ } : {}),
362
369
  };
363
370
 
364
371
  if (wantsJson) {
@@ -36,8 +36,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
36
36
  },
37
37
  {
38
38
  name: "recover-history",
39
- usage: "ocx recover-history --legacy-openai",
40
- summary: "Explicitly recover pre-backup syncResumeHistory rows.",
39
+ usage: "ocx recover-history --legacy-openai --yes",
40
+ summary: "Force all user-message opencodex rows to OpenAI for legacy recovery.",
41
41
  },
42
42
  {
43
43
  name: "uninstall",
@@ -88,20 +88,22 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
88
88
  { name: "ensure", usage: "ocx ensure", summary: "Ensure the proxy is running and Codex config/cache are current." },
89
89
  {
90
90
  name: "sync",
91
- usage: "ocx sync [--restart-codex]",
91
+ usage: "ocx sync [--restart-codex] [--restart-desktop-app]",
92
92
  summary: "Fetch provider models and inject them into Codex config.",
93
93
  details: [
94
94
  "After writing the catalog, warns if long-lived Codex app-server processes are still running.",
95
95
  "--restart-codex sends SIGTERM only to matching app-server / code-mode-host processes (may interrupt active turns).",
96
+ "--restart-desktop-app (Windows only, opt-in) fully restarts the Codex desktop app so its model picker re-reads the catalog. Never implied by --restart-codex: it ends live conversations.",
96
97
  ],
97
98
  },
98
99
  {
99
100
  name: "sync-cache",
100
- usage: "ocx sync-cache [--restart-codex]",
101
+ usage: "ocx sync-cache [--restart-codex] [--restart-desktop-app]",
101
102
  summary: "Refresh Codex's model cache from the active catalog.",
102
103
  details: [
103
104
  "Warns when Codex app-server processes still hold an in-memory model list.",
104
105
  "--restart-codex sends SIGTERM only to matching app-server / code-mode-host processes (may interrupt active turns).",
106
+ "--restart-desktop-app (Windows only, opt-in) fully restarts the Codex desktop app so its model picker re-reads the catalog. Never implied by --restart-codex: it ends live conversations.",
105
107
  ],
106
108
  },
107
109
  { name: "status", usage: "ocx status", summary: "Check proxy server status." },
@@ -209,7 +211,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
209
211
  { name: "logs", usage: "ocx logs [filters] [--follow] [--json|--jsonl]", summary: "Alias of ocx observe logs." },
210
212
  {
211
213
  name: "usage",
212
- usage: "ocx usage [--range <7d|30d|all>] [--surface <all|codex|claude|grok>] [--json]",
214
+ usage: "ocx usage [--range <today|1d|7d|30d|all>] [--surface <all|codex|claude|grok>] [--provider <name>] [--model <id>] [--json]",
213
215
  summary: "Alias of ocx observe usage.",
214
216
  },
215
217
  { name: "storage", usage: "ocx storage [--json]", summary: "Alias of ocx observe storage." },
package/src/cli/status.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { durableBunRuntime } from "../lib/bun-runtime";
2
- import { codexAutoStartEnabled, getConfigPath, getPidPath, readConfigDiagnostics, readPid, readRuntimePort, type RuntimePortState } from "../config";
2
+ import { codexAutoStartEnabled, getConfigPath, readConfigDiagnostics } from "../config";
3
+ import { getPidPath, readPid, readRuntimePort, type RuntimePortState } from "../config/process-state";
3
4
  import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../codex/plugins-doctor";
4
5
  import { findLiveProxy, isOpencodexHealthz, probeHostname } from "../server/proxy-liveness";
5
6
  import { directLocalHttpFetch } from "../server/direct-local-http";
@@ -117,6 +118,29 @@ export function proxyHealthFailureReason(error: unknown, signal: AbortSignal): "
117
118
  : "unreachable";
118
119
  }
119
120
 
121
+ /**
122
+ * `ocx status` greens on process liveness alone, so a proxy that answers
123
+ * /healthz reads healthy even when Codex is not pointed at it and every routed
124
+ * request goes to OpenAI instead (#2411). The proxy line is not wrong — the
125
+ * listener really is up — so it keeps its check, and this supplies the signal
126
+ * that was missing rather than corrupting the one that was already honest.
127
+ *
128
+ * Only `native` warns. `custom-local` and `unknown` are also "this proxy is
129
+ * unused", but startupHealthSummary already renders both as AT RISK with a
130
+ * remedy command, and `custom-remote` is a deliberate operator choice. Warning
131
+ * on all four would teach operators to skip the line that matters.
132
+ */
133
+ export function unusedProxyWarningLines(input: {
134
+ proxyUp: boolean;
135
+ routingKind: StartupHealth["routingKind"];
136
+ }): string[] {
137
+ if (!input.proxyUp || input.routingKind !== "native") return [];
138
+ return [
139
+ "⚠️ Codex routing is native — the running proxy is unused.",
140
+ " Codex requests go to OpenAI, not this proxy. Re-point with: ocx start",
141
+ ];
142
+ }
143
+
120
144
  async function checkProxyHealth(target: ListenTarget): Promise<HealthCheck> {
121
145
  const url = target.healthUrl;
122
146
  const controller = new AbortController();
@@ -1,4 +1,4 @@
1
- import { readRuntimePort } from "../config";
1
+ import { readRuntimePort } from "../config/process-state";
2
2
  import {
3
3
  LOCAL_ATTESTATION_CHALLENGE_HEADER,
4
4
  LOCAL_ATTESTATION_PROOF_HEADER,
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Human rendering for `ocx usage`.
3
+ *
4
+ * Kept out of `observe.ts` and away from the shared `summaryLines()` helper on
5
+ * purpose. `summaryLines()` is a generic depth-1 flattener shared with
6
+ * storage/memory/debug/claude-inbound/injection; it renders any array as
7
+ * "N item(s)", which is why every per-model and per-provider cost the server
8
+ * computes used to vanish before reaching the terminal. Deepening it would
9
+ * change five unrelated commands.
10
+ *
11
+ * Formatting follows the existing CLI house style: dynamic `padEnd` columns
12
+ * (as in `formatAccountTable`), plain text, no ANSI colour.
13
+ */
14
+
15
+ interface CostRow {
16
+ provider: string;
17
+ model?: string;
18
+ requests: number;
19
+ totalTokens: number;
20
+ estimatedCostUsd?: number;
21
+ }
22
+
23
+ interface UsageReportInput {
24
+ range?: string;
25
+ surface?: string;
26
+ since?: number | null;
27
+ summary?: {
28
+ requests?: number;
29
+ totalTokens?: number;
30
+ inputTokens?: number;
31
+ outputTokens?: number;
32
+ cachedInputTokens?: number;
33
+ estimatedCostUsd?: number;
34
+ unpricedRequests?: number;
35
+ unmeteredRequests?: number;
36
+ };
37
+ models?: CostRow[];
38
+ providers?: CostRow[];
39
+ days?: { date: string; requests: number; totalTokens: number; estimatedCostUsd?: number }[];
40
+ filter?: { provider: string | null; model: string | null; matched: boolean; comboOverlap: boolean };
41
+ }
42
+
43
+ const MAX_MODEL_ROWS = 10;
44
+
45
+ function count(value: number | undefined): string {
46
+ return (value ?? 0).toLocaleString("en-US");
47
+ }
48
+
49
+ /**
50
+ * Matches the dashboard's `~$` with four fraction digits. Estimates below a
51
+ * hundredth of a cent still read as a number rather than collapsing to $0.00,
52
+ * which matters when a single request is being inspected.
53
+ */
54
+ function usd(value: number | undefined): string {
55
+ if (typeof value !== "number" || !Number.isFinite(value)) return "—";
56
+ return `~$${value.toFixed(4)}`;
57
+ }
58
+
59
+ function table(header: string[], rows: string[][]): string[] {
60
+ if (rows.length === 0) return [];
61
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map(r => (r[i] ?? "").length)));
62
+ const line = (cols: string[]): string => cols.map((c, i) => (c ?? "").padEnd(widths[i]!)).join(" ").trimEnd();
63
+ return [line(header), ...rows.map(line)];
64
+ }
65
+
66
+ function describeScope(data: UsageReportInput): string {
67
+ const parts = [`Usage — ${data.range ?? "?"}`];
68
+ if (data.surface && data.surface !== "all") parts.push(`surface=${data.surface}`);
69
+ if (data.filter?.provider) parts.push(`provider=${data.filter.provider}`);
70
+ if (data.filter?.model) parts.push(`model=${data.filter.model}`);
71
+ return parts.join(", ");
72
+ }
73
+
74
+ export function formatUsageReport(data: UsageReportInput): string[] {
75
+ const summary = data.summary ?? {};
76
+ const lines: string[] = [describeScope(data), ""];
77
+
78
+ if (data.filter && !data.filter.matched) {
79
+ const what = [data.filter.provider && `provider "${data.filter.provider}"`, data.filter.model && `model "${data.filter.model}"`]
80
+ .filter(Boolean).join(" and ");
81
+ lines.push(`No usage recorded for ${what} in this range.`);
82
+ lines.push("Check the spelling against `ocx usage --json`, or widen --range.");
83
+ return lines;
84
+ }
85
+
86
+ const tokenSplit = [
87
+ summary.inputTokens !== undefined ? `in ${count(summary.inputTokens)}` : null,
88
+ summary.outputTokens !== undefined ? `out ${count(summary.outputTokens)}` : null,
89
+ summary.cachedInputTokens ? `cached ${count(summary.cachedInputTokens)}` : null,
90
+ ].filter(Boolean).join(" / ");
91
+
92
+ lines.push(`Requests ${count(summary.requests)}`);
93
+ lines.push(`Tokens ${count(summary.totalTokens)}${tokenSplit ? ` (${tokenSplit})` : ""}`);
94
+ lines.push(`Est. cost ${usd(summary.estimatedCostUsd)} API list-price equivalent (this range)`);
95
+
96
+ const unpriced = summary.unpricedRequests ?? 0;
97
+ const unmetered = summary.unmeteredRequests ?? 0;
98
+ if (unpriced > 0 || unmetered > 0) {
99
+ // Spelled out because a $0 total is ambiguous otherwise: it can mean "no
100
+ // spend" or "no price row matched", and those are very different answers.
101
+ lines.push(` ${count(unpriced)} unpriced, ${count(unmetered)} unmetered excluded from ~$`);
102
+ }
103
+
104
+ const providers = (data.providers ?? []).filter(row => row.requests > 0);
105
+ if (providers.length > 0) {
106
+ lines.push("");
107
+ lines.push(...table(
108
+ ["PROVIDER", "REQUESTS", "TOKENS", "EST. COST"],
109
+ providers.map(row => [row.provider, count(row.requests), count(row.totalTokens), usd(row.estimatedCostUsd)]),
110
+ ));
111
+ }
112
+
113
+ const models = (data.models ?? []).filter(row => row.requests > 0);
114
+ if (models.length > 0) {
115
+ lines.push("");
116
+ const shown = models.slice(0, MAX_MODEL_ROWS);
117
+ lines.push(...table(
118
+ ["MODEL", "PROVIDER", "REQUESTS", "TOKENS", "EST. COST"],
119
+ shown.map(row => [row.model ?? "-", row.provider, count(row.requests), count(row.totalTokens), usd(row.estimatedCostUsd)]),
120
+ ));
121
+ if (models.length > shown.length) {
122
+ lines.push(`... ${models.length - shown.length} more (use --json)`);
123
+ }
124
+ }
125
+
126
+ if (data.filter?.comboOverlap) {
127
+ lines.push("");
128
+ lines.push("Some requests ran as combos, so per-model request counts can overlap. Cost does not.");
129
+ }
130
+
131
+ lines.push("");
132
+ lines.push("Not a billing receipt. Subscription usage or provider credits may apply instead.");
133
+ return lines;
134
+ }
@@ -17,7 +17,8 @@ import {
17
17
  import { readCodexCatalogPath } from "./catalog/parsing";
18
18
 
19
19
  export const STALE_CODEX_APP_SERVER_HINT =
20
- "If Codex still shows an older model list, restart its long-lived app-server process after sync (ocx sync --restart-codex).";
20
+ "If Codex still shows an older model list, restart its long-lived app-server process after sync (ocx sync --restart-codex). "
21
+ + "On Windows the desktop app itself may also need a full restart (ocx sync --restart-desktop-app).";
21
22
 
22
23
  /** Attach the shared dashboard hint only after a catalog or models_cache write. */
23
24
  export function attachStaleAppServerHint<T extends {
@@ -505,6 +506,7 @@ export function formatStaleCodexAppServerWarning(
505
506
  `WARNING: ${processes.length} Codex app-server process(es) still running (PID${processes.length === 1 ? "" : "s"}: ${pids}). `
506
507
  + "Disk catalog/cache were updated, but Codex may keep showing the old model list until those processes restart. "
507
508
  + "Re-run with `ocx sync --restart-codex` (or `ocx sync-cache --restart-codex`) to send SIGTERM only to matching app-server processes. "
509
+ + "On Windows the desktop app itself may also need a full restart (`ocx sync --restart-desktop-app`). "
508
510
  + "Active turns may be interrupted."
509
511
  );
510
512
  }
@@ -1806,7 +1806,7 @@ export async function handleCodexAuthAPI(
1806
1806
  }
1807
1807
 
1808
1808
  if (url.pathname === "/api/codex-auth/login" && req.method === "POST") {
1809
- const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean };
1809
+ const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean; openBrowser?: unknown };
1810
1810
  const requestedAccountId = body.id?.trim();
1811
1811
  const reauth = body.reauth === true;
1812
1812
  if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) {
@@ -1840,7 +1840,9 @@ export async function handleCodexAuthAPI(
1840
1840
 
1841
1841
  // Open the browser server-side (same pattern as /api/oauth/login in management-api.ts).
1842
1842
  // The GUI's window.open is popup-blocked because it runs after an await, not a direct click.
1843
- if (result.url) {
1843
+ // Both login routes share one resolver so this surface cannot drift from the other.
1844
+ const { shouldOpenBrowserForLogin } = await import("../oauth/open-browser-choice");
1845
+ if (result.url && shouldOpenBrowserForLogin(body.openBrowser, runtimeConfig)) {
1844
1846
  const { openUrl } = await import("../lib/open-url");
1845
1847
  openUrl(result.url);
1846
1848
  }
@@ -154,3 +154,19 @@ export function startupHealthSummary(health: StartupHealth): string {
154
154
  if (health.serviceInstalled && !health.serviceViable) return `AT RISK after restart (installed service is disabled, stopped, or unhealthy; run '${command}')`;
155
155
  return `AT RISK after restart (no viable background service; run '${command}')`;
156
156
  }
157
+
158
+ /**
159
+ * The routing/service/shim token `ocx doctor` prints under restart safety.
160
+ * Extracted so `ocx status` can show the same string rather than growing a
161
+ * second copy that drifts (#2411). Two management routes computing the same
162
+ * thing separately is exactly how #2457 happened.
163
+ */
164
+ export function formatStartupRoutingDetail(health: StartupHealth): string {
165
+ const service = health.serviceViable
166
+ ? "viable"
167
+ : health.serviceInstalled ? "installed-but-unhealthy" : "absent";
168
+ const shim = health.shimHealthy
169
+ ? "healthy"
170
+ : health.shimInstalled ? "stale" : "absent";
171
+ return `routing=${health.routingKind}, service=${service}, shim=${shim}`;
172
+ }
@@ -13,6 +13,7 @@ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, r
13
13
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
14
14
  import { getProviderRegistryEntry } from "../../providers/registry";
15
15
  import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
16
+ import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget";
16
17
  import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec";
17
18
  import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
18
19
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
@@ -154,8 +155,16 @@ export function deriveComboCatalogModel(
154
155
  // combo would have the same window even without the cap.
155
156
  const contextCapped = limitingMembers.every(member => member.contextCapped === true);
156
157
  const maxInputTokens = Math.min(
158
+ contextWindow,
157
159
  ...members.map(member => member.maxInputTokens ?? member.contextWindow!),
158
160
  );
161
+ const autoCompactTokenLimit = Math.min(
162
+ ...members.map(member => clampAutoCompactTokenLimit(
163
+ member.contextWindow!,
164
+ member.maxInputTokens,
165
+ member.autoCompactTokenLimit,
166
+ )),
167
+ );
159
168
  const defaultReasoningEffort = effectiveComboDefault(
160
169
  combo.defaultEffort,
161
170
  reasoningEfforts,
@@ -167,6 +176,7 @@ export function deriveComboCatalogModel(
167
176
  owned_by: COMBO_NAMESPACE,
168
177
  contextWindow,
169
178
  maxInputTokens,
179
+ autoCompactTokenLimit,
170
180
  ...(hasLimitingContextCapMetadata ? { contextCapped } : {}),
171
181
  inputModalities,
172
182
  reasoningEfforts,
@@ -210,6 +220,7 @@ export function comboCatalogWarningSignature(
210
220
  key,
211
221
  contextWindow: member?.contextWindow ?? null,
212
222
  maxInputTokens: member?.maxInputTokens ?? null,
223
+ autoCompactTokenLimit: member?.autoCompactTokenLimit ?? null,
213
224
  inputModalities: [...new Set(member?.inputModalities ?? [])].sort(),
214
225
  reasoningEfforts: [...new Set(member?.reasoningEfforts ?? [])].sort(),
215
226
  parallelToolCalls: member?.parallelToolCalls === true,
@@ -299,6 +310,7 @@ export function normalizedOpenAiApiSignature(model: CatalogModel): string {
299
310
  id: model.id,
300
311
  contextWindow: model.contextWindow ?? null,
301
312
  maxInputTokens: model.maxInputTokens ?? null,
313
+ autoCompactTokenLimit: model.autoCompactTokenLimit ?? null,
302
314
  inputModalities: [...new Set(model.inputModalities ?? [])].sort(),
303
315
  reasoningEfforts: [...new Set(model.reasoningEfforts ?? [])].sort(),
304
316
  ownedBy: model.owned_by ?? null,
@@ -13,6 +13,7 @@ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, r
13
13
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
14
14
  import { getProviderRegistryEntry } from "../../providers/registry";
15
15
  import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap";
16
+ import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget";
16
17
  import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
17
18
  import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
18
19
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
@@ -33,6 +34,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
33
34
 
34
35
  import { generatedModelMetadata, readCatalog, readCodexCatalogPath } from "./parsing";
35
36
  import type { CatalogModel, RawEntry } from "./parsing";
37
+ import { shouldStampContextProvenance } from "./model-metadata";
36
38
  import { UPSTREAM_NATIVE_ENTRIES } from "./metadata";
37
39
  import { nativeOpenAiCapabilitySourceSlug } from "./native-models";
38
40
  import { loadBundledCodexCatalog } from "./bundled";
@@ -128,9 +130,23 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel)
128
130
  if (typeof resolvedContext === "number" && resolvedContext > 0) {
129
131
  entry.context_window = resolvedContext;
130
132
  entry.max_context_window = resolvedContext;
131
- entry.auto_compact_token_limit = Math.min(
132
- Math.floor(resolvedContext * 0.9),
133
- model.maxInputTokens ?? Number.POSITIVE_INFINITY,
133
+ entry.auto_compact_token_limit = clampAutoCompactTokenLimit(
134
+ resolvedContext,
135
+ model.maxInputTokens,
136
+ model.autoCompactTokenLimit,
137
+ );
138
+ } else if (
139
+ typeof entry.context_window === "number"
140
+ && entry.context_window > 0
141
+ && typeof model.maxInputTokens === "number"
142
+ && model.maxInputTokens > 0
143
+ ) {
144
+ // A conservative routed fallback is not evidence for applying the optional soft policy,
145
+ // but a measured/configured input ceiling is still a hard bound. Compact before that
146
+ // ceiling even when the provider supplied no authoritative context window.
147
+ entry.auto_compact_token_limit = clampAutoCompactTokenLimit(
148
+ entry.context_window,
149
+ model.maxInputTokens,
134
150
  );
135
151
  }
136
152
  if (Array.isArray(model.inputModalities) && model.inputModalities.length > 0) {
@@ -171,33 +187,47 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel)
171
187
  * live in that table.
172
188
  */
173
189
  function stampCapabilityProvenance(entry: RawEntry, model: CatalogModel): void {
174
- // Virtual combo rows are synthesized from last-resort defaults (a generic 128k
175
- // context and a `["text"]` modality), so their values are placeholders rather
176
- // than assertions. Stamping them would reintroduce the exact false-evidence
177
- // defect this block exists to prevent.
178
- if (model.provider === COMBO_NAMESPACE) return;
190
+ // Virtual combo rows without derived member evidence are placeholders
191
+ // (generic 128k / `["text"]`). Stamping those would reintroduce the
192
+ // false-evidence defect this block exists to prevent. Combos that actually
193
+ // min() real member windows carry `metadataSource: "derived"`.
194
+ if (model.provider === COMBO_NAMESPACE && model.metadataSource !== "derived") return;
179
195
 
180
196
  const meta = generatedModelMetadata(model.provider, model.id);
181
197
  const metaContext = typeof meta?.contextWindow === "number" && meta.contextWindow > 0
182
- // The generated context is capped before it reaches the entry, so provenance
183
- // must apply the same cap or routing would advertise a window the cap refused.
184
198
  ? applyProviderContextCap(meta.contextWindow, model.contextCap) ?? meta.contextWindow
185
199
  : undefined;
186
- const contextWindow = typeof model.contextWindow === "number" && model.contextWindow > 0
200
+ const stampModelContext = shouldStampContextProvenance(model);
201
+ const contextWindow = stampModelContext
187
202
  ? model.contextWindow
188
- : metaContext;
203
+ : (model.metadataSource === undefined ? metaContext : undefined);
189
204
  const inputModalities = Array.isArray(model.inputModalities) && model.inputModalities.length > 0
190
205
  ? model.inputModalities
191
206
  : (Array.isArray(meta?.input) && meta.input.length > 0 ? meta.input : undefined);
207
+ const maxInputTokens = typeof model.maxInputTokens === "number" && model.maxInputTokens > 0
208
+ ? model.maxInputTokens
209
+ : undefined;
210
+ const source = model.metadataSource;
211
+ const stampSource = source === "live" || source === "registry" || source === "snapshot" || source === "derived";
212
+
213
+ if (contextWindow === undefined && inputModalities === undefined
214
+ && !(Array.isArray(model.capabilities) && model.capabilities.length > 0)) {
215
+ return;
216
+ }
192
217
 
193
218
  entry.opencodex_capability_provenance = {
194
219
  provider: model.provider,
195
220
  model_id: model.id,
196
221
  ...(contextWindow !== undefined ? { context_window: contextWindow } : {}),
222
+ ...(maxInputTokens !== undefined && stampModelContext ? { max_input_tokens: maxInputTokens } : {}),
197
223
  ...(inputModalities !== undefined ? { input_modalities: [...inputModalities] } : {}),
198
224
  ...(Array.isArray(model.capabilities) && model.capabilities.length > 0
199
225
  ? { capabilities: [...model.capabilities] }
200
226
  : {}),
227
+ ...(stampSource ? { source } : {}),
228
+ ...(typeof model.metadataObservedAt === "string" && stampSource
229
+ ? { observed_at: model.metadataObservedAt }
230
+ : {}),
201
231
  };
202
232
  }
203
233
 
@@ -14,6 +14,7 @@ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, r
14
14
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
15
15
  import { getProviderRegistryEntry, providerCodexAccountMode } from "../../providers/registry";
16
16
  import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
17
+ import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget";
17
18
  import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
18
19
  import { identifyRoutedModel } from "../../adapters/identity";
19
20
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
@@ -204,6 +205,8 @@ export interface NativeContextLimits {
204
205
  readonly providerWindow?: number;
205
206
  /** `providers.openai.modelContextWindows` — per-model, wins over `providerWindow`. */
206
207
  readonly modelWindows?: Readonly<Record<string, number>>;
208
+ /** `providers.openai.modelAutoCompactTokenLimits` — soft, lowering-only budgets. */
209
+ readonly modelAutoCompactTokenLimits?: Readonly<Record<string, number>>;
207
210
  }
208
211
 
209
212
  export type NativeContextLimitsInput = NativeContextLimits | number | undefined;
@@ -227,12 +230,18 @@ export function nativeContextLimits(
227
230
  const window = positiveInt(value);
228
231
  if (window !== undefined) modelWindows[slug] = window;
229
232
  }
233
+ const modelAutoCompactTokenLimits: Record<string, number> = {};
234
+ for (const [slug, value] of Object.entries(provider?.modelAutoCompactTokenLimits ?? {})) {
235
+ const budget = positiveInt(value);
236
+ if (budget !== undefined) modelAutoCompactTokenLimits[slug] = budget;
237
+ }
230
238
  return {
231
239
  ...(positiveInt(providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)) !== undefined
232
240
  ? { cap: providerContextCap(config, OPENAI_CODEX_PROVIDER_ID) }
233
241
  : {}),
234
242
  ...(positiveInt(provider?.contextWindow) !== undefined ? { providerWindow: provider!.contextWindow } : {}),
235
243
  ...(Object.keys(modelWindows).length > 0 ? { modelWindows } : {}),
244
+ ...(Object.keys(modelAutoCompactTokenLimits).length > 0 ? { modelAutoCompactTokenLimits } : {}),
236
245
  };
237
246
  }
238
247
 
@@ -277,6 +286,21 @@ export function nativeOpenAiMaxInputTokens(slug: string, limits?: NativeContextL
277
286
  return window === undefined ? narrowed : Math.min(narrowed, window);
278
287
  }
279
288
 
289
+ /** Effective native soft budget after every hard window/input limit is resolved. */
290
+ export function nativeOpenAiAutoCompactTokenLimit(
291
+ slug: string,
292
+ limits?: NativeContextLimitsInput,
293
+ ): number | undefined {
294
+ const contextWindow = nativeOpenAiContextWindow(slug, limits);
295
+ if (contextWindow === undefined) return undefined;
296
+ const configured = positiveInt(asLimits(limits).modelAutoCompactTokenLimits?.[slug]);
297
+ return clampAutoCompactTokenLimit(
298
+ contextWindow,
299
+ nativeOpenAiMaxInputTokens(slug, limits),
300
+ configured,
301
+ );
302
+ }
303
+
280
304
  export function nativeInputModalities(slug: string): string[] {
281
305
  const upstream = PINNED_NATIVE_CAPABILITY_ENTRIES.get(slug);
282
306
  if (Array.isArray(upstream?.input_modalities) && upstream!.input_modalities!.length > 0) {
@@ -387,7 +411,7 @@ export function desktopVisibleNativeSlugs(
387
411
  ]);
388
412
  }
389
413
 
390
- export function nativeModelRows(config: Pick<OcxConfig, "disabledModels" | "combos" | "providerContextCaps" | "providers">): Array<{ slug: string; disabled: boolean; contextWindow?: number; maxInputTokens?: number }> {
414
+ export function nativeModelRows(config: Pick<OcxConfig, "disabledModels" | "combos" | "providerContextCaps" | "providers">): Array<{ slug: string; disabled: boolean; contextWindow?: number; maxInputTokens?: number; autoCompactTokenLimit?: number }> {
391
415
  const disabled = disabledNativeSlugs(config);
392
416
  const shadowed = configuredNativeAliasSlugs(config);
393
417
  // Both user levers, not just the cap: a per-model window set from the dashboard has to show
@@ -403,11 +427,13 @@ export function nativeModelRows(config: Pick<OcxConfig, "disabledModels" | "comb
403
427
  .filter(slug => !shadowed.has(slug)).map(slug => {
404
428
  const contextWindow = nativeOpenAiContextWindow(slug, limits);
405
429
  const maxInputTokens = nativeOpenAiMaxInputTokens(slug, limits);
430
+ const autoCompactTokenLimit = nativeOpenAiAutoCompactTokenLimit(slug, limits);
406
431
  return {
407
432
  slug,
408
433
  disabled: disabled.has(slug),
409
434
  ...(contextWindow !== undefined ? { contextWindow } : {}),
410
435
  ...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
436
+ ...(autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}),
411
437
  };
412
438
  });
413
439
  }