@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
@@ -0,0 +1,74 @@
1
+ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import type { OcxConfig } from "../../types";
3
+
4
+ export const API_KEY_ROTATION_TTL_MS = 10 * 60_000;
5
+
6
+ export type ApiKeyRotationStart = {
7
+ id: string;
8
+ name: string;
9
+ key: string;
10
+ rotationId: string;
11
+ expiresAt: string;
12
+ };
13
+
14
+ function equalOpaqueId(left: string, right: string): boolean {
15
+ const encoder = new TextEncoder();
16
+ const a = encoder.encode(left);
17
+ const b = encoder.encode(right);
18
+ return a.length === b.length && timingSafeEqual(a, b);
19
+ }
20
+
21
+ export function removeExpiredApiKeyRotations(config: OcxConfig, now = Date.now()): boolean {
22
+ let changed = false;
23
+ for (const entry of config.apiKeys ?? []) {
24
+ if (entry.pendingRotation && Date.parse(entry.pendingRotation.expiresAt) <= now) {
25
+ delete entry.pendingRotation;
26
+ changed = true;
27
+ }
28
+ }
29
+ return changed;
30
+ }
31
+
32
+ export function startApiKeyRotation(
33
+ config: OcxConfig,
34
+ keyId: string,
35
+ now = Date.now(),
36
+ ): ApiKeyRotationStart | { error: "not-found" | "already-pending" } {
37
+ const entry = (config.apiKeys ?? []).find(candidate => candidate.id === keyId);
38
+ if (!entry) return { error: "not-found" };
39
+ if (entry.pendingRotation && Date.parse(entry.pendingRotation.expiresAt) > now) {
40
+ return { error: "already-pending" };
41
+ }
42
+ const key = `ocx_data_${randomBytes(20).toString("hex")}`;
43
+ const rotationId = randomUUID();
44
+ const createdAt = new Date(now).toISOString();
45
+ const expiresAt = new Date(now + API_KEY_ROTATION_TTL_MS).toISOString();
46
+ entry.pendingRotation = { id: rotationId, key, createdAt, expiresAt };
47
+ return { id: entry.id, name: entry.name, key, rotationId, expiresAt };
48
+ }
49
+
50
+ export function commitApiKeyRotation(
51
+ config: OcxConfig,
52
+ keyId: string,
53
+ rotationId: string,
54
+ now = Date.now(),
55
+ ): { ok: true } | { error: "not-found" | "expired" | "mismatch" } {
56
+ const entry = (config.apiKeys ?? []).find(candidate => candidate.id === keyId);
57
+ if (!entry?.pendingRotation) return { error: "not-found" };
58
+ const pending = entry.pendingRotation;
59
+ if (Date.parse(pending.expiresAt) <= now) {
60
+ delete entry.pendingRotation;
61
+ return { error: "expired" };
62
+ }
63
+ if (!equalOpaqueId(pending.id, rotationId)) return { error: "mismatch" };
64
+ entry.key = pending.key;
65
+ delete entry.pendingRotation;
66
+ return { ok: true };
67
+ }
68
+
69
+ export function abortApiKeyRotation(config: OcxConfig, keyId: string, rotationId: string): boolean {
70
+ const entry = (config.apiKeys ?? []).find(candidate => candidate.id === keyId);
71
+ if (!entry?.pendingRotation || !equalOpaqueId(entry.pendingRotation.id, rotationId)) return false;
72
+ delete entry.pendingRotation;
73
+ return true;
74
+ }
@@ -68,12 +68,24 @@ import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
68
68
  import { shadowCallTargetError } from "./shadow-call-validation";
69
69
 
70
70
 
71
- /** Management wire shape: omit default imageInput "auto" (persist/response sparse). */
72
- function sparseComboConfig<T extends { imageInput?: "auto" | "disabled" }>(combo: T): Omit<T, "imageInput"> & { imageInput?: "disabled" } {
73
- const { imageInput, ...rest } = combo;
71
+ /**
72
+ * Management wire shape: omit fields whose value is the default, so GET responses and
73
+ * persisted config stay sparse. A default echoed here would be written straight back by
74
+ * any client that round-trips GET into PUT, which is how an unset option ends up
75
+ * materialized in every user's config.json.
76
+ */
77
+ function sparseComboConfig<T extends {
78
+ imageInput?: "auto" | "disabled";
79
+ reasoningEffortMode?: "strict" | "adaptive";
80
+ }>(combo: T): Omit<T, "imageInput" | "reasoningEffortMode"> & {
81
+ imageInput?: "disabled";
82
+ reasoningEffortMode?: "adaptive";
83
+ } {
84
+ const { imageInput, reasoningEffortMode, ...rest } = combo;
74
85
  return {
75
86
  ...rest,
76
87
  ...(imageInput === "disabled" ? { imageInput: "disabled" as const } : {}),
88
+ ...(reasoningEffortMode === "adaptive" ? { reasoningEffortMode: "adaptive" as const } : {}),
77
89
  };
78
90
  }
79
91
 
@@ -137,19 +149,19 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
137
149
  if (error) return jsonResponse({ error }, 400);
138
150
  const normalized = normalizeComboConfig(body.combo as import("../../types").OcxComboConfig);
139
151
  // Persist only non-default identity/capability fields so config stays sparse.
152
+ // Capability defaults (`imageInput`, `reasoningEffortMode`) go through the same
153
+ // helper the GET/PUT responses use, so the wire shape and the stored shape cannot drift.
140
154
  const {
141
155
  alias: normalizedAlias,
142
156
  nativeAlias: normalizedNativeAlias,
143
157
  displayName: normalizedDisplayName,
144
- imageInput: normalizedImageInput,
145
158
  ...normalizedBase
146
- } = normalized;
159
+ } = sparseComboConfig(normalized);
147
160
  const stored: import("../../types").OcxComboConfig = {
148
161
  ...normalizedBase,
149
162
  ...(normalizedAlias ? { alias: normalizedAlias } : {}),
150
163
  ...(normalizedNativeAlias ? { nativeAlias: true } : {}),
151
164
  ...(normalizedDisplayName ? { displayName: normalizedDisplayName } : {}),
152
- ...(normalizedImageInput === "disabled" ? { imageInput: "disabled" as const } : {}),
153
165
  };
154
166
  const sourceId = renameFrom ?? id;
155
167
  const previous = config.combos?.[sourceId];
@@ -306,6 +306,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
306
306
  // Absent means the historical auto-open, so the GUI can render the toggle
307
307
  // without having to know that `undefined` and `true` mean the same thing.
308
308
  oauthOpenBrowser: config.oauthOpenBrowser !== false,
309
+ // Absent means off (today's Design B injection), so the GUI/CLI render a plain switch.
310
+ codexDesktopAuthless: config.codexDesktopAuthless === true,
309
311
  startupHealth: await readStartupHealth(config),
310
312
  codexRuntime: {
311
313
  path: displayCodexRuntimePath(resolved.runtime.command),
@@ -392,14 +394,16 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
392
394
  codexAccountPickerEnabled?: unknown;
393
395
  oauthOpenBrowser?: unknown;
394
396
  showCodexSparkQuota?: unknown;
397
+ codexDesktopAuthless?: unknown;
395
398
  };
396
399
  if (body.codexAutoStart === undefined
397
400
  && body.streamMode === undefined
398
401
  && body.appOwnedMemoryBudgetMb === undefined
399
402
  && body.codexAccountPickerEnabled === undefined
400
403
  && body.oauthOpenBrowser === undefined
401
- && body.showCodexSparkQuota === undefined) {
402
- return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, oauthOpenBrowser, or showCodexSparkQuota" }, 400);
404
+ && body.showCodexSparkQuota === undefined
405
+ && body.codexDesktopAuthless === undefined) {
406
+ return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, oauthOpenBrowser, showCodexSparkQuota, or codexDesktopAuthless" }, 400);
403
407
  }
404
408
  if (body.codexAutoStart !== undefined && typeof body.codexAutoStart !== "boolean") {
405
409
  return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
@@ -417,6 +421,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
417
421
  if (body.showCodexSparkQuota !== undefined && typeof body.showCodexSparkQuota !== "boolean") {
418
422
  return jsonResponse({ error: "showCodexSparkQuota boolean is required" }, 400);
419
423
  }
424
+ if (body.codexDesktopAuthless !== undefined && typeof body.codexDesktopAuthless !== "boolean") {
425
+ return jsonResponse({ error: "codexDesktopAuthless boolean is required" }, 400);
426
+ }
420
427
  if (body.appOwnedMemoryBudgetMb !== undefined && (
421
428
  typeof body.appOwnedMemoryBudgetMb !== "number"
422
429
  || !Number.isInteger(body.appOwnedMemoryBudgetMb)
@@ -440,9 +447,12 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
440
447
  hasOauthOpenBrowser: Object.hasOwn(config, "oauthOpenBrowser"),
441
448
  showCodexSparkQuota: config.showCodexSparkQuota,
442
449
  hasShowCodexSparkQuota: Object.hasOwn(config, "showCodexSparkQuota"),
450
+ codexDesktopAuthless: config.codexDesktopAuthless,
451
+ hasCodexDesktopAuthless: Object.hasOwn(config, "codexDesktopAuthless"),
443
452
  };
444
453
  const pickerWasEnabled = codexAccountPickerEnabled(config);
445
454
  let pickerIsEnabled = pickerWasEnabled;
455
+ const authlessWasEnabled = config.codexDesktopAuthless === true;
446
456
  try {
447
457
  if (typeof body.codexAutoStart === "boolean") {
448
458
  config.codexAutoStart = body.codexAutoStart;
@@ -469,6 +479,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
469
479
  if (typeof body.showCodexSparkQuota === "boolean") {
470
480
  config.showCodexSparkQuota = body.showCodexSparkQuota;
471
481
  }
482
+ if (body.codexDesktopAuthless === true) config.codexDesktopAuthless = true;
483
+ else if (body.codexDesktopAuthless === false) deleteConfigTopLevelKey(config, "codexDesktopAuthless");
472
484
  pickerIsEnabled = codexAccountPickerEnabled(config);
473
485
  (deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode)(config);
474
486
  } catch (error) {
@@ -491,13 +503,19 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
491
503
  if (previousSettings.hasShowCodexSparkQuota) {
492
504
  config.showCodexSparkQuota = previousSettings.showCodexSparkQuota;
493
505
  } else deleteConfigTopLevelKey(config, "showCodexSparkQuota");
506
+ if (previousSettings.hasCodexDesktopAuthless) {
507
+ config.codexDesktopAuthless = previousSettings.codexDesktopAuthless;
508
+ } else deleteConfigTopLevelKey(config, "codexDesktopAuthless");
494
509
  throw error;
495
510
  }
496
511
  if (typeof body.appOwnedMemoryBudgetMb === "number") {
497
512
  configureAppOwnedMemoryBudget(resolveAppOwnedMemoryBudgetBytes(body.appOwnedMemoryBudgetMb));
498
513
  enforceAppOwnedMemoryBudget();
499
514
  }
500
- const catalogRefresh = pickerWasEnabled !== pickerIsEnabled
515
+ // The authless switch changes the injected config.toml shape, so converge now rather than
516
+ // waiting for the next start; the injector re-reads config and rewrites the form.
517
+ const authlessIsEnabled = config.codexDesktopAuthless === true;
518
+ const catalogRefresh = pickerWasEnabled !== pickerIsEnabled || authlessWasEnabled !== authlessIsEnabled
501
519
  ? await convergeCodexCatalog()
502
520
  : undefined;
503
521
  const catalogRefreshPending = catalogRefresh
@@ -513,6 +531,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
513
531
  oauthOpenBrowser: config.oauthOpenBrowser !== false,
514
532
  catalogRefreshPending,
515
533
  showCodexSparkQuota: config.showCodexSparkQuota === true,
534
+ codexDesktopAuthless: authlessIsEnabled,
516
535
  startupHealth: await readStartupHealth(config),
517
536
  });
518
537
  }
@@ -4,7 +4,7 @@ import type { CodexLogGuardProtectionDeps } from "../../codex/log-guard/protecti
4
4
  import type { CodexLogGuardMaintenanceDeps } from "../../codex/log-guard/maintenance";
5
5
  import type { StartupHealth } from "../../codex/autostart-health";
6
6
  import type { StartupInstallAction } from "../startup-action-control";
7
- import type { ManagementPrincipal } from "../management-auth";
7
+ import type { ManagementPrincipal, ManagementSessionControl } from "../management-auth";
8
8
  import type { CatalogModel } from "../../codex/catalog";
9
9
  import type { Paths as CodexPromptPaths } from "../../codex/prompt-layers";
10
10
  import type { injectGrokConfig } from "../../grok/inject";
@@ -109,6 +109,8 @@ export interface ManagementContext {
109
109
  url: URL;
110
110
  config: OcxConfig;
111
111
  deps: ManagementApiDeps;
112
+ /** Installed package version projected through bounded system identity routes. */
113
+ version: string;
112
114
  /**
113
115
  * Which credential authorized this request, resolved by the auth gate before
114
116
  * dispatch. Routes that spend the USER's identity (not just the proxy's) must
@@ -118,6 +120,8 @@ export interface ManagementContext {
118
120
  * tests, which are treated as the untrusted `admin-token` case.
119
121
  */
120
122
  principal?: ManagementPrincipal;
123
+ /** Narrow current-session revocation seam; contains neither the token nor session map. */
124
+ sessionControl?: ManagementSessionControl;
121
125
  convergeCodexCatalog: () => Promise<CatalogDisposition>;
122
126
  syncClaudeAgentDefsBestEffort: () => Promise<void>;
123
127
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Read-only status for the Cursor integration card.
3
+ *
4
+ * Cursor Private Inference is configured inside Cursor (Settings > Models > Gateway), not by
5
+ * this proxy: its settings live in a SQLite database the running app rewrites and its API key
6
+ * in the OS keychain, both out of bounds for opencodex. So this route only answers the three
7
+ * questions the dashboard needs — which Cursor builds are installed, what to paste into the
8
+ * gateway form, and whether a Cursor client has actually called `/v1/models` since the proxy
9
+ * started — plus which active models will show Cursor's Reasoning and Context controls.
10
+ */
11
+ import { readRuntimePort } from "../../config/process-state";
12
+ import { filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextTier, uniqueCatalogModelsForRawPublicList, visibleNativeSlugs } from "../../codex/catalog";
13
+ import { cursorLastSeen, type CursorSeen } from "../../integrations/cursor-seen";
14
+ import { detectCursorInstalls, type CursorInstall } from "../../integrations/cursor-detect";
15
+ import { configuredApiAuthToken, isApiAuthRequired, jsonResponse } from "../auth-cors";
16
+ import { fetchAllModels } from "../management-api";
17
+ import { cursorEffortFamily } from "../models-capabilities";
18
+ import type { ManagementContext } from "./context";
19
+
20
+ export const CURSOR_GATEWAY_PLACEHOLDER_KEY = "opencodex-loopback";
21
+ export const CURSOR_GUIDE_URL = "https://lidge-jun.github.io/opencodex/guides/cursor-private-inference/";
22
+
23
+ export interface CursorIntegrationStatus {
24
+ privateInference: { installed: boolean; path: string | null; version: string | null };
25
+ regularCursor: { installed: boolean; path: string | null };
26
+ gateway: { baseUrl: string; apiKeyMode: "credential" | "placeholder"; placeholder: string };
27
+ lastSeen: CursorSeen | null;
28
+ models: Array<{
29
+ id: string;
30
+ reasoning: string[] | null;
31
+ context: { defaultWindow: number; longWindow: number } | null;
32
+ }>;
33
+ guideUrl: string;
34
+ }
35
+
36
+ function pick(installs: CursorInstall[], build: CursorInstall["build"]): CursorInstall | undefined {
37
+ return installs.find(install => install.build === build);
38
+ }
39
+
40
+ export async function buildCursorIntegrationStatus(
41
+ ctx: Pick<ManagementContext, "config" | "deps"> & { url?: URL },
42
+ installs: CursorInstall[] = detectCursorInstalls(),
43
+ ): Promise<CursorIntegrationStatus> {
44
+ const { config, deps } = ctx;
45
+ const privateInference = pick(installs, "private-inference");
46
+ const regular = pick(installs, "regular");
47
+ const runtime = (deps.readRuntimePort ?? readRuntimePort)(process.pid);
48
+ // The port the browser reached is the one Cursor on the same machine will reach too; the
49
+ // runtime record and config.port are fallbacks for a request that carries no port.
50
+ const port = runtime?.port ?? (Number(ctx.url?.port) || config.port);
51
+ // Describes the public bind. A second unauthenticated loopback listener may exist, but the
52
+ // value a user pastes into Cursor must work against the bind they will actually reach.
53
+ const credentialConfigured = !!configuredApiAuthToken(config)
54
+ || (config.apiKeys ?? []).some(entry => !!entry.key.trim());
55
+ const apiKeyMode = isApiAuthRequired(config) || credentialConfigured ? "credential" : "placeholder";
56
+
57
+ const limits = nativeContextLimits(config);
58
+ // Same visibility rules as the raw /v1/models list Cursor will read: disabled models and
59
+ // provider allowlists drop out here too, or the prediction shows rows Cursor never gets.
60
+ const goModels = filterCatalogVisibleModels(await fetchAllModels(config), config);
61
+ const ids = [
62
+ ...visibleNativeSlugs(config),
63
+ ...uniqueCatalogModelsForRawPublicList(goModels).map(model => model.alias ?? `${model.provider}/${model.id}`),
64
+ ];
65
+ const models = ids.map(id => {
66
+ const tier = nativeOpenAiContextTier(id, limits);
67
+ return {
68
+ id,
69
+ reasoning: cursorEffortFamily(id),
70
+ context: tier ? { defaultWindow: tier.defaultWindow, longWindow: tier.longWindow } : null,
71
+ };
72
+ });
73
+
74
+ return {
75
+ privateInference: {
76
+ installed: privateInference !== undefined,
77
+ path: privateInference?.path ?? null,
78
+ version: privateInference?.version ?? null,
79
+ },
80
+ regularCursor: { installed: regular !== undefined, path: regular?.path ?? null },
81
+ gateway: {
82
+ baseUrl: `http://127.0.0.1:${port}/v1`,
83
+ apiKeyMode,
84
+ placeholder: CURSOR_GATEWAY_PLACEHOLDER_KEY,
85
+ },
86
+ lastSeen: cursorLastSeen(),
87
+ models,
88
+ guideUrl: CURSOR_GUIDE_URL,
89
+ };
90
+ }
91
+
92
+ export async function handleCursorIntegrationRoutes(ctx: ManagementContext): Promise<Response | null> {
93
+ const { req, url } = ctx;
94
+ if (url.pathname === "/api/native-integrations/cursor" && req.method === "GET") {
95
+ return jsonResponse(await buildCursorIntegrationStatus(ctx));
96
+ }
97
+ return null;
98
+ }
@@ -202,10 +202,11 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
202
202
  const filter = {
203
203
  provider: url.searchParams.get("provider"),
204
204
  model: url.searchParams.get("model"),
205
+ apiKeyId: url.searchParams.get("apiKeyId"),
205
206
  };
206
207
  const project = <T extends UsageSummary>(summary: T, entries?: PersistedUsageEntry[]) =>
207
208
  projectUsageSummary(summary, filter, entries);
208
- const filterRequested = Boolean(filter.provider ?? filter.model);
209
+ const filterRequested = Boolean(filter.provider ?? filter.model ?? filter.apiKeyId);
209
210
  const now = Date.now();
210
211
  try {
211
212
  const cacheKey = `${range}:${surface}`;
@@ -72,13 +72,15 @@ function readDefaultReasoningEffort(raw: unknown, efforts: string[] | undefined)
72
72
  import type { CatalogModel } from "../../codex/catalog";
73
73
  import { accountBoundNativeOpenAiSlugsBySelector, catalogModelSlug, configuredNativeAliasSlugs, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, shouldIncludeAccountBoundNativeOpenAi, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
74
74
  import { CatalogGatherBusyError } from "../../codex/catalog/provider-fetch";
75
+ import { clearModelCache, getProviderLiveModelCount } from "../../codex/model-cache";
75
76
  import { NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models";
76
- import { getProviderLiveModelCount } from "../../codex/model-cache";
77
+
77
78
  import {
78
79
  DEFAULT_SUBAGENT_MODELS,
79
80
  codexAutoStartEnabled,
80
81
  hasOwnProvider,
81
82
  isValidProviderName,
83
+ modelDisplayNamesConfigError,
82
84
  multiAgentGuidanceEnabled,
83
85
  providerBaseUrlConfigError,
84
86
  providerHeadersConfigError,
@@ -101,6 +103,7 @@ import { providerCodexAccountMode } from "../../providers/registry";
101
103
  import { encodedModelIdCollides, routedSlug, slugEquals } from "../../providers/slug-codec";
102
104
  import { knownModelIdsForProvider } from "../../router";
103
105
  import { effectiveModelAliases, MODEL_ALIAS_PATTERN } from "../../providers/default-aliases";
106
+ import { isValidModelDiscoveryModelId } from "../../providers/model-discovery-limits";
104
107
  import { comboPublicModelId } from "../../combos/types";
105
108
  import { COMBO_NAMESPACE, comboDisabledModelSelectors, comboModelId, preservesPhysicalComboProvider } from "../../combos";
106
109
  import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
@@ -353,6 +356,81 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
353
356
  return jsonResponse(await listManagementModelRows(config));
354
357
  }
355
358
 
359
+ const displayNameMatch = url.pathname.match(/^\/api\/providers\/([^/]+)\/model-display-names$/);
360
+ if (displayNameMatch && req.method === "PUT") {
361
+ let name: string;
362
+ try { name = decodeURIComponent(displayNameMatch[1]!); } catch { return jsonResponse({ error: "invalid provider encoding" }, 400); }
363
+ if (name === "keys") return null;
364
+ if (!hasOwnProvider(config.providers, name)) {
365
+ return jsonResponse({ error: `provider '${name}' not found` }, 404, req, config);
366
+ }
367
+ let body: unknown;
368
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
369
+ if (!isPlainRecord(body) || typeof body.modelId !== "string"
370
+ || (body.displayName !== null && typeof body.displayName !== "string")) {
371
+ return jsonResponse({ error: "modelId and displayName string or null are required" }, 400, req, config);
372
+ }
373
+ const modelId = body.modelId.trim();
374
+ const displayName = typeof body.displayName === "string" ? body.displayName.trim() : null;
375
+ if (!isValidModelDiscoveryModelId(modelId)) {
376
+ return jsonResponse({ error: "modelId must be a valid model id" }, 400, req, config);
377
+ }
378
+ const validationError = displayName === null
379
+ ? null
380
+ : modelDisplayNamesConfigError({ [modelId]: displayName });
381
+ if (validationError) return jsonResponse({ error: validationError }, 400, req, config);
382
+
383
+ const provider = config.providers[name]!;
384
+ const hadDisplayNames = Object.hasOwn(provider, "modelDisplayNames");
385
+ const previousDisplayNames = provider.modelDisplayNames;
386
+ const nextDisplayNames = Object.assign(
387
+ Object.create(null) as Record<string, string>,
388
+ previousDisplayNames ?? {},
389
+ );
390
+ if (displayName === null) delete nextDisplayNames[modelId];
391
+ else nextDisplayNames[modelId] = displayName;
392
+ const mergedValidationError = modelDisplayNamesConfigError(nextDisplayNames);
393
+ if (mergedValidationError) return jsonResponse({ error: mergedValidationError }, 400, req, config);
394
+ if (Object.keys(nextDisplayNames).length > 0) provider.modelDisplayNames = nextDisplayNames;
395
+ else delete provider.modelDisplayNames;
396
+
397
+ try {
398
+ persistConfig(config);
399
+ } catch (error) {
400
+ if (hadDisplayNames) provider.modelDisplayNames = previousDisplayNames;
401
+ else delete provider.modelDisplayNames;
402
+ throw error;
403
+ }
404
+ clearModelCache(name);
405
+ const catalogRefresh = await convergeCodexCatalog();
406
+ const storedDisplayName = provider.modelDisplayNames?.[modelId] ?? null;
407
+ if (catalogRefresh.status === "failed") {
408
+ return jsonResponse({
409
+ error: "model display name saved but catalog refresh failed",
410
+ saved: true,
411
+ provider: name,
412
+ modelId,
413
+ displayNameOverride: storedDisplayName,
414
+ catalogRefresh,
415
+ }, 503, req, config);
416
+ }
417
+ const row = (await listManagementModelRows(config)).find(candidate => (
418
+ candidate.native !== true
419
+ && candidate.custom !== true
420
+ && candidate.provider === name
421
+ && candidate.id === modelId
422
+ ));
423
+ return jsonResponse({
424
+ ok: true,
425
+ provider: name,
426
+ modelId,
427
+ displayName: row?.displayName ?? storedDisplayName ?? routedSlug(name, modelId),
428
+ displayNameOverride: storedDisplayName,
429
+ displayNameSource: row?.displayNameSource ?? (storedDisplayName ? "operator" : "fallback"),
430
+ catalogRefresh,
431
+ });
432
+ }
433
+
356
434
  /**
357
435
  * Client config document for OpenCode / Pi, built from the SAME function `ocx export`
358
436
  * calls, so the bytes a user downloads here and the bytes they pipe from the CLI cannot
@@ -41,8 +41,28 @@ export type ManagementModelRow = Partial<CatalogModel> & {
41
41
  native?: boolean;
42
42
  custom?: boolean;
43
43
  customId?: string;
44
+ displayNameOverride?: string;
45
+ displayNameSource?: "operator" | "provider" | "fallback";
44
46
  };
45
47
 
48
+ /** Resolve the exact text and source shown for one routed discovered model. */
49
+ export function effectiveManagementDisplayName(
50
+ config: Pick<OcxConfig, "providers">,
51
+ model: CatalogModel,
52
+ ): Pick<ManagementModelRow, "displayName" | "displayNameOverride" | "displayNameSource"> {
53
+ const provider = config.providers[model.provider];
54
+ const configured = provider?.modelDisplayNames;
55
+ if (configured && Object.hasOwn(configured, model.id)) {
56
+ const displayName = configured[model.id]?.trim();
57
+ if (displayName) {
58
+ return { displayName, displayNameOverride: displayName, displayNameSource: "operator" };
59
+ }
60
+ }
61
+ const providerDisplayName = model.displayName?.trim();
62
+ if (providerDisplayName) return { displayName: providerDisplayName, displayNameSource: "provider" };
63
+ return { displayName: catalogModelSlug(model), displayNameSource: "fallback" };
64
+ }
65
+
46
66
  /**
47
67
  * The exact row list `/api/models` returns. Extracted so `/api/client-config` exports the
48
68
  * models the GUI's Models tab shows — including this function's `disabled` computation,
@@ -133,8 +153,10 @@ export async function listManagementModelRows(
133
153
  if (m.provider !== "combo" && customNamespaced.has(namespaced)) return null;
134
154
  const contextCap = providerContextCap(config, m.provider);
135
155
  const nativeAlias = m.provider === "combo" && m.nativeAlias === true;
156
+ const displayName = effectiveManagementDisplayName(config, m);
136
157
  return {
137
158
  ...m,
159
+ ...displayName,
138
160
  namespaced,
139
161
  disabled: [...disabled].some(stored => (
140
162
  (!nativeAlias && stored === namespaced) || slugEquals(stored, m.provider, m.id)
@@ -152,7 +174,7 @@ export function toExportModel(row: ManagementModelRow): ExportModel {
152
174
  provider: row.provider,
153
175
  id: row.id,
154
176
  ...(row.native ? { native: true } : {}),
155
- ...(row.displayName ? { displayName: row.displayName } : {}),
177
+ ...(row.displayName && row.displayNameSource !== "fallback" ? { displayName: row.displayName } : {}),
156
178
  ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
157
179
  ...(row.inputModalities ? { inputModalities: row.inputModalities } : {}),
158
180
  ...(row.reasoningEfforts ? { reasoningEfforts: row.reasoningEfforts } : {}),