@yansigit/opencodex 2.32.0 → 2.33.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 (142) hide show
  1. package/README.md +2 -2
  2. package/gui/dist/assets/index-CIDo4y4k.js +102 -0
  3. package/gui/dist/assets/index-DrSQdTRd.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +8 -5
  6. package/src/adapters/anthropic.ts +20 -6
  7. package/src/adapters/azure.ts +20 -4
  8. package/src/adapters/base.ts +3 -1
  9. package/src/adapters/command-code.ts +131 -17
  10. package/src/adapters/cursor/envelope-echo.ts +162 -0
  11. package/src/adapters/cursor/live-transport.ts +4 -2
  12. package/src/adapters/cursor/native-exec-fs.ts +13 -12
  13. package/src/adapters/cursor/native-exec-network.ts +3 -5
  14. package/src/adapters/cursor/native-exec-policy.ts +47 -0
  15. package/src/adapters/cursor/native-exec-shell.ts +13 -25
  16. package/src/adapters/cursor/native-exec.ts +18 -10
  17. package/src/adapters/cursor/protobuf-events.ts +186 -9
  18. package/src/adapters/cursor/protobuf-request.ts +53 -18
  19. package/src/adapters/cursor/request-builder.ts +11 -3
  20. package/src/adapters/cursor/tool-definitions.ts +49 -2
  21. package/src/adapters/cursor/tool-result-normalize.ts +21 -8
  22. package/src/adapters/cursor/types.ts +11 -3
  23. package/src/adapters/cursor.ts +123 -0
  24. package/src/adapters/google-aistudio-parser.ts +49 -0
  25. package/src/adapters/google-antigravity-replay.ts +2 -2
  26. package/src/adapters/google-antigravity-wire.ts +7 -0
  27. package/src/adapters/google-errors.ts +6 -2
  28. package/src/adapters/google-http.ts +30 -7
  29. package/src/adapters/google-truncation.ts +5 -0
  30. package/src/adapters/google-wire-compiler.ts +38 -6
  31. package/src/adapters/google.ts +242 -28
  32. package/src/adapters/kiro-tools.ts +20 -9
  33. package/src/adapters/openai-chat.ts +9 -0
  34. package/src/adapters/openai-responses.ts +2 -1
  35. package/src/bridge.ts +112 -9
  36. package/src/chat/inbound.ts +15 -0
  37. package/src/claude/context-windows.ts +16 -9
  38. package/src/cli/doctor.ts +2 -2
  39. package/src/cli/index.ts +10 -3
  40. package/src/cli/provider.ts +6 -0
  41. package/src/cli/status.ts +23 -0
  42. package/src/codex/auth-api.ts +4 -2
  43. package/src/codex/autostart-health.ts +16 -0
  44. package/src/codex/catalog/aggregation.ts +12 -12
  45. package/src/codex/catalog/effort.ts +18 -3
  46. package/src/codex/catalog/metadata.ts +27 -1
  47. package/src/codex/catalog/model-metadata.ts +39 -12
  48. package/src/codex/catalog/parsing.ts +38 -27
  49. package/src/codex/catalog/provider-fetch.ts +232 -133
  50. package/src/codex/catalog/sync.ts +1 -1
  51. package/src/codex/convergence.ts +5 -0
  52. package/src/codex/shim.ts +56 -3
  53. package/src/config/provider-validation.ts +37 -0
  54. package/src/config.ts +78 -2
  55. package/src/generated/compatibility-version.json +187 -119
  56. package/src/generated/model-metadata.ts +3 -0
  57. package/src/images/loop.ts +37 -6
  58. package/src/lib/azure-identity.ts +154 -0
  59. package/src/lib/debug.ts +42 -0
  60. package/src/lib/errors.ts +14 -0
  61. package/src/lib/provider-outbound.ts +45 -33
  62. package/src/lib/provider-tls-profile.ts +309 -0
  63. package/src/lib/proxy-env.ts +49 -0
  64. package/src/lib/redact.ts +10 -1
  65. package/src/oauth/aistudio-native-daemon.ts +62 -0
  66. package/src/oauth/aistudio-session-sync.ts +95 -0
  67. package/src/oauth/antigravity-routing.ts +282 -236
  68. package/src/oauth/callback-server.ts +22 -2
  69. package/src/oauth/command-code.ts +5 -16
  70. package/src/oauth/google-aistudio-auth.ts +98 -0
  71. package/src/oauth/google-antigravity.ts +42 -5
  72. package/src/oauth/index.ts +15 -3
  73. package/src/oauth/key-providers.ts +8 -0
  74. package/src/oauth/kimi.ts +9 -1
  75. package/src/oauth/login-cli.ts +66 -1
  76. package/src/oauth/open-browser-choice.ts +26 -0
  77. package/src/oauth/store.ts +6 -0
  78. package/src/providers/antigravity-quota.ts +3 -1
  79. package/src/providers/api-keys.ts +2 -1
  80. package/src/providers/auto-compact-budget.ts +65 -0
  81. package/src/providers/derive.ts +5 -1
  82. package/src/providers/key-failover.ts +5 -1
  83. package/src/providers/openai-tiers.ts +5 -0
  84. package/src/providers/provider-id-rewrite.ts +1 -0
  85. package/src/providers/quota.ts +148 -50
  86. package/src/providers/registry.ts +27 -4
  87. package/src/providers/request-pacing.ts +33 -6
  88. package/src/providers/xai-transport.ts +21 -0
  89. package/src/responses/google-provider-options.ts +36 -0
  90. package/src/responses/namespace-tool-compat.ts +84 -4
  91. package/src/responses/parser.ts +11 -0
  92. package/src/responses/provider-opaque-metadata.ts +3 -3
  93. package/src/responses/schema.ts +37 -0
  94. package/src/responses/state.ts +94 -4
  95. package/src/router.ts +11 -2
  96. package/src/routing/account-pool/cooldown.ts +8 -0
  97. package/src/routing/account-pool/index.ts +1 -0
  98. package/src/server/aistudio-ws-hub.ts +295 -0
  99. package/src/server/auth-cors.ts +29 -0
  100. package/src/server/chat-completions.ts +2 -0
  101. package/src/server/images.ts +19 -35
  102. package/src/server/index.ts +94 -0
  103. package/src/server/management/agent-settings-routes.ts +205 -15
  104. package/src/server/management/combo-routes.ts +6 -0
  105. package/src/server/management/config-routes.ts +31 -5
  106. package/src/server/management/logs-usage-routes.ts +11 -5
  107. package/src/server/management/model-rows.ts +4 -0
  108. package/src/server/management/oauth-account-routes.ts +38 -7
  109. package/src/server/management/provider-routes.ts +113 -15
  110. package/src/server/management/routing-profile-routes.ts +3 -0
  111. package/src/server/port-reclaim.ts +19 -1
  112. package/src/server/request-log-conversation.ts +12 -0
  113. package/src/server/request-log.ts +23 -1
  114. package/src/server/responses/agent-task-recovery.ts +1 -1
  115. package/src/server/responses/compact.ts +30 -1
  116. package/src/server/responses/core.ts +363 -156
  117. package/src/server/responses/empty-completion-guard.ts +35 -6
  118. package/src/server/responses/fetch-helpers.ts +18 -5
  119. package/src/server/responses/policy-fallback.ts +1 -1
  120. package/src/server/responses/v2-native-parent-override.ts +59 -0
  121. package/src/server/responses/ws-upstream.ts +75 -2
  122. package/src/server/responses-undeclared-tool-guard.ts +90 -8
  123. package/src/server/ws-bridge.ts +2 -1
  124. package/src/service.ts +1 -1
  125. package/src/smoke/fingerprint-cache.ts +133 -0
  126. package/src/smoke/live-scenarios.ts +33 -0
  127. package/src/smoke/runner.ts +119 -0
  128. package/src/types/config.ts +16 -1
  129. package/src/types/provider.ts +18 -1
  130. package/src/types/request.ts +30 -0
  131. package/src/types/tools.ts +51 -0
  132. package/src/types.ts +6 -0
  133. package/src/usage/command-code-manifest.ts +116 -0
  134. package/src/usage/cost.ts +2 -2
  135. package/src/usage/expected-prices.ts +83 -0
  136. package/src/usage/log.ts +2 -2
  137. package/src/usage/summary.ts +34 -12
  138. package/src/web-search/gemini-executor.ts +6 -4
  139. package/src/web-search/index.ts +16 -8
  140. package/src/web-search/loop.ts +42 -6
  141. package/gui/dist/assets/index-BG43zwVe.js +0 -102
  142. package/gui/dist/assets/index-CiSI-jrP.css +0 -1
package/src/bridge.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  awaitThoughtSignatureDurability,
20
20
  } from "./responses/thought-signature-replay";
21
21
  import { resolveStallTimeoutSec } from "./stall-timeout";
22
+ import { normalizeDeclaredToolName } from "./types";
22
23
  import { usageDisplayTotalTokens } from "./usage/totals";
23
24
  import { appendSafeWebSearchSource, safeWebSearchSources } from "./web-search/sources";
24
25
  import {
@@ -28,6 +29,7 @@ import {
28
29
  type TranslatorBudget,
29
30
  type TranslatorBufferKind,
30
31
  } from "./lib/translator-budget";
32
+ import { debugFingerprint, debugStreamDiagnostic, type DebugStreamDiagnosticContext } from "./lib/debug";
31
33
 
32
34
  function uuid(): string {
33
35
  return crypto.randomUUID().replace(/-/g, "");
@@ -118,7 +120,9 @@ function adapterFailureFromEvent(event: Extract<AdapterEvent, { type: "error" }>
118
120
  }
119
121
  const fallback = adapterFailureFromMessage(event.message);
120
122
  let httpStatus = event.status ?? fallback.httpStatus;
121
- const error = classifyError(httpStatus, event.errorType ?? fallback.error.type, event.message);
123
+ const error = httpStatus === 400 && event.errorType === "upstream_error"
124
+ ? { message: event.message, type: "upstream_error", code: event.code ?? null }
125
+ : classifyError(httpStatus, event.errorType ?? fallback.error.type, event.message);
122
126
  if (event.errorType !== undefined) error.type = event.errorType;
123
127
  if (event.code !== undefined) error.code = event.code;
124
128
  // Codex maps cyber_policy on HTTP 400 (body) or mid-stream code; never leave it as 502.
@@ -166,6 +170,88 @@ interface OutputItem {
166
170
 
167
171
  export type ResponsesTerminalStatus = "completed" | "failed" | "incomplete";
168
172
 
173
+ export interface BridgeDiagnosticSequence { value: number }
174
+
175
+ export interface BridgeDiagnosticContext extends DebugStreamDiagnosticContext {
176
+ sequence?: BridgeDiagnosticSequence;
177
+ }
178
+
179
+ export function adapterEventDiagnosticDetails(event: AdapterEvent): Record<string, unknown> {
180
+ switch (event.type) {
181
+ case "text_delta":
182
+ return { byteLength: Buffer.byteLength(event.text), fingerprint: debugFingerprint(event.text) };
183
+ case "thinking_delta":
184
+ return { byteLength: Buffer.byteLength(event.thinking), fingerprint: debugFingerprint(event.thinking) };
185
+ case "reasoning_raw_delta":
186
+ return { byteLength: Buffer.byteLength(event.text), fingerprint: debugFingerprint(event.text) };
187
+ case "thinking_signature":
188
+ case "redacted_thinking":
189
+ case "kiro_redacted_reasoning": {
190
+ const content = event.type === "thinking_signature" ? event.signature : event.data;
191
+ return { byteLength: Buffer.byteLength(content), fingerprint: debugFingerprint(content) };
192
+ }
193
+ case "tool_call_delta":
194
+ return { byteLength: Buffer.byteLength(event.arguments), fingerprint: debugFingerprint(event.arguments) };
195
+ case "tool_call_start":
196
+ return {
197
+ idByteLength: Buffer.byteLength(event.id),
198
+ idFingerprint: debugFingerprint(event.id),
199
+ nameByteLength: Buffer.byteLength(event.name),
200
+ nameFingerprint: debugFingerprint(event.name),
201
+ };
202
+ case "web_search_call_begin":
203
+ return { idByteLength: Buffer.byteLength(event.id), idFingerprint: debugFingerprint(event.id) };
204
+ case "web_search_call_end": {
205
+ const queries = JSON.stringify(event.queries);
206
+ return {
207
+ idByteLength: Buffer.byteLength(event.id),
208
+ idFingerprint: debugFingerprint(event.id),
209
+ byteLength: Buffer.byteLength(queries),
210
+ fingerprint: debugFingerprint(queries),
211
+ status: event.status,
212
+ };
213
+ }
214
+ case "error":
215
+ return {
216
+ byteLength: Buffer.byteLength(event.message),
217
+ fingerprint: debugFingerprint(event.message),
218
+ ...(event.status !== undefined ? { status: event.status } : {}),
219
+ ...(event.code !== undefined
220
+ ? { codeByteLength: Buffer.byteLength(event.code), codeFingerprint: debugFingerprint(event.code) }
221
+ : {}),
222
+ ...(event.retryable !== undefined ? { retryable: event.retryable } : {}),
223
+ };
224
+ case "incomplete":
225
+ return {
226
+ ...(event.message !== undefined ? { byteLength: Buffer.byteLength(event.message), fingerprint: debugFingerprint(event.message) } : {}),
227
+ reasonByteLength: Buffer.byteLength(event.reason),
228
+ reasonFingerprint: debugFingerprint(event.reason),
229
+ ...(event.retryable !== undefined ? { retryable: event.retryable } : {}),
230
+ };
231
+ case "done":
232
+ return {
233
+ ...(event.stopReason !== undefined
234
+ ? { stopReasonByteLength: Buffer.byteLength(event.stopReason), stopReasonFingerprint: debugFingerprint(event.stopReason) }
235
+ : {}),
236
+ ...(event.endTurn !== undefined ? { endTurn: event.endTurn } : {}),
237
+ };
238
+ default:
239
+ return {};
240
+ }
241
+ }
242
+
243
+ /** Emit one adapter-stage diagnostic while preserving one sequence across sidecar iterations. */
244
+ export function diagnoseAdapterEvent(context: BridgeDiagnosticContext, event: AdapterEvent): void {
245
+ const sequence = context.sequence ??= { value: 0 };
246
+ debugStreamDiagnostic(
247
+ context,
248
+ "adapter",
249
+ ++sequence.value,
250
+ event.type,
251
+ adapterEventDiagnosticDetails(event),
252
+ );
253
+ }
254
+
169
255
  export function bridgeToResponsesSSE(
170
256
  events: AsyncIterable<AdapterEvent>,
171
257
  modelId: string,
@@ -226,6 +312,8 @@ export function bridgeToResponsesSSE(
226
312
  setInterval: (handler: () => void, ms: number) => unknown;
227
313
  clearInterval: (id: unknown) => void;
228
314
  };
315
+ /** Internal, opt-in structural stream diagnostics. */
316
+ diagnostic?: BridgeDiagnosticContext;
229
317
  },
230
318
  ): ReadableStream<Uint8Array> {
231
319
  const replayCacheScope = options?.replayCacheScope;
@@ -326,6 +414,7 @@ export function bridgeToResponsesSSE(
326
414
  };
327
415
  const responseId = options?.responseId ?? `resp_${uuid()}`;
328
416
  let seq = 0;
417
+ let diagnosticSequence = 0;
329
418
  // Set once the client is gone (cancel) or an enqueue throws on a torn-down controller, so we
330
419
  // never enqueue again and never throw a second time inside start() — the RC2 double-throw that
331
420
  // otherwise surfaced as proxy-side stream noise on every client disconnect.
@@ -858,6 +947,17 @@ export function bridgeToResponsesSSE(
858
947
  }
859
948
  if (next.done) { upstreamDone = true; break; }
860
949
  const event = next.value;
950
+ if (options?.diagnostic) {
951
+ debugStreamDiagnostic(
952
+ options.diagnostic,
953
+ "bridge",
954
+ options.diagnostic.sequence
955
+ ? ++options.diagnostic.sequence.value
956
+ : ++diagnosticSequence,
957
+ event.type,
958
+ adapterEventDiagnosticDetails(event),
959
+ );
960
+ }
861
961
  let terminalEvent = false;
862
962
  // Invisible adapter heartbeats (and buffered web-search progress) count as upstream
863
963
  // liveness only — they must not suppress wire keepalives that re-arm Codex idle timers.
@@ -1041,13 +1141,14 @@ export function bridgeToResponsesSSE(
1041
1141
  rememberReasoningForCall(event.id, rawReasoningForNextToolCall, replayCacheScope);
1042
1142
  }
1043
1143
  if (currentToolCall) closeCurrentToolCall();
1044
- const mapped = toolNsMap?.get(event.name);
1045
- const realName = mapped?.name ?? event.name;
1046
- if (options?.declaredToolNames && !options.declaredToolNames.has(event.name)) {
1144
+ const effectiveName = normalizeDeclaredToolName(event.name, options?.declaredToolNames);
1145
+ const mapped = toolNsMap?.get(effectiveName);
1146
+ const realName = mapped?.name ?? effectiveName;
1147
+ if (options?.declaredToolNames && !options.declaredToolNames.has(effectiveName)) {
1047
1148
  const failure = responseError(
1048
1149
  502,
1049
1150
  "upstream_error",
1050
- `routed provider emitted undeclared client tool "${event.name}"; only request-declared tools may be called`,
1151
+ `routed provider emitted undeclared client tool "${effectiveName}"; only request-declared tools may be called`,
1051
1152
  );
1052
1153
  emit("response.failed", {
1053
1154
  response: {
@@ -1783,7 +1884,7 @@ function buildResponseJSONWithBudget(
1783
1884
  ));
1784
1885
  }
1785
1886
  break;
1786
- case "tool_call_start":
1887
+ case "tool_call_start": {
1787
1888
  if (currentText) flushText("commentary");
1788
1889
  if (currentSummaryReasoning) flushSummaryReasoning();
1789
1890
  if (currentRawReasoning) flushRawReasoning();
@@ -1791,10 +1892,11 @@ function buildResponseJSONWithBudget(
1791
1892
  rememberReasoningForCall(e.id, rawReasoningForNextToolCall, replayCacheScope);
1792
1893
  }
1793
1894
  flushToolCall();
1794
- if (options?.declaredToolNames && !options.declaredToolNames.has(e.name)) {
1895
+ const effectiveName = normalizeDeclaredToolName(e.name, options?.declaredToolNames);
1896
+ if (options?.declaredToolNames && !options.declaredToolNames.has(effectiveName)) {
1795
1897
  errorEvent = {
1796
1898
  type: "error",
1797
- message: `routed provider emitted undeclared client tool "${e.name}"; only request-declared tools may be called`,
1899
+ message: `routed provider emitted undeclared client tool "${effectiveName}"; only request-declared tools may be called`,
1798
1900
  status: 502,
1799
1901
  errorType: "upstream_error",
1800
1902
  };
@@ -1802,11 +1904,12 @@ function buildResponseJSONWithBudget(
1802
1904
  }
1803
1905
  currentToolCallId = e.id;
1804
1906
  budget?.openCall(e.id);
1805
- currentToolCallName = e.name;
1907
+ currentToolCallName = effectiveName;
1806
1908
  currentToolCallArgs = "";
1807
1909
  currentToolCallArgsBytes = 0;
1808
1910
  currentToolCallProviderMetadata = e.providerMetadata;
1809
1911
  break;
1912
+ }
1810
1913
  case "tool_call_delta":
1811
1914
  {
1812
1915
  ({ value: currentToolCallArgs, bytes: currentToolCallArgsBytes } = appendBatchString(
@@ -10,6 +10,21 @@ export class ChatCompletionsRequestError extends Error {}
10
10
  type Rec = Record<string, unknown>;
11
11
  type ChatCompletionsRoutingBody = Rec & { model: string; messages: unknown[] };
12
12
 
13
+ /** Session/thread headers the Chat -> Responses bridge must preserve for provider affinity. */
14
+ export const CHAT_RESPONSES_SESSION_HEADERS = [
15
+ "session_id",
16
+ "session-id",
17
+ "x-session-id",
18
+ "thread-id",
19
+ ] as const;
20
+
21
+ export function copyChatResponsesSessionHeaders(source: Headers, target: Headers): void {
22
+ for (const name of CHAT_RESPONSES_SESSION_HEADERS) {
23
+ const value = source.get(name);
24
+ if (value) target.set(name, value);
25
+ }
26
+ }
27
+
13
28
  function isRec(v: unknown): v is Rec {
14
29
  return !!v && typeof v === "object" && !Array.isArray(v);
15
30
  }
@@ -118,17 +118,24 @@ export function buildClaudeContextWindows(
118
118
  put(desktop3pAlias("native", slug), window);
119
119
  put(aliasForNative(slug), window);
120
120
  }
121
+ // Anthropic passthrough guard (audit 021 #3): canonical claude ids ride the
122
+ // subscription passthrough — marking a sub-1M one would strap [1m]/1M-beta onto
123
+ // a model that cannot host it. Register anthropic rows only at >=1M.
124
+ const registrable = routedModels.filter(
125
+ m =>
126
+ typeof m.contextWindow === "number" &&
127
+ m.contextWindow > 0 &&
128
+ !(m.provider === "anthropic" && m.contextWindow < ONE_MILLION),
129
+ );
121
130
  // Bare routed ids are registered only when unambiguous across providers (audit
122
- // 021 #5) — natives are registered first, so a native slug always wins the bare key.
131
+ // 021 #5) — natives are registered first, so a native slug always wins the bare
132
+ // key. Counted over the rows that can actually claim the key: a row this loop
133
+ // skips contributes no window, so letting it veto the bare key withholds an
134
+ // answer that was never in doubt.
123
135
  const bareCounts = new Map<string, number>();
124
- for (const m of routedModels) bareCounts.set(m.id, (bareCounts.get(m.id) ?? 0) + 1);
125
- for (const m of routedModels) {
126
- const window = m.contextWindow;
127
- if (typeof window !== "number" || window <= 0) continue;
128
- // Anthropic passthrough guard (audit 021 #3): canonical claude ids ride the
129
- // subscription passthrough — marking a sub-1M one would strap [1m]/1M-beta onto
130
- // a model that cannot host it. Register anthropic rows only at >=1M.
131
- if (m.provider === "anthropic" && window < ONE_MILLION) continue;
136
+ for (const m of registrable) bareCounts.set(m.id, (bareCounts.get(m.id) ?? 0) + 1);
137
+ for (const m of registrable) {
138
+ const window = m.contextWindow as number;
132
139
  put(`${m.provider}/${m.id}`, window);
133
140
  put(desktop3pAlias(m.provider, m.id), window);
134
141
  put(aliasForRoute(m.provider, m.id), window);
package/src/cli/doctor.ts CHANGED
@@ -42,7 +42,7 @@ import {
42
42
  resolveEffectiveUserIdentity,
43
43
  } from "../codex/user-identity";
44
44
  import { collectProjectCodexConfigWarnings, formatProjectCodexConfigWarningsForDoctor } from "../codex/project-config-warnings";
45
- import { collectStartupHealth, startupHealthSummary } from "../codex/autostart-health";
45
+ import { collectStartupHealth, formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health";
46
46
  import {
47
47
  displayCodexRuntimePath,
48
48
  loadLastEffortClamp,
@@ -983,7 +983,7 @@ export async function runDoctor(args: string[] = []): Promise<void> {
983
983
  const startup = collectStartupHealth(doctorConfig);
984
984
  console.log("\nCodex restart safety");
985
985
  console.log(` ${startup.rebootSafe ? "ok " : "!! "} ${startupHealthSummary(startup)}`);
986
- console.log(` routing=${startup.routingKind}, service=${startup.serviceViable ? "viable" : startup.serviceInstalled ? "installed-but-unhealthy" : "absent"}, shim=${startup.shimHealthy ? "healthy" : startup.shimInstalled ? "stale" : "absent"}`);
986
+ console.log(` ${formatStartupRoutingDetail(startup)}`);
987
987
 
988
988
  console.log("\nCodex runtime selection");
989
989
  {
package/src/cli/index.ts CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  writePid,
26
26
  writeRuntimePort,
27
27
  } from "../config/process-state";
28
- import { collectStatus } from "./status";
28
+ import { collectStatus, unusedProxyWarningLines } from "./status";
29
29
 
30
30
  import {
31
31
  discoverStableProxyForRestart,
@@ -46,7 +46,7 @@ import { runCli } from "./root";
46
46
  import { ProxyOwnershipRefusedError, stopProxy } from "../lib/process-control";
47
47
  import { loadServiceTokenFromFile } from "../lib/service-secrets";
48
48
  import { diagnoseService, isServiceOwnershipError, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
49
- import { startupHealthSummary } from "../codex/autostart-health";
49
+ import { formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health";
50
50
  import { drainAndShutdown, isRecyclingForExit, startServer } from "../server";
51
51
  import { injectSystemEnv, reconcileShellHook, revertSystemEnv, uninstallShellHook } from "../server/system-env";
52
52
  import { buildDesktop3pRegistry } from "../claude/desktop-3p";
@@ -164,7 +164,7 @@ async function chooseListenPort(requestedPort?: number): Promise<number> {
164
164
  // Ghost LISTEN rows with a dead PID can outlive the process for a while.
165
165
  // SetTcpEntry(DELETE_TCB) needs elevation (often returns 317), so the only
166
166
  // reliable non-admin recovery is to wait for the OS to release the TCB.
167
- timeoutMs: 60_000,
167
+ timeoutMs: process.platform === "win32" ? 60_000 : 10_000,
168
168
  intervalMs: 100,
169
169
  scanIntervalMs: 500,
170
170
  killOcxHolders: false,
@@ -849,6 +849,12 @@ async function handleStatus() {
849
849
  console.log(`❌ Proxy: ${status.proxyLabel}`);
850
850
  }
851
851
  console.log(` Health: ${status.healthLabel}`);
852
+ for (const line of unusedProxyWarningLines({
853
+ proxyUp: Boolean(status.json.proxy.pid || status.json.proxy.health.ok),
854
+ routingKind: status.json.startup.routingKind,
855
+ })) {
856
+ console.log(` ${line}`);
857
+ }
852
858
  if (!(status.json.proxy.pid || status.json.proxy.health.ok)) {
853
859
  console.log(" ↳ Not running — Codex/Claude requests will fail with connection errors.");
854
860
  // The service summary a few lines below already tells a registered-but-not-serving
@@ -868,6 +874,7 @@ async function handleStatus() {
868
874
  console.log(` Default provider: ${status.json.defaultProvider}`);
869
875
  console.log(` Codex autostart: ${status.json.codexAutostart ? "enabled" : "disabled"}`);
870
876
  console.log(` Restart safety: ${startupHealthSummary(status.json.startup)}`);
877
+ console.log(` ${formatStartupRoutingDetail(status.json.startup)}`);
871
878
  console.log(` Service: ${status.json.service.summary}`);
872
879
  console.log(` ${status.json.codexShim.summary}`);
873
880
  console.log(` Codex runtime: ${status.json.codexRuntime.path}`);
@@ -360,6 +360,12 @@ function handleShow(args: string[]): void {
360
360
  ...(prov.modelCosts !== undefined ? { modelCosts: sanitizeModelCostsForDisplay(prov.modelCosts) } : {}),
361
361
  ...(prov.apiKey ? { apiKey: maskSecret(prov.apiKey) } : {}),
362
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
+ } : {}),
363
369
  };
364
370
 
365
371
  if (wantsJson) {
package/src/cli/status.ts CHANGED
@@ -118,6 +118,29 @@ export function proxyHealthFailureReason(error: unknown, signal: AbortSignal): "
118
118
  : "unreachable";
119
119
  }
120
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
+
121
144
  async function checkProxyHealth(target: ListenTarget): Promise<HealthCheck> {
122
145
  const url = target.healthUrl;
123
146
  const controller = new AbortController();
@@ -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";
@@ -152,19 +153,17 @@ export function deriveComboCatalogModel(
152
153
  // A combo is cap-limited only when every member defining its effective minimum was
153
154
  // itself reduced by a provider cap. An uncapped member at the same minimum means the
154
155
  // combo would have the same window even without the cap.
155
- const contextCapped = limitingMembers.every(member => (
156
- member.contextCapped === true
157
- || (
158
- typeof member.detectedContextWindow === "number"
159
- && typeof member.contextWindow === "number"
160
- && member.detectedContextWindow > member.contextWindow
161
- )
162
- ));
156
+ const contextCapped = limitingMembers.every(member => member.contextCapped === true);
163
157
  const maxInputTokens = Math.min(
158
+ contextWindow,
164
159
  ...members.map(member => member.maxInputTokens ?? member.contextWindow!),
165
160
  );
166
- const detectedContextWindow = Math.min(
167
- ...limitingMembers.map(member => member.detectedContextWindow ?? member.contextWindow!),
161
+ const autoCompactTokenLimit = Math.min(
162
+ ...members.map(member => clampAutoCompactTokenLimit(
163
+ member.contextWindow!,
164
+ member.maxInputTokens,
165
+ member.autoCompactTokenLimit,
166
+ )),
168
167
  );
169
168
  const defaultReasoningEffort = effectiveComboDefault(
170
169
  combo.defaultEffort,
@@ -177,8 +176,7 @@ export function deriveComboCatalogModel(
177
176
  owned_by: COMBO_NAMESPACE,
178
177
  contextWindow,
179
178
  maxInputTokens,
180
- metadataSource: "derived",
181
- detectedContextWindow,
179
+ autoCompactTokenLimit,
182
180
  ...(hasLimitingContextCapMetadata ? { contextCapped } : {}),
183
181
  inputModalities,
184
182
  reasoningEfforts,
@@ -222,6 +220,7 @@ export function comboCatalogWarningSignature(
222
220
  key,
223
221
  contextWindow: member?.contextWindow ?? null,
224
222
  maxInputTokens: member?.maxInputTokens ?? null,
223
+ autoCompactTokenLimit: member?.autoCompactTokenLimit ?? null,
225
224
  inputModalities: [...new Set(member?.inputModalities ?? [])].sort(),
226
225
  reasoningEfforts: [...new Set(member?.reasoningEfforts ?? [])].sort(),
227
226
  parallelToolCalls: member?.parallelToolCalls === true,
@@ -311,6 +310,7 @@ export function normalizedOpenAiApiSignature(model: CatalogModel): string {
311
310
  id: model.id,
312
311
  contextWindow: model.contextWindow ?? null,
313
312
  maxInputTokens: model.maxInputTokens ?? null,
313
+ autoCompactTokenLimit: model.autoCompactTokenLimit ?? null,
314
314
  inputModalities: [...new Set(model.inputModalities ?? [])].sort(),
315
315
  reasoningEfforts: [...new Set(model.reasoningEfforts ?? [])].sort(),
316
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";
@@ -129,9 +130,23 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel)
129
130
  if (typeof resolvedContext === "number" && resolvedContext > 0) {
130
131
  entry.context_window = resolvedContext;
131
132
  entry.max_context_window = resolvedContext;
132
- entry.auto_compact_token_limit = Math.min(
133
- Math.floor(resolvedContext * 0.9),
134
- 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,
135
150
  );
136
151
  }
137
152
  if (Array.isArray(model.inputModalities) && model.inputModalities.length > 0) {
@@ -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
  }