@bitkyc08/opencodex 2.7.43 → 2.8.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 (73) hide show
  1. package/bin/ocx.mjs +34 -8
  2. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  3. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/discovery.ts +4 -1
  7. package/src/adapters/cursor/effort-map.ts +3 -0
  8. package/src/adapters/kiro.ts +15 -1
  9. package/src/claude/alias.ts +94 -14
  10. package/src/claude/outbound.ts +6 -3
  11. package/src/cli/catalog-prewarm.ts +24 -0
  12. package/src/cli/claude.ts +32 -7
  13. package/src/cli/doctor.ts +48 -1
  14. package/src/cli/index.ts +5 -0
  15. package/src/cli/interactive-confirm.ts +5 -1
  16. package/src/cli/star-prompt.ts +26 -4
  17. package/src/cli/v2.ts +10 -1
  18. package/src/codex/account-store.ts +2 -0
  19. package/src/codex/catalog/bundled.ts +9 -2
  20. package/src/codex/catalog/parsing.ts +26 -1
  21. package/src/codex/catalog/provider-fetch.ts +240 -82
  22. package/src/codex/catalog/sync.ts +27 -5
  23. package/src/codex/catalog.ts +1 -1
  24. package/src/codex/features.ts +524 -5
  25. package/src/codex/quota.ts +77 -2
  26. package/src/codex/runtime.ts +10 -1
  27. package/src/config.ts +8 -0
  28. package/src/generated/jawcode-model-metadata.ts +12 -12
  29. package/src/github/star-state.ts +191 -0
  30. package/src/lib/bun-binary-validator.d.mts +3 -0
  31. package/src/lib/bun-binary-validator.mjs +18 -0
  32. package/src/lib/bun-runtime.ts +6 -20
  33. package/src/lib/destination-policy.ts +10 -3
  34. package/src/lib/provider-outbound.ts +5 -2
  35. package/src/lib/shadow-call.ts +30 -0
  36. package/src/lib/test-home-guard.ts +90 -0
  37. package/src/lib/win-exec.ts +12 -2
  38. package/src/oauth/index.ts +29 -5
  39. package/src/oauth/key-providers.ts +21 -2
  40. package/src/oauth/kiro-credentials.ts +57 -8
  41. package/src/oauth/kiro.ts +2 -1
  42. package/src/oauth/login-cli.ts +1 -1
  43. package/src/oauth/store.ts +2 -0
  44. package/src/providers/derive.ts +2 -2
  45. package/src/providers/model-discovery.ts +356 -0
  46. package/src/providers/registry.ts +114 -0
  47. package/src/router.ts +5 -3
  48. package/src/server/auth-cors.ts +4 -2
  49. package/src/server/live.ts +75 -25
  50. package/src/server/management/agent-settings-routes.ts +78 -4
  51. package/src/server/management/config-routes.ts +19 -7
  52. package/src/server/management/context.ts +11 -1
  53. package/src/server/management/model-routes.ts +46 -13
  54. package/src/server/management/provider-routes.ts +44 -9
  55. package/src/server/management/shared.ts +2 -2
  56. package/src/server/management/sidebar-routes.ts +39 -0
  57. package/src/server/management-api.ts +3 -1
  58. package/src/server/responses/core.ts +31 -20
  59. package/src/server/responses/upstream-error.ts +48 -0
  60. package/src/server/startup-action-control.ts +30 -14
  61. package/src/service.ts +237 -19
  62. package/src/storage/policy-job.ts +26 -5
  63. package/src/storage/restore-job.ts +16 -5
  64. package/src/storage/worker-lifecycle.ts +81 -0
  65. package/src/tray/windows.ts +32 -4
  66. package/src/types.ts +11 -0
  67. package/src/update/badge.ts +72 -0
  68. package/src/update/job.ts +8 -4
  69. package/src/usage/expected-prices.ts +6 -5
  70. package/src/usage/log.ts +8 -0
  71. package/src/web-search/loop.ts +57 -16
  72. package/gui/dist/assets/index-Czw-jpTU.css +0 -1
  73. package/gui/dist/assets/index-cmds12BG.js +0 -67
@@ -104,22 +104,43 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
104
104
  // itself never writes config — this endpoint is the only server-side mutation
105
105
  // surface for the flag.
106
106
  if (url.pathname === "/api/v2" && req.method === "GET") {
107
- const { isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads } = await import("../../codex/features");
107
+ const {
108
+ isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads,
109
+ getAgentsEnabled, getAgentsMaxDepth, getSubagentDeveloperInstructions,
110
+ } = await import("../../codex/features");
108
111
  const enabled = isMultiAgentV2Enabled();
109
112
  return jsonResponse({
110
113
  enabled,
111
114
  agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(),
112
115
  maxConcurrentThreadsPerSession: getLogicalMaxThreads(),
113
116
  multiAgentMode: config.multiAgentMode ?? "default",
117
+ agentsEnabled: getAgentsEnabled(),
118
+ agentsMaxDepth: getAgentsMaxDepth(),
119
+ subagentDeveloperInstructions: getSubagentDeveloperInstructions(),
120
+ // max_depth is V1-only upstream; this is the global-flag statement, derived
121
+ // server-side so no client can present it as an effective V2 limit.
122
+ agentsMaxDepthAppliesWhenV2Disabled: !enabled,
114
123
  });
115
124
  }
116
125
  if (url.pathname === "/api/v2" && req.method === "PUT") {
117
- let body: { enabled?: unknown; maxConcurrentThreadsPerSession?: unknown; multiAgentMode?: unknown };
126
+ let body: {
127
+ enabled?: unknown;
128
+ maxConcurrentThreadsPerSession?: unknown;
129
+ multiAgentMode?: unknown;
130
+ agentsEnabled?: unknown;
131
+ agentsMaxDepth?: unknown;
132
+ subagentDeveloperInstructions?: unknown;
133
+ };
118
134
  try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
119
135
  const wantsFlag = body.enabled !== undefined;
120
136
  const wantsThreads = body.maxConcurrentThreadsPerSession !== undefined;
121
137
  const wantsMode = body.multiAgentMode !== undefined;
122
- if (!wantsFlag && !wantsThreads && !wantsMode) return jsonResponse({ error: "body must set enabled, multiAgentMode, and/or maxConcurrentThreadsPerSession" }, 400);
138
+ const wantsAgentsEnabled = body.agentsEnabled !== undefined;
139
+ const wantsMaxDepth = body.agentsMaxDepth !== undefined;
140
+ const wantsSubagentInstructions = body.subagentDeveloperInstructions !== undefined;
141
+ if (!wantsFlag && !wantsThreads && !wantsMode && !wantsAgentsEnabled && !wantsMaxDepth && !wantsSubagentInstructions) {
142
+ return jsonResponse({ error: "body must set enabled, multiAgentMode, maxConcurrentThreadsPerSession, agentsEnabled, agentsMaxDepth, and/or subagentDeveloperInstructions" }, 400);
143
+ }
123
144
  if (wantsFlag && typeof body.enabled !== "boolean") return jsonResponse({ error: "body.enabled must be a boolean" }, 400);
124
145
  if (wantsMode && body.multiAgentMode !== "v1" && body.multiAgentMode !== "default" && body.multiAgentMode !== "v2") {
125
146
  return jsonResponse({ error: "body.multiAgentMode must be 'v1', 'default', or 'v2'" }, 400);
@@ -127,12 +148,31 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
127
148
  if (wantsThreads && (typeof body.maxConcurrentThreadsPerSession !== "number" || !Number.isInteger(body.maxConcurrentThreadsPerSession) || body.maxConcurrentThreadsPerSession < 1)) {
128
149
  return jsonResponse({ error: "body.maxConcurrentThreadsPerSession must be an integer >= 1" }, 400);
129
150
  }
151
+ // Validate every new field BEFORE any write, so each 400 leaves config untouched.
152
+ // null unsets the key; "" is a meaningful value for instructions and must not be
153
+ // collapsed by a falsy check. The i32 preflight mirrors the upstream Option<i32>
154
+ // contract — out-of-range would otherwise surface as a mid-sequence write failure.
155
+ if (wantsAgentsEnabled && body.agentsEnabled !== null && typeof body.agentsEnabled !== "boolean") {
156
+ return jsonResponse({ error: "body.agentsEnabled must be a boolean or null" }, 400);
157
+ }
158
+ if (wantsMaxDepth && body.agentsMaxDepth !== null
159
+ && (typeof body.agentsMaxDepth !== "number" || !Number.isInteger(body.agentsMaxDepth)
160
+ || body.agentsMaxDepth < -2_147_483_648 || body.agentsMaxDepth > 2_147_483_647)) {
161
+ return jsonResponse({ error: "body.agentsMaxDepth must be an integer within signed i32 range, or null" }, 400);
162
+ }
163
+ if (wantsSubagentInstructions && body.subagentDeveloperInstructions !== null && typeof body.subagentDeveloperInstructions !== "string") {
164
+ return jsonResponse({ error: "body.subagentDeveloperInstructions must be a string or null" }, 400);
165
+ }
130
166
  const mode = wantsMode ? body.multiAgentMode as "v1" | "default" | "v2" : undefined;
131
167
  const modeFlag = mode === "v2" ? true : mode === "v1" ? false : undefined;
132
168
  if (wantsFlag && modeFlag !== undefined && body.enabled !== modeFlag) {
133
169
  return jsonResponse({ error: `body.enabled conflicts with multiAgentMode '${mode}'` }, 400);
134
170
  }
135
- const { isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads, transitionMultiAgentV2 } = await import("../../codex/features");
171
+ const {
172
+ isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads, transitionMultiAgentV2,
173
+ getAgentsEnabled, getAgentsMaxDepth, getSubagentDeveloperInstructions,
174
+ setAgentsEnabled, setAgentsMaxDepth, setSubagentDeveloperInstructions,
175
+ } = await import("../../codex/features");
136
176
  const warnings: string[] = [];
137
177
  const requestedFlag = wantsFlag ? body.enabled as boolean : modeFlag;
138
178
  if (requestedFlag !== undefined || wantsThreads) {
@@ -159,6 +199,36 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
159
199
  saveConfigPreservingClaudeCode(config);
160
200
  warnings.push(`Multi-agent mode set to '${mode}'. Applies to new sessions.`);
161
201
  }
202
+ // New-key scalar writes: each writer is individually atomic, so apply them in
203
+ // sequence after the transition. A failure here is a persistence failure (the
204
+ // writers' ok:false result or a throw from the underlying atomic write helper),
205
+ // reported as 502 naming the failed key plus the writes that already landed.
206
+ // NOTE: do not name that helper literally here — tests/grok-writer-boundary.test.ts
207
+ // asserts this route file contains no direct write primitive, and matches on the
208
+ // symbol name even inside a comment.
209
+ const scalarWrites: Array<{ field: string; run: () => { ok: true; changed: boolean } | { ok: false; error: string } }> = [];
210
+ if (wantsAgentsEnabled) scalarWrites.push({ field: "agentsEnabled", run: () => setAgentsEnabled(body.agentsEnabled as boolean | null) });
211
+ if (wantsMaxDepth) scalarWrites.push({ field: "agentsMaxDepth", run: () => setAgentsMaxDepth(body.agentsMaxDepth as number | null) });
212
+ if (wantsSubagentInstructions) scalarWrites.push({ field: "subagentDeveloperInstructions", run: () => setSubagentDeveloperInstructions(body.subagentDeveloperInstructions as string | null) });
213
+ const landed: string[] = [];
214
+ for (const write of scalarWrites) {
215
+ try {
216
+ const result = write.run();
217
+ if (!result.ok) {
218
+ return jsonResponse({ error: `writing ${write.field} failed: ${result.error}${landed.length > 0 ? ` (already applied: ${landed.join(", ")})` : ""}` }, 502);
219
+ }
220
+ landed.push(write.field);
221
+ } catch (err) {
222
+ const message = err instanceof Error ? err.message : String(err);
223
+ return jsonResponse({ error: `writing ${write.field} failed: ${message}${landed.length > 0 ? ` (already applied: ${landed.join(", ")})` : ""}` }, 502);
224
+ }
225
+ }
226
+ // Derived from fresh post-write readers (readConfigText is uncached): upstream
227
+ // lets an enabled multi_agent_v2 feature override [agents].enabled = false, so
228
+ // warn rather than reject — silently accepting would imply multi-agent is off.
229
+ if (getAgentsEnabled() === false && isMultiAgentV2Enabled()) {
230
+ warnings.push("agents.enabled = false has no effect while features.multi_agent_v2 is enabled; upstream keeps V2 active.");
231
+ }
162
232
  await refreshCodexCatalogBestEffort();
163
233
  if (requestedFlag !== undefined) warnings.push("Applies to new sessions; restart the Codex app or wait out its picker cache to see the ladder change.");
164
234
  const enabled = isMultiAgentV2Enabled();
@@ -168,6 +238,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
168
238
  agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(),
169
239
  maxConcurrentThreadsPerSession: getLogicalMaxThreads(),
170
240
  multiAgentMode: config.multiAgentMode ?? "default",
241
+ agentsEnabled: getAgentsEnabled(),
242
+ agentsMaxDepth: getAgentsMaxDepth(),
243
+ subagentDeveloperInstructions: getSubagentDeveloperInstructions(),
244
+ agentsMaxDepthAppliesWhenV2Disabled: !enabled,
171
245
  warnings,
172
246
  });
173
247
  }
@@ -24,6 +24,7 @@ import {
24
24
  import { removeCredential } from "../../oauth/store";
25
25
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
26
26
  import { isStreamMode } from "../../lib/bun-stream-caps";
27
+ import { shadowSourceModels } from "../../lib/shadow-call";
27
28
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
28
29
  import { deriveProviderPresets } from "../../providers/derive";
29
30
  import { providerCodexAccountMode } from "../../providers/registry";
@@ -140,16 +141,20 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
140
141
  }
141
142
 
142
143
  if (url.pathname === "/api/startup-action" && req.method === "POST") {
143
- let body: { action?: unknown };
144
+ let body: { action?: unknown; repair?: unknown };
144
145
  try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
145
146
  if (!body || !["install-service", "install-shim"].includes(String(body.action))) {
146
147
  return jsonResponse({ error: "action must be install-service or install-shim" }, 400);
147
148
  }
149
+ if (body.repair !== undefined && typeof body.repair !== "boolean") {
150
+ return jsonResponse({ error: "repair must be a boolean when provided" }, 400);
151
+ }
148
152
  try {
149
153
  const action = body.action as StartupInstallAction;
150
- const result = await (deps.runStartupInstallAction ?? runStartupInstallAction)(action);
154
+ const repair = body.repair === true;
155
+ const result = await (deps.runStartupInstallAction ?? runStartupInstallAction)(action, { repair });
151
156
  invalidateStartupHealthCache();
152
- return jsonResponse({ ok: true, action, message: result.message });
157
+ return jsonResponse({ ok: true, action, repair, message: result.message });
153
158
  } catch (error) {
154
159
  return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500);
155
160
  }
@@ -227,8 +232,6 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
227
232
  const { syncModelsToCodex } = await import("../../codex/sync");
228
233
  const { attachStaleAppServerHint } = await import("../../codex/app-server-processes");
229
234
  const result = await syncModelsToCodex(undefined, config, null);
230
- // Hint only after a real catalog/cache write — never enumerate processes here
231
- // (WMIC/PowerShell would block Bun's event loop on every dashboard sync).
232
235
  return jsonResponse({
233
236
  ...attachStaleAppServerHint(result),
234
237
  ...(result.ok ? {} : { error: result.message }),
@@ -352,7 +355,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
352
355
 
353
356
  if (url.pathname === "/api/shadow-call-settings" && req.method === "GET") {
354
357
  const sci = config.shadowCallIntercept ?? {};
355
- return jsonResponse({ enabled: sci.enabled === true, model: sci.model ?? "" });
358
+ return jsonResponse({
359
+ enabled: sci.enabled === true,
360
+ model: sci.model ?? "",
361
+ sourceModels: shadowSourceModels(sci.sourceModels),
362
+ });
356
363
  }
357
364
 
358
365
  if (url.pathname === "/api/shadow-call-settings" && req.method === "PUT") {
@@ -374,7 +381,12 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
374
381
  }
375
382
  saveConfigPreservingClaudeCode(config);
376
383
  const sci = config.shadowCallIntercept;
377
- return jsonResponse({ ok: true, enabled: sci.enabled === true, model: sci.model ?? "" });
384
+ return jsonResponse({
385
+ ok: true,
386
+ enabled: sci.enabled === true,
387
+ model: sci.model ?? "",
388
+ sourceModels: shadowSourceModels(sci.sourceModels),
389
+ });
378
390
  }
379
391
  return null;
380
392
  }
@@ -4,10 +4,20 @@ import type { StartupInstallAction } from "../startup-action-control";
4
4
  export interface ManagementApiDeps {
5
5
  toggleCodexMultiAgentV2?: (enabled: boolean) => void;
6
6
  refreshCodexCatalog?: () => Promise<void>;
7
+ /**
8
+ * Persistence seam for route-level tests. Production leaves this unset and uses
9
+ * `saveConfigPreservingClaudeCode`; tests that pass an in-memory fixture config
10
+ * MUST inject a no-op/spy so the fixture can never overwrite the user's real
11
+ * OPENCODEX_HOME (incident: devlog 260730.../070).
12
+ */
13
+ saveConfigPreservingClaudeCode?: (config: OcxConfig) => void;
7
14
  clearThreadAccountMap?: () => void;
8
15
  clearProviderQuotaCache?: () => void;
9
16
  primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise<void> | void;
10
- runStartupInstallAction?: (action: StartupInstallAction) => Promise<{ message: string }>;
17
+ runStartupInstallAction?: (
18
+ action: StartupInstallAction,
19
+ options?: { repair?: boolean },
20
+ ) => Promise<{ message: string }>;
11
21
  }
12
22
 
13
23
 
@@ -1,5 +1,34 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
+
4
+ /**
5
+ * Codex parses a catalog entry's `input_modalities` as a closed enum, and one out-of-enum
6
+ * value makes it reject the ENTIRE catalog file — plugins, apps and MCP servers all stop
7
+ * loading over one model's metadata (#759).
8
+ *
9
+ * The catalog writer normalizes on the way out, but a rejected value stored here would still
10
+ * be handed back to the GUI and CLI as if it were real, and the offline `ocx models add` path
11
+ * already refuses it. Validate at ingress so all three paths agree.
12
+ */
13
+ const ALLOWED_INPUT_MODALITIES = new Set(["text", "image", "audio"]);
14
+
15
+ function readInputModalities(raw: unknown): { values?: string[]; error?: string } {
16
+ if (raw === undefined) return {};
17
+ if (!Array.isArray(raw)) return { error: "inputModalities must be an array" };
18
+ // Reject non-strings rather than filtering them out. Dropping them silently accepted a
19
+ // malformed POST and, worse, let a PUT of `[42]` clear the stored modalities while
20
+ // answering 200 — the opposite of the contract this validator exists to state. An empty
21
+ // array stays valid: that is how `ocx models edit --modalities -` clears the field.
22
+ const rejected: string[] = [];
23
+ for (const value of raw) {
24
+ if (typeof value !== "string") return { error: "inputModalities must contain only strings" };
25
+ if (!ALLOWED_INPUT_MODALITIES.has(value)) rejected.push(value);
26
+ }
27
+ if (rejected.length > 0) {
28
+ return { error: `unsupported input modality: ${rejected.join(", ")} (allowed: text, image, audio)` };
29
+ }
30
+ return { values: raw as string[] };
31
+ }
3
32
  import type { CatalogModel } from "../../codex/catalog";
4
33
  import { catalogModelSlug, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
5
34
  import { getProviderLiveModelCount } from "../../codex/model-cache";
@@ -63,6 +92,11 @@ import type { ManagementContext } from "./context";
63
92
 
64
93
  export async function handleModelRoutes(ctx: ManagementContext): Promise<Response | null> {
65
94
  const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
95
+ // A handler persists the exact config object passed in. Production defaults to
96
+ // the real store; tests that pass an in-memory fixture inject a no-op/spy. Do not
97
+ // bypass this seam with a dynamic config import — doing so replaced a user's
98
+ // ~/.opencodex/config.json with the `existing-uuid` test fixture.
99
+ const persistConfig = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
66
100
 
67
101
  if (url.pathname === "/api/models" && req.method === "GET") {
68
102
  const models = await fetchAllModels(config);
@@ -123,8 +157,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
123
157
  try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
124
158
  const disabled = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string") : [];
125
159
  config.disabledModels = disabled;
126
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
127
- save(config);
160
+ persistConfig(config);
128
161
  await refreshCodexCatalogBestEffort();
129
162
  return jsonResponse({ ok: true, disabled });
130
163
  }
@@ -223,7 +256,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
223
256
  }
224
257
 
225
258
  config.disabledModels = disabled;
226
- saveConfigPreservingClaudeCode(config);
259
+ persistConfig(config);
227
260
  await refreshCodexCatalogBestEffort();
228
261
  return jsonResponse({ ok: true, scope, provider, enabled: body.enabled, disabled });
229
262
  }
@@ -244,7 +277,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
244
277
  const displayName = typeof body.displayName === "string" && body.displayName.trim() ? body.displayName.trim() : undefined;
245
278
  if (displayName?.includes("/")) return jsonResponse({ error: "displayName must not contain /" }, 400);
246
279
  const contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
247
- const inputModalities = Array.isArray(body.inputModalities) ? body.inputModalities.filter((m): m is string => typeof m === "string") : undefined;
280
+ const modalities = readInputModalities(body.inputModalities);
281
+ if (modalities.error) return jsonResponse({ error: modalities.error }, 400);
282
+ const inputModalities = modalities.values;
248
283
  const existing = config.customModels ?? [];
249
284
  const newSlug = routedSlug(provider, modelId);
250
285
  if (existing.some(cm => routedSlug(cm.provider, cm.modelId) === newSlug)) {
@@ -260,8 +295,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
260
295
  addedAt: new Date().toISOString(),
261
296
  };
262
297
  config.customModels = [...existing, entry];
263
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
264
- save(config);
298
+ persistConfig(config);
265
299
  await refreshCodexCatalogBestEffort();
266
300
  return jsonResponse(entry, 201);
267
301
  }
@@ -289,7 +323,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
289
323
  cm.contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
290
324
  }
291
325
  if (body.inputModalities !== undefined) {
292
- cm.inputModalities = Array.isArray(body.inputModalities) ? body.inputModalities.filter((m): m is string => typeof m === "string") : undefined;
326
+ const edited = readInputModalities(body.inputModalities);
327
+ if (edited.error) return jsonResponse({ error: edited.error }, 400);
328
+ cm.inputModalities = edited.values && edited.values.length > 0 ? edited.values : undefined;
293
329
  }
294
330
  const updatedSlug = routedSlug(cm.provider, cm.modelId);
295
331
  if (list.some((other, i) => i !== idx && routedSlug(other.provider, other.modelId) === updatedSlug)) {
@@ -297,8 +333,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
297
333
  }
298
334
  list[idx] = cm;
299
335
  config.customModels = list;
300
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
301
- save(config);
336
+ persistConfig(config);
302
337
  await refreshCodexCatalogBestEffort();
303
338
  return jsonResponse(cm);
304
339
  }
@@ -312,8 +347,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
312
347
  if (idx === -1) return jsonResponse({ error: "not found" }, 404);
313
348
  list.splice(idx, 1);
314
349
  config.customModels = list.length > 0 ? list : undefined;
315
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
316
- save(config);
350
+ persistConfig(config);
317
351
  await refreshCodexCatalogBestEffort();
318
352
  return jsonResponse({ ok: true });
319
353
  }
@@ -349,8 +383,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
349
383
  // Empty list clears the allowlist (provider reverts to exposing all models).
350
384
  if (models.length > 0) config.providers[provider].selectedModels = models;
351
385
  else delete config.providers[provider].selectedModels;
352
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
353
- save(config);
386
+ persistConfig(config);
354
387
  await refreshCodexCatalogBestEffort();
355
388
  return jsonResponse({ ok: true, provider, selected: models });
356
389
  }
@@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
  import type { CatalogModel } from "../../codex/catalog";
4
4
  import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
5
- import { providerModelsListFromProbeResponse } from "../../codex/catalog/provider-fetch";
6
5
  import {
7
6
  DEFAULT_SUBAGENT_MODELS,
8
7
  codexAutoStartEnabled,
@@ -24,10 +23,16 @@ import {
24
23
  } from "../../oauth";
25
24
  import { removeCredential } from "../../oauth/store";
26
25
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
27
- import { providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound";
26
+ import { ProviderOutboundPolicyError, providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound";
28
27
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
29
28
  import { deriveProviderPresets } from "../../providers/derive";
30
29
  import { providerCodexAccountMode } from "../../providers/registry";
30
+ import {
31
+ extractModelEnvelopeRows,
32
+ extractProviderModelItems,
33
+ readBoundedDiscoveryJson,
34
+ resolveProviderModelDiscovery,
35
+ } from "../../providers/model-discovery";
31
36
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
32
37
  import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
33
38
  import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
@@ -339,6 +344,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
339
344
  return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" });
340
345
  }
341
346
  const { url: modelsUrl, headers } = buildModelsRequest(prov, apiKey, name);
347
+ const discovery = resolveProviderModelDiscovery(name, prov);
342
348
  const started = Date.now();
343
349
  try {
344
350
  const res = await providerOutboundGet(name, prov, modelsUrl, {
@@ -355,15 +361,42 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
355
361
  });
356
362
  }
357
363
  if (!res.ok) {
364
+ try {
365
+ void res.body?.cancel().catch(() => undefined);
366
+ } catch {
367
+ // Best-effort release for non-conforming response streams.
368
+ }
358
369
  return jsonResponse({ ok: false, latencyMs, error: `upstream /models returned ${res.status}` });
359
370
  }
360
- const json = await res.json().catch(() => null);
361
- // OpenAI-style { data }, Google { models }, and Together-style top-level arrays (#617).
362
- const list = providerModelsListFromProbeResponse(json);
363
- if (!Array.isArray(list)) {
364
- return jsonResponse({ ok: false, latencyMs, error: "upstream /models returned an unexpected shape" });
371
+ const bounded = await readBoundedDiscoveryJson(res, discovery.maxResponseBytes);
372
+ if (!bounded.ok) {
373
+ return jsonResponse({
374
+ ok: false,
375
+ latencyMs,
376
+ error: bounded.reason === "response_too_large"
377
+ ? `upstream /models exceeded the ${discovery.maxResponseBytes}-byte response limit`
378
+ : "upstream /models returned invalid JSON",
379
+ });
380
+ }
381
+ // OpenAI-style lists (and Together top-level arrays) use the same validation/dedupe/filter
382
+ // as catalog discovery. Google's /v1beta/models uses `models[].name` and remains a
383
+ // connectivity-only count because it is not an authoritative catalog source.
384
+ const record = bounded.value !== null && typeof bounded.value === "object" && !Array.isArray(bounded.value)
385
+ ? bounded.value as Record<string, unknown>
386
+ : undefined;
387
+ const extracted = Array.isArray(bounded.value) || Array.isArray(record?.data)
388
+ ? extractProviderModelItems(bounded.value, discovery)
389
+ : extractModelEnvelopeRows(bounded.value, discovery.maxModels, ["models"]);
390
+ if (!extracted.ok) {
391
+ return jsonResponse({
392
+ ok: false,
393
+ latencyMs,
394
+ error: extracted.reason === "too_many_models"
395
+ ? `upstream /models exceeded the ${discovery.maxModels}-row model limit`
396
+ : "upstream /models returned an unexpected shape",
397
+ });
365
398
  }
366
- const models = list.length;
399
+ const models = "items" in extracted ? extracted.items.length : extracted.rows.length;
367
400
  return jsonResponse({
368
401
  ok: true,
369
402
  latencyMs,
@@ -374,7 +407,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
374
407
  return jsonResponse({
375
408
  ok: false,
376
409
  latencyMs: Date.now() - started,
377
- error: err instanceof Error ? err.message : "Connection test failed",
410
+ error: err instanceof ProviderOutboundPolicyError
411
+ ? `upstream /models blocked by destination policy: ${err.message}`
412
+ : err instanceof Error ? err.message : "Connection test failed",
378
413
  });
379
414
  }
380
415
  }
@@ -37,7 +37,7 @@ import { readUsageEntries } from "../../usage/log";
37
37
  import { getUsageDebugLogEntries } from "../../usage/debug";
38
38
  import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
39
39
  import { stripCodexRuntimeProviderFields } from "../../codex/auth-context";
40
- import { getProviderRegistryEntry } from "../../providers/registry";
40
+ import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry";
41
41
  import { getDebugLogEntries } from "../../lib/debug-log-buffer";
42
42
  import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log";
43
43
  import {
@@ -201,7 +201,7 @@ export async function fetchGrokCandidateModels(config: OcxConfig): Promise<GrokC
201
201
  }
202
202
 
203
203
  export function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProviderConfig): OcxProviderConfig {
204
- const entry = getProviderRegistryEntry(name);
204
+ const entry = providerMatchesRegistryTransport(name, provider) ? getProviderRegistryEntry(name) : undefined;
205
205
  if (!entry?.staticHeaders || !provider.headers) return provider;
206
206
  const headerEntries = Object.entries(provider.headers);
207
207
  const staticEntries = Object.entries(entry.staticHeaders);
@@ -0,0 +1,39 @@
1
+ /**
2
+ * /api/github/star and /api/update/badge — the two cheap polls behind the
3
+ * sidebar's GitHub star and update controls.
4
+ *
5
+ * Both ride the standard management gate (auth + origin check happen before
6
+ * dispatch), and both are scalar-only: a star state enum, a repo slug, version
7
+ * strings, and a fixed error code. No GitHub token, account login, or raw `gh`/npm
8
+ * output is ever serialized here — starring runs through the user's own `gh` CLI and
9
+ * this surface only learns the yes/no answer. `gh` writes the authenticated account
10
+ * name to stderr, so that output is discarded at the source rather than forwarded.
11
+ */
12
+ import { jsonResponse } from "../auth-cors";
13
+ import type { ManagementContext } from "./context";
14
+
15
+ export async function handleSidebarRoutes(ctx: ManagementContext): Promise<Response | null> {
16
+ const { req, url } = ctx;
17
+
18
+ if (url.pathname === "/api/github/star" && req.method === "GET") {
19
+ const { getStarStatus } = await import("../../github/star-state");
20
+ return jsonResponse(await getStarStatus());
21
+ }
22
+
23
+ if (url.pathname === "/api/github/star" && req.method === "POST") {
24
+ const { starRepository } = await import("../../github/star-state");
25
+ const result = await starRepository();
26
+ return jsonResponse({
27
+ ...result.status,
28
+ ok: result.ok,
29
+ ...(result.code ? { code: result.code } : {}),
30
+ });
31
+ }
32
+
33
+ if (url.pathname === "/api/update/badge" && req.method === "GET") {
34
+ const { readUpdateBadge } = await import("../../update/badge");
35
+ return jsonResponse(readUpdateBadge());
36
+ }
37
+
38
+ return null;
39
+ }
@@ -65,6 +65,7 @@ import { handleAgentSettingsRoutes } from "./management/agent-settings-routes";
65
65
  import { handleOauthAccountRoutes } from "./management/oauth-account-routes";
66
66
  import { handleComboRoutes } from "./management/combo-routes";
67
67
  import { handleSystemRoutes } from "./management/system-routes";
68
+ import { handleSidebarRoutes } from "./management/sidebar-routes";
68
69
  import type { ManagementContext } from "./management/context";
69
70
  export type { ManagementApiDeps } from "./management/context";
70
71
  import { fetchAllModels } from "./management/shared";
@@ -130,7 +131,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
130
131
  ?? (await handleAgentSettingsRoutes(ctx))
131
132
  ?? (await handleOauthAccountRoutes(ctx))
132
133
  ?? (await handleComboRoutes(ctx))
133
- ?? (await handleSystemRoutes(ctx));
134
+ ?? (await handleSystemRoutes(ctx))
135
+ ?? (await handleSidebarRoutes(ctx));
134
136
  if (routed) return routed;
135
137
 
136
138
  if (url.pathname === "/api/stop" && req.method === "POST") {
@@ -1,6 +1,7 @@
1
1
  import type { Server } from "bun";
2
2
  import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge";
3
3
  import { formatPassthroughUpstreamError } from "./passthrough-error";
4
+ import { describeUpstreamConnectFailure } from "./upstream-error";
4
5
  import {
5
6
  getConfigPath,
6
7
  multiAgentGuidanceEnabled,
@@ -173,16 +174,9 @@ export function sidecarOutcomeRecorder(
173
174
 
174
175
 
175
176
 
176
- export const DEFAULT_SHADOW_SOURCE_MODELS = ["gpt-5.4-mini", "gpt-5.6-luna"] as const;
177
+ import { isShadowSourceModel } from "../../lib/shadow-call";
177
178
 
178
- export function isShadowSourceModel(modelId: string, configured?: unknown): boolean {
179
- if (modelId.includes("/")) return false;
180
- const configuredStrings = Array.isArray(configured)
181
- ? configured.filter((v): v is string => typeof v === "string" && v.trim() !== "")
182
- : [];
183
- const prefixes = configuredStrings.length > 0 ? configuredStrings : DEFAULT_SHADOW_SOURCE_MODELS;
184
- return prefixes.some(prefix => modelId.startsWith(prefix.trim()));
185
- }
179
+ export { DEFAULT_SHADOW_SOURCE_MODELS, isShadowSourceModel, shadowSourceModels } from "../../lib/shadow-call";
186
180
 
187
181
 
188
182
 
@@ -1143,10 +1137,6 @@ export async function handleResponses(
1143
1137
  }
1144
1138
  if (parsed._compactionRequest === true) parsed._cursorIsolateConversation = true;
1145
1139
 
1146
- if (isThreadSpawnRequest(req.headers)) {
1147
- await maybePrimeSubagentQuota(config);
1148
- }
1149
-
1150
1140
  let route: RouteResult;
1151
1141
  try {
1152
1142
  route = routeModel(config, parsed.modelId);
@@ -1157,6 +1147,17 @@ export async function handleResponses(
1157
1147
  return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
1158
1148
  }
1159
1149
 
1150
+ const hasUnexpandedPreviousResponse = !!parsed.previousResponseId
1151
+ && parsed._previousResponseInputExpanded !== true;
1152
+ // A canonical replay miss must not poll quota upstream before the final fail-closed decision.
1153
+ // Cached fallback state can still select a provider with native continuation support below.
1154
+ if (
1155
+ isThreadSpawnRequest(req.headers)
1156
+ && !(hasUnexpandedPreviousResponse && isCanonicalOpenAiForwardProvider(route.provider))
1157
+ ) {
1158
+ await maybePrimeSubagentQuota(config);
1159
+ }
1160
+
1160
1161
  let authCtx: CodexAuthContext = { kind: "main", accountId: null };
1161
1162
  let selectedForwardHeaders = req.headers;
1162
1163
  let subagentFallbackAccountId = config.activeCodexAccountId ?? null;
@@ -1205,6 +1206,20 @@ export async function handleResponses(
1205
1206
  return unreadableEncryptedAgentTaskResponse();
1206
1207
  }
1207
1208
 
1209
+ // The canonical ChatGPT backend rejects previous_response_id, so a local replay miss leaves no
1210
+ // safe way to recover the omitted history. Fail before auth, adapter construction, or upstream
1211
+ // I/O instead of stripping the id and silently forwarding a context-free delta (#702).
1212
+ if (
1213
+ hasUnexpandedPreviousResponse
1214
+ && isCanonicalOpenAiForwardProvider(route.provider)
1215
+ ) {
1216
+ return formatErrorResponse(
1217
+ 400,
1218
+ "invalid_request_error",
1219
+ "OpenAI forward continuation state is unavailable or expired; start a new session instead of reusing this previous_response_id.",
1220
+ );
1221
+ }
1222
+
1208
1223
  await applyFinalRouteRequestNormalization({ parsed, route, config, req, logCtx });
1209
1224
 
1210
1225
  {
@@ -1443,7 +1458,7 @@ export async function handleResponses(
1443
1458
  }
1444
1459
  const msg = outcome === "timeout"
1445
1460
  ? `Provider connect timeout after ${connectMs}ms`
1446
- : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
1461
+ : describeUpstreamConnectFailure(err, connectMs);
1447
1462
  return formatErrorResponse(502, "upstream_error", msg);
1448
1463
  };
1449
1464
  try {
@@ -2101,9 +2116,7 @@ export async function handleResponses(
2101
2116
  cleanupUpstreamAbort();
2102
2117
  upstream.abort();
2103
2118
  if (options.abortSignal?.aborted) return clientCancelledResponse();
2104
- const msg = err instanceof Error && err.name === "TimeoutError"
2105
- ? `Provider connect timeout after ${connectMs}ms`
2106
- : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
2119
+ const msg = describeUpstreamConnectFailure(err, connectMs);
2107
2120
  return formatErrorResponse(502, "upstream_error", msg);
2108
2121
  }
2109
2122
 
@@ -2143,9 +2156,7 @@ export async function handleResponses(
2143
2156
  if (options.abortSignal?.aborted) {
2144
2157
  return { failed: clientCancelledResponse() };
2145
2158
  }
2146
- const msg = err instanceof Error && err.name === "TimeoutError"
2147
- ? `Provider connect timeout after ${connectMs}ms`
2148
- : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
2159
+ const msg = describeUpstreamConnectFailure(err, connectMs);
2149
2160
  return { failed: formatErrorResponse(502, "upstream_error", msg) };
2150
2161
  }
2151
2162
  };