@bitkyc08/opencodex 2.39.0 → 2.40.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 (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. package/gui/dist/assets/index-uvENYLin.css +0 -1
@@ -1,4 +1,4 @@
1
- import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
1
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import {
3
3
  chmodSync,
4
4
  closeSync,
@@ -39,35 +39,41 @@ import {
39
39
  parseExpectedLocalProviderReloadPid,
40
40
  verifyLocalProviderReloadCapability,
41
41
  } from "../lib/local-provider-reload-contract";
42
+ import {
43
+ GUI_PAIR_BROWSER_ORIGIN_HEADER,
44
+ GUI_PAIR_CAPABILITY_HEADER,
45
+ GUI_PAIR_EXPECTED_PID_HEADER,
46
+ GUI_PAIR_EXPIRES_AT_HEADER,
47
+ GUI_PAIR_NONCE_HEADER,
48
+ GUI_PAIR_PATH,
49
+ parseExpectedGuiPairPid,
50
+ verifyGuiPairCapability,
51
+ } from "../lib/gui-pair-capability";
42
52
  import { forgetEphemeralSecretPath, forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
43
53
  import type { OcxConfig } from "../types";
44
54
  import {
45
- isAllowedManagementOrigin,
46
- isApiAuthRequired,
47
55
  isDataPlaneAdmissionSecret,
48
- isLoopbackHostname,
49
- managementRequestOrigin,
50
- parseHttpHost,
51
56
  } from "./auth-cors";
57
+ import {
58
+ authorizeGuiSessionRequest,
59
+ issueGuiSession as issueGuiSessionFromState,
60
+ type GuiPairingGrantRecord,
61
+ type GuiSessionBootstrap,
62
+ type GuiSessionRecord,
63
+ type GuiSessionRequestContext,
64
+ } from "./gui-session";
65
+ export type { GuiSessionBootstrap, GuiSessionRequestContext } from "./gui-session";
52
66
 
53
- const GUI_SESSION_TTL_MS = 5 * 60_000;
54
- const GUI_SESSION_LIMIT = 128;
55
67
  const LOCAL_READ_REPLAY_LIMIT = 256;
56
68
  const consumedLocalReadCapabilities = new Map<string, number>();
57
69
  const admittedLocalReadRequests = new WeakSet<Request>();
58
70
  const LOCAL_PROVIDER_RELOAD_REPLAY_LIMIT = 256;
59
71
  const consumedLocalProviderReloadCapabilities = new Map<string, number>();
60
72
  const admittedLocalProviderReloadRequests = new WeakSet<Request>();
61
-
62
- interface GuiSessionRecord {
63
- csrfToken: string;
64
- origin: string;
65
- expiresAt: number;
66
- }
67
-
68
- export interface GuiSessionBootstrap extends GuiSessionRecord {
69
- token: string;
70
- }
73
+ const GUI_PAIR_REPLAY_LIMIT = 256;
74
+ const consumedGuiPairCapabilities = new Map<string, number>();
75
+ const admittedGuiPairRequests = new WeakSet<Request>();
76
+ const admittedManagementRequests = new WeakMap<Request, ManagementPrincipal>();
71
77
 
72
78
  export type ManagementAuthState =
73
79
  | {
@@ -75,6 +81,7 @@ export type ManagementAuthState =
75
81
  token: string;
76
82
  source: "environment" | "file";
77
83
  sessions: Map<string, GuiSessionRecord>;
84
+ pairingGrants: Map<string, GuiPairingGrantRecord>;
78
85
  }
79
86
  | { available: false; reason: string };
80
87
 
@@ -201,7 +208,7 @@ function ready(token: string, source: "environment" | "file", config: OcxConfig)
201
208
  if (isDataPlaneAdmissionSecret(token, config)) {
202
209
  return fail("management credential conflicts with a data-plane credential");
203
210
  }
204
- return { available: true, token, source, sessions: new Map() };
211
+ return { available: true, token, source, sessions: new Map(), pairingGrants: new Map() };
205
212
  }
206
213
 
207
214
  export function initializeManagementAuthState(config: OcxConfig): ManagementAuthState {
@@ -232,41 +239,32 @@ function equalSecret(actual: string, expected: string): boolean {
232
239
  return left.length === right.length && timingSafeEqual(left, right);
233
240
  }
234
241
 
235
- function removeExpiredSessions(state: Extract<ManagementAuthState, { available: true }>, now = Date.now()): void {
236
- for (const [token, session] of state.sessions) {
237
- if (session.expiresAt <= now) state.sessions.delete(token);
238
- }
239
- }
240
-
241
- function randomSessionSecret(prefix: "ocx_session_"): string {
242
- return `${prefix}${randomBytes(32).toString("base64url")}`;
243
- }
244
-
245
242
  export function issueGuiSession(
246
243
  req: Request,
247
244
  config: OcxConfig,
248
245
  state: ManagementAuthState,
246
+ context?: GuiSessionRequestContext,
249
247
  ): GuiSessionBootstrap | null {
250
- if (isApiAuthRequired(config) || !state.available || req.method !== "GET" || !isAllowedManagementOrigin(req, config)) return null;
251
- const host = parseHttpHost(req.headers.get("Host"));
252
- if (!host || !isLoopbackHostname(host.hostname)) return null;
253
- const origin = managementRequestOrigin(req, config);
254
- if (!origin) return null;
255
- const now = Date.now();
256
- removeExpiredSessions(state, now);
257
- while (state.sessions.size >= GUI_SESSION_LIMIT) {
258
- const oldest = state.sessions.keys().next().value as string | undefined;
259
- if (!oldest) break;
260
- state.sessions.delete(oldest);
261
- }
262
- const token = randomSessionSecret("ocx_session_");
263
- const session: GuiSessionRecord = {
264
- csrfToken: randomBytes(32).toString("base64url"),
265
- origin,
266
- expiresAt: now + GUI_SESSION_TTL_MS,
248
+ if (!state.available) return null;
249
+ return issueGuiSessionFromState(req, config, state, context);
250
+ }
251
+
252
+ export interface ManagementSessionControl {
253
+ revokeCurrent(req: Request): boolean;
254
+ }
255
+
256
+ export function createManagementSessionControl(state: ManagementAuthState): ManagementSessionControl {
257
+ return {
258
+ revokeCurrent(req: Request): boolean {
259
+ if (!state.available) return false;
260
+ const credential = requestManagementCredential(req);
261
+ if (!credential) return false;
262
+ for (const token of state.sessions.keys()) {
263
+ if (equalSecret(credential, token)) return state.sessions.delete(token);
264
+ }
265
+ return false;
266
+ },
267
267
  };
268
- state.sessions.set(token, session);
269
- return { token, ...session };
270
268
  }
271
269
 
272
270
  /**
@@ -284,6 +282,7 @@ export function issueGuiSession(
284
282
  export type ManagementPrincipal =
285
283
  | "admin-token"
286
284
  | "gui-session"
285
+ | "gui-pair-capability"
287
286
  | "local-read-capability"
288
287
  | "local-provider-reload-capability"
289
288
  | "system-restart-capability";
@@ -416,6 +415,81 @@ function hasLocalProviderReloadCapability(
416
415
  return true;
417
416
  }
418
417
 
418
+ function hasGuiPairCapability(
419
+ req: Request,
420
+ local: LocalManagementAuthContext | undefined,
421
+ ): boolean {
422
+ if (admittedGuiPairRequests.has(req)) return true;
423
+ if (!local || req.method !== "POST") return false;
424
+ let url: URL;
425
+ try {
426
+ url = new URL(req.url);
427
+ } catch {
428
+ return false;
429
+ }
430
+ if (url.pathname !== GUI_PAIR_PATH || url.search !== "") return false;
431
+ const contentLength = req.headers.get("content-length");
432
+ if (contentLength !== "0" || req.headers.has("transfer-encoding")) return false;
433
+ const expectedPid = parseExpectedGuiPairPid(req.headers.get(GUI_PAIR_EXPECTED_PID_HEADER));
434
+ if (expectedPid.kind !== "present" || expectedPid.pid !== local.pid) return false;
435
+ const expiresAtRaw = req.headers.get(GUI_PAIR_EXPIRES_AT_HEADER);
436
+ if (!expiresAtRaw || !/^[1-9]\d*$/.test(expiresAtRaw)) return false;
437
+ const expiresAt = Number(expiresAtRaw);
438
+ if (!Number.isSafeInteger(expiresAt)) return false;
439
+ const capability = req.headers.get(GUI_PAIR_CAPABILITY_HEADER);
440
+ const now = Date.now();
441
+ if (!verifyGuiPairCapability(
442
+ local.attestationSecret,
443
+ req.headers.get(GUI_PAIR_NONCE_HEADER),
444
+ req.method,
445
+ url.pathname,
446
+ req.headers.get(GUI_PAIR_BROWSER_ORIGIN_HEADER),
447
+ local.pid,
448
+ local.port,
449
+ expiresAt,
450
+ capability,
451
+ now,
452
+ )) return false;
453
+ for (const [consumed, retainedUntil] of consumedGuiPairCapabilities) {
454
+ if (retainedUntil <= now) consumedGuiPairCapabilities.delete(consumed);
455
+ }
456
+ if (!capability) return false;
457
+ const capabilityDigest = createHash("sha256").update(capability).digest("base64url");
458
+ if (consumedGuiPairCapabilities.has(capabilityDigest)) return false;
459
+ if (consumedGuiPairCapabilities.size >= GUI_PAIR_REPLAY_LIMIT) return false;
460
+ consumedGuiPairCapabilities.set(capabilityDigest, expiresAt);
461
+ admittedGuiPairRequests.add(req);
462
+ return true;
463
+ }
464
+
465
+ function requestManagementCredential(req: Request): string | null {
466
+ return req.headers.get("x-opencodex-api-key")?.trim()
467
+ || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim()
468
+ || null;
469
+ }
470
+
471
+ function resolveManagementAdmission(
472
+ req: Request,
473
+ state: ManagementAuthState,
474
+ config?: OcxConfig,
475
+ local?: LocalManagementAuthContext,
476
+ ): ManagementPrincipal | null {
477
+ const cached = admittedManagementRequests.get(req);
478
+ if (cached) return cached;
479
+ let principal: ManagementPrincipal | null = null;
480
+ if (hasSystemRestartCapability(req, local)) principal = "system-restart-capability";
481
+ else if (hasLocalProviderReloadCapability(req, local)) principal = "local-provider-reload-capability";
482
+ else if (hasLocalReadCapability(req, local)) principal = "local-read-capability";
483
+ else if (hasGuiPairCapability(req, local)) principal = "gui-pair-capability";
484
+ else if (state.available) {
485
+ const actual = requestManagementCredential(req);
486
+ if (actual && equalSecret(actual, state.token)) principal = "admin-token";
487
+ else if (config && authorizeGuiSessionRequest(req, config, state).ok) principal = "gui-session";
488
+ }
489
+ if (principal) admittedManagementRequests.set(req, principal);
490
+ return principal;
491
+ }
492
+
419
493
  /**
420
494
  * The principal for a request that already passed `requireManagementAuth`. Kept as a
421
495
  * separate resolution (rather than a changed return type) so every existing caller
@@ -429,17 +503,7 @@ export function managementPrincipal(
429
503
  config?: OcxConfig,
430
504
  local?: LocalManagementAuthContext,
431
505
  ): ManagementPrincipal | null {
432
- if (hasSystemRestartCapability(req, local)) return "system-restart-capability";
433
- if (hasLocalProviderReloadCapability(req, local)) return "local-provider-reload-capability";
434
- if (hasLocalReadCapability(req, local)) return "local-read-capability";
435
- if (!state.available) return null;
436
- const actual = req.headers.get("x-opencodex-api-key")?.trim()
437
- || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
438
- if (!actual) return null;
439
- if (equalSecret(actual, state.token)) return "admin-token";
440
- if (!config) return null;
441
- removeExpiredSessions(state);
442
- return state.sessions.has(actual) ? "gui-session" : null;
506
+ return resolveManagementAdmission(req, state, config, local);
443
507
  }
444
508
 
445
509
  export function requireManagementAuth(
@@ -448,9 +512,7 @@ export function requireManagementAuth(
448
512
  config?: OcxConfig,
449
513
  local?: LocalManagementAuthContext,
450
514
  ): Response | null {
451
- if (hasSystemRestartCapability(req, local)) return null;
452
- if (hasLocalProviderReloadCapability(req, local)) return null;
453
- if (hasLocalReadCapability(req, local)) return null;
515
+ if (resolveManagementAdmission(req, state, config, local)) return null;
454
516
  if (!state.available) {
455
517
  return Response.json({
456
518
  error: "management API unavailable",
@@ -458,25 +520,5 @@ export function requireManagementAuth(
458
520
  hint: "Set OPENCODEX_ADMIN_AUTH_TOKEN to bypass file-backed admin token ACL hardening",
459
521
  }, { status: 503 });
460
522
  }
461
- const actual = req.headers.get("x-opencodex-api-key")?.trim()
462
- || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
463
- if (actual && equalSecret(actual, state.token)) return null;
464
- if (actual && config) {
465
- removeExpiredSessions(state);
466
- const session = state.sessions.get(actual);
467
- if (session) {
468
- const requestOrigin = managementRequestOrigin(req, config);
469
- const claimedOrigin = req.headers.get("x-opencodex-gui-origin");
470
- const browserOrigin = req.headers.get("Origin");
471
- const sameOrigin = requestOrigin === session.origin
472
- && claimedOrigin === session.origin
473
- && (!browserOrigin || browserOrigin === session.origin);
474
- const safeMethod = req.method === "GET" || req.method === "HEAD";
475
- const csrf = req.headers.get("x-opencodex-csrf-token")?.trim();
476
- if (sameOrigin && (safeMethod || (browserOrigin === session.origin && !!csrf && equalSecret(csrf, session.csrfToken)))) {
477
- return null;
478
- }
479
- }
480
- }
481
523
  return Response.json({ error: "opencodex admin token required" }, { status: 401 });
482
524
  }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Extended capability advertisement for the OpenAI-shape `GET /v1/models` list.
3
+ *
4
+ * Cursor's local-agent runtime (the "Private Inference" build, `localMode=true`) enables its
5
+ * reasoning-effort control only when at least one row in `data[]` carries `api_types` naming an
6
+ * API family it can speak, optionally with a `capabilities` object. Plain OpenAI clients, Grok
7
+ * Build, and the Codex catalog branch ignore both keys. Every OpenCodex route serves Chat
8
+ * Completions, Responses and Anthropic Messages, streams, and accepts tool calls, so those are
9
+ * constants; context length and vision come from catalog data when known and are omitted
10
+ * otherwise, matching Cursor's optional-field schema.
11
+ */
12
+
13
+ /**
14
+ * Membership is load-bearing for Cursor: its wire selector picks the Anthropic Messages path only
15
+ * when NO OpenAI-family type (`chat_completions`/`responses`/`openai_chat`/`openai_responses`)
16
+ * is present. Keep at least one OpenAI-family entry; a unit test guards this.
17
+ */
18
+ export const OPENCODEX_MODEL_API_TYPES: readonly string[] = Object.freeze(["chat_completions", "responses", "anthropic_messages"]);
19
+
20
+ export const OPENAI_FAMILY_API_TYPES: ReadonlySet<string> = new Set(["chat_completions", "responses", "openai_chat", "openai_responses"]);
21
+
22
+ /**
23
+ * The reasoning-effort ladder Cursor's local-agent runtime attaches to a model, keyed by the
24
+ * model id after its last `/`. Cursor decides this from its own table rather than from the
25
+ * gateway's `reasoning_effort` list, so the dashboard can only PREDICT it; the values here
26
+ * mirror that table (read from the 3.18.25 bundle) and carry no Cursor behavior of their own.
27
+ * Null means Cursor shows no Reasoning control for the id. Distinct from
28
+ * `src/adapters/cursor/effort-map.ts`, which maps opencodex efforts onto Cursor's *backend*
29
+ * tiers for the outbound provider; this is what Cursor's *local* picker renders.
30
+ */
31
+ const CURSOR_EFFORT_FAMILIES: ReadonlyArray<{ test: RegExp; ladder: readonly string[] }> = [
32
+ { test: /^gpt-5[.-]6-(?:luna|sol|terra)$/u, ladder: ["low", "medium", "high", "xhigh"] },
33
+ { test: /^gpt-5(?:\.\d+)?$/u, ladder: ["low", "medium", "high", "xhigh"] },
34
+ { test: /^claude-opus-5$/u, ladder: ["low", "medium", "high", "xhigh", "max"] },
35
+ { test: /^claude-opus-4[-.](?:7|8)$/u, ladder: ["low", "medium", "high", "xhigh", "max"] },
36
+ { test: /^claude-sonnet-5$/u, ladder: ["low", "medium", "high", "xhigh", "max"] },
37
+ { test: /^claude-opus-4[-.](?:5|6)$/u, ladder: ["low", "medium", "high", "max"] },
38
+ { test: /^claude-sonnet-4[-.]6$/u, ladder: ["low", "medium", "high", "max"] },
39
+ { test: /^grok-4[.-](?:3|5|6)(?:-(?:batch|build|nocomp))?$/u, ladder: ["minimal", "low", "medium", "high", "xhigh"] },
40
+ { test: /^grok-build-latest$/u, ladder: ["minimal", "low", "medium", "high", "xhigh"] },
41
+ { test: /^gemini-3\.[1-9].*flash-lite/u, ladder: [] },
42
+ { test: /^gemini-/u, ladder: ["minimal", "low", "medium", "high"] },
43
+ ];
44
+
45
+ export function cursorEffortFamily(modelId: string): string[] | null {
46
+ let id = modelId.trim().toLowerCase();
47
+ const slash = id.lastIndexOf("/");
48
+ if (slash !== -1) id = id.slice(slash + 1);
49
+ const at = id.indexOf("@");
50
+ if (at !== -1) id = id.slice(0, at);
51
+ for (const family of CURSOR_EFFORT_FAMILIES) {
52
+ if (family.test.test(id)) return family.ladder.length > 0 ? [...family.ladder] : null;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ export interface ModelCapabilityInput {
58
+ reasoningEfforts?: readonly string[];
59
+ contextWindow?: number;
60
+ /**
61
+ * Larger opt-in window (Cursor "Max Mode"). When it exceeds contextWindow, the row advertises
62
+ * the long window as context_length and the default window as the long-context threshold,
63
+ * which makes Cursor's local runtime show a Context selector (default vs long, long marked
64
+ * as costing more).
65
+ */
66
+ longContextWindow?: number;
67
+ inputModalities?: readonly string[];
68
+ }
69
+
70
+ export interface ModelCapabilityFields {
71
+ api_types: readonly string[];
72
+ capabilities: {
73
+ context_length?: number;
74
+ /** Cursor's extended-row filter REQUIRES this to contain "text"; every route emits text. */
75
+ output_modalities: string[];
76
+ input_modalities?: string[];
77
+ supports_tool_use: true;
78
+ supports_streaming: true;
79
+ supports_reasoning: boolean;
80
+ supports_vision?: boolean;
81
+ reasoning_effort?: string[];
82
+ };
83
+ /**
84
+ * Cursor reads the long-context threshold from `pricing.overrides[].min_prompt_tokens`. That
85
+ * key sits outside its validated capability schema, so it is the one place a threshold can
86
+ * be carried without failing row validation (`cost.long_context` is rejected by that schema).
87
+ */
88
+ pricing?: { overrides: Array<{ min_prompt_tokens: number }> };
89
+ }
90
+
91
+ function positiveInt(value: unknown): number | undefined {
92
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
93
+ const floored = Math.floor(value);
94
+ return floored > 0 ? floored : undefined;
95
+ }
96
+
97
+ export function modelCapabilityFields(input: ModelCapabilityInput): ModelCapabilityFields {
98
+ const efforts = (input.reasoningEfforts ?? []).filter(effort => typeof effort === "string" && effort.length > 0);
99
+ const contextLength = positiveInt(input.contextWindow);
100
+ const longContextLength = positiveInt(input.longContextWindow);
101
+ const hasLongTier = contextLength !== undefined && longContextLength !== undefined && longContextLength > contextLength;
102
+ const modalities = Array.isArray(input.inputModalities)
103
+ ? input.inputModalities.filter(modality => typeof modality === "string" && modality.length > 0)
104
+ : undefined;
105
+ const supportsVision = modalities !== undefined ? modalities.includes("image") : undefined;
106
+ return {
107
+ api_types: [...OPENCODEX_MODEL_API_TYPES],
108
+ capabilities: {
109
+ ...(hasLongTier
110
+ ? { context_length: longContextLength }
111
+ : contextLength !== undefined ? { context_length: contextLength } : {}),
112
+ // Once a gateway advertises api_types, Cursor keeps only rows whose output_modalities
113
+ // include "text"; omitting the key drops the row from the extended catalog.
114
+ output_modalities: ["text"],
115
+ ...(modalities !== undefined && modalities.length > 0 ? { input_modalities: [...modalities] } : {}),
116
+ supports_tool_use: true,
117
+ supports_streaming: true,
118
+ supports_reasoning: efforts.length > 0,
119
+ ...(supportsVision !== undefined ? { supports_vision: supportsVision } : {}),
120
+ ...(efforts.length > 0 ? { reasoning_effort: [...efforts] } : {}),
121
+ },
122
+ ...(hasLongTier ? { pricing: { overrides: [{ min_prompt_tokens: contextLength }] } } : {}),
123
+ };
124
+ }
@@ -151,6 +151,13 @@ export function shouldPersistSelectedPort(
151
151
  configPort: number | undefined,
152
152
  selectedPort: number,
153
153
  preferredPort: number,
154
+ options: { sibling?: boolean } = {},
154
155
  ): boolean {
156
+ // A sibling start (`--port X` beside a live proxy on the configured port) is a
157
+ // second instance, not a new home for this config. Persisting its port rewrote
158
+ // config.port under the still-running configured-port proxy, and the next
159
+ // `ocx service` install then baked the sibling's port into the service and
160
+ // re-pointed every client at a listener that no longer existed.
161
+ if (options.sibling) return false;
155
162
  return selectedPort === preferredPort && configPort !== selectedPort;
156
163
  }
@@ -22,6 +22,7 @@ export interface HealthzIdentity {
22
22
  port?: unknown;
23
23
  restartCapability?: unknown;
24
24
  providerReloadCapability?: unknown;
25
+ guiPairCapability?: unknown;
25
26
  }
26
27
 
27
28
  export interface LivenessIo {
@@ -269,6 +270,12 @@ interface ReadyzBody {
269
270
  pid?: unknown;
270
271
  port?: unknown;
271
272
  status?: unknown;
273
+ // Remote protocol metadata is intentionally additive here. Ordinary
274
+ // readiness remains compatible with legacy standalone servers; `ocx connect`
275
+ // validates these fields separately in src/remote/protocol.ts.
276
+ protocol?: unknown;
277
+ minimumClientProtocol?: unknown;
278
+ managementUrl?: unknown;
272
279
  }
273
280
 
274
281
  export interface ReadinessProbeResult {
@@ -8,6 +8,7 @@ import {
8
8
  isClientClosedMessage,
9
9
  isCyberPolicyCode,
10
10
  isCyberPolicyMessage,
11
+ upstreamErrorMessageFromPayload,
11
12
  } from "../lib/errors";
12
13
  import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths";
13
14
  import { readCodexCatalogPath } from "../codex/catalog";
@@ -124,6 +125,12 @@ export interface RequestLogContext {
124
125
  terminalHttpStatus?: number;
125
126
  /** Recognized structured terminal code whose exact identity must survive status mapping. */
126
127
  terminalErrorCode?: typeof CYBER_POLICY_ERROR_CODE;
128
+ /**
129
+ * Proxy-owned error code for a request OpenCodex terminated locally, before or instead of an
130
+ * upstream send. Status-derived classification cannot name these: there is no upstream
131
+ * message to classify, and the status alone would read as a provider failure.
132
+ */
133
+ errorCode?: string;
127
134
  /** Structured reason from `response.incomplete`; internal-only input to log classification. */
128
135
  terminalIncompleteReason?: string;
129
136
  affinity?: "reused" | "new_bind" | "rebound" | "cleared";
@@ -794,10 +801,7 @@ function captureUpstreamErrorParsed(
794
801
  logCtx.terminalIncompleteReason = reason.trim();
795
802
  }
796
803
  if (logCtx.upstreamError) return;
797
- const message = json?.error?.message
798
- ?? json?.last_error?.message
799
- ?? json?.response?.error?.message
800
- ?? json?.response?.incomplete_details?.message;
804
+ const message = upstreamErrorMessageFromPayload(parsed);
801
805
  if (typeof message === "string" && message.trim()) {
802
806
  logCtx.upstreamError = redactSecretString(message).slice(0, 500);
803
807
  return;
@@ -927,7 +931,9 @@ export function addFinalRequestLog(
927
931
  const effectiveStatus = status >= 500 && logCtx.upstreamError && isClientClosedMessage(logCtx.upstreamError)
928
932
  ? 499
929
933
  : status;
930
- const errorCode = requestLogErrorCode(
934
+ // A locally assigned code wins: it names a refusal this proxy made itself, which no
935
+ // status-plus-upstream-message classification can reconstruct.
936
+ const errorCode = logCtx.errorCode ?? requestLogErrorCode(
931
937
  effectiveStatus,
932
938
  logCtx.upstreamError,
933
939
  logCtx.terminalErrorCode,
@@ -23,6 +23,7 @@ const CODEX_ORIGINATORS = new Set([
23
23
  "Codex Desktop",
24
24
  "codex_app",
25
25
  "codex_work_desktop",
26
+ "codexless_agent",
26
27
  ]);
27
28
  const CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
28
29
  const OPENAI_TOKEN_ISSUERS = new Set(["https://auth.openai.com", "https://auth.openai.com/"]);
@@ -15,6 +15,7 @@ import {
15
15
  MainAccountTokenRefreshError,
16
16
  MainAuthJsonChangedDuringRefreshError,
17
17
  } from "../../codex/main-account";
18
+ import { NativeProfileError } from "../../codex/native-profile-types";
18
19
 
19
20
  export interface CodexAuthContextErrorResponseOptions {
20
21
  accountSelector?: string;
@@ -26,7 +27,8 @@ export function nativeMainRefreshFailureResponse(error: unknown): Response {
26
27
  return formatErrorResponse(401, "authentication_error", "Codex main account needs reauthentication");
27
28
  }
28
29
  if (error instanceof MainAccountTokenRefreshError
29
- || error instanceof MainAuthJsonChangedDuringRefreshError) {
30
+ || error instanceof MainAuthJsonChangedDuringRefreshError
31
+ || (error instanceof NativeProfileError && error.retryable)) {
30
32
  const response = formatErrorResponse(
31
33
  503,
32
34
  "server_busy",
@@ -108,12 +108,16 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
108
108
  /** Declared parameter schema per request-visible tool name (#1611 integer repair). */
109
109
  toolParameterSchemas: Map<string, Record<string, unknown>>;
110
110
  freeformToolNames: Set<string>;
111
+ bareCustomToolNames: Set<string>;
112
+ bareFunctionToolNames: Set<string>;
111
113
  toolSearchToolNames: Set<string>;
112
114
  } {
113
115
  const toolNsMap = new Map<string, { namespace: string; name: string; freeform?: true }>();
114
116
  const declaredToolNames = new Set<string>();
115
117
  const toolParameterSchemas = new Map<string, Record<string, unknown>>();
116
118
  const freeformToolNames = new Set<string>();
119
+ const bareCustomToolNames = new Set<string>();
120
+ const bareFunctionToolNames = new Set<string>();
117
121
  const toolSearchToolNames = new Set<string>();
118
122
  const requestedTools = parsed.context.tools ?? [];
119
123
  const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice, requestedTools);
@@ -133,6 +137,19 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
133
137
  if (t.freeform) {
134
138
  budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
135
139
  freeformToolNames.add(t.name);
140
+ if (!t.namespace || t.namespace === "functions") {
141
+ budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
142
+ bareCustomToolNames.add(t.name);
143
+ }
144
+ } else if (
145
+ !t.toolSearch
146
+ && !t.webSearch
147
+ && !t.imageGeneration
148
+ && !t.videoGeneration
149
+ && (!t.namespace || t.namespace === "functions")
150
+ ) {
151
+ budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
152
+ bareFunctionToolNames.add(t.name);
136
153
  }
137
154
  if (t.toolSearch) {
138
155
  budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
@@ -162,7 +179,15 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
162
179
  toolParameterSchemas.set(t.name, t.parameters);
163
180
  }
164
181
  }
165
- return { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames };
182
+ return {
183
+ toolNsMap,
184
+ declaredToolNames,
185
+ toolParameterSchemas,
186
+ freeformToolNames,
187
+ bareCustomToolNames,
188
+ bareFunctionToolNames,
189
+ toolSearchToolNames,
190
+ };
166
191
  }
167
192
 
168
193
 
@@ -24,6 +24,24 @@ const TERMINAL_EVENTS = new Set([
24
24
  "response.incomplete",
25
25
  ]);
26
26
 
27
+ const RETRYABLE_ZERO_OUTPUT_INCOMPLETE_REASONS = new Set([
28
+ "adapter_eof",
29
+ "missing_terminal_event",
30
+ "upstream_stall_timeout",
31
+ ]);
32
+
33
+ function retryableZeroOutputTerminal(payload: unknown): boolean {
34
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
35
+ const event = payload as {
36
+ type?: unknown;
37
+ response?: { incomplete_details?: { reason?: unknown } };
38
+ };
39
+ if (event.type === "response.failed") return true;
40
+ if (event.type !== "response.incomplete") return false;
41
+ const reason = event.response?.incomplete_details?.reason;
42
+ return typeof reason === "string" && RETRYABLE_ZERO_OUTPUT_INCOMPLETE_REASONS.has(reason);
43
+ }
44
+
27
45
  /**
28
46
  * Decide when replaying the request on another combo target would risk duplicating
29
47
  * client-visible output or a tool-side effect. Unknown event types commit the child
@@ -128,14 +146,14 @@ export async function preflightComboStreamResponse(
128
146
  let bufferedBytes = 0;
129
147
  let outputCommitted = false;
130
148
  let terminalStatus: ResponsesTerminalStatus | undefined;
131
- let failedPayload: Record<string, unknown> | undefined;
149
+ let retryableTerminalPayload: Record<string, unknown> | undefined;
132
150
  const inspector = createSseInspector({
133
151
  logCtx,
134
152
  onParsedPayload: payload => {
135
153
  if (comboStreamPayloadCommitsOutput(payload)) outputCommitted = true;
136
154
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return;
137
- if ((payload as { type?: unknown }).type === "response.failed") {
138
- failedPayload = payload as Record<string, unknown>;
155
+ if (retryableZeroOutputTerminal(payload)) {
156
+ retryableTerminalPayload = payload as Record<string, unknown>;
139
157
  }
140
158
  },
141
159
  onTerminal: status => { terminalStatus = status; },
@@ -162,9 +180,10 @@ export async function preflightComboStreamResponse(
162
180
  inspector.feed(retained);
163
181
  }
164
182
 
165
- if (terminalStatus === "failed" && !outputCommitted && failedPayload) {
166
- await reader.cancel("retrying zero-output combo stream failure").catch(() => undefined);
167
- return { kind: "failed", response: failedTerminalResponse(response, failedPayload, logCtx) };
183
+ if ((terminalStatus === "failed" || terminalStatus === "incomplete")
184
+ && !outputCommitted && retryableTerminalPayload) {
185
+ await reader.cancel("retrying zero-output combo stream terminal").catch(() => undefined);
186
+ return { kind: "failed", response: failedTerminalResponse(response, retryableTerminalPayload, logCtx) };
168
187
  }
169
188
  if (next.done || terminalStatus !== undefined || outputCommitted
170
189
  || bufferedBytes >= COMBO_STREAM_PREFLIGHT_MAX_BYTES