@bitkyc08/opencodex 2.7.23 → 2.7.24

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 (55) hide show
  1. package/README.ko.md +37 -7
  2. package/README.md +52 -11
  3. package/README.zh-CN.md +36 -7
  4. package/bin/ocx.mjs +5 -3
  5. package/gui/dist/assets/index-BzhyTAco.js +40 -0
  6. package/gui/dist/assets/index-Dq3eZ1cU.css +1 -0
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/opencode.svg +1 -1
  9. package/package.json +5 -2
  10. package/src/adapters/anthropic-image-normalize.ts +70 -29
  11. package/src/adapters/cursor/transport-retry.ts +20 -1
  12. package/src/adapters/run-turn-queue.ts +40 -0
  13. package/src/codex/auth-api.ts +10 -1
  14. package/src/codex/auth-context.ts +33 -7
  15. package/src/codex/catalog.ts +357 -23
  16. package/src/codex/routing.ts +10 -4
  17. package/src/combos/failover.ts +102 -0
  18. package/src/combos/index.ts +37 -0
  19. package/src/combos/request.ts +31 -0
  20. package/src/combos/resolve.ts +171 -0
  21. package/src/combos/types.ts +203 -0
  22. package/src/config.ts +280 -11
  23. package/src/lib/errors.ts +86 -24
  24. package/src/lib/upstream-retry.ts +8 -4
  25. package/src/oauth/index.ts +7 -1
  26. package/src/oauth/key-providers.ts +2 -32
  27. package/src/oauth/login-cli.ts +4 -3
  28. package/src/oauth/token-guardian.ts +38 -3
  29. package/src/providers/derive.ts +27 -2
  30. package/src/providers/kiro-models.ts +8 -3
  31. package/src/providers/label.ts +3 -1
  32. package/src/providers/openai-sidecar.ts +94 -0
  33. package/src/providers/openai-tier-startup.ts +27 -0
  34. package/src/providers/openai-tiers.ts +283 -0
  35. package/src/providers/openai-virtual-models.ts +82 -0
  36. package/src/providers/quota.ts +344 -24
  37. package/src/providers/registry.ts +112 -20
  38. package/src/reasoning-effort.ts +12 -11
  39. package/src/router.ts +80 -36
  40. package/src/server/auth-cors.ts +85 -9
  41. package/src/server/images.ts +31 -75
  42. package/src/server/index.ts +45 -86
  43. package/src/server/management-api.ts +273 -21
  44. package/src/server/request-log.ts +221 -20
  45. package/src/server/responses.ts +594 -75
  46. package/src/server/search.ts +22 -37
  47. package/src/types.ts +49 -1
  48. package/src/update/index.ts +50 -6
  49. package/src/update/job.ts +21 -4
  50. package/src/usage/log.ts +124 -1
  51. package/src/usage/summary.ts +147 -56
  52. package/src/vision/index.ts +20 -19
  53. package/src/web-search/index.ts +15 -17
  54. package/gui/dist/assets/index-Bk_GgFrh.css +0 -1
  55. package/gui/dist/assets/index-DQjt6Hly.js +0 -40
@@ -13,7 +13,7 @@ import {
13
13
  import {
14
14
  clearLoginState,
15
15
  getLoginStatus,
16
- isOAuthProvider,
16
+ isPublicOAuthProvider,
17
17
  listOAuthProviders,
18
18
  startLoginFlow,
19
19
  submitManualLoginCode,
@@ -23,7 +23,11 @@ import { removeCredential } from "../oauth/store";
23
23
  import { providerDestinationResolvedError } from "../lib/destination-policy";
24
24
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../oauth/key-providers";
25
25
  import { deriveProviderPresets } from "../providers/derive";
26
- import { fetchProviderQuotaReports } from "../providers/quota";
26
+ import { providerCodexAccountMode } from "../providers/registry";
27
+ import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../providers/quota";
28
+ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
29
+ import { clearThreadAccountMap } from "../codex/routing";
30
+ import { primeCodexPoolQuotas } from "../codex/auth-api";
27
31
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../providers/context-cap";
28
32
  import { readUsageEntries } from "../usage/log";
29
33
  import { getUsageDebugLogEntries } from "../usage/debug";
@@ -58,6 +62,9 @@ export const VERSION = (() => {
58
62
  export interface ManagementApiDeps {
59
63
  toggleCodexMultiAgentV2?: (enabled: boolean) => void;
60
64
  refreshCodexCatalog?: () => Promise<void>;
65
+ clearThreadAccountMap?: () => void;
66
+ clearProviderQuotaCache?: () => void;
67
+ primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise<void> | void;
61
68
  }
62
69
 
63
70
  /** Narrow an unknown JSON value to a plain (non-array) object for strict request-body validation. */
@@ -80,7 +87,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
80
87
  }
81
88
  // Management bodies are small JSON (provider names, key ids, settings). Reject oversized
82
89
  // payloads before any handler buffers them — the data plane has its own decompression cap.
83
- if (req.method === "POST" || req.method === "PUT") {
90
+ if (req.method === "POST" || req.method === "PUT" || req.method === "PATCH") {
84
91
  const contentLength = Number(req.headers.get("content-length") ?? "0");
85
92
  if (Number.isFinite(contentLength) && contentLength > 2 * 1024 * 1024) {
86
93
  return jsonResponse({ error: "request body too large" }, 413, req, config);
@@ -403,6 +410,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
403
410
  hasApiKey: !!p.apiKey,
404
411
  allowPrivateNetwork: p.allowPrivateNetwork === true,
405
412
  disabled: p.disabled === true,
413
+ codexAccountMode: providerCodexAccountMode(name, p),
406
414
  })));
407
415
  }
408
416
 
@@ -410,18 +418,18 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
410
418
  // persists — existing providers' real keys are never round-tripped (unlike PUT /api/config,
411
419
  // which would re-save the masked keys from GET). Live routing picks it up immediately.
412
420
  if (url.pathname === "/api/providers" && req.method === "POST") {
413
- let body: { name?: string; provider?: OcxProviderConfig; setDefault?: boolean };
421
+ let body: { name?: unknown; provider?: unknown; setDefault?: boolean };
414
422
  try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
415
- const name = body.name?.trim();
416
- const prov = body.provider ? stripCodexRuntimeProviderFields(body.provider) : undefined;
423
+ const name = typeof body.name === "string" ? body.name.trim() : "";
424
+ const providerError = providerManagementConfigError(name, body.provider);
425
+ if (providerError) return jsonResponse({ error: providerError }, 400);
426
+ const prov = body.provider ? stripCodexRuntimeProviderFields(body.provider as OcxProviderConfig) : undefined;
417
427
  if (!name || !prov?.adapter || !prov?.baseUrl) {
418
428
  return jsonResponse({ error: "name, provider.adapter and provider.baseUrl are required" }, 400);
419
429
  }
420
430
  if (!isValidProviderName(name)) {
421
431
  return jsonResponse({ error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key" }, 400);
422
432
  }
423
- const providerError = providerManagementConfigError(name, prov);
424
- if (providerError) return jsonResponse({ error: providerError }, 400);
425
433
  // Hostname destinations additionally get a DNS-resolved SSRF check at write time —
426
434
  // the sync check above only classifies literal IPs (review finding, PR #96).
427
435
  const resolvedError = await providerDestinationResolvedError(name, prov);
@@ -450,23 +458,202 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
450
458
  if (url.pathname === "/api/providers" && req.method === "PATCH") {
451
459
  const name = url.searchParams.get("name")?.trim();
452
460
  if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
453
- let body: { disabled?: unknown };
454
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
455
- if (typeof body.disabled !== "boolean") return jsonResponse({ error: "disabled boolean is required" }, 400);
456
- if (body.disabled && name === config.defaultProvider) {
457
- return jsonResponse({ error: "cannot disable the default provider; set another default first" }, 400);
461
+ let rawBody: unknown;
462
+ try { rawBody = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
463
+ if (!isPlainRecord(rawBody)) return jsonResponse({ error: "provider patch body must be a plain object" }, 400);
464
+ const keys = Object.keys(rawBody);
465
+ const hasMode = Object.hasOwn(rawBody, "codexAccountMode");
466
+
467
+ // codexAccountMode keeps its dedicated side-effect path (quota cache clear, thread map
468
+ // clear, pool prime) and is mutually exclusive with every other patch field.
469
+ if (hasMode) {
470
+ if (keys.length !== 1) {
471
+ return jsonResponse({ error: "codexAccountMode cannot be combined with other patch fields" }, 400);
472
+ }
473
+ if (name !== "openai") return jsonResponse({ error: "codexAccountMode is valid only for provider openai" }, 400);
474
+ const mode = rawBody.codexAccountMode;
475
+ if (mode !== "pool" && mode !== "direct") {
476
+ return jsonResponse({ error: "codexAccountMode must be pool or direct" }, 400);
477
+ }
478
+ const provider = config.providers.openai;
479
+ if (!provider || !isCanonicalOpenAiForwardProvider(provider)) {
480
+ return jsonResponse({ error: "provider openai must be the canonical built-in provider" }, 400);
481
+ }
482
+ const { saveConfig: save } = await import("../config");
483
+ config.providers.openai = { ...provider, codexAccountMode: mode };
484
+ save(config);
485
+ (deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
486
+ (deps.clearThreadAccountMap ?? clearThreadAccountMap)();
487
+ if (mode === "pool") {
488
+ try {
489
+ const prime = deps.primeCodexPoolQuotas ?? primeCodexPoolQuotas;
490
+ void Promise.resolve(prime(config, "mode-change")).catch(() => undefined);
491
+ } catch {
492
+ // Quota priming is best-effort; the persisted live mode is already authoritative.
493
+ }
494
+ }
495
+ return jsonResponse({ success: true, name: "openai", codexAccountMode: mode });
496
+ }
497
+
498
+ // Field-mask editor: apply recognized fields onto a copy, then validate the MERGED
499
+ // provider (canonical-seed guard covers openai; local-guard covers registry key providers).
500
+ // API keys are never writable here — the api-keys endpoints own pool-integrated key writes.
501
+ if (Object.hasOwn(rawBody, "apiKey")) {
502
+ return jsonResponse({ error: "apiKey cannot be patched here; use the provider API-key endpoints" }, 400);
503
+ }
504
+ const next: OcxProviderConfig = { ...config.providers[name]! };
505
+ let touched = false;
506
+
507
+ if (Object.hasOwn(rawBody, "disabled")) {
508
+ if (typeof rawBody.disabled !== "boolean") return jsonResponse({ error: "disabled must be a boolean" }, 400);
509
+ if (rawBody.disabled && name === config.defaultProvider) {
510
+ return jsonResponse({ error: "cannot disable the default provider; set another default first" }, 400);
511
+ }
512
+ next.disabled = rawBody.disabled;
513
+ touched = true;
514
+ }
515
+ if (Object.hasOwn(rawBody, "adapter")) {
516
+ if (typeof rawBody.adapter !== "string" || !rawBody.adapter.trim()) return jsonResponse({ error: "adapter must be a non-empty string" }, 400);
517
+ next.adapter = rawBody.adapter.trim();
518
+ touched = true;
519
+ }
520
+ if (Object.hasOwn(rawBody, "baseUrl")) {
521
+ if (typeof rawBody.baseUrl !== "string" || !rawBody.baseUrl.trim()) return jsonResponse({ error: "baseUrl must be a non-empty string" }, 400);
522
+ next.baseUrl = rawBody.baseUrl.trim();
523
+ touched = true;
524
+ }
525
+ if (Object.hasOwn(rawBody, "defaultModel")) {
526
+ if (typeof rawBody.defaultModel !== "string") return jsonResponse({ error: "defaultModel must be a string" }, 400);
527
+ const dm = rawBody.defaultModel.trim();
528
+ if (dm) next.defaultModel = dm;
529
+ else delete next.defaultModel;
530
+ touched = true;
458
531
  }
532
+ if (Object.hasOwn(rawBody, "authMode")) {
533
+ if (typeof rawBody.authMode !== "string") return jsonResponse({ error: "authMode must be a string" }, 400);
534
+ const mode = rawBody.authMode.trim();
535
+ if (mode === "key" || mode === "forward" || mode === "oauth" || mode === "local") {
536
+ next.authMode = mode;
537
+ touched = true;
538
+ } else if (mode === "") {
539
+ delete next.authMode;
540
+ touched = true;
541
+ } else {
542
+ return jsonResponse({ error: "authMode must be key, forward, oauth, or local" }, 400);
543
+ }
544
+ }
545
+ if (Object.hasOwn(rawBody, "note")) {
546
+ if (typeof rawBody.note !== "string") return jsonResponse({ error: "note must be a string" }, 400);
547
+ const note = rawBody.note.trim();
548
+ if (note) next.note = note;
549
+ else delete next.note;
550
+ touched = true;
551
+ }
552
+
553
+ if (!touched) return jsonResponse({ error: "no recognized fields to update" }, 400);
554
+
555
+ // A disabled-only toggle preserves the v2 fast lane: it changes routing eligibility,
556
+ // not the provider shape, so the merged-shape validators (canonical-seed guard for
557
+ // openai, destination/local checks) do not apply.
558
+ const editorTouched = keys.some(key => key !== "disabled");
559
+ if (editorTouched) {
560
+ const providerError = providerManagementConfigError(name, next);
561
+ if (providerError) return jsonResponse({ error: providerError }, 400);
562
+ const resolvedError = await providerDestinationResolvedError(name, next);
563
+ if (resolvedError) return jsonResponse({ error: resolvedError }, 400);
564
+ }
565
+
459
566
  const { saveConfig: save } = await import("../config");
460
- config.providers[name] = { ...config.providers[name], disabled: body.disabled };
567
+ config.providers[name] = stripRegistryOnlyStaticHeaders(name, next);
461
568
  save(config);
569
+ if (editorTouched) {
570
+ const { clearModelCache } = await import("../codex/model-cache");
571
+ clearModelCache(name);
572
+ }
462
573
  await refreshCodexCatalogBestEffort();
463
- return jsonResponse({ success: true, name, disabled: body.disabled });
574
+ return jsonResponse({
575
+ success: true,
576
+ name,
577
+ disabled: config.providers[name]!.disabled === true,
578
+ hasApiKey: !!config.providers[name]!.apiKey,
579
+ });
580
+ }
581
+
582
+ // Lightweight connectivity probe: perform the provider's live /models fetch DIRECTLY and
583
+ // report only real upstream evidence. The catalog aggregate (fetchAllModels) deliberately
584
+ // hides fetch failures behind stale/static fallbacks, so a catalog-presence check would
585
+ // let a static-catalog provider with a fake key "pass" — this endpoint never uses it.
586
+ if (url.pathname === "/api/providers/test" && req.method === "POST") {
587
+ const name = url.searchParams.get("name")?.trim();
588
+ if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) {
589
+ return jsonResponse({ error: "unknown provider" }, 404);
590
+ }
591
+ const prov = config.providers[name]!;
592
+ if (prov.disabled) {
593
+ return jsonResponse({ ok: false, error: "Provider is disabled", latencyMs: 0 });
594
+ }
595
+ if (prov.authMode === "forward") {
596
+ return jsonResponse({
597
+ ok: true,
598
+ latencyMs: 0,
599
+ message: "Passthrough provider is configured (forwards your Codex login; no upstream /models).",
600
+ });
601
+ }
602
+ if (prov.liveModels === false) {
603
+ return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified" });
604
+ }
605
+ const { resolveModelsAuthToken, buildModelsRequest } = await import("../oauth");
606
+ const apiKey = await resolveModelsAuthToken(name, prov);
607
+ if (prov.authMode === "oauth" && !apiKey) {
608
+ return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" });
609
+ }
610
+ const { url: modelsUrl, headers } = buildModelsRequest(prov, apiKey, name);
611
+ const started = Date.now();
612
+ try {
613
+ const res = await fetch(modelsUrl, { headers, signal: AbortSignal.timeout(8000) });
614
+ const latencyMs = Date.now() - started;
615
+ if (!res.ok) {
616
+ return jsonResponse({ ok: false, latencyMs, error: `upstream /models returned ${res.status}` });
617
+ }
618
+ const json = await res.json().catch(() => null) as { data?: unknown; models?: unknown } | null;
619
+ // OpenAI-style lists use { data: [...] }; Google's /v1beta/models (the other shape
620
+ // buildModelsRequest can produce) returns { models: [...] }.
621
+ const list = json && typeof json === "object" && !Array.isArray(json)
622
+ ? (Array.isArray(json.data) ? json.data : Array.isArray(json.models) ? json.models : undefined)
623
+ : undefined;
624
+ if (!Array.isArray(list)) {
625
+ return jsonResponse({ ok: false, latencyMs, error: "upstream /models returned an unexpected shape" });
626
+ }
627
+ const models = list.length;
628
+ return jsonResponse({
629
+ ok: true,
630
+ latencyMs,
631
+ models,
632
+ message: `Connected — ${models} model${models === 1 ? "" : "s"} available.`,
633
+ });
634
+ } catch (err) {
635
+ return jsonResponse({
636
+ ok: false,
637
+ latencyMs: Date.now() - started,
638
+ error: err instanceof Error ? err.message : "Connection test failed",
639
+ });
640
+ }
464
641
  }
465
642
 
466
643
  if (url.pathname === "/api/providers" && req.method === "DELETE") {
467
644
  const name = url.searchParams.get("name")?.trim();
468
645
  if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
469
646
  if (name === config.defaultProvider) return jsonResponse({ error: "cannot delete the default provider; set another default first" }, 400);
647
+ const dependentCombos = Object.entries(config.combos ?? {})
648
+ .filter(([, combo]) => combo.targets.some(target => target.provider === name))
649
+ .map(([id]) => id)
650
+ .sort((a, b) => a.localeCompare(b));
651
+ if (dependentCombos.length > 0) {
652
+ return jsonResponse({
653
+ error: `cannot delete provider "${name}" while combos depend on it`,
654
+ combos: dependentCombos,
655
+ }, 409);
656
+ }
470
657
  const { saveConfig: save } = await import("../config");
471
658
  delete config.providers[name];
472
659
  setProviderContextCap(config, name, false);
@@ -1030,7 +1217,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1030
1217
  if (url.pathname === "/api/oauth/login" && req.method === "POST") {
1031
1218
  const body = await req.json().catch(() => ({})) as { provider?: string; addAccount?: boolean };
1032
1219
  const provider = (body.provider ?? "").trim().toLowerCase();
1033
- if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1220
+ if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1034
1221
  try {
1035
1222
  // addAccount forces a fresh browser identity (skips local-CLI token import) so a
1036
1223
  // SECOND account can be added instead of re-importing the first one.
@@ -1048,12 +1235,23 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1048
1235
  }
1049
1236
  }
1050
1237
 
1238
+ // Cancel an in-progress browser/device OAuth login (GUI "Cancel" / modal close). Guarded by
1239
+ // the same public predicate as /api/oauth/login — only publicly startable flows are cancellable.
1240
+ if (url.pathname === "/api/oauth/login/cancel" && req.method === "POST") {
1241
+ const body = await req.json().catch(() => ({})) as { provider?: string };
1242
+ const provider = (body.provider ?? "").trim().toLowerCase();
1243
+ if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1244
+ const { cancelLoginFlow } = await import("../oauth");
1245
+ const cancelled = cancelLoginFlow(provider);
1246
+ return jsonResponse({ ok: true, cancelled });
1247
+ }
1248
+
1051
1249
  // Manual fallback for browser OAuth: paste the final redirect URL (or authorization code)
1052
1250
  // when the browser cannot reach the loopback callback (remote/SSH/blocked localhost).
1053
1251
  if (url.pathname === "/api/oauth/login/code" && req.method === "POST") {
1054
1252
  const body = await req.json().catch(() => ({})) as { provider?: string; input?: string; code?: string };
1055
1253
  const provider = (body.provider ?? "").trim().toLowerCase();
1056
- if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1254
+ if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1057
1255
  const input = typeof body.input === "string" ? body.input : typeof body.code === "string" ? body.code : "";
1058
1256
  // Authorization responses are measured in hundreds of bytes; never accept the
1059
1257
  // generic management-body allowance here.
@@ -1065,14 +1263,18 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1065
1263
 
1066
1264
  if (url.pathname === "/api/oauth/status" && req.method === "GET") {
1067
1265
  const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
1266
+ if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1068
1267
  return jsonResponse(getLoginStatus(provider));
1069
1268
  }
1070
1269
 
1071
1270
  if (url.pathname === "/api/oauth/logout" && req.method === "POST") {
1072
1271
  const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
1073
- if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1272
+ if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1074
1273
  await removeCredential(provider);
1075
1274
  clearLoginState(provider);
1275
+ // Drop cached/last-good quota rows tied to the removed credential.
1276
+ const { clearProviderQuotaCache } = await import("../providers/quota");
1277
+ clearProviderQuotaCache();
1076
1278
  return jsonResponse({ success: true });
1077
1279
  }
1078
1280
 
@@ -1080,14 +1282,14 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1080
1282
  // one, or remove one. Emails are masked; tokens never leave the store.
1081
1283
  if (url.pathname === "/api/oauth/accounts" && req.method === "GET") {
1082
1284
  const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
1083
- if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1285
+ if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1084
1286
  const status = getLoginStatus(provider);
1085
1287
  return jsonResponse({ activeAccountId: status.activeAccountId ?? null, accounts: status.accounts ?? [] });
1086
1288
  }
1087
1289
  if (url.pathname === "/api/oauth/accounts/active" && req.method === "PUT") {
1088
1290
  const body = await req.json().catch(() => ({})) as { provider?: string; accountId?: string };
1089
1291
  const provider = (body.provider ?? "").trim().toLowerCase();
1090
- if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1292
+ if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1091
1293
  if (!body.accountId) return jsonResponse({ error: "missing accountId" }, 400);
1092
1294
  const { setActiveAccount } = await import("../oauth/store");
1093
1295
  if (!(await setActiveAccount(provider, body.accountId))) return jsonResponse({ error: "account not found" }, 404);
@@ -1098,7 +1300,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1098
1300
  if (url.pathname === "/api/oauth/accounts" && req.method === "DELETE") {
1099
1301
  const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
1100
1302
  const id = url.searchParams.get("id") ?? "";
1101
- if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1303
+ if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1102
1304
  if (!id) return jsonResponse({ error: "missing id" }, 400);
1103
1305
  const { removeAccount, getAccountSet } = await import("../oauth/store");
1104
1306
  if (!(await removeAccount(provider, id))) return jsonResponse({ error: "account not found" }, 404);
@@ -1195,6 +1397,56 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1195
1397
  return jsonResponse({ success: true }, 200, req, config);
1196
1398
  }
1197
1399
 
1400
+ if (url.pathname === "/api/combos" && req.method === "GET") {
1401
+ const { comboModelId, getCombo, listComboIds } = await import("../combos");
1402
+ return jsonResponse({ combos: listComboIds(config).map(id => ({
1403
+ id,
1404
+ model: comboModelId(id),
1405
+ ...getCombo(config, id)!,
1406
+ })) });
1407
+ }
1408
+
1409
+ if (url.pathname === "/api/combos" && req.method === "PUT") {
1410
+ let rawBody: unknown;
1411
+ try { rawBody = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
1412
+ if (!isPlainRecord(rawBody)) {
1413
+ return jsonResponse({ error: "request body must be an object" }, 400);
1414
+ }
1415
+ const body = rawBody;
1416
+ if (typeof body.id !== "string" || !body.id.trim()) {
1417
+ return jsonResponse({ error: "id is required and must be a string" }, 400);
1418
+ }
1419
+ const id = body.id.trim();
1420
+ const { comboConfigError, normalizeComboConfig, comboModelId, clearComboSelectionState, clearComboTargetCooldowns } = await import("../combos");
1421
+ const error = comboConfigError(id, body.combo, config.providers, {
1422
+ requireEnabledTarget: true,
1423
+ });
1424
+ if (error) return jsonResponse({ error }, 400);
1425
+ const normalized = normalizeComboConfig(body.combo as import("../types").OcxComboConfig);
1426
+ config.combos = { ...(config.combos ?? {}), [id]: normalized };
1427
+ saveConfig(config);
1428
+ clearComboSelectionState(id);
1429
+ clearComboTargetCooldowns(id);
1430
+ await refreshCodexCatalogBestEffort();
1431
+ return jsonResponse({ success: true, id, model: comboModelId(id), combo: normalized });
1432
+ }
1433
+
1434
+ if (url.pathname === "/api/combos" && req.method === "DELETE") {
1435
+ const id = url.searchParams.get("id")?.trim();
1436
+ if (!id) return jsonResponse({ error: "id query param is required" }, 400);
1437
+ if (!Object.hasOwn(config.combos ?? {}, id)) {
1438
+ return jsonResponse({ error: "unknown combo" }, 404);
1439
+ }
1440
+ const { clearComboSelectionState, clearComboTargetCooldowns } = await import("../combos");
1441
+ delete config.combos![id];
1442
+ if (Object.keys(config.combos!).length === 0) delete config.combos;
1443
+ saveConfig(config);
1444
+ clearComboSelectionState(id);
1445
+ clearComboTargetCooldowns(id);
1446
+ await refreshCodexCatalogBestEffort();
1447
+ return jsonResponse({ success: true, id });
1448
+ }
1449
+
1198
1450
  if (url.pathname === "/api/stop" && req.method === "POST") {
1199
1451
  const { restoreNativeCodex } = await import("../codex/inject");
1200
1452
  const { stopServiceIfInstalled } = await import("../service");