@bitkyc08/opencodex 2.7.40 → 2.7.41

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 (110) hide show
  1. package/README.md +63 -12
  2. package/assets/claude-code-models.gif +0 -0
  3. package/gui/dist/assets/index-B2J4t3te.css +1 -0
  4. package/gui/dist/assets/index-BmvM6wRb.js +65 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -2
  7. package/src/adapters/google.ts +48 -9
  8. package/src/adapters/kiro-events.ts +15 -3
  9. package/src/adapters/kiro.ts +292 -28
  10. package/src/adapters/openai-chat.ts +63 -15
  11. package/src/adapters/openai-responses.ts +18 -0
  12. package/src/bridge.ts +76 -21
  13. package/src/chat/outbound.ts +66 -34
  14. package/src/claude/auth-detect.ts +229 -0
  15. package/src/claude/auth-mode-migration.ts +32 -0
  16. package/src/claude/auth-mode.ts +62 -0
  17. package/src/claude/desktop-3p-guard.ts +35 -0
  18. package/src/claude/desktop-3p.ts +121 -21
  19. package/src/claude/desktop-health.ts +26 -0
  20. package/src/claude/desktop-profile.ts +263 -0
  21. package/src/claude/inbound-debug.ts +4 -0
  22. package/src/claude/model-info.ts +9 -3
  23. package/src/cli/account-extended.ts +34 -0
  24. package/src/cli/account.ts +3 -1
  25. package/src/cli/claude-desktop.ts +152 -0
  26. package/src/cli/claude.ts +37 -3
  27. package/src/cli/doctor.ts +187 -6
  28. package/src/cli/help.ts +13 -1
  29. package/src/cli/index.ts +151 -57
  30. package/src/cli/status-oauth.ts +68 -0
  31. package/src/cli/status.ts +4 -0
  32. package/src/codex/account-lifecycle.ts +31 -0
  33. package/src/codex/auth-api.ts +133 -26
  34. package/src/codex/auth-collision.ts +55 -11
  35. package/src/codex/auth-context.ts +50 -6
  36. package/src/codex/catalog/provider-fetch.ts +6 -2
  37. package/src/codex/home.ts +61 -1
  38. package/src/codex/inject.ts +28 -66
  39. package/src/codex/injected-marker.ts +72 -0
  40. package/src/codex/journal.ts +39 -3
  41. package/src/codex/main-account-cache.ts +25 -0
  42. package/src/codex/model-cache.ts +20 -1
  43. package/src/codex/paths.ts +5 -0
  44. package/src/codex/routing.ts +138 -11
  45. package/src/codex/subagent-model-fallback.ts +455 -0
  46. package/src/codex/sync.ts +17 -0
  47. package/src/combos/failover.ts +10 -2
  48. package/src/combos/index.ts +1 -0
  49. package/src/combos/types.ts +9 -0
  50. package/src/config.ts +117 -0
  51. package/src/grok/inject.ts +339 -0
  52. package/src/grok/status.ts +88 -0
  53. package/src/grok/sync.ts +66 -0
  54. package/src/lib/destination-policy.ts +13 -0
  55. package/src/lib/errors.ts +59 -0
  56. package/src/lib/privacy.ts +9 -0
  57. package/src/lib/process-control.ts +57 -4
  58. package/src/oauth/health.ts +375 -0
  59. package/src/oauth/index.ts +69 -22
  60. package/src/oauth/kiro.ts +5 -3
  61. package/src/oauth/log.ts +48 -0
  62. package/src/oauth/store.ts +55 -6
  63. package/src/providers/alibaba-region-backup.ts +75 -0
  64. package/src/providers/alibaba-region-migration.ts +143 -0
  65. package/src/providers/alibaba-region-startup.ts +36 -0
  66. package/src/providers/api-keys.ts +5 -5
  67. package/src/providers/derive.ts +22 -1
  68. package/src/providers/free-directory.ts +181 -0
  69. package/src/providers/key-failover.ts +2 -2
  70. package/src/providers/kiro-models.ts +3 -2
  71. package/src/providers/openai-tiers.ts +1 -1
  72. package/src/providers/provider-id-rewrite.ts +150 -0
  73. package/src/providers/registry.ts +49 -2
  74. package/src/responses/parser.ts +49 -20
  75. package/src/responses/state.ts +156 -2
  76. package/src/router.ts +73 -5
  77. package/src/server/chat-completions.ts +78 -22
  78. package/src/server/claude-messages.ts +8 -0
  79. package/src/server/images.ts +2 -1
  80. package/src/server/index.ts +50 -30
  81. package/src/server/live.ts +2 -1
  82. package/src/server/management/agent-settings-routes.ts +337 -18
  83. package/src/server/management/api-access.ts +141 -0
  84. package/src/server/management/combo-routes.ts +3 -3
  85. package/src/server/management/config-routes.ts +4 -4
  86. package/src/server/management/logs-usage-routes.ts +64 -4
  87. package/src/server/management/model-routes.ts +114 -8
  88. package/src/server/management/oauth-account-routes.ts +31 -5
  89. package/src/server/management/provider-routes.ts +42 -11
  90. package/src/server/management/shared.ts +81 -2
  91. package/src/server/management/system-routes.ts +6 -1
  92. package/src/server/management-api.ts +24 -7
  93. package/src/server/port-reclaim.ts +261 -0
  94. package/src/server/request-log.ts +11 -5
  95. package/src/server/responses/collaboration.ts +11 -6
  96. package/src/server/responses/compact.ts +2 -1
  97. package/src/server/responses/core.ts +358 -155
  98. package/src/server/responses/passthrough-error.ts +53 -0
  99. package/src/server/search.ts +2 -1
  100. package/src/server/system-env.ts +23 -6
  101. package/src/server/windows-tcp-drop.ts +174 -0
  102. package/src/service.ts +43 -3
  103. package/src/types.ts +74 -6
  104. package/src/update/index.ts +30 -19
  105. package/src/update/job.ts +30 -15
  106. package/src/usage/log.ts +158 -2
  107. package/src/usage/summary.ts +8 -4
  108. package/src/web-search/loop.ts +4 -1
  109. package/gui/dist/assets/index-CMip1DzF.css +0 -1
  110. package/gui/dist/assets/index-cydcmbzC.js +0 -52
@@ -10,7 +10,7 @@ import {
10
10
  multiAgentGuidanceEnabled,
11
11
  providerBaseUrlConfigError,
12
12
  providerHeadersConfigError,
13
- saveConfig,
13
+ saveConfigPreservingClaudeCode,
14
14
  } from "../../config";
15
15
  import {
16
16
  clearLoginState,
@@ -34,9 +34,14 @@ import { primeCodexPoolQuotas } from "../../codex/auth-api";
34
34
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
35
35
  import { resolveCodexHomeDir } from "../../codex/home";
36
36
  import { scanStorage } from "../../storage/scanner";
37
- import { readUsageEntries } from "../../usage/log";
37
+ import {
38
+ currentUsageLogRevision,
39
+ readUsageSnapshotForManagement,
40
+ usageLogRevisionKey,
41
+ type PersistedUsageEntry,
42
+ } from "../../usage/log";
38
43
  import { getUsageDebugLogEntries } from "../../usage/debug";
39
- import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
44
+ import { parseRange, parseUsageSurface, summarizeUsage, type UsageRange, type UsageSummary, type UsageSurface } from "../../usage/summary";
40
45
  import { stripCodexRuntimeProviderFields } from "../../codex/auth-context";
41
46
  import { getProviderRegistryEntry } from "../../providers/registry";
42
47
  import { getDebugLogEntries } from "../../lib/debug-log-buffer";
@@ -60,6 +65,48 @@ import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostR
60
65
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
61
66
  import type { ManagementContext } from "./context";
62
67
 
68
+ const USAGE_DAY_MS = 86_400_000;
69
+ const usageSummaryCache = new Map<string, {
70
+ revisionKey: string;
71
+ expiresAt: number;
72
+ summary: UsageSummary;
73
+ }>();
74
+
75
+ function usageEntryMatchesSurface(entry: PersistedUsageEntry, surface: UsageSurface): boolean {
76
+ if (surface === "claude") return entry.surface === "claude" || entry.surface === "claude-desktop";
77
+ if (surface === "grok") return entry.surface === "grok";
78
+ if (surface === "codex") return entry.surface === undefined;
79
+ return true;
80
+ }
81
+
82
+ function nextLocalMidnight(now: number): number {
83
+ const next = new Date(now);
84
+ next.setHours(24, 0, 0, 0);
85
+ return next.getTime();
86
+ }
87
+
88
+ function usageSummaryExpiresAt(
89
+ entries: PersistedUsageEntry[],
90
+ range: UsageRange,
91
+ surface: UsageSurface,
92
+ now: number,
93
+ ): number {
94
+ let expiresAt = nextLocalMidnight(now);
95
+ const windowMs = range === "7d" ? 7 * USAGE_DAY_MS : range === "30d" ? 30 * USAGE_DAY_MS : null;
96
+ if (windowMs === null) return expiresAt;
97
+ for (const entry of entries) {
98
+ if (!usageEntryMatchesSurface(entry, surface)) continue;
99
+ const expiry = entry.timestamp + windowMs;
100
+ if (expiry > now && expiry < expiresAt) expiresAt = expiry;
101
+ }
102
+ return expiresAt;
103
+ }
104
+
105
+ function refreshedUsageSummary(summary: UsageSummary, range: UsageRange, now: number): UsageSummary {
106
+ const since = range === "7d" ? now - 7 * USAGE_DAY_MS : range === "30d" ? now - 30 * USAGE_DAY_MS : null;
107
+ return { ...summary, since, generatedAt: now };
108
+ }
109
+
63
110
  export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Response | null> {
64
111
  const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
65
112
 
@@ -123,7 +170,20 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
123
170
  const surface = parseUsageSurface(url.searchParams.get("surface"));
124
171
  const now = Date.now();
125
172
  try {
126
- return jsonResponse(summarizeUsage(readUsageEntries(), range, now, surface));
173
+ const cacheKey = `${range}:${surface}`;
174
+ const observedRevisionKey = usageLogRevisionKey(currentUsageLogRevision());
175
+ const cached = usageSummaryCache.get(cacheKey);
176
+ if (cached && cached.revisionKey === observedRevisionKey && now < cached.expiresAt) {
177
+ return jsonResponse(refreshedUsageSummary(cached.summary, range, now));
178
+ }
179
+ const snapshot = await readUsageSnapshotForManagement();
180
+ const summary = summarizeUsage(snapshot.entries, range, now, surface);
181
+ usageSummaryCache.set(cacheKey, {
182
+ revisionKey: usageLogRevisionKey(snapshot.revision),
183
+ expiresAt: usageSummaryExpiresAt(snapshot.entries, range, surface, now),
184
+ summary,
185
+ });
186
+ return jsonResponse(summary);
127
187
  } catch {
128
188
  return jsonResponse({
129
189
  range,
@@ -1,7 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
  import type { CatalogModel } from "../../codex/catalog";
4
- import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
4
+ import { catalogModelSlug, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
5
+ import { getProviderLiveModelCount } from "../../codex/model-cache";
5
6
  import {
6
7
  DEFAULT_SUBAGENT_MODELS,
7
8
  codexAutoStartEnabled,
@@ -10,7 +11,7 @@ import {
10
11
  multiAgentGuidanceEnabled,
11
12
  providerBaseUrlConfigError,
12
13
  providerHeadersConfigError,
13
- saveConfig,
14
+ saveConfigPreservingClaudeCode,
14
15
  } from "../../config";
15
16
  import {
16
17
  clearLoginState,
@@ -27,6 +28,7 @@ import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/ke
27
28
  import { deriveProviderPresets } from "../../providers/derive";
28
29
  import { providerCodexAccountMode } from "../../providers/registry";
29
30
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
31
+ import { COMBO_NAMESPACE, comboModelId, comboPublicModelId, preservesPhysicalComboProvider } from "../../combos";
30
32
  import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
31
33
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
32
34
  import { clearThreadAccountMap } from "../../codex/routing";
@@ -122,12 +124,111 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
122
124
  try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
123
125
  const disabled = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string") : [];
124
126
  config.disabledModels = disabled;
125
- const { saveConfig: save } = await import("../../config");
127
+ const { saveConfigPreservingClaudeCode: save } = await import("../../config");
126
128
  save(config);
127
129
  await refreshCodexCatalogBestEffort();
128
130
  return jsonResponse({ ok: true, disabled });
129
131
  }
130
132
 
133
+ // One user-facing visibility switch spans two persisted filters: a provider allowlist and the
134
+ // shared blocklist. Keep the update atomic so an interrupted request cannot expose a half-applied
135
+ // state. Native rows only use the blocklist; routed/custom rows also join a non-empty allowlist.
136
+ if (url.pathname === "/api/model-visibility" && req.method === "PUT") {
137
+ let parsedBody: unknown;
138
+ try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
139
+ if (!isPlainRecord(parsedBody)) return jsonResponse({ error: "invalid model visibility request" }, 400);
140
+ const body = parsedBody;
141
+ const scope = body.scope === "models" || body.scope === "provider" ? body.scope : null;
142
+ const provider = typeof body.provider === "string" ? body.provider.trim() : "";
143
+ if (!scope || !provider || !isValidProviderName(provider) || typeof body.enabled !== "boolean" || !Array.isArray(body.targets)) {
144
+ return jsonResponse({ error: "invalid model visibility request" }, 400);
145
+ }
146
+
147
+ const providerConfig = hasOwnProvider(config.providers, provider) ? config.providers[provider] : undefined;
148
+ const isVirtualComboNamespace = provider === COMBO_NAMESPACE && !preservesPhysicalComboProvider(config);
149
+ if (!providerConfig && provider !== "openai" && !isVirtualComboNamespace) {
150
+ return jsonResponse({ error: "unknown model visibility provider" }, 400);
151
+ }
152
+ const supportedNative = new Set(nativeModelRows(config).map(row => row.slug));
153
+ const targets: Array<{ id: string; native: boolean }> = [];
154
+ const seen = new Set<string>();
155
+ for (const value of body.targets) {
156
+ if (!isPlainRecord(value) || typeof value.id !== "string" || (value.native !== undefined && typeof value.native !== "boolean")) {
157
+ return jsonResponse({ error: "invalid model visibility target" }, 400);
158
+ }
159
+ const id = value.id.trim();
160
+ const native = value.native === true;
161
+ if (!id || (provider === "openai") !== native || (native && !supportedNative.has(id))) {
162
+ return jsonResponse({ error: "invalid model visibility target" }, 400);
163
+ }
164
+ const key = `${native ? "native" : "routed"}:${id}`;
165
+ if (!seen.has(key)) {
166
+ seen.add(key);
167
+ targets.push({ id, native });
168
+ }
169
+ }
170
+ if (targets.length === 0) return jsonResponse({ error: "model visibility targets required" }, 400);
171
+
172
+ const knownComboSelectors = new Set(
173
+ Object.entries(config.combos ?? {}).flatMap(([id, combo]) => [
174
+ comboModelId(id),
175
+ comboPublicModelId(id, combo),
176
+ ]),
177
+ );
178
+ const targetComboSelectors = new Map<string, Set<string>>();
179
+ if (isVirtualComboNamespace) {
180
+ for (const target of targets) {
181
+ const combo = config.combos && Object.hasOwn(config.combos, target.id) ? config.combos[target.id] : undefined;
182
+ if (!combo) return jsonResponse({ error: "invalid model visibility target" }, 400);
183
+ targetComboSelectors.set(target.id, new Set([comboModelId(target.id), comboPublicModelId(target.id, combo)]));
184
+ }
185
+ }
186
+ const matchesTarget = (stored: string, target: { id: string; native: boolean }) => target.native
187
+ ? stored === target.id
188
+ : isVirtualComboNamespace
189
+ ? targetComboSelectors.get(target.id)!.has(stored)
190
+ : slugEquals(stored, provider, target.id);
191
+
192
+ let disabled = [...new Set(config.disabledModels ?? [])];
193
+ if (body.enabled) {
194
+ if (scope === "provider") {
195
+ if (providerConfig && !isVirtualComboNamespace) delete providerConfig.selectedModels;
196
+ if (isVirtualComboNamespace) {
197
+ disabled = disabled.filter(stored => !knownComboSelectors.has(stored));
198
+ } else {
199
+ const nativeIds = provider === "openai"
200
+ ? disabledNativeSlugs({ disabledModels: disabled })
201
+ : new Set<string>();
202
+ disabled = disabled.filter(stored => (
203
+ knownComboSelectors.has(stored)
204
+ || (!stored.startsWith(`${provider}/`) && !nativeIds.has(stored))
205
+ ));
206
+ }
207
+ } else {
208
+ if (!isVirtualComboNamespace && providerConfig?.selectedModels && providerConfig.selectedModels.length > 0) {
209
+ const additions = targets.filter(target => !target.native).map(target => target.id);
210
+ providerConfig.selectedModels = [...new Set([...providerConfig.selectedModels, ...additions])];
211
+ }
212
+ disabled = disabled.filter(stored => !targets.some(target => matchesTarget(stored, target)));
213
+ }
214
+ } else {
215
+ for (const target of targets) {
216
+ const canonical = target.native
217
+ ? target.id
218
+ : isVirtualComboNamespace
219
+ ? comboModelId(target.id)
220
+ : routedSlug(provider, target.id);
221
+ const alreadyDisabled = disabled.some(stored => matchesTarget(stored, target));
222
+ if (!alreadyDisabled) disabled.push(canonical);
223
+ }
224
+ }
225
+
226
+ config.disabledModels = disabled;
227
+ saveConfigPreservingClaudeCode(config);
228
+ await refreshCodexCatalogBestEffort();
229
+ return jsonResponse({ ok: true, scope, provider, enabled: body.enabled, disabled });
230
+ }
231
+
131
232
  if (url.pathname === "/api/custom-models" && req.method === "GET") {
132
233
  return jsonResponse(config.customModels ?? []);
133
234
  }
@@ -160,7 +261,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
160
261
  addedAt: new Date().toISOString(),
161
262
  };
162
263
  config.customModels = [...existing, entry];
163
- const { saveConfig: save } = await import("../../config");
264
+ const { saveConfigPreservingClaudeCode: save } = await import("../../config");
164
265
  save(config);
165
266
  await refreshCodexCatalogBestEffort();
166
267
  return jsonResponse(entry, 201);
@@ -197,7 +298,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
197
298
  }
198
299
  list[idx] = cm;
199
300
  config.customModels = list;
200
- const { saveConfig: save } = await import("../../config");
301
+ const { saveConfigPreservingClaudeCode: save } = await import("../../config");
201
302
  save(config);
202
303
  await refreshCodexCatalogBestEffort();
203
304
  return jsonResponse(cm);
@@ -212,7 +313,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
212
313
  if (idx === -1) return jsonResponse({ error: "not found" }, 404);
213
314
  list.splice(idx, 1);
214
315
  config.customModels = list.length > 0 ? list : undefined;
215
- const { saveConfig: save } = await import("../../config");
316
+ const { saveConfigPreservingClaudeCode: save } = await import("../../config");
216
317
  save(config);
217
318
  await refreshCodexCatalogBestEffort();
218
319
  return jsonResponse({ ok: true });
@@ -226,10 +327,15 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
226
327
  const available: Record<string, string[]> = {};
227
328
  for (const m of models) (available[m.provider] ??= []).push(m.id);
228
329
  const selected: Record<string, string[]> = {};
330
+ // Live-catalog provenance. The GUI cannot infer this by subtracting known custom ids: an id
331
+ // that is both custom and discovered would make a real live catalog look custom-only.
332
+ const liveModelCounts: Record<string, number> = {};
229
333
  for (const [name, prov] of Object.entries(config.providers)) {
230
334
  if (Array.isArray(prov.selectedModels) && prov.selectedModels.length > 0) selected[name] = [...prov.selectedModels];
335
+ const liveCount = getProviderLiveModelCount(name);
336
+ if (liveCount !== undefined) liveModelCounts[name] = liveCount;
231
337
  }
232
- return jsonResponse({ selected, available });
338
+ return jsonResponse({ selected, available, liveModelCounts });
233
339
  }
234
340
  if (url.pathname === "/api/selected-models" && req.method === "PUT") {
235
341
  let body: { provider?: unknown; models?: unknown };
@@ -244,7 +350,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
244
350
  // Empty list clears the allowlist (provider reverts to exposing all models).
245
351
  if (models.length > 0) config.providers[provider].selectedModels = models;
246
352
  else delete config.providers[provider].selectedModels;
247
- const { saveConfig: save } = await import("../../config");
353
+ const { saveConfigPreservingClaudeCode: save } = await import("../../config");
248
354
  save(config);
249
355
  await refreshCodexCatalogBestEffort();
250
356
  return jsonResponse({ ok: true, provider, selected: models });
@@ -10,7 +10,7 @@ import {
10
10
  multiAgentGuidanceEnabled,
11
11
  providerBaseUrlConfigError,
12
12
  providerHeadersConfigError,
13
- saveConfig,
13
+ saveConfigPreservingClaudeCode,
14
14
  } from "../../config";
15
15
  import {
16
16
  clearLoginState,
@@ -55,6 +55,7 @@ import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerS
55
55
  import type { PersistedUsageAttempt } from "../../usage/log";
56
56
  import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
57
57
  import { applySystemEnvToggle } from "../system-env";
58
+ import { buildApiAccessEndpoints } from "./api-access";
58
59
 
59
60
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
60
61
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
@@ -157,7 +158,24 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
157
158
  const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
158
159
  if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
159
160
  const status = getLoginStatus(provider);
160
- return jsonResponse({ activeAccountId: status.activeAccountId ?? null, accounts: status.accounts ?? [] });
161
+ const { getAccountSet } = await import("../../oauth/store");
162
+ const {
163
+ oauthAccountHealthFields,
164
+ projectOAuthAccountHealth,
165
+ projectStoredOAuthAccountHealth,
166
+ } = await import("../../oauth/health");
167
+ const set = getAccountSet(provider);
168
+ const accounts = (status.accounts ?? []).map(summary => {
169
+ const full = set?.accounts.find(account => account.id === summary.id);
170
+ const health = full
171
+ ? projectStoredOAuthAccountHealth(provider, full)
172
+ : projectOAuthAccountHealth({
173
+ needsReauth: summary.needsReauth === true,
174
+ reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined,
175
+ });
176
+ return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health) };
177
+ });
178
+ return jsonResponse({ activeAccountId: status.activeAccountId ?? null, accounts });
161
179
  }
162
180
  if (url.pathname === "/api/oauth/accounts/active" && req.method === "PUT") {
163
181
  const body = await req.json().catch(() => ({})) as { provider?: string; accountId?: string };
@@ -272,7 +290,15 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
272
290
  // ---------------------------------------------------------------------------
273
291
  if (url.pathname === "/api/keys" && req.method === "GET") {
274
292
  const keys = config.apiKeys ?? [];
275
- return jsonResponse({ keys: keys.map(k => ({ id: k.id, name: k.name, prefix: k.key.slice(0, 8) + "...", createdAt: k.createdAt })), endpoint: `http://${config.hostname ?? "127.0.0.1"}:${config.port ?? 10100}/v1/responses` }, 200, req, config);
293
+ const endpoints = buildApiAccessEndpoints(config, {
294
+ requestUrl: req.url,
295
+ requestHost: req.headers.get("host"),
296
+ requestOrigin: req.headers.get("origin"),
297
+ });
298
+ return jsonResponse({
299
+ keys: keys.map(k => ({ id: k.id, name: k.name, prefix: k.key.slice(0, 8) + "...", createdAt: k.createdAt })),
300
+ ...endpoints,
301
+ }, 200, req, config);
276
302
  }
277
303
 
278
304
  if (url.pathname === "/api/keys" && req.method === "POST") {
@@ -286,7 +312,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
286
312
  const key = "ocx_" + Buffer.from(hashBuf).toString("hex").slice(0, 40);
287
313
  const entry = { id: crypto.randomUUID(), name, key, createdAt: new Date().toISOString() };
288
314
  config.apiKeys = [...(config.apiKeys ?? []), entry];
289
- saveConfig(config);
315
+ saveConfigPreservingClaudeCode(config);
290
316
  return jsonResponse({ id: entry.id, name: entry.name, key: entry.key, createdAt: entry.createdAt }, 201, req, config);
291
317
  }
292
318
 
@@ -294,7 +320,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
294
320
  const body = await req.json() as { id?: string };
295
321
  if (!body.id) return jsonResponse({ error: "id required" }, 400, req, config);
296
322
  config.apiKeys = (config.apiKeys ?? []).filter(k => k.id !== body.id);
297
- saveConfig(config);
323
+ saveConfigPreservingClaudeCode(config);
298
324
  return jsonResponse({ success: true }, 200, req, config);
299
325
  }
300
326
  return null;
@@ -10,7 +10,7 @@ import {
10
10
  multiAgentGuidanceEnabled,
11
11
  providerBaseUrlConfigError,
12
12
  providerHeadersConfigError,
13
- saveConfig,
13
+ saveConfigPreservingClaudeCode,
14
14
  } from "../../config";
15
15
  import {
16
16
  clearLoginState,
@@ -28,7 +28,7 @@ import { deriveProviderPresets } from "../../providers/derive";
28
28
  import { providerCodexAccountMode } from "../../providers/registry";
29
29
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
30
30
  import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
31
- import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
31
+ import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
32
32
  import { clearThreadAccountMap } from "../../codex/routing";
33
33
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
34
34
  import { getProviderDiscoveryStatus } from "../../codex/model-cache";
@@ -101,12 +101,15 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
101
101
  }
102
102
  // Hostname destinations additionally get a DNS-resolved SSRF check at write time —
103
103
  // the sync check above only classifies literal IPs (review finding, PR #96).
104
- const resolvedError = await providerDestinationResolvedError(name, prov);
104
+ // Canonical openai still runs the resolver: only Clash fake-IP (198.18.0.0/15)
105
+ // answers are ignored; loopback/RFC1918/metadata/mixed sets still fail.
106
+ const allowBenchmarkAddresses = name === "openai" && isCanonicalOpenAiForwardProvider(prov);
107
+ const resolvedError = await providerDestinationResolvedError(name, prov, { allowBenchmarkAddresses });
105
108
  if (resolvedError) return jsonResponse({ error: resolvedError }, 400);
106
109
  // Catalog providers (e.g. ollama-cloud) carry a models + vision/reasoning classification the GUI
107
110
  // doesn't send — merge it in so the sidecars are gated correctly.
108
111
  enrichProviderFromCatalog(name, prov);
109
- const { saveConfig: save } = await import("../../config");
112
+ const { saveConfigPreservingClaudeCode: save } = await import("../../config");
110
113
  // Overwriting an existing provider must not drop its multi-key pool: carry it over, then
111
114
  // let the (possibly new) apiKey join the pool as the active entry.
112
115
  const existingPool = config.providers[name]?.apiKeyPool;
@@ -148,7 +151,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
148
151
  if (!provider || !isCanonicalOpenAiForwardProvider(provider)) {
149
152
  return jsonResponse({ error: "provider openai must be the canonical built-in provider" }, 400);
150
153
  }
151
- const { saveConfig: save } = await import("../../config");
154
+ const { saveConfigPreservingClaudeCode: save } = await import("../../config");
152
155
  config.providers.openai = { ...provider, codexAccountMode: mode };
153
156
  save(config);
154
157
  (deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
@@ -233,18 +236,46 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
233
236
 
234
237
  if (!touched) return jsonResponse({ error: "no recognized fields to update" }, 400);
235
238
 
236
- // A disabled-only toggle preserves the v2 fast lane: it changes routing eligibility,
237
- // not the provider shape, so the merged-shape validators (canonical-seed guard for
238
- // openai, destination/local checks) do not apply.
239
+ // A disabled-only toggle preserves the v2 fast lane for non-openai providers: it changes
240
+ // routing eligibility, not the provider shape. Re-enabling `openai` is different a
241
+ // malformed disabled row must not come back online unchanged, so canonicalize/reject
242
+ // against the same built-in gate used by mode PATCH and POST.
239
243
  const editorTouched = keys.some(key => key !== "disabled");
244
+ const enablingOpenAi = name === "openai"
245
+ && Object.hasOwn(rawBody, "disabled")
246
+ && rawBody.disabled === false
247
+ && config.providers[name]?.disabled === true;
240
248
  if (editorTouched) {
241
249
  const providerError = providerManagementConfigError(name, next);
242
250
  if (providerError) return jsonResponse({ error: providerError }, 400);
243
251
  const resolvedError = await providerDestinationResolvedError(name, next);
244
252
  if (resolvedError) return jsonResponse({ error: resolvedError }, 400);
253
+ } else if (enablingOpenAi) {
254
+ if (!isCanonicalOpenAiForwardProvider(next)) {
255
+ return jsonResponse({ error: "provider openai must be the canonical built-in provider" }, 400);
256
+ }
257
+ // Persist the byte-identical canonical URL so config.ts startup checks (case-sensitive)
258
+ // accept the row after we fill mode. Equivalent hosts like CHATGPT.com/:443 normalize here.
259
+ next.baseUrl = CODEX_FORWARD_BASE_URL;
260
+ // Fill missing mode so a disabled canonical row becomes a complete live openai entry.
261
+ if (next.codexAccountMode !== "pool" && next.codexAccountMode !== "direct") {
262
+ next.codexAccountMode = "pool";
263
+ }
264
+ // Same DNS gate as POST: Clash fake-IP only. Never honor a persisted
265
+ // allowPrivateNetwork on this path — it must not bypass the built-in guard.
266
+ const resolvedError = await providerDestinationResolvedError(
267
+ "openai",
268
+ { baseUrl: CODEX_FORWARD_BASE_URL },
269
+ { allowBenchmarkAddresses: true },
270
+ );
271
+ if (resolvedError) return jsonResponse({ error: resolvedError }, 400);
272
+ if (next.disabled === false) delete next.disabled;
273
+ // Canonical openai never uses private-network opt-in; drop a stale flag that
274
+ // was ignored for the DNS probe so it cannot linger on the live row.
275
+ delete next.allowPrivateNetwork;
245
276
  }
246
277
 
247
- const { saveConfig: save } = await import("../../config");
278
+ const { saveConfigPreservingClaudeCode: save } = await import("../../config");
248
279
  config.providers[name] = stripRegistryOnlyStaticHeaders(name, next);
249
280
  save(config);
250
281
  if (editorTouched) {
@@ -335,7 +366,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
335
366
  combos: dependentCombos,
336
367
  }, 409);
337
368
  }
338
- const { saveConfig: save } = await import("../../config");
369
+ const { saveConfigPreservingClaudeCode: save } = await import("../../config");
339
370
  delete config.providers[name];
340
371
  setProviderContextCap(config, name, false);
341
372
  save(config);
@@ -352,7 +383,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
352
383
  if (url.pathname === "/api/provider-context-caps" && req.method === "PUT") {
353
384
  let body: { provider?: unknown; enabled?: unknown; value?: unknown; setAll?: unknown };
354
385
  try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
355
- const { saveConfig: save } = await import("../../config");
386
+ const { saveConfigPreservingClaudeCode: save } = await import("../../config");
356
387
  const { clearModelCache } = await import("../../codex/model-cache");
357
388
  const respond = () => jsonResponse({ ok: true, cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config) });
358
389
 
@@ -10,7 +10,7 @@ import {
10
10
  multiAgentGuidanceEnabled,
11
11
  providerBaseUrlConfigError,
12
12
  providerHeadersConfigError,
13
- saveConfig,
13
+ saveConfigPreservingClaudeCode,
14
14
  } from "../../config";
15
15
  import {
16
16
  clearLoginState,
@@ -48,7 +48,8 @@ import {
48
48
  setDebugSettings,
49
49
  type DebugFlag,
50
50
  } from "../../lib/debug-settings";
51
- import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
51
+ import type { OcxClaudeCodeConfig, OcxClaudeDesktopProfile, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
52
+ import type { DesktopProfileModel } from "../../claude/desktop-profile";
52
53
  import { drainAndShutdown } from "../lifecycle";
53
54
  import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
54
55
  import { estimateComboCost, estimateRequestCost, effectiveServiceTier, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
@@ -172,6 +173,34 @@ export async function fetchAllModels(config: OcxConfig): Promise<CatalogModel[]>
172
173
  return gatherRoutedModels(config);
173
174
  }
174
175
 
176
+ export interface GrokCandidateModel {
177
+ id: string;
178
+ contextWindow?: number;
179
+ native: boolean;
180
+ }
181
+
182
+ /**
183
+ * The model list `syncGrokConfig` would inject, BEFORE the user's exclusions. The Grok
184
+ * page needs this to show a switch for a model the user has already excluded — such a
185
+ * model is absent from the fence, so readGrokStatus alone could never list it. Built
186
+ * from the same two sources as the sync so the two can never disagree.
187
+ */
188
+ export async function fetchGrokCandidateModels(config: OcxConfig): Promise<GrokCandidateModel[]> {
189
+ const { filterCatalogVisibleModels, nativeOpenAiContextWindow, visibleNativeSlugs } = await import("../../codex/catalog");
190
+ const routed = filterCatalogVisibleModels(await fetchAllModels(config), config);
191
+ return [
192
+ ...visibleNativeSlugs(config).map(id => {
193
+ const contextWindow = nativeOpenAiContextWindow(id);
194
+ return { id, native: true, ...(contextWindow !== undefined ? { contextWindow } : {}) };
195
+ }),
196
+ ...routed.map(m => ({
197
+ id: m.alias ?? `${m.provider}/${m.id}`,
198
+ native: false,
199
+ ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}),
200
+ })),
201
+ ];
202
+ }
203
+
175
204
  export function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProviderConfig): OcxProviderConfig {
176
205
  const entry = getProviderRegistryEntry(name);
177
206
  if (!entry?.staticHeaders || !provider.headers) return provider;
@@ -184,3 +213,53 @@ export function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProvid
184
213
  return rest;
185
214
  }
186
215
 
216
+ /** Shared Desktop profile DTO builder for the management API and CLI. */
217
+ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxClaudeDesktopProfile) {
218
+ const { filterCatalogVisibleModels, nativeOpenAiContextWindow, visibleNativeSlugs } = await import("../../codex/catalog");
219
+ const { DESKTOP_SUPPORTS_1M_THRESHOLD } = await import("../../claude/desktop-3p");
220
+ const { reconcileDesktopProfile, renderDesktopProfile } = await import("../../claude/desktop-profile");
221
+ const routed = filterCatalogVisibleModels(await fetchAllModels(config), config);
222
+ const profileModels: DesktopProfileModel[] = [
223
+ // Native rows carry their real context window from the same accessor the Grok sync
224
+ // uses — otherwise Sol's 372k and gpt-5.5's 272k render as blank on Desktop.
225
+ ...visibleNativeSlugs(config).map(id => {
226
+ const contextWindow = nativeOpenAiContextWindow(id);
227
+ return { route: `native/${id}`, label: `${id} (native)`,
228
+ ...(contextWindow !== undefined ? { contextWindow } : {}) };
229
+ }),
230
+ ...routed.map(model => ({
231
+ route: `${model.provider}/${model.id}`,
232
+ label: `${model.id} (${model.provider})`,
233
+ ...(typeof model.contextWindow === "number" ? { contextWindow: model.contextWindow } : {}),
234
+ })),
235
+ ];
236
+ const profile = reconcileDesktopProfile(stored ?? config.claudeCode?.desktopProfile, profileModels);
237
+ const available = new Set(profileModels.map(model => model.route));
238
+ const modelByRoute = new Map(profileModels.map(model => [model.route, model]));
239
+ // Effort support: routed models with a non-empty reasoningEfforts ladder support effort;
240
+ // native models always support it (Anthropic native effort).
241
+ const effortByRoute = new Map<string, boolean>();
242
+ for (const m of routed) {
243
+ effortByRoute.set(`${m.provider}/${m.id}`, Array.isArray(m.reasoningEfforts) && m.reasoningEfforts.length > 0);
244
+ }
245
+ for (const id of visibleNativeSlugs(config)) {
246
+ effortByRoute.set(`native/${id}`, true);
247
+ }
248
+ const models = Object.keys(profile.assignments).sort().map(route => ({
249
+ route,
250
+ label: modelByRoute.get(route)?.label ?? route,
251
+ available: available.has(route),
252
+ ...(modelByRoute.get(route)?.contextWindow ? { contextWindow: modelByRoute.get(route)!.contextWindow } : {}),
253
+ effortSupported: effortByRoute.get(route) ?? false,
254
+ // Read-only view of the 1M capability the written Desktop config already emits,
255
+ // derived from the SAME threshold so the dashboard chip can never disagree.
256
+ supports1m: (modelByRoute.get(route)?.contextWindow ?? 0) >= DESKTOP_SUPPORTS_1M_THRESHOLD,
257
+ assignment: profile.assignments[route]!,
258
+ }));
259
+ return {
260
+ profile,
261
+ models,
262
+ rendered: renderDesktopProfile(profile, profileModels),
263
+ port: config.port,
264
+ };
265
+ }
@@ -9,10 +9,14 @@
9
9
  * The payload is scalar-only (numbers, enum strings): no paths, no tokens, no
10
10
  * account identifiers. `jscHeap` (bun:jsc heapStats) is the js-vs-native
11
11
  * discriminator: a flat JS heap under a growing RSS points at native runtime
12
- * memory (the #314 shape), not an app-level JS leak.
12
+ * memory (the #314 shape), not an app-level JS leak. `responseState` attributes
13
+ * JS-heap growth further: it is the proxy's previous_response_id continuation
14
+ * store, so a growing responseState.totalBytes under a growing heap points the
15
+ * finger at conversation retention rather than the runtime allocator.
13
16
  */
14
17
  import { decideEagerRelay } from "../../lib/bun-stream-caps";
15
18
  import { getActiveMemoryWatchdog } from "../memory-watchdog";
19
+ import { responseStateMetrics } from "../../responses/state";
16
20
  import { jsonResponse } from "../auth-cors";
17
21
  import type { ManagementContext } from "./context";
18
22
 
@@ -56,6 +60,7 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
56
60
  heapUsed: usage.heapUsed,
57
61
  heapTotal: usage.heapTotal,
58
62
  jscHeap,
63
+ responseState: responseStateMetrics(),
59
64
  streamMode,
60
65
  eagerRelay: process.platform === "win32" ? decideEagerRelay(streamMode) : null,
61
66
  watchdog,