@yansigit/opencodex 2.36.1-dev.20260829.47 → 2.36.1-dev.20260829.48
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/gui/dist/assets/{ApiKeys-DAuYQsGJ.js → ApiKeys-LOOFiZfv.js} +1 -1
- package/gui/dist/assets/{Claude-bN_80RT8.js → Claude-BwKzpXe3.js} +1 -1
- package/gui/dist/assets/{CodexSet-D3dWX_ay.js → CodexSet-B5qi9KeE.js} +1 -1
- package/gui/dist/assets/{FileIntegrationPage-BGyzkPs2.js → FileIntegrationPage-B0sUef6W.js} +1 -1
- package/gui/dist/assets/{Grok-CuaKd1TT.js → Grok-bsb4n-f4.js} +1 -1
- package/gui/dist/assets/{Integrations-C7NGQoxR.js → Integrations-CSMwFDvM.js} +2 -2
- package/gui/dist/assets/{IntegrationsOverview-DjVorYO5.js → IntegrationsOverview-CTORdKJA.js} +1 -1
- package/gui/dist/assets/{Logs-B21a-9xe.js → Logs-BWYbfELf.js} +1 -1
- package/gui/dist/assets/{Models-CN7Q3kuK.js → Models-CexEtdT1.js} +1 -1
- package/gui/dist/assets/{NumberStepper-BWg90tEs.js → NumberStepper-BapSFQnW.js} +1 -1
- package/gui/dist/assets/{Providers-CP_i-7LS.js → Providers-BwUFeAgA.js} +1 -1
- package/gui/dist/assets/{RestoreDialog-3M0NqCiD.js → RestoreDialog-CD8piq90.js} +1 -1
- package/gui/dist/assets/{Startup-BVPFoP2s.js → Startup-BgX731C2.js} +1 -1
- package/gui/dist/assets/{Storage-BdsG9ypz.js → Storage-Bgq7HphP.js} +1 -1
- package/gui/dist/assets/{Subagents-CguR_bGQ.js → Subagents-ChxItbeF.js} +1 -1
- package/gui/dist/assets/{Usage-C0kKi9_H.js → Usage-BkylzP50.js} +1 -1
- package/gui/dist/assets/{data-surface-BCycbObp.js → data-surface-pCXChiBf.js} +1 -1
- package/gui/dist/assets/{index-CU1jE0st.js → index-CNopOid3.js} +2 -2
- package/gui/dist/assets/{model-display-D2elXkuE.js → model-display-CiUpg8T9.js} +1 -1
- package/gui/dist/assets/{provider-payload-DfkXWE7v.js → provider-payload-b2jlS-On.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/cli/config-command.ts +7 -3
- package/src/cli/init.ts +2 -2
- package/src/cli/provider.ts +58 -74
- package/src/codex/convergence.ts +110 -15
- package/src/config.ts +209 -11
- package/src/generated/compatibility-version.json +24 -20
- package/src/oauth/index.ts +143 -82
- package/src/oauth/login-cli.ts +53 -11
- package/src/providers/alibaba-region-startup.ts +31 -11
- package/src/providers/api-keys.ts +58 -34
- package/src/providers/key-failover.ts +59 -46
- package/src/providers/model-rename-startup.ts +25 -7
- package/src/providers/openai-tier-startup.ts +38 -14
- package/src/server/index.ts +10 -0
- package/src/server/management/context.ts +10 -3
- package/src/server/management/logs-usage-routes.ts +19 -16
- package/src/server/management/model-routes.ts +284 -165
- package/src/server/management/oauth-account-routes.ts +93 -15
- package/src/server/management/provider-routes.ts +200 -87
- package/src/storage/policy-input.ts +166 -0
- package/src/storage/policy.ts +59 -211
|
@@ -64,10 +64,11 @@ import { AUTH_MATRIX, isAllowedRequestOrigin, jsonResponse, providerManagementCo
|
|
|
64
64
|
import { applySystemEnvToggle } from "../system-env";
|
|
65
65
|
import { buildApiAccessEndpoints } from "./api-access";
|
|
66
66
|
import { isAzureIdentityProvider } from "../../config/provider-validation";
|
|
67
|
+
import { apiKeyPoolEntryId } from "../../providers/api-keys";
|
|
67
68
|
|
|
68
69
|
import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
|
|
69
70
|
import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
|
|
70
|
-
import { saveManagementConfig, type ManagementContext } from "./context";
|
|
71
|
+
import { mutateManagementConfig, saveManagementConfig, type ManagementContext } from "./context";
|
|
71
72
|
import { readManagementJsonBody, readManagementJsonBodyOr, rethrowManagementBodyTooLarge } from "./body";
|
|
72
73
|
import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match";
|
|
73
74
|
import { ACCOUNT_IMPORT_DEADLINE_MS, ACCOUNT_IMPORT_MAX_REQUEST_BYTES } from "../../oauth/account-import";
|
|
@@ -95,6 +96,44 @@ async function readJsonBody(req: Request): Promise<Record<string, unknown> | nul
|
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
function providerKeyPool(provider: OcxProviderConfig): NonNullable<OcxProviderConfig["apiKeyPool"]> {
|
|
100
|
+
const pool = provider.apiKeyPool ??= [];
|
|
101
|
+
if (pool.length === 0 && provider.apiKey) {
|
|
102
|
+
pool.push({ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey });
|
|
103
|
+
}
|
|
104
|
+
return pool;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function providerUsesKeyAuth(provider: OcxProviderConfig): boolean {
|
|
108
|
+
return !isAzureIdentityProvider(provider) && provider.authMode !== "oauth" && provider.authMode !== "forward";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function mutateProviderKey<T>(
|
|
112
|
+
deps: ManagementContext["deps"],
|
|
113
|
+
config: OcxConfig,
|
|
114
|
+
name: string,
|
|
115
|
+
mutate: (provider: OcxProviderConfig) => T | null,
|
|
116
|
+
): { ok: true; value: T } | { ok: false; error: string; status: number } {
|
|
117
|
+
const outcome = mutateManagementConfig(deps, fresh => {
|
|
118
|
+
const provider = fresh.providers[name];
|
|
119
|
+
if (!provider || !providerUsesKeyAuth(provider)) {
|
|
120
|
+
return { changed: false, value: null };
|
|
121
|
+
}
|
|
122
|
+
const before = JSON.stringify(provider);
|
|
123
|
+
const value = mutate(provider);
|
|
124
|
+
return {
|
|
125
|
+
changed: value !== null && JSON.stringify(provider) !== before,
|
|
126
|
+
value: value === null ? null : { provider: structuredClone(provider), value },
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
if (outcome.status === "unavailable") {
|
|
130
|
+
return { ok: false, error: outcome.reason === "conflict" ? "config changed; retry" : `config is ${outcome.reason}`, status: outcome.reason === "conflict" ? 409 : 500 };
|
|
131
|
+
}
|
|
132
|
+
if (outcome.value === null) return { ok: false, error: "key not found", status: 404 };
|
|
133
|
+
config.providers[name] = outcome.value.provider;
|
|
134
|
+
return { ok: true, value: outcome.value.value };
|
|
135
|
+
}
|
|
136
|
+
|
|
98
137
|
/**
|
|
99
138
|
* The single place key-name rules live. The config read schema is deliberately
|
|
100
139
|
* permissive so an existing config can never become unloadable; this is the write
|
|
@@ -560,27 +599,48 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
560
599
|
const body = await readManagementJsonBodyOr(req, {}) as { name?: string; key?: string; label?: string };
|
|
561
600
|
const name = (body.name ?? "").trim();
|
|
562
601
|
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
563
|
-
if (
|
|
602
|
+
if (!providerUsesKeyAuth(config.providers[name]!)) return jsonResponse({ error: "provider does not use API-key auth" }, 400);
|
|
564
603
|
if (typeof body.key !== "string" || !body.key.trim()) return jsonResponse({ error: "key is required" }, 400);
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
604
|
+
const key = body.key.trim();
|
|
605
|
+
if (/[\r\n]/.test(key)) return jsonResponse({ error: "key must not include line breaks" }, 400);
|
|
606
|
+
const result = mutateProviderKey(deps, config, name, provider => {
|
|
607
|
+
const pool = providerKeyPool(provider);
|
|
608
|
+
const label = body.label?.trim();
|
|
609
|
+
const existing = pool.find(entry => entry.key === key);
|
|
610
|
+
if (existing) {
|
|
611
|
+
if (label) existing.label = label;
|
|
612
|
+
provider.apiKey = existing.key;
|
|
613
|
+
return { id: existing.id };
|
|
614
|
+
}
|
|
615
|
+
const id = apiKeyPoolEntryId(key);
|
|
616
|
+
if (pool.some(entry => entry.id === id)) return { error: "API-key pool ID collision" };
|
|
617
|
+
pool.push({ id, key, ...(label ? { label } : {}), addedAt: Date.now() });
|
|
618
|
+
provider.apiKey = key;
|
|
619
|
+
return { id };
|
|
620
|
+
});
|
|
621
|
+
if (!result.ok) return jsonResponse({ error: result.error }, result.status);
|
|
622
|
+
if ("error" in result.value) return jsonResponse({ error: result.value.error }, 409);
|
|
568
623
|
const { clearModelCache } = await import("../../codex/model-cache");
|
|
569
624
|
clearModelCache(name);
|
|
570
625
|
const { clearProviderQuotaCache } = await import("../../providers/quota");
|
|
571
626
|
clearProviderQuotaCache();
|
|
572
627
|
const { clearKeyCooldowns } = await import("../../providers/key-failover");
|
|
573
628
|
clearKeyCooldowns(name); // manual key management resets 429 cooldown state
|
|
574
|
-
return jsonResponse({ ok: true, id: result.id }, 201);
|
|
629
|
+
return jsonResponse({ ok: true, id: result.value.id }, 201);
|
|
575
630
|
}
|
|
576
631
|
if (url.pathname === "/api/providers/keys/active" && req.method === "PUT") {
|
|
577
632
|
const body = await readManagementJsonBodyOr(req, {}) as { name?: string; id?: string };
|
|
578
633
|
const name = (body.name ?? "").trim();
|
|
579
634
|
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
580
|
-
if (
|
|
635
|
+
if (!providerUsesKeyAuth(config.providers[name]!)) return jsonResponse({ error: "provider does not use API-key auth" }, 400);
|
|
581
636
|
if (!body.id) return jsonResponse({ error: "missing id" }, 400);
|
|
582
|
-
const
|
|
583
|
-
|
|
637
|
+
const result = mutateProviderKey(deps, config, name, provider => {
|
|
638
|
+
const entry = providerKeyPool(provider).find(candidate => candidate.id === body.id);
|
|
639
|
+
if (!entry) return null;
|
|
640
|
+
provider.apiKey = entry.key;
|
|
641
|
+
return true;
|
|
642
|
+
});
|
|
643
|
+
if (!result.ok) return jsonResponse({ error: result.error }, result.status);
|
|
584
644
|
const { clearModelCache } = await import("../../codex/model-cache");
|
|
585
645
|
clearModelCache(name);
|
|
586
646
|
const { clearProviderQuotaCache } = await import("../../providers/quota");
|
|
@@ -595,23 +655,41 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
595
655
|
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
596
656
|
const alias = typeof body.alias === "string" ? body.alias.trim() : "";
|
|
597
657
|
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
598
|
-
if (
|
|
658
|
+
if (!providerUsesKeyAuth(config.providers[name]!)) return jsonResponse({ error: "provider does not use API-key auth" }, 400);
|
|
599
659
|
if (!id) return jsonResponse({ error: "missing id" }, 400);
|
|
600
660
|
if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) {
|
|
601
661
|
return jsonResponse({ error: "alias must be at most 80 printable characters" }, 400);
|
|
602
662
|
}
|
|
603
|
-
const
|
|
604
|
-
|
|
663
|
+
const result = mutateProviderKey(deps, config, name, provider => {
|
|
664
|
+
const entry = providerKeyPool(provider).find(candidate => candidate.id === id);
|
|
665
|
+
if (!entry) return null;
|
|
666
|
+
if (alias) entry.label = alias;
|
|
667
|
+
else delete entry.label;
|
|
668
|
+
return true;
|
|
669
|
+
});
|
|
670
|
+
if (!result.ok) return jsonResponse({ error: result.error }, result.status);
|
|
605
671
|
return jsonResponse({ ok: true, name, id, alias: alias || null });
|
|
606
672
|
}
|
|
607
673
|
if (url.pathname === "/api/providers/keys" && req.method === "DELETE") {
|
|
608
674
|
const name = (url.searchParams.get("name") ?? "").trim();
|
|
609
675
|
const id = url.searchParams.get("id") ?? "";
|
|
610
676
|
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
611
|
-
if (
|
|
677
|
+
if (!providerUsesKeyAuth(config.providers[name]!)) return jsonResponse({ error: "provider does not use API-key auth" }, 400);
|
|
612
678
|
if (!id) return jsonResponse({ error: "missing id" }, 400);
|
|
613
|
-
const
|
|
614
|
-
|
|
679
|
+
const result = mutateProviderKey(deps, config, name, provider => {
|
|
680
|
+
const pool = providerKeyPool(provider);
|
|
681
|
+
const entry = pool.find(candidate => candidate.id === id);
|
|
682
|
+
if (!entry) return null;
|
|
683
|
+
provider.apiKeyPool = pool.filter(candidate => candidate.id !== id);
|
|
684
|
+
if (provider.apiKey === entry.key) {
|
|
685
|
+
const next = provider.apiKeyPool[0];
|
|
686
|
+
if (next) provider.apiKey = next.key;
|
|
687
|
+
else delete provider.apiKey;
|
|
688
|
+
}
|
|
689
|
+
if (provider.apiKeyPool.length === 0) delete provider.apiKeyPool;
|
|
690
|
+
return true;
|
|
691
|
+
});
|
|
692
|
+
if (!result.ok) return jsonResponse({ error: result.error }, result.status);
|
|
615
693
|
const { clearModelCache } = await import("../../codex/model-cache");
|
|
616
694
|
clearModelCache(name);
|
|
617
695
|
const { clearProviderQuotaCache } = await import("../../providers/quota");
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
upsertOAuthProvider,
|
|
30
30
|
} from "../../oauth";
|
|
31
31
|
import { replaceProviderAccountSet } from "../../oauth/store";
|
|
32
|
+
import { comboPublicModelId } from "../../combos/types";
|
|
32
33
|
import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
33
34
|
import {
|
|
34
35
|
antigravityOAuthDestinationConfigError,
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
import { routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
52
53
|
import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
|
|
53
54
|
import { clearKeyCooldowns } from "../../providers/key-failover";
|
|
55
|
+
import { apiKeyPoolEntryId } from "../../providers/api-keys";
|
|
54
56
|
import { providerRequestPacingStatus } from "../../providers/request-pacing";
|
|
55
57
|
import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
56
58
|
import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match";
|
|
@@ -65,6 +67,7 @@ import { getUsageDebugLogEntries } from "../../usage/debug";
|
|
|
65
67
|
import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
|
|
66
68
|
import { stripCodexRuntimeProviderFields } from "../../codex/auth-context";
|
|
67
69
|
import { getProviderRegistryEntry } from "../../providers/registry";
|
|
70
|
+
import { dropProviderCustomModels } from "../../providers/provider-id-rewrite";
|
|
68
71
|
import { getDebugLogEntries } from "../../lib/debug-log-buffer";
|
|
69
72
|
import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log";
|
|
70
73
|
import {
|
|
@@ -94,7 +97,7 @@ import {
|
|
|
94
97
|
|
|
95
98
|
import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
|
|
96
99
|
import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
|
|
97
|
-
import { saveManagementConfig, type ManagementContext } from "./context";
|
|
100
|
+
import { mutateManagementConfig, saveManagementConfig, type ManagementContext } from "./context";
|
|
98
101
|
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
|
|
99
102
|
import { resolveAiStudioCredentials } from "../../oauth/aistudio-credentials";
|
|
100
103
|
import { buildAiStudioHeaders, parseGoogleCookieJar } from "../../oauth/google-aistudio-auth";
|
|
@@ -110,6 +113,93 @@ type ProviderPatchApplication =
|
|
|
110
113
|
headersTouched: boolean;
|
|
111
114
|
};
|
|
112
115
|
|
|
116
|
+
type ProviderMutationValue =
|
|
117
|
+
| { config: OcxConfig; fallbackDefault?: string; droppedCustomModels?: number }
|
|
118
|
+
| {
|
|
119
|
+
error: string;
|
|
120
|
+
code?: string;
|
|
121
|
+
status?: number;
|
|
122
|
+
combos?: string[];
|
|
123
|
+
routingProfiles?: string[];
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
function reconcileSubmittedApiKey(provider: OcxProviderConfig): string | undefined {
|
|
127
|
+
if (!provider.apiKey || !provider.apiKeyPool) return;
|
|
128
|
+
const key = provider.apiKey.trim();
|
|
129
|
+
if (!key || /[\r\n]/.test(key)) return;
|
|
130
|
+
if (provider.apiKeyPool.some(entry => entry.key === key)) {
|
|
131
|
+
provider.apiKey = key;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const id = apiKeyPoolEntryId(key);
|
|
135
|
+
if (provider.apiKeyPool.some(entry => entry.id === id)) return "API-key pool ID collision";
|
|
136
|
+
provider.apiKeyPool.push({ id, key, addedAt: Date.now() });
|
|
137
|
+
provider.apiKey = key;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function providerDependencies(config: OcxConfig, name: string): {
|
|
141
|
+
combos: string[];
|
|
142
|
+
routingProfiles: string[];
|
|
143
|
+
} {
|
|
144
|
+
return {
|
|
145
|
+
combos: Object.entries(config.combos ?? {})
|
|
146
|
+
.filter(([, combo]) => combo.targets.some(target => target.provider === name))
|
|
147
|
+
.map(([id]) => id)
|
|
148
|
+
.sort((a, b) => a.localeCompare(b)),
|
|
149
|
+
routingProfiles: Object.entries(config.routingProfiles ?? {})
|
|
150
|
+
.filter(([, profile]) => profile.candidates.some(candidate => candidate.provider === name))
|
|
151
|
+
.map(([id]) => id)
|
|
152
|
+
.sort((a, b) => a.localeCompare(b)),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function providerDependencyError(config: OcxConfig, name: string): Extract<ProviderMutationValue, { error: string }> | null {
|
|
157
|
+
const dependencies = providerDependencies(config, name);
|
|
158
|
+
if (dependencies.routingProfiles.length > 0) return {
|
|
159
|
+
error: `cannot delete provider "${name}" while routing profiles depend on it`,
|
|
160
|
+
code: "provider_has_dependent_routing_profiles",
|
|
161
|
+
routingProfiles: dependencies.routingProfiles,
|
|
162
|
+
};
|
|
163
|
+
if (dependencies.combos.length > 0) return {
|
|
164
|
+
error: `cannot delete provider "${name}" while combos depend on it`,
|
|
165
|
+
code: "provider_has_dependent_combos",
|
|
166
|
+
combos: dependencies.combos,
|
|
167
|
+
};
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function providerNamespaceCollisionError(config: OcxConfig, name: string): string | undefined {
|
|
172
|
+
const accountCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name);
|
|
173
|
+
if (accountCollision) return accountCollision;
|
|
174
|
+
const comboCollision = (name === "combo" && Object.keys(config.combos ?? {}).length > 0)
|
|
175
|
+
|| Object.entries(config.combos ?? {}).some(([id, combo]) => {
|
|
176
|
+
const publicId = comboPublicModelId(id, combo);
|
|
177
|
+
return id === name || publicId === name || publicId.startsWith(`${name}/`);
|
|
178
|
+
});
|
|
179
|
+
if (comboCollision) {
|
|
180
|
+
return "provider name must not collide with a configured combo namespace";
|
|
181
|
+
}
|
|
182
|
+
const profileCollision = Object.values(config.routingProfiles ?? {}).some(profile => {
|
|
183
|
+
const alias = profile.alias?.trim();
|
|
184
|
+
return alias === name || alias?.startsWith(`${name}/`);
|
|
185
|
+
});
|
|
186
|
+
return profileCollision
|
|
187
|
+
? "provider name must not collide with a configured routing profile namespace"
|
|
188
|
+
: undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function adoptCommittedConfig(target: OcxConfig, source: OcxConfig): void {
|
|
192
|
+
for (const key of Object.keys(target)) delete (target as unknown as Record<string, unknown>)[key];
|
|
193
|
+
Object.assign(target, structuredClone(source));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function unavailableMutationResponse(reason: "missing" | "invalid" | "conflict", req: Request, config: OcxConfig): Response {
|
|
197
|
+
const message = reason === "conflict"
|
|
198
|
+
? "config changed while applying this update; retry"
|
|
199
|
+
: `config is ${reason}`;
|
|
200
|
+
return jsonResponse({ error: message }, reason === "conflict" ? 409 : 500, req, config);
|
|
201
|
+
}
|
|
202
|
+
|
|
113
203
|
const AI_STUDIO_REAUTH_ERROR = "Session expired or missing — re-authentication required";
|
|
114
204
|
const AI_STUDIO_PROBE_TIMEOUT_MS = 8_000;
|
|
115
205
|
const AI_STUDIO_PROBE_MODEL = "gemini-2.5-flash";
|
|
@@ -714,7 +804,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
714
804
|
if (!isValidProviderName(name)) {
|
|
715
805
|
return jsonResponse({ error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key" }, 400);
|
|
716
806
|
}
|
|
717
|
-
const namespaceCollision =
|
|
807
|
+
const namespaceCollision = providerNamespaceCollisionError(config, name);
|
|
718
808
|
if (namespaceCollision) {
|
|
719
809
|
return jsonResponse({ error: namespaceCollision }, 409);
|
|
720
810
|
}
|
|
@@ -731,65 +821,56 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
731
821
|
if (body.setDefault === true && prov.disabled) {
|
|
732
822
|
return jsonResponse({ error: "cannot set a disabled provider as default", code: "default_provider_disabled" }, 400);
|
|
733
823
|
}
|
|
734
|
-
// Catalog providers (e.g. ollama-cloud) carry a models + vision/reasoning classification the GUI
|
|
735
|
-
// doesn't send — merge it in so the sidecars are gated correctly.
|
|
736
|
-
// Sample request ownership BEFORE enrichment. Enrichment fills absent fields from the
|
|
737
|
-
// registry seed, after which "the client omitted this" and "the registry supplied it" are
|
|
738
|
-
// indistinguishable — so a carry-over guard written as `prov.x === undefined` after this
|
|
739
|
-
// call can never fire.
|
|
740
824
|
const submittedContextWindow = Object.hasOwn(prov, "contextWindow");
|
|
741
825
|
const submittedModelContextWindows = Object.hasOwn(prov, "modelContextWindows");
|
|
742
826
|
const submittedModelAutoCompactTokenLimits = Object.hasOwn(prov, "modelAutoCompactTokenLimits");
|
|
743
827
|
const submittedRequestPacing = Object.hasOwn(prov, "requestPacing");
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
if (
|
|
787
|
-
|
|
828
|
+
const buildProvider = (existing: OcxProviderConfig | undefined): OcxProviderConfig => {
|
|
829
|
+
const next = structuredClone(prov);
|
|
830
|
+
// Native OpenAI capabilities are registry-owned runtime defaults. Enriching this exact
|
|
831
|
+
// canonical row adds supportsServiceTier to the document, which no longer equals the
|
|
832
|
+
// immutable forward seed and is correctly rejected by the write validator.
|
|
833
|
+
if (name !== "openai") enrichProviderFromCatalog(name, next);
|
|
834
|
+
const existingPool = existing?.apiKeyPool;
|
|
835
|
+
if (existingPool && !next.apiKeyPool && !next.azureCredential) next.apiKeyPool = existingPool;
|
|
836
|
+
if (typeof next.apiKey === "string" && !next.apiKey.trim()) delete next.apiKey;
|
|
837
|
+
if (!next.apiKey && existing?.apiKey && !next.azureCredential
|
|
838
|
+
&& next.authMode !== "oauth" && next.authMode !== "forward") next.apiKey = existing.apiKey;
|
|
839
|
+
const existingCosts = existing?.modelCosts;
|
|
840
|
+
if (existingCosts && !next.modelCosts) next.modelCosts = existingCosts;
|
|
841
|
+
const existingFailover = existing?.oauthAccountFailover;
|
|
842
|
+
if (existingFailover && !next.oauthAccountFailover) next.oauthAccountFailover = existingFailover;
|
|
843
|
+
if (!submittedRequestPacing && existing?.requestPacing) next.requestPacing = structuredClone(existing.requestPacing);
|
|
844
|
+
if (!submittedContextWindow && existing?.contextWindow !== undefined) next.contextWindow = existing.contextWindow;
|
|
845
|
+
if (existing?.modelContextWindows) {
|
|
846
|
+
next.modelContextWindows = submittedModelContextWindows
|
|
847
|
+
? { ...existing.modelContextWindows, ...(next.modelContextWindows ?? {}) }
|
|
848
|
+
: { ...existing.modelContextWindows };
|
|
849
|
+
}
|
|
850
|
+
if (existing?.modelAutoCompactTokenLimits) {
|
|
851
|
+
next.modelAutoCompactTokenLimits = submittedModelAutoCompactTokenLimits
|
|
852
|
+
? { ...existing.modelAutoCompactTokenLimits, ...(next.modelAutoCompactTokenLimits ?? {}) }
|
|
853
|
+
: { ...existing.modelAutoCompactTokenLimits };
|
|
854
|
+
}
|
|
855
|
+
return stripRegistryOnlyStaticHeaders(name, next);
|
|
856
|
+
};
|
|
857
|
+
const outcome = mutateManagementConfig<ProviderMutationValue>(deps, fresh => {
|
|
858
|
+
const namespaceCollision = providerNamespaceCollisionError(fresh, name);
|
|
859
|
+
if (namespaceCollision) return { changed: false, value: { error: namespaceCollision, status: 409 } };
|
|
860
|
+
const next = buildProvider(fresh.providers[name]);
|
|
861
|
+
const keyCollision = reconcileSubmittedApiKey(next);
|
|
862
|
+
if (keyCollision) return { changed: false, value: { error: keyCollision, status: 409 } };
|
|
863
|
+
const providerError = providerManagementConfigError(name, next)
|
|
864
|
+
?? providerServiceTierConfigError(name, next);
|
|
865
|
+
if (providerError) return { changed: false, value: { error: providerError, status: 400 } };
|
|
866
|
+
fresh.providers[name] = next;
|
|
867
|
+
if (body.setDefault === true) fresh.defaultProvider = name;
|
|
868
|
+
return { changed: true, value: { config: structuredClone(fresh) } };
|
|
869
|
+
});
|
|
870
|
+
if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config);
|
|
871
|
+
if ("error" in outcome.value) return jsonResponse({ error: outcome.value.error }, outcome.value.status, req, config);
|
|
872
|
+
adoptCommittedConfig(config, outcome.value.config);
|
|
788
873
|
reconcileLiveStateStores();
|
|
789
|
-
if (prov.apiKey && prov.apiKeyPool) {
|
|
790
|
-
const { addProviderApiKey } = await import("../../providers/api-keys");
|
|
791
|
-
addProviderApiKey(config, name, prov.apiKey);
|
|
792
|
-
}
|
|
793
874
|
const { clearModelCache } = await import("../../codex/model-cache");
|
|
794
875
|
clearModelCache(name);
|
|
795
876
|
const catalogRefresh = await convergeCodexCatalog();
|
|
@@ -824,8 +905,17 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
824
905
|
if (!provider || !isCanonicalOpenAiForwardProvider(provider)) {
|
|
825
906
|
return jsonResponse({ error: "provider openai must be the canonical built-in provider" }, 400);
|
|
826
907
|
}
|
|
827
|
-
|
|
828
|
-
|
|
908
|
+
const outcome = mutateManagementConfig<ProviderMutationValue>(deps, fresh => {
|
|
909
|
+
const provider = fresh.providers.openai;
|
|
910
|
+
if (!provider || !isCanonicalOpenAiForwardProvider(provider)) {
|
|
911
|
+
return { changed: false, value: { error: "provider openai must be the canonical built-in provider" } };
|
|
912
|
+
}
|
|
913
|
+
fresh.providers.openai = { ...provider, codexAccountMode: mode };
|
|
914
|
+
return { changed: true, value: { config: structuredClone(fresh) } };
|
|
915
|
+
});
|
|
916
|
+
if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config);
|
|
917
|
+
if ("error" in outcome.value) return jsonResponse({ error: outcome.value.error }, 409);
|
|
918
|
+
adoptCommittedConfig(config, outcome.value.config);
|
|
829
919
|
reconcileLiveStateStores();
|
|
830
920
|
(deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
|
|
831
921
|
(deps.clearThreadAccountMap ?? clearThreadAccountMap)();
|
|
@@ -850,8 +940,17 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
850
940
|
if (config.providers[name]!.disabled) {
|
|
851
941
|
return jsonResponse({ error: "cannot set a disabled provider as default", code: "default_provider_disabled" }, 400);
|
|
852
942
|
}
|
|
853
|
-
|
|
854
|
-
|
|
943
|
+
const outcome = mutateManagementConfig<ProviderMutationValue>(deps, fresh => {
|
|
944
|
+
if (!hasOwnProvider(fresh.providers, name)) return { changed: false, value: { error: "unknown provider" } };
|
|
945
|
+
if (fresh.providers[name]!.disabled) {
|
|
946
|
+
return { changed: false, value: { error: "cannot set a disabled provider as default", code: "default_provider_disabled" } };
|
|
947
|
+
}
|
|
948
|
+
fresh.defaultProvider = name;
|
|
949
|
+
return { changed: true, value: { config: structuredClone(fresh) } };
|
|
950
|
+
});
|
|
951
|
+
if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config);
|
|
952
|
+
if ("error" in outcome.value) return jsonResponse(outcome.value, outcome.value.error === "unknown provider" ? 404 : 400);
|
|
953
|
+
adoptCommittedConfig(config, outcome.value.config);
|
|
855
954
|
reconcileLiveStateStores();
|
|
856
955
|
return jsonResponse({ success: true, name, defaultProvider: name });
|
|
857
956
|
}
|
|
@@ -894,36 +993,41 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
894
993
|
// concurrent PATCHes updating different fields/headers both survive instead of the
|
|
895
994
|
// later save clobbering the earlier snapshot.
|
|
896
995
|
let replayError: string | undefined;
|
|
897
|
-
|
|
898
|
-
const
|
|
996
|
+
const outcome = mutateManagementConfig<ProviderMutationValue>(deps, fresh => {
|
|
997
|
+
const provider = fresh.providers[name];
|
|
998
|
+
if (!provider) return { changed: false, value: { error: "unknown provider" } };
|
|
999
|
+
const replay = applyProviderPatchFields(name, provider, rawBody, keys, fresh);
|
|
899
1000
|
if ("error" in replay) {
|
|
900
1001
|
replayError = replay.error;
|
|
901
|
-
return;
|
|
1002
|
+
return { changed: false, value: { error: replay.error } };
|
|
902
1003
|
}
|
|
903
1004
|
if (replay.editorTouched && !pacingOnly) {
|
|
904
1005
|
const syncError = canonicalBudgetOnly
|
|
905
|
-
? canonicalOpenAiBudgetPatchError(replay.next, rawBody, keys,
|
|
1006
|
+
? canonicalOpenAiBudgetPatchError(replay.next, rawBody, keys, fresh)
|
|
906
1007
|
: providerManagementConfigError(name, replay.next);
|
|
907
1008
|
if (syncError) {
|
|
908
1009
|
replayError = syncError;
|
|
909
|
-
return;
|
|
1010
|
+
return { changed: false, value: { error: syncError } };
|
|
910
1011
|
}
|
|
911
1012
|
if (!canonicalBudgetOnly) {
|
|
912
1013
|
const serviceTierError = providerServiceTierConfigError(name, replay.next);
|
|
913
1014
|
if (serviceTierError) {
|
|
914
1015
|
replayError = serviceTierError;
|
|
915
|
-
return;
|
|
1016
|
+
return { changed: false, value: { error: serviceTierError } };
|
|
916
1017
|
}
|
|
917
1018
|
}
|
|
918
1019
|
} else if (replay.enablingOpenAi && !isCanonicalOpenAiForwardProvider(replay.next)) {
|
|
919
1020
|
replayError = "provider openai must be the canonical built-in provider";
|
|
920
|
-
return;
|
|
1021
|
+
return { changed: false, value: { error: replayError } };
|
|
921
1022
|
}
|
|
922
1023
|
// A PATCH that managed headers owns the resulting block: the clear path restores
|
|
923
1024
|
// registry static headers, so exact-match stripping must not erase them again.
|
|
924
|
-
|
|
925
|
-
|
|
1025
|
+
fresh.providers[name] = replay.headersTouched ? replay.next : stripRegistryOnlyStaticHeaders(name, replay.next);
|
|
1026
|
+
return { changed: true, value: { config: structuredClone(fresh) } };
|
|
926
1027
|
});
|
|
1028
|
+
if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config);
|
|
1029
|
+
if ("error" in outcome.value) return jsonResponse({ error: outcome.value.error }, outcome.value.error === "unknown provider" ? 404 : 409);
|
|
1030
|
+
adoptCommittedConfig(config, outcome.value.config);
|
|
927
1031
|
if (replayError !== undefined) return jsonResponse({ error: replayError }, 409);
|
|
928
1032
|
reconcileLiveStateStores();
|
|
929
1033
|
if (applied.editorTouched && !pacingOnly) {
|
|
@@ -1110,23 +1214,32 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1110
1214
|
code: "last_provider",
|
|
1111
1215
|
}, 409);
|
|
1112
1216
|
}
|
|
1113
|
-
const
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1217
|
+
const dependencyError = providerDependencyError(config, name);
|
|
1218
|
+
if (dependencyError) return jsonResponse(dependencyError, 409);
|
|
1219
|
+
const outcome = mutateManagementConfig<ProviderMutationValue>(deps, fresh => {
|
|
1220
|
+
if (!hasOwnProvider(fresh.providers, name)) return { changed: false, value: { error: "unknown provider" } };
|
|
1221
|
+
const fallbackDefault = name === fresh.defaultProvider
|
|
1222
|
+
? Object.entries(fresh.providers)
|
|
1223
|
+
.find(([provider, providerConfig]) => provider !== name && providerConfig.disabled !== true)
|
|
1224
|
+
?.[0]
|
|
1225
|
+
: undefined;
|
|
1226
|
+
if (name === fresh.defaultProvider && !fallbackDefault) {
|
|
1227
|
+
return { changed: false, value: { error: "cannot delete the default provider when no enabled replacement remains", code: "last_provider" } };
|
|
1228
|
+
}
|
|
1229
|
+
const dependencyError = providerDependencyError(fresh, name);
|
|
1230
|
+
if (dependencyError) return { changed: false, value: dependencyError };
|
|
1231
|
+
if (fallbackDefault) fresh.defaultProvider = fallbackDefault;
|
|
1232
|
+
delete fresh.providers[name];
|
|
1233
|
+
const droppedCustomModels = dropProviderCustomModels(fresh, name);
|
|
1234
|
+
setProviderContextCap(fresh, name, false);
|
|
1235
|
+
return { changed: true, value: { config: structuredClone(fresh), fallbackDefault, droppedCustomModels } };
|
|
1236
|
+
});
|
|
1237
|
+
if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config);
|
|
1238
|
+
if ("error" in outcome.value) {
|
|
1239
|
+
const status = outcome.value.code?.startsWith("provider_has_dependent_") || outcome.value.code === "last_provider" ? 409 : 404;
|
|
1240
|
+
return jsonResponse(outcome.value, status);
|
|
1123
1241
|
}
|
|
1124
|
-
|
|
1125
|
-
delete config.providers[name];
|
|
1126
|
-
const { dropProviderCustomModels } = await import("../../providers/provider-id-rewrite");
|
|
1127
|
-
const droppedCustomModels = dropProviderCustomModels(config, name);
|
|
1128
|
-
setProviderContextCap(config, name, false);
|
|
1129
|
-
saveManagementConfig(deps, config);
|
|
1242
|
+
adoptCommittedConfig(config, outcome.value.config);
|
|
1130
1243
|
await replaceProviderAccountSet(name, null);
|
|
1131
1244
|
reconcileLiveStateStores();
|
|
1132
1245
|
const { clearModelCache: clearCache } = await import("../../codex/model-cache");
|
|
@@ -1134,8 +1247,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1134
1247
|
const catalogRefresh = await convergeCodexCatalog();
|
|
1135
1248
|
return jsonResponse({
|
|
1136
1249
|
success: true,
|
|
1137
|
-
...(fallbackDefault ? { defaultProvider: fallbackDefault } : {}),
|
|
1138
|
-
|
|
1250
|
+
...(outcome.value.fallbackDefault ? { defaultProvider: outcome.value.fallbackDefault } : {}),
|
|
1251
|
+
droppedCustomModels: outcome.value.droppedCustomModels,
|
|
1139
1252
|
catalogRefresh,
|
|
1140
1253
|
});
|
|
1141
1254
|
}
|