@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.
- package/README.ko.md +37 -7
- package/README.md +52 -11
- package/README.zh-CN.md +36 -7
- package/bin/ocx.mjs +5 -3
- package/gui/dist/assets/index-BzhyTAco.js +40 -0
- package/gui/dist/assets/index-Dq3eZ1cU.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/opencode.svg +1 -1
- package/package.json +5 -2
- package/src/adapters/anthropic-image-normalize.ts +70 -29
- package/src/adapters/cursor/transport-retry.ts +20 -1
- package/src/adapters/run-turn-queue.ts +40 -0
- package/src/codex/auth-api.ts +10 -1
- package/src/codex/auth-context.ts +33 -7
- package/src/codex/catalog.ts +357 -23
- package/src/codex/routing.ts +10 -4
- package/src/combos/failover.ts +102 -0
- package/src/combos/index.ts +37 -0
- package/src/combos/request.ts +31 -0
- package/src/combos/resolve.ts +171 -0
- package/src/combos/types.ts +203 -0
- package/src/config.ts +280 -11
- package/src/lib/errors.ts +86 -24
- package/src/lib/upstream-retry.ts +8 -4
- package/src/oauth/index.ts +7 -1
- package/src/oauth/key-providers.ts +2 -32
- package/src/oauth/login-cli.ts +4 -3
- package/src/oauth/token-guardian.ts +38 -3
- package/src/providers/derive.ts +27 -2
- package/src/providers/kiro-models.ts +8 -3
- package/src/providers/label.ts +3 -1
- package/src/providers/openai-sidecar.ts +94 -0
- package/src/providers/openai-tier-startup.ts +27 -0
- package/src/providers/openai-tiers.ts +283 -0
- package/src/providers/openai-virtual-models.ts +82 -0
- package/src/providers/quota.ts +344 -24
- package/src/providers/registry.ts +112 -20
- package/src/reasoning-effort.ts +12 -11
- package/src/router.ts +80 -36
- package/src/server/auth-cors.ts +85 -9
- package/src/server/images.ts +31 -75
- package/src/server/index.ts +45 -86
- package/src/server/management-api.ts +273 -21
- package/src/server/request-log.ts +221 -20
- package/src/server/responses.ts +594 -75
- package/src/server/search.ts +22 -37
- package/src/types.ts +49 -1
- package/src/update/index.ts +50 -6
- package/src/update/job.ts +21 -4
- package/src/usage/log.ts +124 -1
- package/src/usage/summary.ts +147 -56
- package/src/vision/index.ts +20 -19
- package/src/web-search/index.ts +15 -17
- package/gui/dist/assets/index-Bk_GgFrh.css +0 -1
- package/gui/dist/assets/index-DQjt6Hly.js +0 -40
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import {
|
|
14
14
|
clearLoginState,
|
|
15
15
|
getLoginStatus,
|
|
16
|
-
|
|
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 {
|
|
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?:
|
|
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
|
|
416
|
-
const
|
|
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
|
|
454
|
-
try {
|
|
455
|
-
if (
|
|
456
|
-
|
|
457
|
-
|
|
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] =
|
|
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({
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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");
|