@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.
Files changed (72) hide show
  1. package/README.md +4 -0
  2. package/gui/dist/assets/index-B2YjLA-i.css +1 -0
  3. package/gui/dist/assets/{index-BHe2rl_C.js → index-aPup8CKb.js} +20 -20
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/meta.svg +1 -0
  6. package/package.json +4 -3
  7. package/src/adapters/cursor/catalog.ts +71 -29
  8. package/src/adapters/cursor/claude-id.ts +76 -0
  9. package/src/adapters/cursor/discovery.ts +16 -3
  10. package/src/adapters/cursor/effort-map.ts +27 -12
  11. package/src/adapters/google.ts +39 -2
  12. package/src/adapters/openai-responses.ts +14 -1
  13. package/src/cli/claude.ts +11 -2
  14. package/src/cli/connect.ts +7 -1
  15. package/src/cli/registry.ts +1 -1
  16. package/src/cli/status.ts +19 -4
  17. package/src/client/connect.ts +5 -1
  18. package/src/client/hub-client.ts +29 -5
  19. package/src/clients/config-export.ts +12 -2
  20. package/src/codex/catalog/aggregation.ts +8 -0
  21. package/src/codex/catalog/metadata.ts +5 -0
  22. package/src/codex/catalog/parsing.ts +2 -0
  23. package/src/codex/catalog/provider-fetch.ts +163 -26
  24. package/src/codex/catalog.ts +1 -1
  25. package/src/codex/convergence-types.ts +1 -0
  26. package/src/codex/desired-state.ts +18 -11
  27. package/src/combos/failover.ts +185 -6
  28. package/src/combos/index.ts +6 -0
  29. package/src/combos/resolve.ts +43 -6
  30. package/src/config.ts +5 -1
  31. package/src/generated/compatibility-version.json +85 -61
  32. package/src/generated/model-metadata.ts +1 -1
  33. package/src/grok/sync.ts +10 -2
  34. package/src/integrations/cursor-effort-table.ts +143 -0
  35. package/src/integrations/state.ts +1 -1
  36. package/src/integrations/writer.ts +2 -2
  37. package/src/lib/app-owned-memory-stores.ts +27 -8
  38. package/src/lib/bounded-body.ts +16 -1
  39. package/src/oauth/generic-account-failover.ts +2 -2
  40. package/src/oauth/index.ts +11 -0
  41. package/src/oauth/meta-muse.ts +235 -0
  42. package/src/providers/antigravity-models.ts +71 -13
  43. package/src/providers/command-code-efforts.ts +15 -0
  44. package/src/providers/free-directory.ts +4 -1
  45. package/src/providers/registry.ts +116 -8
  46. package/src/responses/code-mode-helper-compat.ts +4 -1
  47. package/src/responses/state.ts +5 -4
  48. package/src/server/auth-cors.ts +241 -56
  49. package/src/server/chat-completions.ts +11 -2
  50. package/src/server/chat-native.ts +30 -4
  51. package/src/server/claude-messages.ts +17 -3
  52. package/src/server/effort-row.ts +131 -0
  53. package/src/server/index.ts +67 -38
  54. package/src/server/management/api-key-rotation.ts +2 -1
  55. package/src/server/management/api-key-usage.ts +97 -43
  56. package/src/server/management/context.ts +3 -0
  57. package/src/server/management/cursor-integration-routes.ts +36 -7
  58. package/src/server/management/logs-usage-routes.ts +64 -87
  59. package/src/server/management/provider-routes.ts +218 -1
  60. package/src/server/management/route-registry.ts +1 -0
  61. package/src/server/management/usage-aggregate-cache.ts +464 -0
  62. package/src/server/management/usage-summary-cache.ts +4 -0
  63. package/src/server/models-capabilities.ts +60 -5
  64. package/src/server/responses/core.ts +61 -7
  65. package/src/types/config.ts +10 -1
  66. package/src/types/tools.ts +12 -9
  67. package/src/usage/expected-prices.ts +43 -7
  68. package/src/usage/ledger-scanner.ts +448 -0
  69. package/src/usage/log.ts +1 -1
  70. package/src/usage/summary.ts +915 -655
  71. package/src/web-search/index.ts +1 -1
  72. 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, projectUsageSummary, rangeWindow, summarizeUsage, type UsageRange, type UsageSummary, type UsageSurface } from "../../usage/summary";
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 { cacheApiKeyUsageFromSnapshot } from "./api-key-usage";
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
- // Applied to the OUTGOING payload only. A filtered summary must never reach
199
- // the cache or the warm loop below: the key is `range:surface`, so a
200
- // filtered entry stored under it would be served to the next unfiltered
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 project = <T extends UsageSummary>(summary: T, entries?: PersistedUsageEntry[]) =>
208
- projectUsageSummary(summary, filter, entries);
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
- // A filtered response is re-summarised from entries, which a cached
220
- // summary does not carry. Serving it from cache would mean projecting
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
- // Capture the overlay version BEFORE reading/computing: the cache entry
235
- // must be stamped with the version the summary was priced under. Reading
236
- // it again at stamp time could cache an old-price summary as current,
237
- // and the next request would then accept stale pricing for the whole
238
- // cache lifetime.
239
- const overlayVersion = userCostOverlayVersion();
240
- const snapshot = await readUsageSnapshotForManagement(effectiveReadLimit);
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 window = snapshotWindow(snapshot.entries);
243
- const summary = {
244
- ...summarizeUsage(snapshot.entries, range, now, surface),
245
- historyTruncated: snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated,
246
- truncatedPrefixBytes: snapshot.truncatedPrefixBytes,
247
- entriesTruncated: snapshot.entriesTruncated,
248
- entriesDropped: snapshot.entriesDropped,
249
- snapshotWindowStart: window.start,
250
- snapshotWindowEnd: window.end,
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
- if (userCostOverlayVersion() !== overlayVersion) {
253
- // The overlay changed while the summary was being computed, so this
254
- // summary may mix old and new prices. Serve it uncached: the next
255
- // request recomputes against the settled overlay instead of caching a
256
- // mixed-price entry under either version.
257
- return jsonResponse(project(summary, snapshot.entries));
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 ? summary : {
272
- ...summarizeUsage(snapshot.entries, nextRange, now, nextSurface),
273
- historyTruncated: summary.historyTruncated,
274
- truncatedPrefixBytes: summary.truncatedPrefixBytes,
275
- entriesTruncated: summary.entriesTruncated,
276
- entriesDropped: summary.entriesDropped,
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
- expiresAt: usageSummaryExpiresAt(snapshot.entries, nextRange, nextSurface, now),
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
- cacheApiKeyUsageFromSnapshot(
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 { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
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 },