@bitkyc08/opencodex 2.69.0 → 2.70.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/App-_MrbTrBe.js +51 -0
- package/gui/dist/assets/{Tray-uqKRqfvw.js → Tray-5PuUalET.js} +1 -1
- package/gui/dist/assets/index-DKrO2eF3.js +86 -0
- package/gui/dist/assets/quota-summary-BCLivc0Q.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic-model-contract.ts +119 -0
- package/src/adapters/anthropic.ts +14 -83
- package/src/adapters/base.ts +2 -0
- package/src/adapters/command-code-project-context.ts +542 -0
- package/src/adapters/command-code-tool-text.ts +99 -67
- package/src/adapters/command-code.ts +5 -1
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/devin/cloud-direct/catalog.ts +91 -0
- package/src/adapters/devin/cloud-direct/chat.ts +78 -48
- package/src/adapters/devin/cloud-direct/tool-schema.ts +121 -0
- package/src/adapters/devin/context-overflow.ts +75 -0
- package/src/adapters/devin/live-models.ts +174 -13
- package/src/adapters/devin/reasoning-signature.ts +100 -0
- package/src/adapters/devin.ts +310 -117
- package/src/adapters/kiro/payload.ts +10 -10
- package/src/adapters/kiro-images.ts +58 -7
- package/src/adapters/openai-chat/passthrough.ts +3 -1
- package/src/adapters/openai-chat.ts +2 -1
- package/src/adapters/openai-responses/passthrough.ts +2 -1
- package/src/bridge/response-json.ts +15 -1
- package/src/bridge/sse.ts +3 -1
- package/src/claude/intercept/model-bindings.ts +1 -0
- package/src/cli/account-extended.ts +31 -1
- package/src/cli/account.ts +3 -0
- package/src/cli/capabilities.ts +13 -0
- package/src/cli/client-start-fence.ts +36 -0
- package/src/cli/dispatch.ts +3 -2
- package/src/cli/index.ts +98 -117
- package/src/cli/provider-runtime.ts +23 -2
- package/src/cli/provider.ts +3 -0
- package/src/cli/resolve.ts +18 -0
- package/src/cli/start-owner-fence.ts +30 -0
- package/src/cli/stop-approval.ts +13 -1
- package/src/cli/stop-restore.ts +73 -0
- package/src/cli/tray-proxy.ts +221 -18
- package/src/cli/version-skew.ts +25 -23
- package/src/client/runtime.ts +8 -4
- package/src/codex/auth-api/account-list.ts +11 -5
- package/src/codex/auth-api/login-flow.ts +5 -3
- package/src/codex/auth-api/main-account-probe.ts +186 -110
- package/src/codex/auth-api/pool-mode-gate.ts +61 -7
- package/src/codex/auth-api/pool-quota-probe.ts +142 -90
- package/src/codex/auth-api/reset-credit-service.ts +1 -1
- package/src/codex/catalog/gather-capture.ts +1 -0
- package/src/codex/catalog/model-hints.ts +3 -3
- package/src/codex/catalog/provider-models.ts +2 -0
- package/src/codex/low-quota-events.ts +28 -0
- package/src/codex/low-quota-observer.ts +23 -0
- package/src/codex/low-quota-protection.ts +272 -0
- package/src/codex/quota-query-backoff.ts +148 -0
- package/src/codex/quota.ts +7 -2
- package/src/config/diagnostics.ts +10 -1
- package/src/config/load-degrade.ts +17 -0
- package/src/config/mutation-lock.ts +3 -4
- package/src/config/process-state.ts +1 -1
- package/src/config/provider-validation.ts +22 -0
- package/src/config/schema/blocked-model-redirects.ts +17 -0
- package/src/config/schema/config-schema.ts +4 -1
- package/src/config/schema/leaf-validators.ts +33 -2
- package/src/config/serving-runtimes.ts +412 -0
- package/src/generated/compatibility-version.json +211 -115
- package/src/generated/model-metadata.ts +3 -3
- package/src/images/loop.ts +3 -0
- package/src/lib/errors.ts +2 -1
- package/src/lib/handled-signal-exit.ts +23 -0
- package/src/lib/request-failure-model.ts +1 -0
- package/src/lib/self-launch-argv.ts +9 -0
- package/src/lib/shadow-call.ts +5 -2
- package/src/lib/strict-semver.ts +26 -0
- package/src/lib/transient-service-path.ts +16 -0
- package/src/lib/windows-service-wrappers.ts +10 -6
- package/src/lib/winsw.ts +3 -1
- package/src/oauth/anthropic-model-routes.ts +105 -0
- package/src/oauth/anthropic-routing.ts +85 -48
- package/src/oauth/devin.ts +135 -5
- package/src/oauth/index.ts +20 -3
- package/src/oauth/kiro-terminal-failover.ts +23 -9
- package/src/oauth/pool-settings-capability.ts +10 -2
- package/src/oauth/store.ts +1 -1
- package/src/protocols/encoders/adapter-events.ts +3 -1
- package/src/providers/github-copilot-context.ts +27 -0
- package/src/providers/kiro-models.ts +4 -0
- package/src/providers/model-rename-fields.ts +3 -0
- package/src/providers/quota/account-cache.ts +8 -3
- package/src/providers/quota/devin.ts +286 -0
- package/src/providers/quota/vendor-probes-oauth.ts +6 -2
- package/src/providers/quota.ts +1 -1
- package/src/providers/registry/entries-core.ts +1 -1
- package/src/providers/registry/model-seeds.ts +5 -2
- package/src/responses/compaction-images.ts +27 -0
- package/src/router.ts +104 -16
- package/src/routing/analytics.ts +1 -0
- package/src/server/auth-cors.ts +9 -1
- package/src/server/background-lifecycle.ts +21 -10
- package/src/server/chat-completions.ts +2 -1
- package/src/server/chat-native.ts +1 -0
- package/src/server/index.ts +4 -8
- package/src/server/inference/client-encoder-delivery.ts +3 -0
- package/src/server/management/context.ts +3 -0
- package/src/server/management/low-quota-routes.ts +14 -0
- package/src/server/management/oauth-account-routes.ts +21 -1
- package/src/server/management/provider-overwrite-carry.ts +79 -6
- package/src/server/management/provider-routes.ts +41 -28
- package/src/server/management/route-registry.ts +1 -0
- package/src/server/management-api.ts +7 -0
- package/src/server/responses/adapter-continuation.ts +3 -0
- package/src/server/responses/adapter-delivery.ts +3 -0
- package/src/server/responses/adapter-dispatch.ts +20 -4
- package/src/server/responses/antigravity-validation-refusal.ts +20 -0
- package/src/server/responses/core-auth.ts +1 -0
- package/src/server/responses/core-codex-account.ts +1 -0
- package/src/server/responses/core-normalize.ts +4 -0
- package/src/server/responses/passthrough-dispatch.ts +8 -1
- package/src/server/responses/request-sidecar-auth.ts +2 -0
- package/src/server/responses/request-transport.ts +27 -10
- package/src/server/responses/response-effects.ts +4 -1
- package/src/server/responses/run-turn-execution.ts +126 -42
- package/src/server/responses/sidecar-execution.ts +4 -1
- package/src/service/diagnostics.ts +3 -1
- package/src/service/guarded-manager-target.ts +39 -12
- package/src/service/launchd.ts +14 -6
- package/src/service/repair.ts +6 -1
- package/src/service/service-child-ownership.ts +168 -0
- package/src/service/state.ts +22 -4
- package/src/service/systemd.ts +2 -2
- package/src/service/windows-guarded-manager.ts +530 -0
- package/src/service/windows-taskxml.ts +2 -2
- package/src/service.ts +2 -0
- package/src/types/config.ts +26 -0
- package/src/types/provider.ts +12 -0
- package/src/types/request.ts +2 -0
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +13 -0
- package/src/usage/telemetry-contract.ts +1 -0
- package/src/vision/anthropic-describe.ts +2 -1
- package/src/web-search/anthropic-executor.ts +2 -1
- package/src/web-search/loop.ts +1 -0
- package/gui/dist/assets/App-DDbgOTh6.js +0 -51
- package/gui/dist/assets/index-CuaBJkG4.js +0 -86
- package/gui/dist/assets/quota-summary-COppQSVq.js +0 -1
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type { OcxConfig } from "../types";
|
|
2
|
+
import { isSelectableCodexPoolAccount } from "./account-id";
|
|
3
|
+
import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause";
|
|
4
|
+
import { createLowQuotaEventLedger, type LowQuotaEvent } from "./low-quota-events";
|
|
5
|
+
import { registerLowQuotaObserver } from "./low-quota-observer";
|
|
6
|
+
import { resetAtToMs } from "./quota-types";
|
|
7
|
+
|
|
8
|
+
type Window = "short" | "weekly";
|
|
9
|
+
type Notice = { window: Window; percentUsed: number; threshold: number };
|
|
10
|
+
type Dependencies = {
|
|
11
|
+
persist?: (config: OcxConfig) => void | Promise<void>;
|
|
12
|
+
notify?: (notice: Notice) => void | Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
type EventBase = Omit<LowQuotaEvent, "timestamp" | "status" | "delivery">;
|
|
15
|
+
type Episode = { reset: string; notice: "in-flight" | "logged" | "delivered" | "failed" | undefined; pausedByUs: boolean; noticeBase?: EventBase };
|
|
16
|
+
export type LowQuotaRegistration = (() => void) & {
|
|
17
|
+
hasPendingSave(): boolean;
|
|
18
|
+
flush(): Promise<void>;
|
|
19
|
+
listEvents(limit?: number): LowQuotaEvent[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const RETRY_DELAYS_MS = [100, 250];
|
|
23
|
+
const FLUSH_DEADLINE_MS = 500;
|
|
24
|
+
|
|
25
|
+
/** Each server owns its own policy, episode state and deferred writer. */
|
|
26
|
+
export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Dependencies = {}): LowQuotaRegistration {
|
|
27
|
+
const episodes = new Map<string, Episode>();
|
|
28
|
+
const autoPausedAccounts = new Set<string>();
|
|
29
|
+
const ledger = createLowQuotaEventLedger();
|
|
30
|
+
let policyKey: string | undefined;
|
|
31
|
+
let closed = false;
|
|
32
|
+
let generation = 0;
|
|
33
|
+
let dirty = false;
|
|
34
|
+
let attempts = 0;
|
|
35
|
+
let retryDelay: number | undefined;
|
|
36
|
+
let saveFlight: Promise<void> | null = null;
|
|
37
|
+
let activeSave: { events: Map<string, EventBase>; started: boolean; settled: boolean } | null = null;
|
|
38
|
+
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
39
|
+
const pendingPauseEvents = new Map<string, EventBase>();
|
|
40
|
+
const pauseStatus = new WeakMap<EventBase, LowQuotaEvent["status"]>();
|
|
41
|
+
|
|
42
|
+
function event(base: EventBase,
|
|
43
|
+
delivery: LowQuotaEvent["delivery"], status: LowQuotaEvent["status"]): void {
|
|
44
|
+
if (delivery === "pause-save") pauseStatus.set(base, status);
|
|
45
|
+
ledger.publish({ ...base, delivery, status, timestamp: Date.now() });
|
|
46
|
+
}
|
|
47
|
+
function cancelTimer(): void {
|
|
48
|
+
if (saveTimer) clearTimeout(saveTimer);
|
|
49
|
+
saveTimer = null;
|
|
50
|
+
}
|
|
51
|
+
function scheduleSave(delay = 0): void {
|
|
52
|
+
if (closed || saveTimer || saveFlight) return;
|
|
53
|
+
const ownerGeneration = generation;
|
|
54
|
+
saveTimer = setTimeout(() => {
|
|
55
|
+
saveTimer = null;
|
|
56
|
+
if (closed || ownerGeneration !== generation) {
|
|
57
|
+
for (const base of pendingPauseEvents.values()) {
|
|
58
|
+
if (pauseStatus.get(base) === "pending") event(base, "pause-save", "cancelled");
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
void runSave();
|
|
63
|
+
}, delay);
|
|
64
|
+
saveTimer.unref?.();
|
|
65
|
+
}
|
|
66
|
+
function runSave(): Promise<void> {
|
|
67
|
+
if (closed || !dirty) return saveFlight ?? Promise.resolve();
|
|
68
|
+
if (saveFlight) return saveFlight;
|
|
69
|
+
cancelTimer();
|
|
70
|
+
const ownerGeneration = generation;
|
|
71
|
+
const saving = new Map(pendingPauseEvents);
|
|
72
|
+
const flightState = { events: saving, started: false, settled: false };
|
|
73
|
+
activeSave = flightState;
|
|
74
|
+
dirty = false;
|
|
75
|
+
saveFlight = Promise.resolve().then(async () => {
|
|
76
|
+
if (closed || ownerGeneration !== generation) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (deps.persist) {
|
|
80
|
+
flightState.started = true;
|
|
81
|
+
return deps.persist(config);
|
|
82
|
+
}
|
|
83
|
+
// Import only for an actual deferred save, then recheck ownership: an import
|
|
84
|
+
// that resolves after a timed-out flush must not start a late config write.
|
|
85
|
+
const { saveConfigPreservingClaudeCode } = await import("../config/live-reconcile");
|
|
86
|
+
if (closed || ownerGeneration !== generation) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
flightState.started = true;
|
|
90
|
+
saveConfigPreservingClaudeCode(config);
|
|
91
|
+
}).then(() => {
|
|
92
|
+
flightState.settled = true;
|
|
93
|
+
if (flightState.started) {
|
|
94
|
+
attempts = 0;
|
|
95
|
+
for (const [key, base] of saving) {
|
|
96
|
+
event(base, "pause-save", isCodexAccountPaused(config, base.accountId) ? "succeeded" : "cancelled");
|
|
97
|
+
if (pendingPauseEvents.get(key) === base) pendingPauseEvents.delete(key);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}, () => {
|
|
101
|
+
flightState.settled = true;
|
|
102
|
+
if (flightState.started || !closed) {
|
|
103
|
+
for (const base of saving.values()) event(base, "pause-save", "failed");
|
|
104
|
+
}
|
|
105
|
+
if (flightState.started) {
|
|
106
|
+
for (const [key, base] of saving) {
|
|
107
|
+
if (pendingPauseEvents.get(key) === base && closed) pendingPauseEvents.delete(key);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (closed || ownerGeneration !== generation) return;
|
|
111
|
+
console.warn("[codex-low-quota] pause persistence failed");
|
|
112
|
+
dirty = true;
|
|
113
|
+
retryDelay = RETRY_DELAYS_MS[attempts++];
|
|
114
|
+
}).finally(() => {
|
|
115
|
+
if (activeSave === flightState) activeSave = null;
|
|
116
|
+
saveFlight = null;
|
|
117
|
+
if (dirty && !closed) {
|
|
118
|
+
if (retryDelay !== undefined) scheduleSave(retryDelay);
|
|
119
|
+
else if (attempts === 0) scheduleSave();
|
|
120
|
+
retryDelay = undefined;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
return saveFlight;
|
|
124
|
+
}
|
|
125
|
+
function close(): void {
|
|
126
|
+
if (closed) return;
|
|
127
|
+
closed = true;
|
|
128
|
+
generation++;
|
|
129
|
+
cancelTimer();
|
|
130
|
+
for (const [key, base] of pendingPauseEvents) {
|
|
131
|
+
if (activeSave?.started && activeSave.events.get(key) === base) continue;
|
|
132
|
+
if (pauseStatus.get(base) === "pending") event(base, "pause-save", "cancelled");
|
|
133
|
+
pendingPauseEvents.delete(key);
|
|
134
|
+
}
|
|
135
|
+
for (const episode of episodes.values()) {
|
|
136
|
+
if (episode.notice === "in-flight" && episode.noticeBase) event(episode.noticeBase, "notice", "cancelled");
|
|
137
|
+
}
|
|
138
|
+
dirty = false;
|
|
139
|
+
autoPausedAccounts.clear();
|
|
140
|
+
unregister();
|
|
141
|
+
}
|
|
142
|
+
async function flush(): Promise<void> {
|
|
143
|
+
if (closed) return;
|
|
144
|
+
const deadline = Date.now() + FLUSH_DEADLINE_MS;
|
|
145
|
+
// Drain the in-flight write and any coalesced save before closing the owner.
|
|
146
|
+
while (saveFlight || dirty) {
|
|
147
|
+
cancelTimer();
|
|
148
|
+
const flight = saveFlight ?? runSave();
|
|
149
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
150
|
+
const completed = await Promise.race([
|
|
151
|
+
flight.then(() => true),
|
|
152
|
+
new Promise<false>(resolve => {
|
|
153
|
+
timeout = setTimeout(() => resolve(false), Math.max(0, deadline - Date.now()));
|
|
154
|
+
}),
|
|
155
|
+
]);
|
|
156
|
+
if (timeout) clearTimeout(timeout);
|
|
157
|
+
if (!completed || Date.now() >= deadline) {
|
|
158
|
+
const inFlight = activeSave;
|
|
159
|
+
const stillRunning = inFlight !== null && inFlight.started && !inFlight.settled;
|
|
160
|
+
if (inFlight && stillRunning) {
|
|
161
|
+
for (const base of inFlight.events.values()) event(base, "pause-save", "pending");
|
|
162
|
+
}
|
|
163
|
+
console.warn(stillRunning
|
|
164
|
+
? "[codex-low-quota] pause persistence flush timed out; in-flight save remains pending"
|
|
165
|
+
: "[codex-low-quota] pause persistence flush reached its deadline");
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
if (attempts > RETRY_DELAYS_MS.length) break;
|
|
169
|
+
}
|
|
170
|
+
close();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const unregister = registerLowQuotaObserver((accountId, quota) => {
|
|
174
|
+
if (closed) return;
|
|
175
|
+
const policy = config.codexPool?.lowQuotaProtection;
|
|
176
|
+
const nextKey = JSON.stringify(policy);
|
|
177
|
+
if (policyKey !== nextKey) {
|
|
178
|
+
for (const episode of episodes.values()) {
|
|
179
|
+
if (episode.notice === "in-flight" && episode.noticeBase) event(episode.noticeBase, "notice", "cancelled");
|
|
180
|
+
}
|
|
181
|
+
episodes.clear();
|
|
182
|
+
autoPausedAccounts.clear();
|
|
183
|
+
policyKey = nextKey;
|
|
184
|
+
}
|
|
185
|
+
if (!policy?.enabled) return;
|
|
186
|
+
const liveIds = new Set((config.codexAccounts ?? []).filter(isSelectableCodexPoolAccount).map(account => account.id));
|
|
187
|
+
for (const id of autoPausedAccounts) if (!liveIds.has(id)) autoPausedAccounts.delete(id);
|
|
188
|
+
for (const [key, episode] of episodes) {
|
|
189
|
+
if (liveIds.has(key.split("\u0000")[0]!)) continue;
|
|
190
|
+
if (episode.notice === "in-flight" && episode.noticeBase) event(episode.noticeBase, "notice", "cancelled");
|
|
191
|
+
episodes.delete(key);
|
|
192
|
+
}
|
|
193
|
+
if (!liveIds.has(accountId)) return;
|
|
194
|
+
// A manual resume ends this policy's active pause, but each already-high
|
|
195
|
+
// window keeps its episode marker until recovery or a new reset.
|
|
196
|
+
if (!isCodexAccountPaused(config, accountId)) autoPausedAccounts.delete(accountId);
|
|
197
|
+
for (const window of ["short", "weekly"] as const) {
|
|
198
|
+
if (!policy.windows[window]) continue;
|
|
199
|
+
const percentUsed = quota[`${window}Percent`];
|
|
200
|
+
const rawReset = quota[`${window}ResetAt`];
|
|
201
|
+
if (typeof percentUsed !== "number" || !Number.isFinite(percentUsed) || percentUsed < 0 || percentUsed > 100) continue;
|
|
202
|
+
if (rawReset !== undefined && (!Number.isFinite(rawReset) || resetAtToMs(rawReset) <= Date.now())) continue;
|
|
203
|
+
const key = `${accountId}\u0000${window}`;
|
|
204
|
+
if (percentUsed < policy.threshold) {
|
|
205
|
+
const prior = episodes.get(key);
|
|
206
|
+
if (prior?.notice === "in-flight" && prior.noticeBase) event(prior.noticeBase, "notice", "cancelled");
|
|
207
|
+
episodes.delete(key);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const resetAt = rawReset === undefined ? null : resetAtToMs(rawReset);
|
|
211
|
+
const reset = resetAt === null ? "unknown" : String(resetAt);
|
|
212
|
+
let episode = episodes.get(key);
|
|
213
|
+
if (!episode || episode.reset !== reset) {
|
|
214
|
+
episode = { reset, notice: undefined, pausedByUs: false };
|
|
215
|
+
episodes.set(key, episode);
|
|
216
|
+
}
|
|
217
|
+
const base = { accountId, window, percentUsed, resetAt };
|
|
218
|
+
if (autoPausedAccounts.has(accountId) && isCodexAccountPaused(config, accountId)) {
|
|
219
|
+
episode.pausedByUs = true;
|
|
220
|
+
}
|
|
221
|
+
if (dirty && attempts > RETRY_DELAYS_MS.length) {
|
|
222
|
+
attempts = 0;
|
|
223
|
+
scheduleSave();
|
|
224
|
+
}
|
|
225
|
+
if (policy.actions.pause && !isCodexAccountPaused(config, accountId) && !episode.pausedByUs) {
|
|
226
|
+
setCodexAccountPaused(config, accountId, true);
|
|
227
|
+
autoPausedAccounts.add(accountId);
|
|
228
|
+
episode.pausedByUs = true;
|
|
229
|
+
pendingPauseEvents.set(key, base);
|
|
230
|
+
dirty = true;
|
|
231
|
+
attempts = 0;
|
|
232
|
+
retryDelay = undefined;
|
|
233
|
+
event(base, "pause-save", "pending");
|
|
234
|
+
scheduleSave();
|
|
235
|
+
}
|
|
236
|
+
// A manual resume leaves pausedByUs set until recovery or a new reset episode.
|
|
237
|
+
if (policy.actions.notify && episode.notice !== "in-flight" && episode.notice !== "logged" && episode.notice !== "delivered") {
|
|
238
|
+
console.warn(`[codex-low-quota] ${window === "short" ? "5-hour" : "weekly"} quota reached ${percentUsed}% used (threshold ${policy.threshold}%)`);
|
|
239
|
+
if (!deps.notify) {
|
|
240
|
+
event(base, "notice", "logged");
|
|
241
|
+
episode.notice = "logged";
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
episode.notice = "in-flight";
|
|
245
|
+
episode.noticeBase = base;
|
|
246
|
+
event(base, "notice", "pending");
|
|
247
|
+
try {
|
|
248
|
+
void Promise.resolve(deps.notify({ window, percentUsed, threshold: policy.threshold })).then(() => {
|
|
249
|
+
if (closed || episodes.get(key) !== episode) return;
|
|
250
|
+
event(base, "notice", "delivered");
|
|
251
|
+
episode.notice = "delivered";
|
|
252
|
+
episode.noticeBase = undefined;
|
|
253
|
+
}, () => {
|
|
254
|
+
if (closed || episodes.get(key) !== episode) return;
|
|
255
|
+
event(base, "notice", "failed");
|
|
256
|
+
episode.notice = "failed";
|
|
257
|
+
episode.noticeBase = undefined;
|
|
258
|
+
});
|
|
259
|
+
} catch {
|
|
260
|
+
event(base, "notice", "failed");
|
|
261
|
+
episode.notice = "failed";
|
|
262
|
+
episode.noticeBase = undefined;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
return Object.assign(close, {
|
|
268
|
+
hasPendingSave: () => dirty || saveFlight !== null || saveTimer !== null,
|
|
269
|
+
flush,
|
|
270
|
+
listEvents: ledger.list,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { getConfigDir } from "../config/paths";
|
|
2
|
+
import { parseRetryAfterMs } from "./routing/cooldown-math";
|
|
3
|
+
|
|
4
|
+
const BASE_DELAY_MS = 5 * 60_000;
|
|
5
|
+
const MAX_DELAY_MS = 60 * 60_000;
|
|
6
|
+
const MAX_ENTRIES = 256;
|
|
7
|
+
|
|
8
|
+
/** One capped schedule for failed queries and successful-but-blocked recovery. */
|
|
9
|
+
export function nextQuotaQueryDelay(previous = BASE_DELAY_MS / 2): number {
|
|
10
|
+
return Math.min(previous * 2, MAX_DELAY_MS);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type Attempt = { delay: number; after: number; retryAfterUntil?: number; poolAccountId?: string; inFlight: boolean; pending?: Promise<unknown>; resolve?: (result: unknown) => void };
|
|
14
|
+
// Keys contain configuration-home and caller-owned generation identifiers, never credentials.
|
|
15
|
+
const attempts = new Map<string, Attempt>();
|
|
16
|
+
const sharedDeadlines = new Map<string, { after: number; retryAfterUntil: number; poolAccountId?: string }>();
|
|
17
|
+
const scopedKey = (key: string) => `${getConfigDir()}\0${key}`;
|
|
18
|
+
const baseQueryKey = (key: string) => key.replace(/:post-reset:\d+$/, "");
|
|
19
|
+
|
|
20
|
+
/** The next eligible query time for the current home and credential generation. */
|
|
21
|
+
export function nextCodexUsageQueryAt(key: string): number | undefined {
|
|
22
|
+
const scoped = scopedKey(key);
|
|
23
|
+
const base = scopedKey(baseQueryKey(key));
|
|
24
|
+
return Math.max(attempts.get(scoped)?.after ?? 0, attempts.get(base)?.after ?? 0,
|
|
25
|
+
sharedDeadlines.get(base)?.after ?? 0) || undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CodexUsageOwner<T> {
|
|
29
|
+
kind: "owner";
|
|
30
|
+
response: Response;
|
|
31
|
+
/** Publish the parsed result to same-key joiners after validation and publication. */
|
|
32
|
+
settle(usable: boolean, result?: T): void;
|
|
33
|
+
}
|
|
34
|
+
export type CodexUsageRead<T> = CodexUsageOwner<T> | { kind: "joined"; result: T };
|
|
35
|
+
|
|
36
|
+
export interface CodexUsageSchedule {
|
|
37
|
+
/** Same credential deadline for an epoch-specific dispatch key. */
|
|
38
|
+
pacingKey?: string;
|
|
39
|
+
/** Direct/non-pool main reads retain their original uncached dispatch behavior. */
|
|
40
|
+
unpaced?: true;
|
|
41
|
+
/** Opaque pool identity for removal cleanup; never logged or persisted. */
|
|
42
|
+
poolAccountId?: string;
|
|
43
|
+
/** Recovery claims already have their own five-minute admission interval. */
|
|
44
|
+
recoveryProbe?: true;
|
|
45
|
+
/** The sweep's clock also drives its quota-query deadline. */
|
|
46
|
+
now?: () => number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Removal invalidates even active reads, so their late settlements cannot restore pacing. */
|
|
50
|
+
export function pruneRemovedCodexPoolUsageAccounts(configuredIds: ReadonlySet<string>): void {
|
|
51
|
+
const homePrefix = `${getConfigDir()}\0`;
|
|
52
|
+
for (const [key, attempt] of attempts) {
|
|
53
|
+
if (key.startsWith(homePrefix) && attempt.poolAccountId
|
|
54
|
+
&& !configuredIds.has(attempt.poolAccountId)) attempts.delete(key);
|
|
55
|
+
}
|
|
56
|
+
for (const [key, deadline] of sharedDeadlines) {
|
|
57
|
+
if (key.startsWith(homePrefix) && deadline.poolAccountId
|
|
58
|
+
&& !configuredIds.has(deadline.poolAccountId)) sharedDeadlines.delete(key);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Shared by main, pool and 401-replay usage reads. Cache bypass does not bypass pacing. */
|
|
63
|
+
export async function fetchCodexUsage<T>(
|
|
64
|
+
key: string,
|
|
65
|
+
init: RequestInit,
|
|
66
|
+
onDispatch?: () => void,
|
|
67
|
+
schedule: CodexUsageSchedule = {},
|
|
68
|
+
): Promise<CodexUsageRead<T> | null> {
|
|
69
|
+
if (schedule.unpaced) {
|
|
70
|
+
onDispatch?.();
|
|
71
|
+
const response = await fetch("https://chatgpt.com/backend-api/wham/usage", init);
|
|
72
|
+
return { kind: "owner", response, settle: () => {} };
|
|
73
|
+
}
|
|
74
|
+
const pacingKey = scopedKey(schedule.pacingKey ?? baseQueryKey(key));
|
|
75
|
+
key = scopedKey(key);
|
|
76
|
+
const previous = attempts.get(key);
|
|
77
|
+
if (previous?.inFlight) {
|
|
78
|
+
const result = (await previous.pending) as T | undefined;
|
|
79
|
+
return result === undefined ? null : { kind: "joined", result };
|
|
80
|
+
}
|
|
81
|
+
const now = schedule.now ?? Date.now;
|
|
82
|
+
const base = attempts.get(pacingKey);
|
|
83
|
+
const shared = sharedDeadlines.get(pacingKey);
|
|
84
|
+
if (schedule.recoveryProbe
|
|
85
|
+
? Math.max(previous?.retryAfterUntil ?? 0, base?.retryAfterUntil ?? 0,
|
|
86
|
+
shared?.retryAfterUntil ?? 0) > now()
|
|
87
|
+
: Math.max(previous?.after ?? 0, base?.after ?? 0, shared?.after ?? 0) > now()) return null;
|
|
88
|
+
if (!previous && attempts.size >= MAX_ENTRIES) {
|
|
89
|
+
const evict = [...attempts].find(([, entry]) => !entry.inFlight)?.[0];
|
|
90
|
+
if (!evict) return null;
|
|
91
|
+
attempts.delete(evict);
|
|
92
|
+
}
|
|
93
|
+
let resolve!: (result: unknown) => void;
|
|
94
|
+
const pending = new Promise<unknown>(done => { resolve = done; });
|
|
95
|
+
const attempt: Attempt = { delay: previous?.delay ?? BASE_DELAY_MS / 2, after: 0,
|
|
96
|
+
...(schedule.poolAccountId ? { poolAccountId: schedule.poolAccountId } : {}),
|
|
97
|
+
inFlight: true, pending, resolve };
|
|
98
|
+
attempts.set(key, attempt);
|
|
99
|
+
let response: Response | undefined;
|
|
100
|
+
const settle = (usable: boolean, result?: T) => {
|
|
101
|
+
if (!attempt.inFlight) return;
|
|
102
|
+
attempt.inFlight = false;
|
|
103
|
+
if (attempts.get(key) === attempt) {
|
|
104
|
+
if (usable || response?.status === 401 || response?.status === 403) {
|
|
105
|
+
attempts.delete(key);
|
|
106
|
+
if (usable) {
|
|
107
|
+
sharedDeadlines.delete(pacingKey);
|
|
108
|
+
if (pacingKey !== key && attempts.get(pacingKey)?.inFlight === false)
|
|
109
|
+
attempts.delete(pacingKey);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
const at = now();
|
|
114
|
+
const delay = schedule.recoveryProbe ? BASE_DELAY_MS : nextQuotaQueryDelay(previous?.delay);
|
|
115
|
+
const retryAfter = parseRetryAfterMs(response?.headers.get("retry-after"), at) ?? 0;
|
|
116
|
+
const settledBase = pacingKey === key ? previous : attempts.get(pacingKey);
|
|
117
|
+
const settledShared = sharedDeadlines.get(pacingKey);
|
|
118
|
+
const after = Math.max(previous?.after ?? 0, settledBase?.after ?? 0, settledShared?.after ?? 0,
|
|
119
|
+
at + Math.max(delay, retryAfter));
|
|
120
|
+
const retryAfterUntil = Math.max(previous?.retryAfterUntil ?? 0,
|
|
121
|
+
settledBase?.retryAfterUntil ?? 0, settledShared?.retryAfterUntil ?? 0, at + retryAfter);
|
|
122
|
+
attempts.set(key, { delay, after,
|
|
123
|
+
...(attempt.poolAccountId ? { poolAccountId: attempt.poolAccountId } : {}),
|
|
124
|
+
...(retryAfterUntil > 0 ? { retryAfterUntil } : {}), inFlight: false });
|
|
125
|
+
if (pacingKey !== key) {
|
|
126
|
+
if (!sharedDeadlines.has(pacingKey) && sharedDeadlines.size >= MAX_ENTRIES)
|
|
127
|
+
sharedDeadlines.delete(sharedDeadlines.keys().next().value!);
|
|
128
|
+
sharedDeadlines.set(pacingKey, { after, retryAfterUntil,
|
|
129
|
+
...(attempt.poolAccountId ? { poolAccountId: attempt.poolAccountId } : {}) });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
attempt.resolve?.(result);
|
|
134
|
+
};
|
|
135
|
+
try {
|
|
136
|
+
onDispatch?.();
|
|
137
|
+
response = await fetch("https://chatgpt.com/backend-api/wham/usage", init);
|
|
138
|
+
return { kind: "owner", response, settle };
|
|
139
|
+
} catch (error) {
|
|
140
|
+
settle(false);
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function resetQuotaQueryBackoffForTests(): void {
|
|
146
|
+
attempts.clear();
|
|
147
|
+
sharedDeadlines.clear();
|
|
148
|
+
}
|
package/src/codex/quota.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { atomicWriteFile, getConfigDir } from "../config";
|
|
|
4
4
|
import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
|
|
5
5
|
import { isThirtyDayOnlyCodexPlan } from "./plan";
|
|
6
6
|
import { stampCodexQuotaUsageObservation } from "./quota-observation-freshness";
|
|
7
|
+
import { observeCodexLowQuota } from "./low-quota-observer";
|
|
7
8
|
import { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
|
|
8
9
|
import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaWriter } from "./main-account-cache";
|
|
9
10
|
|
|
@@ -276,7 +277,7 @@ function snapshotHasUsage(quota: Omit<StoredAccountQuota, "updatedAt">): boolean
|
|
|
276
277
|
return snapshotHasWeekly(quota) || snapshotHasMonthly(quota) || snapshotHasShort(quota) || snapshotHasCustom(quota);
|
|
277
278
|
}
|
|
278
279
|
/**
|
|
279
|
-
* Publish parsed display quota and separately validated
|
|
280
|
+
* Publish parsed display quota and separately validated policy evidence after writer checks.
|
|
280
281
|
* A null policy observation retains only the matching main identity's previous evidence;
|
|
281
282
|
* transient replacement markers are consumed during merging and never enter stored snapshots.
|
|
282
283
|
*/
|
|
@@ -285,7 +286,7 @@ export function setAccountQuotaFromParsed(
|
|
|
285
286
|
quota: Omit<StoredAccountQuota, "updatedAt"> | null,
|
|
286
287
|
writerGeneration = captureConfigGeneration(),
|
|
287
288
|
mainWriter?: MainQuotaWriter,
|
|
288
|
-
policyQuota: MainPolicyQuotaObservation | null = quota,
|
|
289
|
+
policyQuota: MainPolicyQuotaObservation | null = accountId === MAIN_CODEX_ACCOUNT_ID ? quota : null,
|
|
289
290
|
historyEvidence?: QuotaObservationEvidence,
|
|
290
291
|
): void {
|
|
291
292
|
quota = withoutRetiredCodexQuota(quota);
|
|
@@ -322,6 +323,7 @@ export function setAccountQuotaFromParsed(
|
|
|
322
323
|
schedulePersistAccountQuotas();
|
|
323
324
|
// Credits carry the previous usage tuple; they must not refresh its observation clock.
|
|
324
325
|
if (!(quota.resetCredits !== undefined && !snapshotHasUsage(quota))) {
|
|
326
|
+
if (!isMain && policyQuota) observeCodexLowQuota(accountId, policyQuota);
|
|
325
327
|
notifyCodexQuotaSnapshot(accountId, next);
|
|
326
328
|
}
|
|
327
329
|
}
|
|
@@ -650,6 +652,9 @@ export function updateAccountQuota(
|
|
|
650
652
|
// otherwise bypass detection AND leave a stale baseline that corrupts the next real diff.
|
|
651
653
|
// The credits-only path at setAccountQuotaFromParsed deliberately does not notify; this one
|
|
652
654
|
// writes window percentages and deadlines, so it must.
|
|
655
|
+
if (accountId !== MAIN_CODEX_ACCOUNT_ID && nextWeekly !== undefined && !isInvalidPolicyUsagePercent(weekly)) observeCodexLowQuota(accountId, {
|
|
656
|
+
weeklyPercent: nextWeekly, weeklyResetAt: nextWeeklyResetAt,
|
|
657
|
+
});
|
|
653
658
|
notifyCodexQuotaSnapshot(accountId, quota);
|
|
654
659
|
}
|
|
655
660
|
|
|
@@ -3,7 +3,9 @@ import { lstatSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import * as z from "zod/v4";
|
|
5
5
|
import { compactionRecoveryConfigError } from "./schema/compaction-recovery";
|
|
6
|
+
import { blockedModelRedirectsError } from "./schema/blocked-model-redirects";
|
|
6
7
|
import type { OcxConfig } from "../types";
|
|
8
|
+
import { parseAnthropicModelRoutes } from "../oauth/anthropic-model-routes";
|
|
7
9
|
import { configReasoningPinsConfigError } from "./provider-validation";
|
|
8
10
|
import { loopbackCompanionAllowed } from "../codex/loopback-target";
|
|
9
11
|
import { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "../codex/upstream-host-health";
|
|
@@ -103,6 +105,7 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
|
|
|
103
105
|
warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning));
|
|
104
106
|
warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized));
|
|
105
107
|
warnings.push(...degradedListenerWarnings(rawParsed, normalized));
|
|
108
|
+
if (blockedModelRedirectsError(rawParsed)) warnings.push("blockedModelRedirects ignored: expected a map of nonempty model keys to nonempty string targets");
|
|
106
109
|
const quotaAutoRefreshWarning = degradedCodexQuotaAutoRefreshWarning(rawParsed, normalized);
|
|
107
110
|
if (quotaAutoRefreshWarning) warnings.push(quotaAutoRefreshWarning);
|
|
108
111
|
if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
|
|
@@ -616,7 +619,13 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
|
|
|
616
619
|
if (memoryModels !== undefined && !memoryModelsSchema.safeParse(memoryModels).success) {
|
|
617
620
|
return { ok: false, error: "schema_invalid: memoryModels: requires a nonblank model and an optional declared reasoningEffort per configured phase, and no other fields" };
|
|
618
621
|
}
|
|
619
|
-
const
|
|
622
|
+
const routeValue = (rawConfigRecord(value)?.anthropicAccountPool as Record<string, unknown> | undefined)?.routes;
|
|
623
|
+
if (routeValue !== undefined) {
|
|
624
|
+
const parsed = parseAnthropicModelRoutes(routeValue);
|
|
625
|
+
if (!parsed.ok) return { ok: false, error: `schema_invalid: anthropicAccountPool.routes: ${parsed.error}` };
|
|
626
|
+
}
|
|
627
|
+
const boundaryError = blockedModelRedirectsError(value)
|
|
628
|
+
?? compactionRecoveryConfigError(value) ?? configReasoningPinsConfigError(value)
|
|
620
629
|
?? blankHostnameError(value)
|
|
621
630
|
?? claudeSubagentEffortError(value)
|
|
622
631
|
?? appOwnedMemoryBudgetError(value)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { chmodSync, existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { compactionRecoveryConfigError } from "./schema/compaction-recovery";
|
|
4
|
+
import { blockedModelRedirectsError } from "./schema/blocked-model-redirects";
|
|
4
5
|
import {
|
|
5
6
|
modelPinnedEffortsConfigError,
|
|
6
7
|
pinnedReasoningEffortConfigError,
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
remoteGuiConfigSchema,
|
|
37
38
|
retryOn429PolicySchema,
|
|
38
39
|
retryOnResetPolicySchema,
|
|
40
|
+
transientRetryOn5xxPolicySchema,
|
|
39
41
|
runtimeRoleSchema,
|
|
40
42
|
spendSchema,
|
|
41
43
|
} from "./schema/leaf-validators";
|
|
@@ -120,6 +122,7 @@ export function warnDegradedCompactionRouting(rawParsed: unknown, validated: Ocx
|
|
|
120
122
|
*/
|
|
121
123
|
export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxConfig): void {
|
|
122
124
|
if (compactionRecoveryConfigError(rawParsed)) console.warn("⚠️ invalid compactionRecovery disabled; the original compaction failure is preserved");
|
|
125
|
+
if (blockedModelRedirectsError(rawParsed)) console.warn("⚠️ invalid blockedModelRedirects ignored; provider routing remains available");
|
|
123
126
|
warnDegradedStreamMode(rawParsed, validated);
|
|
124
127
|
warnDegradedCompactionRouting(rawParsed, validated);
|
|
125
128
|
warnDegradedMemoryModels(rawParsed, validated);
|
|
@@ -241,6 +244,20 @@ export function retryOnResetPolicyConfigError(policy: unknown): string | null {
|
|
|
241
244
|
return strictPolicyConfigError("retryOnReset", retryOnResetPolicySchema, policy);
|
|
242
245
|
}
|
|
243
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Management write-boundary validation for `transientRetryOn5xx`, with the same fail-closed
|
|
249
|
+
* contract as the other retry policies.
|
|
250
|
+
*
|
|
251
|
+
* The load-time schema does not degrade a malformed block: `transientRetryOn5xxPolicySchema` has
|
|
252
|
+
* no `.catch`, so a bad value fails the whole config parse and the loader substitutes the default
|
|
253
|
+
* config, taking every provider with it. That makes this check the only place a malformed ladder
|
|
254
|
+
* can be refused without losing the file, and PATCH never runs the schema — only POST does,
|
|
255
|
+
* through `validateConfigCandidate`.
|
|
256
|
+
*/
|
|
257
|
+
export function transientRetryOn5xxPolicyConfigError(policy: unknown): string | null {
|
|
258
|
+
return strictPolicyConfigError("transientRetryOn5xx", transientRetryOn5xxPolicySchema, policy);
|
|
259
|
+
}
|
|
260
|
+
|
|
244
261
|
/**
|
|
245
262
|
* The shared body of both. Written once because the two differ only in the field name they
|
|
246
263
|
* report, and a second hand-copied formatter is a second place for the redaction to be
|
|
@@ -34,8 +34,7 @@ export class ConfigMutationLockError extends Error {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function configMutationDatabasePath(): string {
|
|
38
|
-
const dir = getConfigDir();
|
|
37
|
+
function configMutationDatabasePath(dir: string = getConfigDir()): string {
|
|
39
38
|
// First statement on purpose: a rejected mutation must leave nothing behind, not a
|
|
40
39
|
// freshly created/chmod'd directory or database. See src/lib/test-home-guard.ts.
|
|
41
40
|
assertNotRealHomeUnderTest(dir);
|
|
@@ -102,7 +101,7 @@ let configMutationDatabase: Database | null = null;
|
|
|
102
101
|
*
|
|
103
102
|
* Reentrancy is limited to the current synchronous call stack; never return a Promise from `fn`.
|
|
104
103
|
*/
|
|
105
|
-
export function withConfigMutationLockSync<T>(fn: () => T): T {
|
|
104
|
+
export function withConfigMutationLockSync<T>(fn: () => T, dir?: string): T {
|
|
106
105
|
if (configMutationLockDepth > 0) {
|
|
107
106
|
configMutationLockDepth += 1;
|
|
108
107
|
try {
|
|
@@ -111,7 +110,7 @@ export function withConfigMutationLockSync<T>(fn: () => T): T {
|
|
|
111
110
|
configMutationLockDepth -= 1;
|
|
112
111
|
}
|
|
113
112
|
}
|
|
114
|
-
const path = configMutationDatabasePath();
|
|
113
|
+
const path = configMutationDatabasePath(dir);
|
|
115
114
|
let database: Database | undefined;
|
|
116
115
|
let transactionOpen = false;
|
|
117
116
|
try {
|
|
@@ -293,7 +293,7 @@ export function setProcessCommandLinePlatformForTests(next: NodeJS.Platform | nu
|
|
|
293
293
|
processCommandLinePlatformForTests = next;
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
-
function readProcessCommandLine(pid: number): string | undefined {
|
|
296
|
+
export function readProcessCommandLine(pid: number): string | undefined {
|
|
297
297
|
if (!Number.isSafeInteger(pid) || pid <= 0) return undefined;
|
|
298
298
|
const platform = processCommandLinePlatformForTests ?? process.platform;
|
|
299
299
|
try {
|
|
@@ -142,6 +142,15 @@ export function apiKeyTransportConfigError(
|
|
|
142
142
|
return null;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/** Keep local project-file disclosure specific to the Command Code native adapter. */
|
|
146
|
+
export function projectContextConfigError(provider: Pick<OcxProviderConfig, "adapter" | "projectContext">): string | null {
|
|
147
|
+
if (provider.projectContext === undefined) return null;
|
|
148
|
+
if (provider.projectContext !== "off" && provider.projectContext !== "on") {
|
|
149
|
+
return 'projectContext must be "off" or "on"';
|
|
150
|
+
}
|
|
151
|
+
return provider.adapter === "command-code" ? null : "projectContext is supported only by the command-code adapter";
|
|
152
|
+
}
|
|
153
|
+
|
|
145
154
|
/** Shared strict boundary for the per-provider upstream HTTP-version pin. */
|
|
146
155
|
export function upstreamHttpVersionConfigError(value: unknown): string | null {
|
|
147
156
|
if (value === undefined || value === null) return null;
|
|
@@ -383,6 +392,19 @@ function capabilityRecord(value: unknown): value is Record<string, unknown> {
|
|
|
383
392
|
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
384
393
|
}
|
|
385
394
|
|
|
395
|
+
/** Strict per-model context-tier values; PATCH accepts null to clear the entire map. */
|
|
396
|
+
export function contextTierRecordConfigError(value: unknown, allowClear = false): string | null {
|
|
397
|
+
if (value === undefined || (allowClear && value === null)) return null;
|
|
398
|
+
if (!capabilityRecord(value)) return "modelContextTiers must be a plain object";
|
|
399
|
+
if (Object.keys(value).length > MODEL_DISCOVERY_MAX_MODELS) return "modelContextTiers has too many models";
|
|
400
|
+
for (const [id, tier] of Object.entries(value)) {
|
|
401
|
+
if (!isValidModelDiscoveryModelId(id) || ["__proto__", "prototype", "constructor"].includes(id))
|
|
402
|
+
return "modelContextTiers keys must be exact non-reserved model ids";
|
|
403
|
+
if (tier !== "default" && tier !== "long_context") return "modelContextTiers values must be default or long_context";
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
386
408
|
/** Strict writes; only PATCH may carry deletion tombstones. Model IDs are exact. */
|
|
387
409
|
export function modelCapabilitiesConfigError(value: unknown, allowTombstones = false): string | null {
|
|
388
410
|
if (value === undefined || (allowTombstones && value === null)) return null;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
|
|
3
|
+
/** Keep invalid hand edits out of the live map without losing the rest of config.json. */
|
|
4
|
+
export const blockedModelRedirectsSchema = z.record(
|
|
5
|
+
z.string().refine(key => key.trim().length > 0),
|
|
6
|
+
z.string().refine(target => target.trim().length > 0),
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
/** Candidate writes must reject maps the read schema would silently discard. */
|
|
10
|
+
export function blockedModelRedirectsError(value: unknown): string | undefined {
|
|
11
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
12
|
+
const raw = (value as Record<string, unknown>).blockedModelRedirects;
|
|
13
|
+
if (raw === undefined) return undefined;
|
|
14
|
+
return blockedModelRedirectsSchema.safeParse(raw).success
|
|
15
|
+
? undefined
|
|
16
|
+
: "schema_invalid: blockedModelRedirects requires a map of nonempty model keys to nonempty string targets";
|
|
17
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as z from "zod/v4";
|
|
2
2
|
import { compactionRecoverySchema } from "./compaction-recovery";
|
|
3
|
+
import { blockedModelRedirectsSchema } from "./blocked-model-redirects";
|
|
3
4
|
import {
|
|
4
5
|
agentTaskRecoverySchema,
|
|
5
6
|
catalogAutoRefreshSchema,
|
|
@@ -284,7 +285,9 @@ export const configSchema = z.object({
|
|
|
284
285
|
// parse: a hand-edited typo must never trip the backup-and-defaults repair
|
|
285
286
|
// path below and wipe providers/pool accounts. Warning emitted in loadConfig.
|
|
286
287
|
streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined),
|
|
287
|
-
blockedModelRedirects:
|
|
288
|
+
blockedModelRedirects: blockedModelRedirectsSchema.optional().catch(undefined),
|
|
289
|
+
// Preserve malformed hand edits for a local routing error; candidate writes use the shared parser.
|
|
290
|
+
anthropicAccountPool: z.unknown().optional(),
|
|
288
291
|
// Same degrade-don't-reject rationale as the fields above: a hand-edited
|
|
289
292
|
// non-string must not trip the backup-and-defaults repair path. Unset then
|
|
290
293
|
// takes the canonical sideband path (src/server/live.ts normalizeSidebandRoot).
|