@bitkyc08/opencodex 2.55.0 → 2.56.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/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +2 -1
- package/src/adapters/base.ts +21 -0
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-schema.ts +477 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +33 -1445
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +611 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +34 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +624 -0
- package/src/bridge/sse.ts +1444 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +71 -9
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +554 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +422 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +95 -28
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +304 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +706 -0
- package/src/codex/catalog/routed-gather.ts +858 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +540 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +81 -1436
- package/src/codex/lineage.ts +458 -0
- package/src/codex/pool-refresh-backoff.ts +152 -0
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cooldown-math.ts +275 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +703 -0
- package/src/codex/routing/thread-affinity.ts +538 -0
- package/src/codex/routing.ts +353 -2234
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +539 -39
- package/src/lib/request-execution-budget.ts +69 -20
- package/src/lib/spend-reservation-ledger.ts +940 -0
- package/src/lib/upstream-retry.ts +55 -11
- package/src/lib/workflow-budget.ts +553 -30
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1221 -0
- package/src/providers/registry/entries-extended.ts +1204 -0
- package/src/providers/registry/model-seeds.ts +908 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +449 -0
- package/src/routing/probe-lease.ts +511 -0
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +565 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +335 -0
- package/src/server/index.ts +40 -2547
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +245 -1
- package/src/server/responses/account-change-state.ts +233 -0
- package/src/server/responses/adapter-continuation.ts +514 -0
- package/src/server/responses/adapter-delivery.ts +214 -0
- package/src/server/responses/adapter-dispatch.ts +971 -0
- package/src/server/responses/compact.ts +59 -4
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +527 -0
- package/src/server/responses/core-codex-account.ts +859 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +707 -0
- package/src/server/responses/core-errors.ts +152 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +225 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/passthrough-delivery.ts +856 -0
- package/src/server/responses/passthrough-dispatch.ts +1476 -0
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +970 -0
- package/src/server/responses/request-send-budget.ts +164 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-transport.ts +744 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +448 -0
- package/src/server/responses/sidecar-execution.ts +469 -0
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/workflow-refusal.ts +84 -0
- package/src/types/config.ts +30 -0
- package/src/usage/log.ts +146 -0
- package/src/usage/summary.ts +171 -21
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { OcxConfig } from "../types";
|
|
2
|
+
|
|
3
|
+
export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolean {
|
|
4
|
+
return config.websockets === true;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Opt-in Ultra Fast, read with the house `=== true` idiom so an absent key and a
|
|
9
|
+
* malformed one both mean off.
|
|
10
|
+
*/
|
|
11
|
+
export function ultraFastTierEnabled(config: Pick<OcxConfig, "ultraFastTier">): boolean {
|
|
12
|
+
return config.ultraFastTier === true;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Default cadence for the opt-in catalog auto-refresh (issue #3630): one converge pass
|
|
17
|
+
* per hour. Each pass spends a live /models call against every enabled provider, and
|
|
18
|
+
* provider catalogs are themselves cached upstream for minutes, so an hour is fresh
|
|
19
|
+
* enough for newly released models to appear without an `ocx sync`.
|
|
20
|
+
*/
|
|
21
|
+
export const CATALOG_AUTO_REFRESH_DEFAULT_INTERVAL_MS: number = 60 * 60_000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Floor under the configured cadence, for the same reason src/quota/reset-poller.ts has
|
|
25
|
+
* MIN_INTERVAL_MS: below this the refresh buys no freshness — upstream caches have not
|
|
26
|
+
* moved — and only multiplies the chance of a rate limit across every enabled provider.
|
|
27
|
+
*/
|
|
28
|
+
export const CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS: number = 15 * 60_000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Opt-in master switch, read with the house `=== true` idiom so an absent key and a
|
|
32
|
+
* malformed one both mean off. Pure on purpose: the scheduler calls this from a
|
|
33
|
+
* dynamically imported context, so it takes an explicit config slice and reads nothing
|
|
34
|
+
* global.
|
|
35
|
+
*/
|
|
36
|
+
export function isCatalogAutoRefreshEnabled(
|
|
37
|
+
config: Pick<OcxConfig, "catalogAutoRefresh">,
|
|
38
|
+
): boolean {
|
|
39
|
+
return config.catalogAutoRefresh?.enabled === true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolved tick interval in milliseconds. An explicit `intervalMinutes: 0` returns 0 —
|
|
44
|
+
* the section stays configured but the timer stays dormant — and any other value is
|
|
45
|
+
* clamped up to CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS so a hand edit cannot outrun the
|
|
46
|
+
* upstream catalog caches. Absent means the hourly default.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveCatalogAutoRefreshIntervalMs(
|
|
49
|
+
config: Pick<OcxConfig, "catalogAutoRefresh">,
|
|
50
|
+
): number {
|
|
51
|
+
const minutes = config.catalogAutoRefresh?.intervalMinutes;
|
|
52
|
+
if (minutes === undefined) return CATALOG_AUTO_REFRESH_DEFAULT_INTERVAL_MS;
|
|
53
|
+
if (minutes === 0) return 0;
|
|
54
|
+
return Math.max(CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS, Math.floor(minutes * 60_000));
|
|
55
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
2
|
+
import { configReasoningPinsConfigError } from "./provider-validation";
|
|
3
|
+
import { adoptCustomModelCatalogMigration, projectCustomModelCatalogMigration } from "../codex/custom-model-catalog-migration";
|
|
4
|
+
import { refreshPreservedProviderOwner, refreshUserCostOverlays } from "../usage/user-cost-overlays";
|
|
5
|
+
import {
|
|
6
|
+
clearPendingConfigTopLevelDeletions,
|
|
7
|
+
configHasRebaseProvenance,
|
|
8
|
+
configRebaseDeletionKeys,
|
|
9
|
+
CONFIG_REBASE_PROVENANCE_KEY,
|
|
10
|
+
projectConfigRebaseProvenance,
|
|
11
|
+
} from "./rebase-provenance";
|
|
12
|
+
import { withConfigMutationLockSync, bumpGenerationForCooperatingConfigWrite } from "./mutation-lock";
|
|
13
|
+
import { persistConfigUnlocked, readRawConfigJson } from "./persist-unlocked";
|
|
14
|
+
import { configDiagnosticsFromRaw, readConfigDiagnostics } from "./diagnostics";
|
|
15
|
+
import { normalizePersistedClaudeCode } from "./load-degrade";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Hand-edit protection for the `claudeCode` subtree (devlog 260726_claude_auth_auto/040 H1).
|
|
19
|
+
//
|
|
20
|
+
// `saveConfig` serializes the WHOLE config object, so ANY service-time save — a model
|
|
21
|
+
// visibility toggle, a 429 key rotation on the request path — rewrites `claudeCode`
|
|
22
|
+
// from whatever the long-lived server config happens to hold. A user who hand-edits
|
|
23
|
+
// `config.json` while the proxy runs then watches their edit vanish for no visible
|
|
24
|
+
// reason (issue #488). Enumerating `claudeCode` mutators cannot fix that; the guard has
|
|
25
|
+
// to live in ONE save wrapper that every live-config writer goes through.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Baseline keyed on the CONFIG INSTANCE, never a module global: a second `loadConfig()`
|
|
30
|
+
* elsewhere must not refresh the baseline the long-lived server config is judged
|
|
31
|
+
* against, or a later stale save would masquerade as "our own change".
|
|
32
|
+
*/
|
|
33
|
+
const claudeCodeBaseline = new WeakMap<OcxConfig, unknown>();
|
|
34
|
+
/**
|
|
35
|
+
* Full live-config baseline used to rebase unrelated cooperating writes. The
|
|
36
|
+
* Claude subtree and the bound listener fields remain on their dedicated
|
|
37
|
+
* reconciliation paths below.
|
|
38
|
+
*/
|
|
39
|
+
const liveConfigBaseline = new WeakMap<OcxConfig, OcxConfig>();
|
|
40
|
+
/**
|
|
41
|
+
* The live config retains the address of the socket Bun actually opened, while
|
|
42
|
+
* this map retains the operator's desired address for the next process start.
|
|
43
|
+
* Keeping them separate prevents an unrelated live save from restoring a stale
|
|
44
|
+
* externally exposed bind after OAuth adopted a newer loopback disk config.
|
|
45
|
+
*/
|
|
46
|
+
type PersistedServerBinding = Pick<OcxConfig, "port" | "hostname">;
|
|
47
|
+
|
|
48
|
+
const persistedLiveServerBinding = new WeakMap<OcxConfig, PersistedServerBinding>();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Arm the baseline for a long-lived config. MANDATORY at `startServer`, not lazy on
|
|
52
|
+
* first save — arming lazily would lose exactly the hand edit made before that first
|
|
53
|
+
* save, which is the case the guard exists for.
|
|
54
|
+
*/
|
|
55
|
+
export function armClaudeCodeBaseline(config: OcxConfig): void {
|
|
56
|
+
liveConfigBaseline.set(config, structuredClone(config));
|
|
57
|
+
claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Adopt one schema-validated provider that was read from the authoritative disk
|
|
62
|
+
* config into a long-lived server config without rebasing any unrelated field.
|
|
63
|
+
* Updating the matching baseline row keeps a later guarded save from treating the
|
|
64
|
+
* adopted provider as an unsaved live edit that should defeat a newer disk change.
|
|
65
|
+
*/
|
|
66
|
+
export function adoptPersistedProviderIntoLiveConfig(
|
|
67
|
+
config: OcxConfig,
|
|
68
|
+
name: string,
|
|
69
|
+
provider: OcxProviderConfig,
|
|
70
|
+
persistedConfig?: OcxConfig,
|
|
71
|
+
): void {
|
|
72
|
+
config.providers[name] = structuredClone(provider);
|
|
73
|
+
const baseline = liveConfigBaseline.get(config);
|
|
74
|
+
if (baseline) baseline.providers[name] = structuredClone(provider);
|
|
75
|
+
if (persistedConfig) refreshPreservedProviderOwner(config, persistedConfig);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Test seam only: is this instance armed? */
|
|
79
|
+
export function claudeCodeBaselineArmed(config: OcxConfig): boolean {
|
|
80
|
+
return claudeCodeBaseline.has(config);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Structural compare of parsed subtrees. NOT `JSON.stringify`: key order must not
|
|
85
|
+
* decide whether a user's hand edit survives.
|
|
86
|
+
*/
|
|
87
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
88
|
+
if (a === b) return true;
|
|
89
|
+
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
|
|
90
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
91
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
92
|
+
return a.length === b.length && a.every((item, index) => deepEqual(item, b[index]));
|
|
93
|
+
}
|
|
94
|
+
const left = a as Record<string, unknown>;
|
|
95
|
+
const right = b as Record<string, unknown>;
|
|
96
|
+
// `undefined` values and absent keys are the same thing after a JSON round-trip.
|
|
97
|
+
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
98
|
+
for (const key of keys) {
|
|
99
|
+
if (left[key] === undefined && right[key] === undefined) continue;
|
|
100
|
+
if (!deepEqual(left[key], right[key])) return false;
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const MISSING_CONFIG_VALUE = Symbol("missing-config-value");
|
|
106
|
+
type ConfigMergeValue = unknown | typeof MISSING_CONFIG_VALUE;
|
|
107
|
+
|
|
108
|
+
function isPlainConfigRecord(value: ConfigMergeValue): value is Record<string, unknown> {
|
|
109
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
110
|
+
const prototype = Object.getPrototypeOf(value);
|
|
111
|
+
return prototype === Object.prototype || prototype === null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function ownConfigValue(record: Record<string, unknown>, key: string): ConfigMergeValue {
|
|
115
|
+
return Object.hasOwn(record, key) ? record[key] : MISSING_CONFIG_VALUE;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function cloneConfigValue(value: ConfigMergeValue): ConfigMergeValue {
|
|
119
|
+
return value === MISSING_CONFIG_VALUE ? value : structuredClone(value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type IndexedCustomModels = {
|
|
123
|
+
order: string[];
|
|
124
|
+
byId: Map<string, Record<string, unknown>>;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function indexCustomModels(value: ConfigMergeValue): IndexedCustomModels | null {
|
|
128
|
+
if (!Array.isArray(value)) return null;
|
|
129
|
+
const order: string[] = [];
|
|
130
|
+
const byId = new Map<string, Record<string, unknown>>();
|
|
131
|
+
for (const item of value) {
|
|
132
|
+
if (!isPlainConfigRecord(item) || typeof item.id !== "string" || item.id.length === 0 || byId.has(item.id)) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
order.push(item.id);
|
|
136
|
+
byId.set(item.id, item);
|
|
137
|
+
}
|
|
138
|
+
return { order, byId };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Merge custom-model rows by their stable id instead of treating the array as
|
|
143
|
+
* one opaque value. A row changed only on disk is adopted, a row changed only
|
|
144
|
+
* in the live config is retained, and disjoint edits to the same row recurse
|
|
145
|
+
* through the normal three-way object merge. A newer persisted row deletion
|
|
146
|
+
* wins over a stale live edit to that row.
|
|
147
|
+
*/
|
|
148
|
+
function reconcileCustomModels(
|
|
149
|
+
baseline: ConfigMergeValue,
|
|
150
|
+
live: ConfigMergeValue,
|
|
151
|
+
persisted: ConfigMergeValue,
|
|
152
|
+
): ConfigMergeValue | null {
|
|
153
|
+
const baselineRows = indexCustomModels(baseline);
|
|
154
|
+
const liveRows = indexCustomModels(live);
|
|
155
|
+
const persistedRows = indexCustomModels(persisted);
|
|
156
|
+
if (!baselineRows || !liveRows || !persistedRows) return null;
|
|
157
|
+
|
|
158
|
+
const order = [...liveRows.order, ...persistedRows.order.filter(id => !liveRows.byId.has(id))];
|
|
159
|
+
const merged: Array<Record<string, unknown>> = [];
|
|
160
|
+
for (const id of order) {
|
|
161
|
+
const baselineRow = baselineRows.byId.get(id) ?? MISSING_CONFIG_VALUE;
|
|
162
|
+
const persistedRow = persistedRows.byId.get(id) ?? MISSING_CONFIG_VALUE;
|
|
163
|
+
const row = baselineRow !== MISSING_CONFIG_VALUE && persistedRow === MISSING_CONFIG_VALUE
|
|
164
|
+
? MISSING_CONFIG_VALUE
|
|
165
|
+
: reconcileConfigValue(
|
|
166
|
+
baselineRow,
|
|
167
|
+
liveRows.byId.get(id) ?? MISSING_CONFIG_VALUE,
|
|
168
|
+
persistedRow,
|
|
169
|
+
);
|
|
170
|
+
if (row !== MISSING_CONFIG_VALUE) merged.push(row as Record<string, unknown>);
|
|
171
|
+
}
|
|
172
|
+
return merged;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function reconcileConfigRecord(
|
|
176
|
+
live: Record<string, unknown>,
|
|
177
|
+
baseline: Record<string, unknown>,
|
|
178
|
+
persisted: Record<string, unknown>,
|
|
179
|
+
skippedKeys?: ReadonlySet<string>,
|
|
180
|
+
persistedDeletionsWin = false,
|
|
181
|
+
): void {
|
|
182
|
+
const keys = new Set([...Object.keys(baseline), ...Object.keys(live), ...Object.keys(persisted)]);
|
|
183
|
+
for (const key of keys) {
|
|
184
|
+
if (skippedKeys?.has(key)) continue;
|
|
185
|
+
const baselineValue = ownConfigValue(baseline, key);
|
|
186
|
+
const liveValue = ownConfigValue(live, key);
|
|
187
|
+
const persistedValue = ownConfigValue(persisted, key);
|
|
188
|
+
const merged = persistedDeletionsWin
|
|
189
|
+
&& baselineValue !== MISSING_CONFIG_VALUE
|
|
190
|
+
&& persistedValue === MISSING_CONFIG_VALUE
|
|
191
|
+
? MISSING_CONFIG_VALUE
|
|
192
|
+
: key === "customModels"
|
|
193
|
+
? reconcileCustomModels(baselineValue, liveValue, persistedValue)
|
|
194
|
+
?? reconcileConfigValue(baselineValue, liveValue, persistedValue)
|
|
195
|
+
: reconcileConfigValue(baselineValue, liveValue, persistedValue, key === "providers");
|
|
196
|
+
if (merged === MISSING_CONFIG_VALUE) delete live[key];
|
|
197
|
+
else live[key] = merged;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function reconcileConfigValue(
|
|
202
|
+
baseline: ConfigMergeValue,
|
|
203
|
+
live: ConfigMergeValue,
|
|
204
|
+
persisted: ConfigMergeValue,
|
|
205
|
+
persistedChildDeletionsWin = false,
|
|
206
|
+
): ConfigMergeValue {
|
|
207
|
+
const liveChanged = !deepEqual(live, baseline);
|
|
208
|
+
const persistedChanged = !deepEqual(persisted, baseline);
|
|
209
|
+
|
|
210
|
+
if (!liveChanged) {
|
|
211
|
+
if (live !== MISSING_CONFIG_VALUE && Array.isArray(live) && Array.isArray(persisted)) {
|
|
212
|
+
live.splice(0, live.length, ...structuredClone(persisted));
|
|
213
|
+
return live;
|
|
214
|
+
}
|
|
215
|
+
if (isPlainConfigRecord(live) && isPlainConfigRecord(persisted)) {
|
|
216
|
+
reconcileConfigRecord(
|
|
217
|
+
live,
|
|
218
|
+
isPlainConfigRecord(baseline) ? baseline : {},
|
|
219
|
+
persisted,
|
|
220
|
+
);
|
|
221
|
+
return live;
|
|
222
|
+
}
|
|
223
|
+
return cloneConfigValue(persisted);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!persistedChanged) return live;
|
|
227
|
+
|
|
228
|
+
if (isPlainConfigRecord(live)
|
|
229
|
+
&& isPlainConfigRecord(persisted)
|
|
230
|
+
&& (baseline === MISSING_CONFIG_VALUE || isPlainConfigRecord(baseline))) {
|
|
231
|
+
reconcileConfigRecord(
|
|
232
|
+
live,
|
|
233
|
+
isPlainConfigRecord(baseline) ? baseline : {},
|
|
234
|
+
persisted,
|
|
235
|
+
undefined,
|
|
236
|
+
persistedChildDeletionsWin,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
// Same-leaf conflicts prefer the pending live management mutation.
|
|
240
|
+
return live;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Reconcile an async OAuth disk commit into the shared live config without erasing
|
|
245
|
+
* management mutations that have not saved yet. The baseline is a normalized disk
|
|
246
|
+
* snapshot from immediately before login; disjoint object edits merge recursively,
|
|
247
|
+
* while same-leaf conflicts prefer live state.
|
|
248
|
+
*/
|
|
249
|
+
export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline: OcxConfig): void {
|
|
250
|
+
const diagnostics = readConfigDiagnostics();
|
|
251
|
+
if (diagnostics.source === "fallback") {
|
|
252
|
+
throw new Error(`OAuth config reconciliation failed: ${diagnostics.error ?? "invalid config file"}`);
|
|
253
|
+
}
|
|
254
|
+
const persisted = diagnostics.config;
|
|
255
|
+
const claudeGuardArmed = claudeCodeBaseline.has(config);
|
|
256
|
+
const pendingLiveClaudeMutation = claudeGuardArmed
|
|
257
|
+
&& !deepEqual(config.claudeCode, claudeCodeBaseline.get(config));
|
|
258
|
+
|
|
259
|
+
persistedLiveServerBinding.set(config, {
|
|
260
|
+
port: persisted.port,
|
|
261
|
+
...(persisted.hostname !== undefined ? { hostname: persisted.hostname } : {}),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
reconcileConfigRecord(
|
|
265
|
+
config as unknown as Record<string, unknown>,
|
|
266
|
+
persistedBaseline as unknown as Record<string, unknown>,
|
|
267
|
+
persisted as unknown as Record<string, unknown>,
|
|
268
|
+
new Set(["hostname", "port", ...(claudeGuardArmed ? ["claudeCode"] : [])]),
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
if (claudeGuardArmed && !pendingLiveClaudeMutation) {
|
|
272
|
+
if (persisted.claudeCode === undefined) delete config.claudeCode;
|
|
273
|
+
else config.claudeCode = structuredClone(persisted.claudeCode);
|
|
274
|
+
claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
|
|
275
|
+
}
|
|
276
|
+
// The reconciliation may have adopted a providers.<name>.modelCosts edit made
|
|
277
|
+
// by a cooperating process while the OAuth login was pending; keep the overlay
|
|
278
|
+
// registry (and the usage-cache overlay version) in sync with the live config.
|
|
279
|
+
refreshUserCostOverlays(config);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Read only schema-valid binding fields from the literal file. Missing fields mean
|
|
284
|
+
* their schema defaults; malformed fields keep the last known persisted value.
|
|
285
|
+
*/
|
|
286
|
+
function readPersistedServerBinding(
|
|
287
|
+
raw: Record<string, unknown>,
|
|
288
|
+
baseline: PersistedServerBinding,
|
|
289
|
+
): PersistedServerBinding {
|
|
290
|
+
const port = raw.port === undefined
|
|
291
|
+
? 10100
|
|
292
|
+
: (typeof raw.port === "number"
|
|
293
|
+
&& Number.isInteger(raw.port)
|
|
294
|
+
&& raw.port >= 0
|
|
295
|
+
&& raw.port <= 65535
|
|
296
|
+
? raw.port
|
|
297
|
+
: baseline.port);
|
|
298
|
+
const hostname = raw.hostname === undefined
|
|
299
|
+
? undefined
|
|
300
|
+
: (typeof raw.hostname === "string" ? raw.hostname : baseline.hostname);
|
|
301
|
+
return { port, ...(hostname !== undefined ? { hostname } : {}) };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The save entry point for every writer holding a LIVE server config.
|
|
306
|
+
*
|
|
307
|
+
* Conflict policy, chosen deliberately:
|
|
308
|
+
* - disk changed, we did not → their hand edit wins;
|
|
309
|
+
* - disk changed AND we changed → disjoint fields are merged, while a same-leaf
|
|
310
|
+
* conflict keeps the live value;
|
|
311
|
+
* - a provider or custom-model row deleted on disk stays deleted even if stale
|
|
312
|
+
* live state edited that same row;
|
|
313
|
+
* - file missing/unreadable → save what we have, no throw.
|
|
314
|
+
*
|
|
315
|
+
* Custom-model rows are merged by their stable `id`, preserving independent
|
|
316
|
+
* edits and deletions across stale whole-config saves.
|
|
317
|
+
*/
|
|
318
|
+
export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
|
|
319
|
+
const pinError = configReasoningPinsConfigError(config);
|
|
320
|
+
if (pinError) throw new Error(pinError);
|
|
321
|
+
withConfigMutationLockSync(() => {
|
|
322
|
+
const bindingBaseline = persistedLiveServerBinding.get(config);
|
|
323
|
+
// One authoritative pre-write read feeds both the live-config reconciliation and
|
|
324
|
+
// custom-model deletion migration. A second read could observe different bytes.
|
|
325
|
+
const onDisk = readRawConfigJson();
|
|
326
|
+
const baseline = liveConfigBaseline.get(config);
|
|
327
|
+
if (baseline && onDisk !== undefined) {
|
|
328
|
+
const persistedDiagnostics = configDiagnosticsFromRaw(JSON.stringify(onDisk));
|
|
329
|
+
if (persistedDiagnostics.source === "file") {
|
|
330
|
+
const deletedKeys = configRebaseDeletionKeys(config);
|
|
331
|
+
const provenanceExists = configHasRebaseProvenance(config);
|
|
332
|
+
// Only keys this live config is actually known to have diverged on may be
|
|
333
|
+
// rebased. The baseline is captured once when the server arms it, so any key
|
|
334
|
+
// that appeared on disk afterwards — through saveConfig(), a hand edit, or
|
|
335
|
+
// another process — is absent from the baseline as well as from the live
|
|
336
|
+
// config. Reconciling those keys reads "live never changed this" and adopts
|
|
337
|
+
// the disk value, which resurrects a field the live writer had deliberately
|
|
338
|
+
// deleted (#1462 regression: PUT /api/grok/selection with an empty list).
|
|
339
|
+
// Restrict the merge to keys the baseline knew about, plus keys the live
|
|
340
|
+
// config still carries; a key that exists only on disk is left to the
|
|
341
|
+
// ordinary whole-config write below.
|
|
342
|
+
const rebaseableKeys = new Set([
|
|
343
|
+
...Object.keys(baseline as unknown as Record<string, unknown>),
|
|
344
|
+
...Object.keys(config as unknown as Record<string, unknown>),
|
|
345
|
+
...(provenanceExists
|
|
346
|
+
? Object.keys(persistedDiagnostics.config as unknown as Record<string, unknown>)
|
|
347
|
+
: []),
|
|
348
|
+
]);
|
|
349
|
+
const skipped = new Set(["hostname", "port", "claudeCode", CONFIG_REBASE_PROVENANCE_KEY]);
|
|
350
|
+
for (const key of Object.keys(persistedDiagnostics.config as unknown as Record<string, unknown>)) {
|
|
351
|
+
if (!rebaseableKeys.has(key)) skipped.add(key);
|
|
352
|
+
}
|
|
353
|
+
reconcileConfigRecord(
|
|
354
|
+
config as unknown as Record<string, unknown>,
|
|
355
|
+
baseline as unknown as Record<string, unknown>,
|
|
356
|
+
persistedDiagnostics.config as unknown as Record<string, unknown>,
|
|
357
|
+
skipped,
|
|
358
|
+
);
|
|
359
|
+
for (const key of deletedKeys) delete (config as unknown as Record<string, unknown>)[key];
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (claudeCodeBaseline.has(config)) {
|
|
363
|
+
if (onDisk !== undefined) {
|
|
364
|
+
const baseline = claudeCodeBaseline.get(config);
|
|
365
|
+
const persistedClaudeCode = normalizePersistedClaudeCode(onDisk.claudeCode);
|
|
366
|
+
const diskChanged = !deepEqual(persistedClaudeCode, baseline);
|
|
367
|
+
const weChanged = !deepEqual(config.claudeCode, baseline);
|
|
368
|
+
if (diskChanged && !weChanged) {
|
|
369
|
+
config.claudeCode = persistedClaudeCode;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const provenanceProjection = projectConfigRebaseProvenance(config);
|
|
374
|
+
const projectedConfig = projectCustomModelCatalogMigration(
|
|
375
|
+
onDisk,
|
|
376
|
+
config,
|
|
377
|
+
);
|
|
378
|
+
if (provenanceProjection.configRebaseProvenance === undefined) delete projectedConfig.configRebaseProvenance;
|
|
379
|
+
else projectedConfig.configRebaseProvenance = provenanceProjection.configRebaseProvenance;
|
|
380
|
+
const persistedBinding = bindingBaseline && onDisk
|
|
381
|
+
? readPersistedServerBinding(onDisk, bindingBaseline)
|
|
382
|
+
: bindingBaseline;
|
|
383
|
+
if (persistedBinding) {
|
|
384
|
+
const persistedConfig: OcxConfig = { ...projectedConfig, port: persistedBinding.port };
|
|
385
|
+
if (persistedBinding.hostname === undefined) delete persistedConfig.hostname;
|
|
386
|
+
else persistedConfig.hostname = persistedBinding.hostname;
|
|
387
|
+
if (persistConfigUnlocked(persistedConfig)) bumpGenerationForCooperatingConfigWrite();
|
|
388
|
+
persistedLiveServerBinding.set(config, persistedBinding);
|
|
389
|
+
} else {
|
|
390
|
+
if (persistConfigUnlocked(projectedConfig)) bumpGenerationForCooperatingConfigWrite();
|
|
391
|
+
}
|
|
392
|
+
adoptCustomModelCatalogMigration(config, projectedConfig);
|
|
393
|
+
if (claudeCodeBaseline.has(config)) {
|
|
394
|
+
claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
|
|
395
|
+
}
|
|
396
|
+
if (liveConfigBaseline.has(config)) {
|
|
397
|
+
if (projectedConfig.configRebaseProvenance === undefined) delete config.configRebaseProvenance;
|
|
398
|
+
else config.configRebaseProvenance = structuredClone(projectedConfig.configRebaseProvenance);
|
|
399
|
+
liveConfigBaseline.set(config, structuredClone(projectedConfig));
|
|
400
|
+
}
|
|
401
|
+
clearPendingConfigTopLevelDeletions(config);
|
|
402
|
+
});
|
|
403
|
+
}
|