@bitkyc08/opencodex 2.40.0 → 2.41.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.
- package/README.md +4 -0
- package/gui/dist/assets/index-B2YjLA-i.css +1 -0
- package/gui/dist/assets/{index-BHe2rl_C.js → index-aPup8CKb.js} +20 -20
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/meta.svg +1 -0
- package/package.json +4 -3
- package/src/adapters/cursor/catalog.ts +71 -29
- package/src/adapters/cursor/claude-id.ts +76 -0
- package/src/adapters/cursor/discovery.ts +16 -3
- package/src/adapters/cursor/effort-map.ts +27 -12
- package/src/adapters/google.ts +39 -2
- package/src/adapters/openai-responses.ts +14 -1
- package/src/cli/claude.ts +11 -2
- package/src/cli/connect.ts +7 -1
- package/src/cli/registry.ts +1 -1
- package/src/cli/status.ts +19 -4
- package/src/client/connect.ts +5 -1
- package/src/client/hub-client.ts +29 -5
- package/src/clients/config-export.ts +12 -2
- package/src/codex/catalog/aggregation.ts +8 -0
- package/src/codex/catalog/metadata.ts +5 -0
- package/src/codex/catalog/parsing.ts +2 -0
- package/src/codex/catalog/provider-fetch.ts +163 -26
- package/src/codex/catalog.ts +1 -1
- package/src/codex/convergence-types.ts +1 -0
- package/src/codex/desired-state.ts +18 -11
- package/src/combos/failover.ts +185 -6
- package/src/combos/index.ts +6 -0
- package/src/combos/resolve.ts +43 -6
- package/src/config.ts +5 -1
- package/src/generated/compatibility-version.json +85 -61
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/sync.ts +10 -2
- package/src/integrations/cursor-effort-table.ts +143 -0
- package/src/integrations/state.ts +1 -1
- package/src/integrations/writer.ts +2 -2
- package/src/lib/app-owned-memory-stores.ts +27 -8
- package/src/lib/bounded-body.ts +16 -1
- package/src/oauth/generic-account-failover.ts +2 -2
- package/src/oauth/index.ts +11 -0
- package/src/oauth/meta-muse.ts +235 -0
- package/src/providers/antigravity-models.ts +71 -13
- package/src/providers/command-code-efforts.ts +15 -0
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/registry.ts +116 -8
- package/src/responses/code-mode-helper-compat.ts +4 -1
- package/src/responses/state.ts +5 -4
- package/src/server/auth-cors.ts +241 -56
- package/src/server/chat-completions.ts +11 -2
- package/src/server/chat-native.ts +30 -4
- package/src/server/claude-messages.ts +17 -3
- package/src/server/effort-row.ts +131 -0
- package/src/server/index.ts +67 -38
- package/src/server/management/api-key-rotation.ts +2 -1
- package/src/server/management/api-key-usage.ts +97 -43
- package/src/server/management/context.ts +3 -0
- package/src/server/management/cursor-integration-routes.ts +36 -7
- package/src/server/management/logs-usage-routes.ts +64 -87
- package/src/server/management/provider-routes.ts +218 -1
- package/src/server/management/route-registry.ts +1 -0
- package/src/server/management/usage-aggregate-cache.ts +464 -0
- package/src/server/management/usage-summary-cache.ts +4 -0
- package/src/server/models-capabilities.ts +60 -5
- package/src/server/responses/core.ts +61 -7
- package/src/types/config.ts +10 -1
- package/src/types/tools.ts +12 -9
- package/src/usage/expected-prices.ts +43 -7
- package/src/usage/ledger-scanner.ts +448 -0
- package/src/usage/log.ts +1 -1
- package/src/usage/summary.ts +915 -655
- package/src/web-search/index.ts +1 -1
- package/gui/dist/assets/index-CJSb3HPe.css +0 -1
|
@@ -47,13 +47,11 @@ import {
|
|
|
47
47
|
} from "../../storage/policy-job";
|
|
48
48
|
import {
|
|
49
49
|
currentUsageLogRevision,
|
|
50
|
-
readUsageSnapshotForManagement,
|
|
51
50
|
usageLogIdentityKey,
|
|
52
51
|
usageLogRevisionKey,
|
|
53
|
-
type PersistedUsageEntry,
|
|
54
52
|
} from "../../usage/log";
|
|
55
53
|
import { getUsageDebugLogEntries } from "../../usage/debug";
|
|
56
|
-
import { USAGE_RANGES, USAGE_SURFACES, parseRange, parseUsageSurface,
|
|
54
|
+
import { USAGE_RANGES, USAGE_SURFACES, parseRange, parseUsageSurface, rangeWindow, type UsageRange, type UsageSummary, type UsageSurface } from "../../usage/summary";
|
|
57
55
|
import { stripCodexRuntimeProviderFields } from "../../codex/auth-context";
|
|
58
56
|
import { getProviderRegistryEntry } from "../../providers/registry";
|
|
59
57
|
import { getDebugLogEntries } from "../../lib/debug-log-buffer";
|
|
@@ -83,7 +81,7 @@ import {
|
|
|
83
81
|
getUsageSummaryCacheEntry,
|
|
84
82
|
setUsageSummaryCacheEntry,
|
|
85
83
|
} from "./usage-summary-cache";
|
|
86
|
-
import {
|
|
84
|
+
import { getFilteredUsageAggregate, getUsageAggregate } from "./usage-aggregate-cache";
|
|
87
85
|
|
|
88
86
|
function nextLocalMidnight(now: number): number {
|
|
89
87
|
const next = new Date(now);
|
|
@@ -92,7 +90,6 @@ function nextLocalMidnight(now: number): number {
|
|
|
92
90
|
}
|
|
93
91
|
|
|
94
92
|
function usageSummaryExpiresAt(
|
|
95
|
-
_entries: PersistedUsageEntry[],
|
|
96
93
|
_range: UsageRange,
|
|
97
94
|
_surface: UsageSurface,
|
|
98
95
|
now: number,
|
|
@@ -105,29 +102,6 @@ function refreshedUsageSummary<T extends UsageSummary & { historyTruncated: bool
|
|
|
105
102
|
return { ...summary, since, generatedAt: now };
|
|
106
103
|
}
|
|
107
104
|
|
|
108
|
-
/**
|
|
109
|
-
* Timestamp bounds of the rows the bounded reader actually loaded.
|
|
110
|
-
*
|
|
111
|
-
* Deliberately computed over the whole snapshot, BEFORE `summarizeUsage` applies the range
|
|
112
|
-
* and surface predicates: truncation is a property of the read, not of the query, so the
|
|
113
|
-
* window that matters to a client is the one the reader could see. It is not a completeness
|
|
114
|
-
* claim and must never be presented as one. `usage.jsonl` is appended when a request
|
|
115
|
-
* COMPLETES while each row carries the request START time, so a long-running request can be
|
|
116
|
-
* appended after shorter ones that started later — meaning the oldest loaded timestamp does
|
|
117
|
-
* not bound what the dropped prefix contains (#1497).
|
|
118
|
-
*/
|
|
119
|
-
function snapshotWindow(entries: PersistedUsageEntry[]): { start: number | null; end: number | null } {
|
|
120
|
-
let start: number | null = null;
|
|
121
|
-
let end: number | null = null;
|
|
122
|
-
for (const entry of entries) {
|
|
123
|
-
const at = entry.timestamp;
|
|
124
|
-
if (typeof at !== "number" || !Number.isFinite(at)) continue;
|
|
125
|
-
if (start === null || at < start) start = at;
|
|
126
|
-
if (end === null || at > end) end = at;
|
|
127
|
-
}
|
|
128
|
-
return { start, end };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
105
|
export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
132
106
|
const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx;
|
|
133
107
|
|
|
@@ -195,18 +169,16 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
195
169
|
if (url.pathname === "/api/usage" && req.method === "GET") {
|
|
196
170
|
const range = parseRange(url.searchParams.get("range"));
|
|
197
171
|
const surface = parseUsageSurface(url.searchParams.get("surface"));
|
|
198
|
-
//
|
|
199
|
-
// the
|
|
200
|
-
//
|
|
201
|
-
// caller, dashboard included.
|
|
172
|
+
// A filtered summary must never reach the cache or the warm loop below:
|
|
173
|
+
// the key is `range:surface`, so a filtered entry stored under it would be
|
|
174
|
+
// served to the next unfiltered caller, dashboard included.
|
|
202
175
|
const filter = {
|
|
203
176
|
provider: url.searchParams.get("provider"),
|
|
204
177
|
model: url.searchParams.get("model"),
|
|
205
178
|
apiKeyId: url.searchParams.get("apiKeyId"),
|
|
206
179
|
};
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
const filterRequested = Boolean(filter.provider ?? filter.model ?? filter.apiKeyId);
|
|
180
|
+
const filterRequested = [filter.provider, filter.model, filter.apiKeyId]
|
|
181
|
+
.some(value => typeof value === "string" && value.trim() !== "");
|
|
210
182
|
const now = Date.now();
|
|
211
183
|
try {
|
|
212
184
|
const cacheKey = `${range}:${surface}`;
|
|
@@ -214,52 +186,68 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
214
186
|
const observed = currentUsageLogRevision();
|
|
215
187
|
const identityKey = `${usageLogIdentityKey(observed)}\0${effectiveReadLimit}`;
|
|
216
188
|
const observedSize = observed?.size ?? 0;
|
|
189
|
+
const observedTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
217
190
|
const cached = getUsageSummaryCacheEntry(cacheKey);
|
|
218
191
|
if (cached
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
// over collapsed breakdown rows again — the exact defect the
|
|
222
|
-
// re-summarisation replaced. The unfiltered cache stays warm either
|
|
223
|
-
// way; only the filtered caller pays for the read.
|
|
192
|
+
// Filtered responses have their own full-scan singleflight and never
|
|
193
|
+
// read or write this cache.
|
|
224
194
|
&& !filterRequested
|
|
225
195
|
&& cached.identityKey === identityKey
|
|
226
196
|
&& cached.maxReadBytes === effectiveReadLimit
|
|
227
197
|
&& cached.overlayVersion === userCostOverlayVersion()
|
|
198
|
+
&& cached.timeZone === observedTimeZone
|
|
228
199
|
&& now < cached.freshUntil
|
|
229
200
|
&& now < cached.expiresAt
|
|
230
201
|
&& observedSize >= cached.lastSeenSize) {
|
|
231
202
|
return jsonResponse(refreshedUsageSummary(cached.summary, range, now));
|
|
232
203
|
}
|
|
233
204
|
if (cached && !filterRequested) discardUsageSummaryCacheEntry(cacheKey);
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
205
|
+
if (filterRequested) {
|
|
206
|
+
const filteredAggregate = await getFilteredUsageAggregate(filter);
|
|
207
|
+
const accumulator = filteredAggregate.accumulator;
|
|
208
|
+
return jsonResponse({
|
|
209
|
+
...accumulator.summarize(range, now, surface),
|
|
210
|
+
historyTruncated: false,
|
|
211
|
+
truncatedPrefixBytes: 0,
|
|
212
|
+
entriesTruncated: false,
|
|
213
|
+
entriesDropped: 0,
|
|
214
|
+
snapshotWindowStart: accumulator.snapshotWindow.start,
|
|
215
|
+
snapshotWindowEnd: accumulator.snapshotWindow.end,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const configuredApiKeyIds = (config.apiKeys ?? []).map(key => key.id);
|
|
220
|
+
const aggregate = await getUsageAggregate({
|
|
221
|
+
now,
|
|
222
|
+
configuredApiKeyIds,
|
|
223
|
+
managementUsageMaxReadBytes: effectiveReadLimit,
|
|
224
|
+
});
|
|
225
|
+
const baseAccumulator = aggregate.accumulator;
|
|
241
226
|
const revisionReadAt = Date.now();
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
227
|
+
const freshUntil = now + 60_000;
|
|
228
|
+
const snapshotIdentity = `${usageLogIdentityKey(aggregate.revision)}\0${effectiveReadLimit}`;
|
|
229
|
+
const revisionKey = `${usageLogRevisionKey(aggregate.revision)}\0${effectiveReadLimit}`;
|
|
230
|
+
const lastSeenSize = aggregate.revision?.size ?? 0;
|
|
231
|
+
const baseReadMetadata = {
|
|
232
|
+
historyTruncated: false,
|
|
233
|
+
truncatedPrefixBytes: 0,
|
|
234
|
+
entriesTruncated: false,
|
|
235
|
+
entriesDropped: 0,
|
|
236
|
+
snapshotWindowStart: baseAccumulator.snapshotWindow.start,
|
|
237
|
+
snapshotWindowEnd: baseAccumulator.snapshotWindow.end,
|
|
238
|
+
} as const;
|
|
239
|
+
const requestedSummary = {
|
|
240
|
+
...baseAccumulator.summarize(range, now, surface),
|
|
241
|
+
...baseReadMetadata,
|
|
251
242
|
};
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
//
|
|
257
|
-
|
|
243
|
+
const currentOverlayVersion = userCostOverlayVersion();
|
|
244
|
+
const currentTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
245
|
+
if (currentOverlayVersion !== aggregate.overlayVersion
|
|
246
|
+
|| currentTimeZone !== aggregate.timeZone) {
|
|
247
|
+
// The aggregate is internally consistent, but an input changed after
|
|
248
|
+
// its scan. Serve it uncached and let the next request rebuild.
|
|
249
|
+
return jsonResponse(requestedSummary);
|
|
258
250
|
}
|
|
259
|
-
const freshUntil = now + 60_000;
|
|
260
|
-
const snapshotIdentity = `${usageLogIdentityKey(snapshot.revision)}\0${effectiveReadLimit}`;
|
|
261
|
-
const revisionKey = `${usageLogRevisionKey(snapshot.revision)}\0${effectiveReadLimit}`;
|
|
262
|
-
const lastSeenSize = snapshot.revision?.size ?? 0;
|
|
263
251
|
// Derived from the canonical constants rather than re-listed: a subset
|
|
264
252
|
// literal type-checks perfectly happily, so a range added to the union
|
|
265
253
|
// and forgotten here would never be warmed and never invalidated
|
|
@@ -268,21 +256,19 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
268
256
|
const surfaces: readonly UsageSurface[] = USAGE_SURFACES;
|
|
269
257
|
for (const nextRange of ranges) {
|
|
270
258
|
for (const nextSurface of surfaces) {
|
|
271
|
-
const nextSummary = nextRange === range && nextSurface === surface
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
snapshotWindowStart: summary.snapshotWindowStart,
|
|
278
|
-
snapshotWindowEnd: summary.snapshotWindowEnd,
|
|
279
|
-
};
|
|
259
|
+
const nextSummary = nextRange === range && nextSurface === surface
|
|
260
|
+
? requestedSummary
|
|
261
|
+
: {
|
|
262
|
+
...baseAccumulator.summarize(nextRange, now, nextSurface),
|
|
263
|
+
...baseReadMetadata,
|
|
264
|
+
};
|
|
280
265
|
setUsageSummaryCacheEntry(`${nextRange}:${nextSurface}`, {
|
|
281
266
|
revisionKey,
|
|
282
267
|
identityKey: snapshotIdentity,
|
|
283
268
|
maxReadBytes: effectiveReadLimit,
|
|
284
|
-
overlayVersion,
|
|
285
|
-
|
|
269
|
+
overlayVersion: aggregate.overlayVersion,
|
|
270
|
+
timeZone: aggregate.timeZone,
|
|
271
|
+
expiresAt: usageSummaryExpiresAt(nextRange, nextSurface, now),
|
|
286
272
|
freshUntil,
|
|
287
273
|
lastSeenSize,
|
|
288
274
|
revisionReadAt,
|
|
@@ -290,16 +276,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
290
276
|
});
|
|
291
277
|
}
|
|
292
278
|
}
|
|
293
|
-
|
|
294
|
-
snapshot.entries,
|
|
295
|
-
(config.apiKeys ?? []).map(key => key.id),
|
|
296
|
-
usageLogIdentityKey(snapshot.revision),
|
|
297
|
-
snapshot.revision?.size ?? 0,
|
|
298
|
-
snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated,
|
|
299
|
-
effectiveReadLimit,
|
|
300
|
-
now,
|
|
301
|
-
);
|
|
302
|
-
return jsonResponse(project(summary, snapshot.entries));
|
|
279
|
+
return jsonResponse(requestedSummary);
|
|
303
280
|
} catch {
|
|
304
281
|
return jsonResponse({
|
|
305
282
|
range,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
4
|
import type { CatalogModel } from "../../codex/catalog";
|
|
4
5
|
import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
|
|
5
6
|
import { clearGatherRoutedModelsInflight } from "../../codex/catalog/provider-fetch";
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
isValidProviderName,
|
|
12
13
|
modelDisplayNamesConfigError,
|
|
13
14
|
multiAgentGuidanceEnabled,
|
|
15
|
+
mutatePersistedConfig,
|
|
14
16
|
nonBlankStringArrayConfigError,
|
|
15
17
|
normalizeNonBlankStringArray,
|
|
16
18
|
providerBaseUrlConfigError,
|
|
@@ -19,6 +21,7 @@ import {
|
|
|
19
21
|
readConfigAdmissionSnapshot,
|
|
20
22
|
saveConfigPreservingClaudeCode,
|
|
21
23
|
upstreamHttpVersionConfigError,
|
|
24
|
+
validateConfigCandidate,
|
|
22
25
|
withConfigMutationLockSync,
|
|
23
26
|
} from "../../config";
|
|
24
27
|
import {
|
|
@@ -77,7 +80,17 @@ import { drainAndShutdown } from "../lifecycle";
|
|
|
77
80
|
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
|
|
78
81
|
import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
|
|
79
82
|
import type { PersistedUsageAttempt } from "../../usage/log";
|
|
80
|
-
import {
|
|
83
|
+
import {
|
|
84
|
+
isAllowedRequestOrigin,
|
|
85
|
+
jsonResponse,
|
|
86
|
+
parseProviderEditorConfigDTO,
|
|
87
|
+
providerEditorConfigDTO,
|
|
88
|
+
providerManagementConfigError,
|
|
89
|
+
publicProviderBaseUrl,
|
|
90
|
+
safeConfigDTO,
|
|
91
|
+
type ProviderEditorConfigDTO,
|
|
92
|
+
type ProviderEditorProviderDTO,
|
|
93
|
+
} from "../auth-cors";
|
|
81
94
|
import { providerServiceTierConfigError } from "./provider-capability-config";
|
|
82
95
|
import { providerEmptyToolOutputConfigError } from "../../config/provider-validation";
|
|
83
96
|
import { applySystemEnvToggle } from "../system-env";
|
|
@@ -86,10 +99,12 @@ import {
|
|
|
86
99
|
LOCAL_PROVIDER_RELOAD_PATH,
|
|
87
100
|
} from "../../lib/local-provider-reload-contract";
|
|
88
101
|
import { refreshUserCostOverlays } from "../../usage/user-cost-overlays";
|
|
102
|
+
import { redactSecretString } from "../../lib/redact";
|
|
89
103
|
import {
|
|
90
104
|
XAI_RESPONSES_OPT_IN_MODELS,
|
|
91
105
|
xaiResponsesOptInState,
|
|
92
106
|
} from "../../providers/xai-responses-opt-in";
|
|
107
|
+
import { dropProviderCustomModels } from "../../providers/provider-id-rewrite";
|
|
93
108
|
|
|
94
109
|
import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
|
|
95
110
|
import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
|
|
@@ -163,6 +178,116 @@ function restorePersistedAliasOverlays(target: OcxProviderConfig, existing: OcxP
|
|
|
163
178
|
}
|
|
164
179
|
}
|
|
165
180
|
|
|
181
|
+
type ProviderEditorCandidateResult =
|
|
182
|
+
| { ok: true; config: OcxConfig; removedProviders: string[] }
|
|
183
|
+
| { ok: false; status: 400 | 409; error: string; code: string };
|
|
184
|
+
|
|
185
|
+
type ProviderEditorMutationValue = ProviderEditorCandidateResult;
|
|
186
|
+
|
|
187
|
+
function mergeProviderEditorRow(
|
|
188
|
+
persisted: OcxProviderConfig | undefined,
|
|
189
|
+
baseline: ProviderEditorProviderDTO | undefined,
|
|
190
|
+
next: ProviderEditorProviderDTO,
|
|
191
|
+
): OcxProviderConfig {
|
|
192
|
+
const merged = structuredClone(persisted ?? {}) as Record<string, unknown>;
|
|
193
|
+
const fields = new Set([...Object.keys(baseline ?? {}), ...Object.keys(next)]);
|
|
194
|
+
for (const field of fields) {
|
|
195
|
+
const baselineHasField = baseline !== undefined && Object.hasOwn(baseline, field);
|
|
196
|
+
const nextHasField = Object.hasOwn(next, field);
|
|
197
|
+
if (
|
|
198
|
+
baselineHasField === nextHasField
|
|
199
|
+
&& (!baselineHasField || isDeepStrictEqual(baseline[field], next[field]))
|
|
200
|
+
) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (nextHasField) merged[field] = structuredClone(next[field]);
|
|
204
|
+
else delete merged[field];
|
|
205
|
+
}
|
|
206
|
+
return merged as unknown as OcxProviderConfig;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Build and validate a complete candidate without mutating the caller's snapshot. */
|
|
210
|
+
function providerEditorCandidate(
|
|
211
|
+
persisted: OcxConfig,
|
|
212
|
+
baseline: ProviderEditorConfigDTO,
|
|
213
|
+
next: ProviderEditorConfigDTO,
|
|
214
|
+
): ProviderEditorCandidateResult {
|
|
215
|
+
const candidate = structuredClone(persisted);
|
|
216
|
+
const removedProviders = Object.keys(persisted.providers)
|
|
217
|
+
.filter(name => !Object.hasOwn(next.providers, name));
|
|
218
|
+
|
|
219
|
+
for (const name of removedProviders) {
|
|
220
|
+
const dependentCombos = Object.entries(persisted.combos ?? {})
|
|
221
|
+
.filter(([, combo]) => combo.targets.some(target => target.provider === name))
|
|
222
|
+
.map(([id]) => id)
|
|
223
|
+
.sort((a, b) => a.localeCompare(b));
|
|
224
|
+
if (dependentCombos.length > 0) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
status: 409,
|
|
228
|
+
error: `cannot delete provider ${JSON.stringify(redactSecretString(name))} while combos depend on it`,
|
|
229
|
+
code: "provider_has_dependent_combos",
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const providers: Record<string, OcxProviderConfig> = Object.create(null);
|
|
235
|
+
for (const [name, publicProvider] of Object.entries(next.providers)) {
|
|
236
|
+
if (!isValidProviderName(name)) {
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
status: 400,
|
|
240
|
+
error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key",
|
|
241
|
+
code: "invalid_provider_name",
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const namespaceCollision = codexAccountNamespaceProviderCollisionError(candidate.codexAccountNamespaces, name);
|
|
245
|
+
if (namespaceCollision) return { ok: false, status: 409, error: namespaceCollision, code: "provider_namespace_conflict" };
|
|
246
|
+
const merged = mergeProviderEditorRow(persisted.providers[name], baseline.providers[name], publicProvider);
|
|
247
|
+
const transportCandidate = providerTransportValidationCandidate(merged as unknown as Record<string, unknown>);
|
|
248
|
+
const providerError = providerManagementConfigError(name, transportCandidate)
|
|
249
|
+
?? providerEmptyToolOutputConfigError(name, transportCandidate)
|
|
250
|
+
?? providerServiceTierConfigError(name, transportCandidate);
|
|
251
|
+
if (providerError) return { ok: false, status: 400, error: providerError, code: "invalid_provider" };
|
|
252
|
+
providers[name] = merged;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const defaultProvider = next.defaultProvider.trim();
|
|
256
|
+
const selectedDefault = providers[defaultProvider];
|
|
257
|
+
if (!selectedDefault) {
|
|
258
|
+
return { ok: false, status: 400, error: "defaultProvider must name a configured provider", code: "invalid_default_provider" };
|
|
259
|
+
}
|
|
260
|
+
if (selectedDefault.disabled === true) {
|
|
261
|
+
return { ok: false, status: 400, error: "defaultProvider cannot be disabled", code: "default_provider_disabled" };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
candidate.defaultProvider = defaultProvider;
|
|
265
|
+
candidate.providers = providers;
|
|
266
|
+
for (const name of removedProviders) {
|
|
267
|
+
dropProviderCustomModels(candidate, name);
|
|
268
|
+
setProviderContextCap(candidate, name, false);
|
|
269
|
+
}
|
|
270
|
+
const validated = validateConfigCandidate(candidate);
|
|
271
|
+
if (!validated.ok) {
|
|
272
|
+
return { ok: false, status: 400, error: validated.error, code: "invalid_provider_editor_config" };
|
|
273
|
+
}
|
|
274
|
+
return { ok: true, config: candidate, removedProviders };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function adoptProviderEditorCandidate(live: OcxConfig, persisted: OcxConfig): void {
|
|
278
|
+
live.defaultProvider = persisted.defaultProvider;
|
|
279
|
+
for (const name of Object.keys(live.providers)) {
|
|
280
|
+
if (!Object.hasOwn(persisted.providers, name)) delete live.providers[name];
|
|
281
|
+
}
|
|
282
|
+
for (const [name, provider] of Object.entries(persisted.providers)) {
|
|
283
|
+
live.providers[name] = structuredClone(provider);
|
|
284
|
+
}
|
|
285
|
+
if (persisted.customModels === undefined) delete live.customModels;
|
|
286
|
+
else live.customModels = structuredClone(persisted.customModels);
|
|
287
|
+
if (persisted.providerContextCaps === undefined) delete live.providerContextCaps;
|
|
288
|
+
else live.providerContextCaps = structuredClone(persisted.providerContextCaps);
|
|
289
|
+
}
|
|
290
|
+
|
|
166
291
|
/**
|
|
167
292
|
* Apply the recognized PATCH field mask onto a provider copy. The caller runs this once
|
|
168
293
|
* for validation and again inside the config mutation lock against the newest provider,
|
|
@@ -649,6 +774,98 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
649
774
|
return jsonResponse({ success: true, name, catalogRefresh });
|
|
650
775
|
}
|
|
651
776
|
|
|
777
|
+
if (url.pathname === "/api/providers" && req.method === "PUT") {
|
|
778
|
+
let rawBody: unknown;
|
|
779
|
+
try {
|
|
780
|
+
rawBody = await readManagementJsonBody(req);
|
|
781
|
+
} catch (error) {
|
|
782
|
+
rethrowManagementBodyTooLarge(error);
|
|
783
|
+
return jsonResponse({ error: "invalid JSON body" }, 400);
|
|
784
|
+
}
|
|
785
|
+
if (!isPlainRecord(rawBody)) {
|
|
786
|
+
return jsonResponse({ error: "provider batch body must be a plain object", code: "invalid_provider_editor_body" }, 400);
|
|
787
|
+
}
|
|
788
|
+
const bodyKeys = Object.keys(rawBody);
|
|
789
|
+
if (bodyKeys.length !== 2 || !Object.hasOwn(rawBody, "baseline") || !Object.hasOwn(rawBody, "next")) {
|
|
790
|
+
return jsonResponse({ error: "provider batch body must contain only baseline and next", code: "invalid_provider_editor_body" }, 400);
|
|
791
|
+
}
|
|
792
|
+
const baselineResult = parseProviderEditorConfigDTO(rawBody.baseline);
|
|
793
|
+
if (!baselineResult.ok) return jsonResponse({ error: baselineResult.error, code: baselineResult.code }, 400);
|
|
794
|
+
const nextResult = parseProviderEditorConfigDTO(rawBody.next);
|
|
795
|
+
if (!nextResult.ok) return jsonResponse({ error: nextResult.error, code: nextResult.code }, 400);
|
|
796
|
+
|
|
797
|
+
const observed = readConfigAdmissionSnapshot();
|
|
798
|
+
if (observed.kind !== "read" || observed.diagnostics.source !== "file" || observed.diagnostics.error !== null) {
|
|
799
|
+
return jsonResponse({ error: "provider config is unavailable", code: "provider_config_unavailable" }, 409);
|
|
800
|
+
}
|
|
801
|
+
if (!isDeepStrictEqual(providerEditorConfigDTO(observed.diagnostics.config), baselineResult.value)) {
|
|
802
|
+
return jsonResponse({ error: "provider editor baseline is stale", code: "stale_provider_editor_baseline" }, 409);
|
|
803
|
+
}
|
|
804
|
+
const preview = providerEditorCandidate(observed.diagnostics.config, baselineResult.value, nextResult.value);
|
|
805
|
+
if (!preview.ok) return jsonResponse({ error: preview.error, code: preview.code }, preview.status);
|
|
806
|
+
|
|
807
|
+
// DNS/SSRF validation happens before the persistence callback. The callback repeats every
|
|
808
|
+
// synchronous check against a fresh snapshot and rejects a changed baseline, so this awaited
|
|
809
|
+
// phase can never authorize a stale provider set.
|
|
810
|
+
for (const [name, provider] of Object.entries(preview.config.providers)) {
|
|
811
|
+
const allowBenchmarkAddresses = name === "openai" && isCanonicalOpenAiForwardProvider(provider);
|
|
812
|
+
const resolvedError = await providerDestinationResolvedError(name, provider, { allowBenchmarkAddresses });
|
|
813
|
+
if (resolvedError) return jsonResponse({ error: resolvedError, code: "invalid_provider_destination" }, 400);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const outcome = mutatePersistedConfig<ProviderEditorMutationValue>(persisted => {
|
|
817
|
+
if (!isDeepStrictEqual(providerEditorConfigDTO(persisted), baselineResult.value)) {
|
|
818
|
+
return {
|
|
819
|
+
changed: false,
|
|
820
|
+
value: {
|
|
821
|
+
ok: false,
|
|
822
|
+
status: 409,
|
|
823
|
+
error: "provider editor baseline is stale",
|
|
824
|
+
code: "stale_provider_editor_baseline",
|
|
825
|
+
},
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
const candidate = providerEditorCandidate(persisted, baselineResult.value, nextResult.value);
|
|
829
|
+
if (!candidate.ok) return { changed: false, value: candidate };
|
|
830
|
+
const changed = !isDeepStrictEqual(providerEditorConfigDTO(persisted), nextResult.value);
|
|
831
|
+
if (!changed) return { changed: false, value: candidate };
|
|
832
|
+
|
|
833
|
+
persisted.defaultProvider = candidate.config.defaultProvider;
|
|
834
|
+
persisted.providers = structuredClone(candidate.config.providers);
|
|
835
|
+
for (const name of candidate.removedProviders) {
|
|
836
|
+
dropProviderCustomModels(persisted, name);
|
|
837
|
+
setProviderContextCap(persisted, name, false);
|
|
838
|
+
}
|
|
839
|
+
return {
|
|
840
|
+
changed: true,
|
|
841
|
+
value: {
|
|
842
|
+
ok: true,
|
|
843
|
+
config: structuredClone(persisted),
|
|
844
|
+
removedProviders: candidate.removedProviders,
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
});
|
|
848
|
+
if (outcome.status === "unavailable") {
|
|
849
|
+
const code = outcome.reason === "conflict" ? "provider_config_conflict" : "provider_config_unavailable";
|
|
850
|
+
return jsonResponse({ error: "provider config changed before it could be saved", code }, 409);
|
|
851
|
+
}
|
|
852
|
+
if (!outcome.value.ok) {
|
|
853
|
+
return jsonResponse({ error: outcome.value.error, code: outcome.value.code }, outcome.value.status);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
adoptProviderEditorCandidate(config, outcome.value.config);
|
|
857
|
+
reconcileLiveStateStores();
|
|
858
|
+
refreshUserCostOverlays(outcome.value.config);
|
|
859
|
+
clearGatherRoutedModelsInflight();
|
|
860
|
+
(deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
|
|
861
|
+
clearAccountQuotaCache();
|
|
862
|
+
clearKeyCooldowns();
|
|
863
|
+
clearModelCache();
|
|
864
|
+
(deps.clearThreadAccountMap ?? clearThreadAccountMap)();
|
|
865
|
+
const catalogRefresh = await convergeCodexCatalog();
|
|
866
|
+
return jsonResponse({ success: true, catalogRefresh });
|
|
867
|
+
}
|
|
868
|
+
|
|
652
869
|
// Add (or overwrite) a single provider. Merges into the live in-memory config and
|
|
653
870
|
// persists — existing providers' real keys are never round-tripped (unlike PUT /api/config,
|
|
654
871
|
// which would re-save the masked keys from GET). Live routing picks it up immediately.
|
|
@@ -273,6 +273,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
273
273
|
{ method: "PATCH", path: "/api/providers", module: "server/management/provider-routes", mutates: true },
|
|
274
274
|
{ method: "POST", path: "/api/providers", module: "server/management/provider-routes", mutates: true },
|
|
275
275
|
{ method: "POST", path: "/api/providers/test", module: "server/management/provider-routes", mutates: true },
|
|
276
|
+
{ method: "PUT", path: "/api/providers", module: "server/management/provider-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Issue #3280 scopes this atomic batch endpoint to the GUI JSON editor; a matching CLI verb is outside wp5 and remains owed.", owner: "wp5-followup", ownerDoc: "devlog/_plan/260903_bug_drawdown_bcda/050_phase5.md" } },
|
|
276
277
|
{ method: "PUT", path: "/api/provider-context-caps", module: "server/management/provider-routes", mutates: true },
|
|
277
278
|
// server/management/request-history-routes
|
|
278
279
|
{ method: "GET", path: "/api/request-history", module: "server/management/request-history-routes", mutates: false },
|