@bitkyc08/opencodex 2.7.43-preview.20260728 → 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 (173) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +47 -22
  3. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  4. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/discovery.ts +4 -1
  10. package/src/adapters/cursor/effort-map.ts +3 -0
  11. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  12. package/src/adapters/cursor/protobuf-events.ts +24 -2
  13. package/src/adapters/cursor/protobuf-request.ts +1 -2
  14. package/src/adapters/cursor/tool-definitions.ts +68 -29
  15. package/src/adapters/google-wire-compiler.ts +4 -0
  16. package/src/adapters/google.ts +128 -2
  17. package/src/adapters/identity.ts +12 -2
  18. package/src/adapters/kiro.ts +64 -7
  19. package/src/adapters/mimo-free.ts +2 -0
  20. package/src/adapters/openai-responses.ts +246 -59
  21. package/src/claude/agents-inject.ts +5 -0
  22. package/src/claude/alias.ts +94 -14
  23. package/src/claude/inbound.ts +26 -9
  24. package/src/claude/outbound.ts +6 -3
  25. package/src/cli/account-auth.ts +1 -1
  26. package/src/cli/agent-driven.ts +37 -0
  27. package/src/cli/catalog-prewarm.ts +24 -0
  28. package/src/cli/claude.ts +35 -10
  29. package/src/cli/doctor.ts +71 -19
  30. package/src/cli/help.ts +42 -6
  31. package/src/cli/index.ts +93 -19
  32. package/src/cli/interactive-confirm.ts +133 -0
  33. package/src/cli/opencode.ts +701 -0
  34. package/src/cli/provider-runtime.ts +3 -0
  35. package/src/cli/provider.ts +31 -10
  36. package/src/cli/star-prompt.ts +79 -18
  37. package/src/cli/status.ts +47 -13
  38. package/src/cli/v2.ts +10 -1
  39. package/src/codex/account-id.ts +34 -0
  40. package/src/codex/account-lifecycle.ts +4 -1
  41. package/src/codex/account-namespace-match.ts +63 -0
  42. package/src/codex/account-namespaces.ts +149 -0
  43. package/src/codex/account-pause.ts +20 -0
  44. package/src/codex/account-store.ts +2 -0
  45. package/src/codex/account-usability.ts +6 -1
  46. package/src/codex/app-server-processes.ts +511 -0
  47. package/src/codex/auth-api.ts +293 -34
  48. package/src/codex/auth-collision.ts +2 -1
  49. package/src/codex/auth-context.ts +60 -17
  50. package/src/codex/catalog/bundled.ts +9 -2
  51. package/src/codex/catalog/parsing.ts +42 -2
  52. package/src/codex/catalog/provider-fetch.ts +264 -70
  53. package/src/codex/catalog/sync.ts +45 -8
  54. package/src/codex/catalog.ts +2 -2
  55. package/src/codex/features.ts +524 -5
  56. package/src/codex/history-provider.ts +145 -1
  57. package/src/codex/inject.ts +114 -14
  58. package/src/codex/main-account.ts +2 -8
  59. package/src/codex/pool-rotation.ts +186 -0
  60. package/src/codex/quota.ts +92 -2
  61. package/src/codex/routing.ts +695 -106
  62. package/src/codex/runtime.ts +10 -1
  63. package/src/codex/shim.ts +4 -1
  64. package/src/codex/subagent-defaults.ts +550 -0
  65. package/src/codex/subagent-model-fallback.ts +2 -0
  66. package/src/codex/sync.ts +3 -0
  67. package/src/config.ts +574 -25
  68. package/src/generated/jawcode-model-metadata.ts +12 -12
  69. package/src/github/star-state.ts +191 -0
  70. package/src/images/artifacts.ts +516 -0
  71. package/src/images/fulfill-video.ts +163 -0
  72. package/src/images/fulfill.ts +111 -0
  73. package/src/images/index.ts +4 -0
  74. package/src/images/loop.ts +789 -0
  75. package/src/images/plan.ts +133 -0
  76. package/src/images/synthetic-tool.ts +133 -0
  77. package/src/images/types.ts +41 -0
  78. package/src/images/xai-client.ts +141 -0
  79. package/src/images/xai-video-client.ts +163 -0
  80. package/src/lib/admin-secrets.ts +25 -0
  81. package/src/lib/bun-binary-validator.d.mts +3 -0
  82. package/src/lib/bun-binary-validator.mjs +18 -0
  83. package/src/lib/bun-runtime.ts +6 -20
  84. package/src/lib/config-ownership.ts +327 -0
  85. package/src/lib/crash-guard.ts +2 -0
  86. package/src/lib/destination-policy.ts +132 -7
  87. package/src/lib/pinned-http.ts +151 -0
  88. package/src/lib/process-control.ts +2 -2
  89. package/src/lib/provider-outbound.ts +167 -0
  90. package/src/lib/provider-url.ts +14 -0
  91. package/src/lib/proxy-env.ts +18 -0
  92. package/src/lib/shadow-call.ts +30 -0
  93. package/src/lib/test-home-guard.ts +90 -0
  94. package/src/lib/win-exec.ts +12 -2
  95. package/src/lib/windows-elevation.ts +81 -3
  96. package/src/lib/windows-secret-acl.ts +189 -12
  97. package/src/lib/winsw.ts +2 -0
  98. package/src/oauth/anthropic-routing.ts +570 -0
  99. package/src/oauth/health.ts +6 -0
  100. package/src/oauth/index.ts +310 -75
  101. package/src/oauth/key-providers.ts +38 -8
  102. package/src/oauth/kimi.ts +2 -0
  103. package/src/oauth/kiro-credentials.ts +373 -12
  104. package/src/oauth/kiro.ts +424 -43
  105. package/src/oauth/login-cli.ts +33 -6
  106. package/src/oauth/store.ts +56 -4
  107. package/src/oauth/types.ts +11 -0
  108. package/src/providers/alibaba-region-migration.ts +16 -3
  109. package/src/providers/antigravity-models.ts +3 -0
  110. package/src/providers/api-keys.ts +13 -6
  111. package/src/providers/derive.ts +8 -2
  112. package/src/providers/key-failover.ts +24 -4
  113. package/src/providers/model-discovery.ts +356 -0
  114. package/src/providers/quota.ts +233 -29
  115. package/src/providers/registry.ts +125 -3
  116. package/src/responses/parser.ts +11 -0
  117. package/src/responses/state.ts +22 -8
  118. package/src/responses/tool-groups.ts +19 -0
  119. package/src/router.ts +19 -7
  120. package/src/server/auth-cors.ts +114 -24
  121. package/src/server/claude-messages.ts +8 -1
  122. package/src/server/gui-static.ts +30 -6
  123. package/src/server/images.ts +303 -9
  124. package/src/server/index.ts +77 -9
  125. package/src/server/lifecycle.ts +25 -1
  126. package/src/server/live.ts +75 -25
  127. package/src/server/management/agent-settings-routes.ts +106 -8
  128. package/src/server/management/combo-routes.ts +7 -0
  129. package/src/server/management/config-routes.ts +22 -7
  130. package/src/server/management/context.ts +11 -1
  131. package/src/server/management/logs-usage-routes.ts +167 -3
  132. package/src/server/management/model-routes.ts +46 -13
  133. package/src/server/management/oauth-account-routes.ts +163 -17
  134. package/src/server/management/provider-routes.ts +73 -10
  135. package/src/server/management/shared.ts +2 -2
  136. package/src/server/management/sidebar-routes.ts +39 -0
  137. package/src/server/management/system-restart.ts +172 -0
  138. package/src/server/management/system-routes.ts +33 -10
  139. package/src/server/management-api.ts +5 -3
  140. package/src/server/management-auth.ts +216 -0
  141. package/src/server/proxy-liveness.ts +14 -3
  142. package/src/server/responses/compact.ts +21 -13
  143. package/src/server/responses/core.ts +614 -172
  144. package/src/server/responses/upstream-error.ts +48 -0
  145. package/src/server/responses-image-gen-repair.ts +118 -0
  146. package/src/server/responses-item-id-repair.ts +10 -85
  147. package/src/server/sse-payload-rewrite.ts +116 -0
  148. package/src/server/startup-action-control.ts +30 -14
  149. package/src/server/system-env.ts +28 -10
  150. package/src/service.ts +284 -19
  151. package/src/storage/cleanup-job.ts +57 -0
  152. package/src/storage/cleanup.ts +1504 -28
  153. package/src/storage/policy-job.ts +387 -0
  154. package/src/storage/policy-scheduler.ts +40 -0
  155. package/src/storage/policy-worker.ts +53 -0
  156. package/src/storage/policy.ts +522 -0
  157. package/src/storage/restore-job.ts +253 -0
  158. package/src/storage/restore-worker.ts +52 -0
  159. package/src/storage/storage-mutation-coordinator.ts +109 -0
  160. package/src/storage/worker-lifecycle.ts +81 -0
  161. package/src/tray/windows.ts +34 -4
  162. package/src/types.ts +107 -1
  163. package/src/update/badge.ts +72 -0
  164. package/src/update/index.ts +36 -18
  165. package/src/update/job.ts +111 -16
  166. package/src/update/npm-invocation.d.mts +23 -0
  167. package/src/update/npm-invocation.mjs +94 -0
  168. package/src/usage/debug.ts +2 -0
  169. package/src/usage/expected-prices.ts +6 -5
  170. package/src/usage/log.ts +12 -0
  171. package/src/web-search/loop.ts +57 -16
  172. package/gui/dist/assets/index-CjKFJHSC.js +0 -65
  173. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -10,6 +10,8 @@ import {
10
10
  multiAgentGuidanceEnabled,
11
11
  providerBaseUrlConfigError,
12
12
  providerHeadersConfigError,
13
+ readConfigDiagnostics,
14
+ reconcileLiveConfigFromDisk,
13
15
  saveConfigPreservingClaudeCode,
14
16
  } from "../../config";
15
17
  import {
@@ -19,7 +21,6 @@ import {
19
21
  listOAuthProviders,
20
22
  startLoginFlow,
21
23
  submitManualLoginCode,
22
- upsertOAuthProvider,
23
24
  } from "../../oauth";
24
25
  import { removeCredential } from "../../oauth/store";
25
26
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
@@ -27,9 +28,15 @@ 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";
30
- import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
31
+ import { clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderQuotaReports, supportsPerAccountQuota } from "../../providers/quota";
31
32
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
32
33
  import { clearThreadAccountMap } from "../../codex/routing";
34
+ import {
35
+ normalizeAccountPoolStickyLimit,
36
+ normalizeAccountPoolStrategy,
37
+ parseAccountPoolStickyLimit,
38
+ parseAccountPoolStrategy,
39
+ } from "../../codex/pool-rotation";
33
40
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
34
41
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
35
42
  import { resolveCodexHomeDir } from "../../codex/home";
@@ -59,6 +66,7 @@ import { buildApiAccessEndpoints } from "./api-access";
59
66
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
60
67
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
61
68
  import type { ManagementContext } from "./context";
69
+ import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match";
62
70
 
63
71
  export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<Response | null> {
64
72
  const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
@@ -80,6 +88,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
80
88
  const body = await req.json().catch(() => ({})) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean };
81
89
  const provider = (body.provider ?? "").trim().toLowerCase();
82
90
  if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
91
+ const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider);
92
+ if (namespaceCollision) return jsonResponse({ error: namespaceCollision }, 409);
83
93
  const accountId = body.accountId?.trim();
84
94
  const reauth = body.reauth === true || Boolean(accountId);
85
95
  try {
@@ -90,12 +100,19 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
90
100
  return jsonResponse({ error: "Unknown account for reauth" }, 404);
91
101
  }
92
102
  }
103
+ // Use persisted state, not the live object, as the merge base: another management
104
+ // request may already have mutated live config and yielded before its save.
105
+ const persistedBaseline = readConfigDiagnostics().config;
93
106
  // addAccount / reauth forces a fresh browser identity (skips local-CLI token import).
94
107
  const { url: authUrl, instructions, deviceCode } = await startLoginFlow(provider, {
95
108
  forceLogin: body.addAccount === true || reauth,
96
109
  ...(accountId ? { reauthAccountId: accountId } : {}),
110
+ }, {
111
+ // startLoginFlow returns the authorization URL before background persistence completes.
112
+ // Three-way reconcile settled disk changes so a failed login cannot leave a provider
113
+ // live-only and an in-flight management mutation cannot be erased before it saves.
114
+ onSettled: () => reconcileLiveConfigFromDisk(config, persistedBaseline),
97
115
  });
98
- upsertOAuthProvider(config, provider); // mutate LIVE config — routing sees it without restart
99
116
  if (authUrl && !deviceCode) {
100
117
  // Open the browser server-side (the proxy runs on the user's machine) — the GUI's
101
118
  // window.open is popup-blocked because it runs after an await, not a direct click.
@@ -146,8 +163,9 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
146
163
  await removeCredential(provider);
147
164
  clearLoginState(provider);
148
165
  // Drop cached/last-good quota rows tied to the removed credential.
149
- const { clearProviderQuotaCache } = await import("../../providers/quota");
166
+ const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota");
150
167
  clearProviderQuotaCache();
168
+ clearAccountQuotaCache(provider);
151
169
  return jsonResponse({ success: true });
152
170
  }
153
171
 
@@ -163,18 +181,46 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
163
181
  projectOAuthAccountHealth,
164
182
  projectStoredOAuthAccountHealth,
165
183
  } = await import("../../oauth/health");
166
- const set = getAccountSet(provider);
167
- const accounts = (status.accounts ?? []).map(summary => {
168
- const full = set?.accounts.find(account => account.id === summary.id);
169
- const health = full
170
- ? projectStoredOAuthAccountHealth(provider, full)
171
- : projectOAuthAccountHealth({
172
- needsReauth: summary.needsReauth === true,
173
- reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined,
174
- });
175
- return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health) };
184
+ const projectAccounts = () => {
185
+ const set = getAccountSet(provider);
186
+ const current = getLoginStatus(provider);
187
+ return {
188
+ activeAccountId: current.activeAccountId ?? null,
189
+ accounts: (current.accounts ?? []).map(summary => {
190
+ const full = set?.accounts.find(account => account.id === summary.id);
191
+ const health = full
192
+ ? projectStoredOAuthAccountHealth(provider, full)
193
+ : projectOAuthAccountHealth({
194
+ needsReauth: summary.needsReauth === true,
195
+ reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined,
196
+ });
197
+ return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health) };
198
+ }),
199
+ };
200
+ };
201
+ // Per-account rate limits: Anthropic reports usage per credential, so every logged-in
202
+ // account can show its own 5h/weekly bars (not just the active one). Opt-in via ?quota=1
203
+ // so the plain account list stays a cheap local read; ?refresh=1 bypasses the TTL.
204
+ const wantQuota = url.searchParams.get("quota") === "1" && supportsPerAccountQuota(provider);
205
+ if (!wantQuota) return jsonResponse(projectAccounts());
206
+ const forceRefresh = url.searchParams.get("refresh") === "1";
207
+ // Probing may refresh the active credential and mark needsReauth — project health
208
+ // from the post-probe store so the response is not stale.
209
+ const rows = await fetchProviderAccountQuotas(provider, forceRefresh);
210
+ const byId = new Map(rows.map(row => [row.accountId, row]));
211
+ const projected = projectAccounts();
212
+ return jsonResponse({
213
+ activeAccountId: projected.activeAccountId,
214
+ accounts: projected.accounts.map(account => {
215
+ const row = byId.get(account.id);
216
+ if (!row) return account;
217
+ return {
218
+ ...account,
219
+ quota: row.quota,
220
+ ...(row.unavailable ? { quotaUnavailable: true } : {}),
221
+ };
222
+ }),
176
223
  });
177
- return jsonResponse({ activeAccountId: status.activeAccountId ?? null, accounts });
178
224
  }
179
225
  if (url.pathname === "/api/oauth/accounts/active" && req.method === "PUT") {
180
226
  const body = await req.json().catch(() => ({})) as { provider?: string; accountId?: string };
@@ -183,10 +229,104 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
183
229
  if (!body.accountId) return jsonResponse({ error: "missing accountId" }, 400);
184
230
  const { setActiveAccount } = await import("../../oauth/store");
185
231
  if (!(await setActiveAccount(provider, body.accountId))) return jsonResponse({ error: "account not found" }, 404);
232
+ if (provider === "anthropic") {
233
+ const { resetAnthropicRoutingForManualSelection } = await import("../../oauth/anthropic-routing");
234
+ resetAnthropicRoutingForManualSelection(body.accountId);
235
+ }
186
236
  const { clearProviderQuotaCache } = await import("../../providers/quota");
187
237
  clearProviderQuotaCache();
188
238
  return jsonResponse({ ok: true, provider, activeAccountId: body.accountId });
189
239
  }
240
+
241
+ // Opt-in Anthropic OAuth account pool (#294): enable/threshold/strategy + clear cooldown.
242
+ if (url.pathname === "/api/oauth/accounts/pool" && req.method === "GET") {
243
+ const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
244
+ if (provider !== "anthropic") return jsonResponse({ error: "pool config is only supported for anthropic" }, 400);
245
+ const pool = config.anthropicAccountPool ?? {};
246
+ return jsonResponse({
247
+ provider,
248
+ enabled: pool.enabled === true,
249
+ autoSwitchThreshold: typeof pool.autoSwitchThreshold === "number" ? pool.autoSwitchThreshold : 80,
250
+ strategy: normalizeAccountPoolStrategy(pool.strategy),
251
+ stickyLimit: normalizeAccountPoolStickyLimit(pool.stickyLimit),
252
+ experimental: true,
253
+ });
254
+ }
255
+ if (url.pathname === "/api/oauth/accounts/pool" && (req.method === "PUT" || req.method === "PATCH")) {
256
+ const parsedBody = await req.json().catch(() => ({}));
257
+ if (!isPlainRecord(parsedBody)) {
258
+ return jsonResponse({ error: "body must be an object" }, 400);
259
+ }
260
+ const body = parsedBody as {
261
+ provider?: unknown;
262
+ enabled?: unknown;
263
+ autoSwitchThreshold?: unknown;
264
+ strategy?: unknown;
265
+ stickyLimit?: unknown;
266
+ };
267
+ const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
268
+ if (provider !== "anthropic") return jsonResponse({ error: "pool config is only supported for anthropic" }, 400);
269
+ let enabled = config.anthropicAccountPool?.enabled === true;
270
+ if (body.enabled !== undefined) {
271
+ if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
272
+ enabled = body.enabled;
273
+ }
274
+ let threshold = config.anthropicAccountPool?.autoSwitchThreshold ?? 80;
275
+ if (body.autoSwitchThreshold !== undefined) {
276
+ if (
277
+ typeof body.autoSwitchThreshold !== "number"
278
+ || !Number.isInteger(body.autoSwitchThreshold)
279
+ || body.autoSwitchThreshold < 0
280
+ || body.autoSwitchThreshold > 100
281
+ ) {
282
+ return jsonResponse({ error: "autoSwitchThreshold must be an integer 0-100" }, 400);
283
+ }
284
+ threshold = body.autoSwitchThreshold;
285
+ }
286
+ let strategy = config.anthropicAccountPool?.strategy;
287
+ if (body.strategy !== undefined) {
288
+ const parsed = parseAccountPoolStrategy(body.strategy);
289
+ if (parsed === null) {
290
+ return jsonResponse({ error: "strategy must be one of: quota, round-robin, fill-first" }, 400);
291
+ }
292
+ strategy = parsed;
293
+ }
294
+ let stickyLimit = config.anthropicAccountPool?.stickyLimit;
295
+ if (body.stickyLimit !== undefined) {
296
+ const parsed = parseAccountPoolStickyLimit(body.stickyLimit);
297
+ if (parsed === null) {
298
+ return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400);
299
+ }
300
+ stickyLimit = parsed;
301
+ }
302
+ config.anthropicAccountPool = {
303
+ enabled,
304
+ autoSwitchThreshold: threshold,
305
+ ...(strategy !== undefined ? { strategy } : {}),
306
+ ...(stickyLimit !== undefined ? { stickyLimit } : {}),
307
+ };
308
+ saveConfigPreservingClaudeCode(config);
309
+ return jsonResponse({
310
+ ok: true,
311
+ provider,
312
+ enabled,
313
+ autoSwitchThreshold: threshold,
314
+ strategy: normalizeAccountPoolStrategy(strategy),
315
+ stickyLimit: normalizeAccountPoolStickyLimit(stickyLimit),
316
+ experimental: true,
317
+ });
318
+ }
319
+ if (url.pathname === "/api/oauth/accounts/clear-cooldown" && req.method === "POST") {
320
+ const body = await req.json().catch(() => ({})) as { provider?: unknown; accountId?: unknown };
321
+ const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
322
+ const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
323
+ if (provider !== "anthropic") return jsonResponse({ error: "clear-cooldown is only supported for anthropic" }, 400);
324
+ if (!accountId) return jsonResponse({ error: "missing accountId" }, 400);
325
+ const { clearAnthropicAccountCooldown } = await import("../../oauth/anthropic-routing");
326
+ const cleared = clearAnthropicAccountCooldown(accountId);
327
+ return jsonResponse({ ok: true, cleared });
328
+ }
329
+
190
330
  if (url.pathname === "/api/oauth/accounts/alias" && req.method === "PUT") {
191
331
  const body = await req.json().catch(() => ({})) as { provider?: unknown; accountId?: unknown; alias?: unknown };
192
332
  const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
@@ -208,9 +348,15 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
208
348
  if (!id) return jsonResponse({ error: "missing id" }, 400);
209
349
  const { removeAccount, getAccountSet } = await import("../../oauth/store");
210
350
  if (!(await removeAccount(provider, id))) return jsonResponse({ error: "account not found" }, 404);
351
+ if (provider === "anthropic") {
352
+ const { clearAnthropicAccountCooldown, clearAnthropicSessionAffinityForAccount } = await import("../../oauth/anthropic-routing");
353
+ clearAnthropicAccountCooldown(id);
354
+ clearAnthropicSessionAffinityForAccount(id);
355
+ }
211
356
  if (!getAccountSet(provider)) clearLoginState(provider);
212
- const { clearProviderQuotaCache } = await import("../../providers/quota");
357
+ const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota");
213
358
  clearProviderQuotaCache();
359
+ clearAccountQuotaCache(provider);
214
360
  return jsonResponse({ ok: true });
215
361
  }
216
362
 
@@ -308,7 +454,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
308
454
  const salt = crypto.randomUUID();
309
455
  const hashInput = `${providerKeys}|${salt}|${Date.now()}`;
310
456
  const hashBuf = new Bun.CryptoHasher("sha256").update(hashInput).digest();
311
- const key = "ocx_" + Buffer.from(hashBuf).toString("hex").slice(0, 40);
457
+ const key = "ocx_data_" + Buffer.from(hashBuf).toString("hex").slice(0, 40);
312
458
  const entry = { id: crypto.randomUUID(), name, key, createdAt: new Date().toISOString() };
313
459
  config.apiKeys = [...(config.apiKeys ?? []), entry];
314
460
  saveConfigPreservingClaudeCode(config);
@@ -23,12 +23,20 @@ import {
23
23
  } from "../../oauth";
24
24
  import { removeCredential } from "../../oauth/store";
25
25
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
26
+ import { ProviderOutboundPolicyError, providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound";
26
27
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
27
28
  import { deriveProviderPresets } from "../../providers/derive";
28
29
  import { providerCodexAccountMode } from "../../providers/registry";
30
+ import {
31
+ extractModelEnvelopeRows,
32
+ extractProviderModelItems,
33
+ readBoundedDiscoveryJson,
34
+ resolveProviderModelDiscovery,
35
+ } from "../../providers/model-discovery";
29
36
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
30
37
  import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
31
38
  import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
39
+ import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match";
32
40
  import { clearThreadAccountMap } from "../../codex/routing";
33
41
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
34
42
  import { getProviderDiscoveryStatus } from "../../codex/model-cache";
@@ -76,6 +84,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
76
84
  liveModels: p.liveModels !== false,
77
85
  models: p.models ?? [],
78
86
  authMode: p.authMode,
87
+ apiKeyTransport: p.apiKeyTransport,
79
88
  disabled: p.disabled === true,
80
89
  codexAccountMode: providerCodexAccountMode(name, p),
81
90
  discovery: p.liveModels === false ? undefined : getProviderDiscoveryStatus(name),
@@ -98,6 +107,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
98
107
  if (!isValidProviderName(name)) {
99
108
  return jsonResponse({ error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key" }, 400);
100
109
  }
110
+ const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name);
111
+ if (namespaceCollision) {
112
+ return jsonResponse({ error: namespaceCollision }, 409);
113
+ }
101
114
  // Hostname destinations additionally get a DNS-resolved SSRF check at write time —
102
115
  // the sync check above only classifies literal IPs (review finding, PR #96).
103
116
  // Canonical openai still runs the resolver: only Clash fake-IP (198.18.0.0/15)
@@ -213,6 +226,18 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
213
226
  return jsonResponse({ error: "authMode must be key, forward, oauth, or local" }, 400);
214
227
  }
215
228
  }
229
+ if (Object.hasOwn(rawBody, "apiKeyTransport")) {
230
+ const transport = rawBody.apiKeyTransport;
231
+ if (transport === "x-api-key" || transport === "bearer") {
232
+ next.apiKeyTransport = transport;
233
+ touched = true;
234
+ } else if (transport === "") {
235
+ delete next.apiKeyTransport;
236
+ touched = true;
237
+ } else {
238
+ return jsonResponse({ error: "apiKeyTransport must be x-api-key, bearer, or empty to clear" }, 400);
239
+ }
240
+ }
216
241
  if (Object.hasOwn(rawBody, "note")) {
217
242
  if (typeof rawBody.note !== "string") return jsonResponse({ error: "note must be a string" }, 400);
218
243
  const note = rawBody.note.trim();
@@ -319,23 +344,59 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
319
344
  return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" });
320
345
  }
321
346
  const { url: modelsUrl, headers } = buildModelsRequest(prov, apiKey, name);
347
+ const discovery = resolveProviderModelDiscovery(name, prov);
322
348
  const started = Date.now();
323
349
  try {
324
- const res = await fetch(modelsUrl, { headers, signal: AbortSignal.timeout(8000) });
350
+ const res = await providerOutboundGet(name, prov, modelsUrl, {
351
+ headers,
352
+ signal: AbortSignal.timeout(8000),
353
+ });
325
354
  const latencyMs = Date.now() - started;
355
+ const redirectError = await providerRedirectError(res, modelsUrl);
356
+ if (redirectError) {
357
+ return jsonResponse({
358
+ ok: false,
359
+ latencyMs,
360
+ error: redirectError,
361
+ });
362
+ }
326
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
+ }
327
369
  return jsonResponse({ ok: false, latencyMs, error: `upstream /models returned ${res.status}` });
328
370
  }
329
- const json = await res.json().catch(() => null) as { data?: unknown; models?: unknown } | null;
330
- // OpenAI-style lists use { data: [...] }; Google's /v1beta/models (the other shape
331
- // buildModelsRequest can produce) returns { models: [...] }.
332
- const list = json && typeof json === "object" && !Array.isArray(json)
333
- ? (Array.isArray(json.data) ? json.data : Array.isArray(json.models) ? json.models : undefined)
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>
334
386
  : undefined;
335
- if (!Array.isArray(list)) {
336
- return jsonResponse({ ok: false, latencyMs, error: "upstream /models returned an unexpected shape" });
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
+ });
337
398
  }
338
- const models = list.length;
399
+ const models = "items" in extracted ? extracted.items.length : extracted.rows.length;
339
400
  return jsonResponse({
340
401
  ok: true,
341
402
  latencyMs,
@@ -346,7 +407,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
346
407
  return jsonResponse({
347
408
  ok: false,
348
409
  latencyMs: Date.now() - started,
349
- 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",
350
413
  });
351
414
  }
352
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
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Dashboard memory-card drain-and-restart (#563).
3
+ *
4
+ * Longer than POST /api/stop's short drain: waits up to 60s for active turns,
5
+ * then respawns. Never runs restoreNativeCodex / stripGrokConfig — this is a
6
+ * recycle to reclaim RSS, not a teardown.
7
+ *
8
+ * Respawn policy (matches real supervisor configs in src/service.ts):
9
+ * - Supervised child (`OCX_SERVICE=1` + service installed): exit(1) so
10
+ * failure-only supervisors (systemd Restart=on-failure, WinSW onfailure,
11
+ * Task Scheduler ERRORLEVEL loop) bring the proxy back.
12
+ * - Otherwise: detached `ocx start --port <live>` (bypasses ensure's
13
+ * codexAutoStart gate), mark recycle so exit cleanup keeps injection, exit(0).
14
+ * - If detached spawn fails (sync throw or pre-start `error`): exit(1) without
15
+ * markRecycling — after drain the listen socket is already closed, so a latch
16
+ * reset cannot recover serving. Clear inherited `OCX_SERVICE` so exit cleanup
17
+ * can restore Codex/Grok fences (ensure/tray daemons set the marker without a
18
+ * real supervisor). Log only a stable errno code — never the raw message
19
+ * (paths in ENOENT often include the OS username).
20
+ */
21
+ import { spawn } from "node:child_process";
22
+ import {
23
+ drainAndShutdown,
24
+ getActiveTurnCount,
25
+ getServerListenPort,
26
+ isDraining,
27
+ markRecyclingForExit,
28
+ setDraining,
29
+ } from "../lifecycle";
30
+ import { isServiceInstalled } from "../../service";
31
+ import { readRuntimePort } from "../../config";
32
+
33
+ /** Fixed v1 drain window for the memory-card action (not config-driven). */
34
+ export const MEMORY_DRAIN_RESTART_MS = 60_000;
35
+
36
+ export interface SystemRestartIo {
37
+ drainAndShutdown?: typeof drainAndShutdown;
38
+ isServiceInstalled?: () => boolean;
39
+ isSupervisedServiceChild?: () => boolean;
40
+ /** Must resolve only after the replacement process has actually started. */
41
+ spawnStart?: (port?: number) => void | Promise<void>;
42
+ markRecycling?: () => void;
43
+ exitProcess?: (code: number) => void;
44
+ schedule?: (fn: () => void | Promise<void>, ms: number) => void;
45
+ isDraining?: () => boolean;
46
+ setDraining?: (value: boolean) => void;
47
+ getActiveTurnCount?: () => number;
48
+ listenPort?: () => number | undefined;
49
+ }
50
+
51
+ let restartIo: SystemRestartIo = {};
52
+ /** Prevents double-scheduling in the 200ms window before drainAndShutdown sets draining. */
53
+ let restartAccepted = false;
54
+
55
+ /** Test seam — reset between tests. */
56
+ export function setSystemRestartIoForTests(io: SystemRestartIo = {}): void {
57
+ restartIo = io;
58
+ restartAccepted = false;
59
+ }
60
+
61
+ function resolveListenPort(): number | undefined {
62
+ const live = getServerListenPort();
63
+ if (live) return live;
64
+ const runtime = readRuntimePort(process.pid);
65
+ if (runtime && runtime.port > 0) return runtime.port;
66
+ return undefined;
67
+ }
68
+
69
+ function isSupervisedServiceChild(): boolean {
70
+ return process.env.OCX_SERVICE === "1" && isServiceInstalled();
71
+ }
72
+
73
+ /** Stable, path-free spawn failure label for logs (never interpolate err.message). */
74
+ function spawnFailureCode(err: unknown): string {
75
+ if (err && typeof err === "object" && "code" in err) {
76
+ const code = (err as NodeJS.ErrnoException).code;
77
+ if (typeof code === "string" && code.length > 0 && code.length <= 64) return code;
78
+ }
79
+ return "spawn_failed";
80
+ }
81
+
82
+ function spawnDetachedStart(port?: number): Promise<void> {
83
+ const args = [process.argv[1], "start"];
84
+ if (typeof port === "number" && Number.isFinite(port) && port > 0 && port <= 65535) {
85
+ args.push("--port", String(Math.trunc(port)));
86
+ }
87
+ return new Promise<void>((resolve, reject) => {
88
+ let child: ReturnType<typeof spawn>;
89
+ try {
90
+ child = spawn(process.execPath, args, {
91
+ detached: true,
92
+ stdio: "ignore",
93
+ windowsHide: true,
94
+ env: { ...process.env, OCX_SERVICE: "1" },
95
+ });
96
+ } catch (err) {
97
+ reject(err);
98
+ return;
99
+ }
100
+ let settled = false;
101
+ const finish = (fn: () => void) => {
102
+ if (settled) return;
103
+ settled = true;
104
+ fn();
105
+ };
106
+ child.once("error", (err) => {
107
+ finish(() => reject(err));
108
+ });
109
+ child.once("spawn", () => {
110
+ finish(() => {
111
+ child.unref();
112
+ resolve();
113
+ });
114
+ });
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Accept a drain-and-restart request. Returns immediately; the drain +
120
+ * respawn runs on a short timer so the HTTP response can flush first.
121
+ * Idempotent while already draining: returns the accepted shape again.
122
+ */
123
+ export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
124
+ accepted: true;
125
+ alreadyDraining: boolean;
126
+ activeTurnCount: number;
127
+ drainTimeoutMs: number;
128
+ } {
129
+ const alreadyDraining = restartAccepted || (io.isDraining ?? isDraining)();
130
+ const activeTurnCount = (io.getActiveTurnCount ?? getActiveTurnCount)();
131
+ const schedule = io.schedule ?? ((fn, ms) => { setTimeout(() => { void fn(); }, ms); });
132
+
133
+ if (!alreadyDraining) {
134
+ restartAccepted = true;
135
+ // Reject new data-plane traffic immediately (503), before the 200ms response-flush delay.
136
+ (io.setDraining ?? setDraining)(true);
137
+ schedule(async () => {
138
+ const drain = io.drainAndShutdown ?? drainAndShutdown;
139
+ await drain(undefined, MEMORY_DRAIN_RESTART_MS);
140
+ const supervised = (io.isSupervisedServiceChild ?? isSupervisedServiceChild)();
141
+ if (supervised) {
142
+ // Failure-only supervisors ignore exit(0); intentional non-zero triggers respawn.
143
+ (io.exitProcess ?? ((code: number) => { process.exit(code); }))(1);
144
+ return;
145
+ }
146
+ const port = (io.listenPort ?? resolveListenPort)();
147
+ const exitProcess = io.exitProcess ?? ((code: number) => { process.exit(code); });
148
+ try {
149
+ await (io.spawnStart ?? spawnDetachedStart)(port);
150
+ } catch (err) {
151
+ console.warn(
152
+ `⚠️ Drain-and-restart spawn failed (${spawnFailureCode(err)}); exiting without replacement`,
153
+ );
154
+ // Listen socket is already stopped; do not markRecycling — no child to inherit fences.
155
+ // ensure/tray children inherit OCX_SERVICE=1 without an installed service; clear it so
156
+ // syncCleanup can restore Codex/Grok fences instead of leaving clients pointed at a dead port.
157
+ delete process.env.OCX_SERVICE;
158
+ exitProcess(1);
159
+ return;
160
+ }
161
+ (io.markRecycling ?? markRecyclingForExit)();
162
+ exitProcess(0);
163
+ }, 200);
164
+ }
165
+
166
+ return {
167
+ accepted: true,
168
+ alreadyDraining,
169
+ activeTurnCount,
170
+ drainTimeoutMs: MEMORY_DRAIN_RESTART_MS,
171
+ };
172
+ }