@bitkyc08/opencodex 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/bin/ocx.mjs +32 -4
- package/gui/dist/assets/index-CHwf3tTD.css +1 -0
- package/gui/dist/assets/index-u5eFOv2y.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-normalize.ts +114 -20
- package/src/adapters/anthropic.ts +126 -10
- package/src/adapters/azure.ts +3 -3
- package/src/adapters/base.ts +7 -3
- package/src/adapters/cursor/discovery.ts +14 -4
- package/src/adapters/cursor/effort-map.ts +18 -6
- package/src/adapters/cursor/framing.ts +102 -27
- package/src/adapters/cursor/kv-store.ts +30 -3
- package/src/adapters/cursor/live-models.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +245 -49
- package/src/adapters/cursor/mcp-manager.ts +105 -8
- package/src/adapters/cursor/native-exec-mcp.ts +5 -3
- package/src/adapters/cursor/native-exec-shell.ts +296 -14
- package/src/adapters/cursor/native-exec.ts +381 -33
- package/src/adapters/cursor/protobuf-events.ts +28 -1
- package/src/adapters/cursor/protobuf-request.ts +71 -39
- package/src/adapters/cursor/request-builder.ts +2 -2
- package/src/adapters/cursor/transport.ts +2 -0
- package/src/adapters/cursor.ts +13 -2
- package/src/adapters/google-antigravity-replay.ts +184 -17
- package/src/adapters/google.ts +58 -8
- package/src/adapters/kiro-thinking.ts +23 -9
- package/src/adapters/kiro-tools.ts +49 -18
- package/src/adapters/kiro.ts +377 -133
- package/src/adapters/mimo-free.ts +36 -4
- package/src/adapters/openai-chat.ts +143 -17
- package/src/adapters/openai-responses.ts +130 -14
- package/src/adapters/run-turn-queue.ts +7 -1
- package/src/bridge.ts +466 -69
- package/src/chat/outbound.ts +144 -38
- package/src/claude/inbound-debug.ts +53 -8
- package/src/claude/outbound.ts +224 -38
- package/src/cli/agent-driven.ts +34 -1
- package/src/cli/catalog-prewarm.ts +5 -2
- package/src/cli/claude-desktop.ts +2 -2
- package/src/cli/doctor.ts +12 -0
- package/src/cli/export-command.ts +187 -0
- package/src/cli/help.ts +11 -0
- package/src/cli/index.ts +13 -3
- package/src/cli/init.ts +129 -102
- package/src/cli/opencode.ts +36 -151
- package/src/cli/star-prompt.ts +13 -4
- package/src/cli/status-oauth.ts +12 -2
- package/src/clients/config-export.ts +377 -0
- package/src/codex/account-runtime-state.ts +19 -1
- package/src/codex/account-store.ts +162 -82
- package/src/codex/auth-api.ts +467 -159
- package/src/codex/auth-context.ts +15 -2
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/effort.ts +16 -6
- package/src/codex/catalog/metadata.ts +6 -0
- package/src/codex/catalog/parsing.ts +3 -1
- package/src/codex/catalog/provider-fetch.ts +29 -0
- package/src/codex/catalog/sync.ts +64 -7
- package/src/codex/catalog.ts +2 -2
- package/src/codex/inject.ts +5 -5
- package/src/codex/main-account-cache.ts +8 -1
- package/src/codex/model-cache.ts +81 -2
- package/src/codex/pool-rotation.ts +39 -0
- package/src/codex/project-config-warnings.ts +12 -1
- package/src/codex/quota.ts +35 -3
- package/src/codex/routing.ts +46 -1
- package/src/codex/shim.ts +10 -4
- package/src/codex/subagent-model-fallback.ts +12 -0
- package/src/codex/websocket-registry.ts +27 -0
- package/src/combos/failover.ts +31 -1
- package/src/combos/request.ts +9 -0
- package/src/combos/resolve.ts +60 -4
- package/src/combos/types.ts +12 -0
- package/src/config.ts +510 -55
- package/src/github/star-state.ts +13 -1
- package/src/images/fulfill.ts +39 -1
- package/src/images/loop.ts +52 -12
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +173 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bun-stream-caps.ts +31 -7
- package/src/lib/config-ownership.ts +33 -0
- package/src/lib/crash-guard.ts +65 -5
- package/src/lib/debug-log-buffer.ts +47 -6
- package/src/lib/destination-policy.ts +12 -1
- package/src/lib/errors.ts +3 -0
- package/src/lib/gcp-adc.ts +40 -2
- package/src/lib/injection-debug-log.ts +26 -2
- package/src/lib/provider-outbound.ts +3 -0
- package/src/lib/sidecar-tracker.ts +5 -2
- package/src/lib/sse-decoder.ts +257 -37
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/translator-budget.ts +356 -0
- package/src/lib/windows-secret-acl.ts +33 -12
- package/src/lib/winsw.ts +14 -1
- package/src/oauth/anthropic-routing.ts +31 -7
- package/src/oauth/google-antigravity.ts +2 -1
- package/src/oauth/health.ts +30 -12
- package/src/oauth/index.ts +127 -23
- package/src/oauth/kiro-credentials.ts +72 -1
- package/src/oauth/kiro.ts +23 -4
- package/src/oauth/store.ts +165 -18
- package/src/oauth/token-guardian.ts +43 -4
- package/src/oauth/types.ts +2 -1
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/derive.ts +12 -0
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/key-failover.ts +12 -0
- package/src/providers/openai-sidecar.ts +4 -1
- package/src/providers/quota.ts +68 -7
- package/src/providers/registry.ts +279 -3
- package/src/responses/parser.ts +5 -1
- package/src/responses/spill-store.ts +394 -0
- package/src/responses/state.ts +520 -102
- package/src/router.ts +18 -1
- package/src/server/adapter-resolve.ts +20 -3
- package/src/server/auth-cors.ts +121 -28
- package/src/server/chat-completions.ts +57 -12
- package/src/server/claude-messages.ts +85 -13
- package/src/server/index.ts +242 -100
- package/src/server/lifecycle.ts +155 -25
- package/src/server/management/agent-settings-routes.ts +79 -36
- package/src/server/management/api-key-usage.ts +167 -0
- package/src/server/management/body.ts +35 -0
- package/src/server/management/combo-routes.ts +5 -1
- package/src/server/management/config-routes.ts +42 -12
- package/src/server/management/logs-usage-routes.ts +41 -21
- package/src/server/management/model-routes.ts +188 -54
- package/src/server/management/oauth-account-routes.ts +115 -26
- package/src/server/management/provider-routes.ts +56 -6
- package/src/server/management/shared.ts +16 -3
- package/src/server/management/sidebar-routes.ts +50 -1
- package/src/server/management/system-restart.ts +13 -6
- package/src/server/management/system-routes.ts +15 -3
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +39 -5
- package/src/server/management-auth.ts +65 -14
- package/src/server/port-reclaim.ts +58 -12
- package/src/server/ports.ts +2 -0
- package/src/server/proxy-liveness.ts +60 -14
- package/src/server/relay-eager.ts +20 -4
- package/src/server/relay.ts +548 -154
- package/src/server/request-decompress.ts +51 -4
- package/src/server/request-log.ts +134 -15
- package/src/server/responses/collaboration.ts +15 -4
- package/src/server/responses/compact.ts +3 -0
- package/src/server/responses/core.ts +241 -66
- package/src/server/responses-image-gen-repair.ts +19 -5
- package/src/server/responses-item-id-repair.ts +23 -5
- package/src/server/sse-payload-rewrite.ts +71 -12
- package/src/server/startup-health-cache.ts +14 -1
- package/src/server/system-env.ts +8 -1
- package/src/server/windows-tcp-drop.ts +15 -5
- package/src/server/ws-bridge.ts +25 -0
- package/src/service.ts +179 -13
- package/src/storage/policy-job.ts +93 -23
- package/src/storage/policy-worker.ts +6 -0
- package/src/storage/restore-job.ts +62 -16
- package/src/storage/restore-worker.ts +6 -0
- package/src/storage/storage-mutation-coordinator.ts +36 -6
- package/src/storage/worker-lifecycle.ts +181 -47
- package/src/tray/windows.ts +97 -25
- package/src/types.ts +39 -6
- package/src/update/index.ts +24 -5
- package/src/update/job.ts +598 -73
- package/src/usage/log.ts +115 -17
- package/src/usage/summary.ts +67 -2
- package/src/vision/index.ts +112 -22
- package/src/web-search/loop.ts +38 -6
- package/src/web-search/progress-stream.ts +14 -3
- package/gui/dist/assets/index-BDjpkcRN.js +0 -67
- package/gui/dist/assets/index-BHsKRFh9.css +0 -1
package/src/server/lifecycle.ts
CHANGED
|
@@ -1,26 +1,111 @@
|
|
|
1
1
|
import { flushResponseState } from "../responses/state";
|
|
2
2
|
import { setStorageCleanupPolicyLiveSink } from "../storage/policy";
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
abortStorageCleanupPolicyJobAsync,
|
|
5
5
|
setStorageCleanupPolicyJobLiveApply,
|
|
6
6
|
} from "../storage/policy-job";
|
|
7
|
+
import { abortRestoreTrashJobAsync } from "../storage/restore-job";
|
|
7
8
|
import { stopStorageCleanupScheduler } from "../storage/policy-scheduler";
|
|
9
|
+
import { stopStateStoreSweeper } from "../lib/state-store-sweeper";
|
|
10
|
+
import {
|
|
11
|
+
cancelQueuedStorageWorkerSpawns,
|
|
12
|
+
drainStorageWorkers,
|
|
13
|
+
} from "../storage/worker-lifecycle";
|
|
14
|
+
import { createAdmissionGate, type AdmissionLease, type AdmissionMetrics } from "../lib/admission";
|
|
15
|
+
import { codexWebSocketAdmissionMetrics } from "../codex/websocket-registry";
|
|
16
|
+
import { storageMutationAdmissionMetrics } from "../storage/storage-mutation-coordinator";
|
|
17
|
+
import { storageWorkerAdmissionMetrics } from "../storage/worker-lifecycle";
|
|
18
|
+
import {
|
|
19
|
+
backgroundShellAdmissionMetrics,
|
|
20
|
+
beginBackgroundShellShutdown,
|
|
21
|
+
terminateAllBackgroundShells,
|
|
22
|
+
} from "../adapters/cursor/native-exec-shell";
|
|
8
23
|
|
|
9
24
|
// ---------------------------------------------------------------------------
|
|
10
25
|
// Active turn tracking + graceful shutdown drain
|
|
11
26
|
// ---------------------------------------------------------------------------
|
|
12
27
|
|
|
13
|
-
const
|
|
28
|
+
export const MAX_ACTIVE_TURNS = 256;
|
|
29
|
+
const turnGate = createAdmissionGate("active_turns", MAX_ACTIVE_TURNS);
|
|
30
|
+
export interface ActiveTurnLease extends AdmissionLease {
|
|
31
|
+
bindAbortController(ac: AbortController): void;
|
|
32
|
+
isTransferred(): boolean;
|
|
33
|
+
}
|
|
34
|
+
const activeTurns = new Map<AbortController, ActiveTurnLease>();
|
|
35
|
+
const admittedTurns = new Set<ActiveTurnLease>();
|
|
36
|
+
const knownTurnControllers = new WeakSet<AbortController>();
|
|
37
|
+
let turnReleaseMisses = 0;
|
|
14
38
|
let draining = false;
|
|
15
39
|
let recyclingForExit = false;
|
|
16
40
|
let _serverRef: ReturnType<typeof Bun.serve> | undefined;
|
|
17
41
|
|
|
18
42
|
export function setServerRef(server: ReturnType<typeof Bun.serve> | undefined): void { _serverRef = server; }
|
|
19
43
|
export function setDraining(value: boolean): void { draining = value; }
|
|
20
|
-
export function
|
|
21
|
-
|
|
44
|
+
export function tryAdmitTurn(): ActiveTurnLease | null {
|
|
45
|
+
const gateLease = turnGate.tryAcquire();
|
|
46
|
+
if (!gateLease) return null;
|
|
47
|
+
const controllers = new Set<AbortController>();
|
|
48
|
+
let active = true;
|
|
49
|
+
let transferred = false;
|
|
50
|
+
const lease: ActiveTurnLease = {
|
|
51
|
+
bindAbortController(ac) {
|
|
52
|
+
knownTurnControllers.add(ac);
|
|
53
|
+
if (!active) {
|
|
54
|
+
ac.abort(new Error("turn already settled"));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
transferred = true;
|
|
58
|
+
controllers.add(ac);
|
|
59
|
+
activeTurns.set(ac, lease);
|
|
60
|
+
},
|
|
61
|
+
isTransferred() { return transferred; },
|
|
62
|
+
release() {
|
|
63
|
+
if (!active) return;
|
|
64
|
+
active = false;
|
|
65
|
+
admittedTurns.delete(lease);
|
|
66
|
+
for (const controller of controllers) {
|
|
67
|
+
if (activeTurns.get(controller) === lease) activeTurns.delete(controller);
|
|
68
|
+
}
|
|
69
|
+
controllers.clear();
|
|
70
|
+
gateLease.release();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
admittedTurns.add(lease);
|
|
74
|
+
return lease;
|
|
75
|
+
}
|
|
76
|
+
export function registerTurn(ac: AbortController, lease?: AdmissionLease): void {
|
|
77
|
+
if (lease && "bindAbortController" in lease) (lease as ActiveTurnLease).bindAbortController(ac);
|
|
78
|
+
}
|
|
79
|
+
export function unregisterTurn(ac: AbortController): void {
|
|
80
|
+
const lease = activeTurns.get(ac);
|
|
81
|
+
if (!lease) {
|
|
82
|
+
if (knownTurnControllers.has(ac)) return;
|
|
83
|
+
turnReleaseMisses += 1;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
lease.release();
|
|
87
|
+
}
|
|
22
88
|
export function isDraining(): boolean { return draining; }
|
|
23
|
-
export function getActiveTurnCount(): number { return
|
|
89
|
+
export function getActiveTurnCount(): number { return turnGate.metrics().active; }
|
|
90
|
+
export function activeRegistryMetrics(): Record<string, AdmissionMetrics> {
|
|
91
|
+
const turns = turnGate.metrics();
|
|
92
|
+
return {
|
|
93
|
+
activeTurns: { ...turns, releaseMisses: turns.releaseMisses + turnReleaseMisses },
|
|
94
|
+
codexWebSockets: codexWebSocketAdmissionMetrics(),
|
|
95
|
+
cursorBackgroundShells: backgroundShellAdmissionMetrics(),
|
|
96
|
+
storageHomeSlots: storageMutationAdmissionMetrics(),
|
|
97
|
+
storageWorkerReservations: storageWorkerAdmissionMetrics(),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function abortAndReleaseAllTurns(reason: unknown = new Error("server shutdown")): void {
|
|
102
|
+
const owners = [...admittedTurns];
|
|
103
|
+
for (const owner of owners) {
|
|
104
|
+
const controllers = [...activeTurns].filter(([, lease]) => lease === owner).map(([controller]) => controller);
|
|
105
|
+
for (const controller of controllers) controller.abort(reason);
|
|
106
|
+
owner.release();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
24
109
|
/** Live listen port of the Bun server, when started. */
|
|
25
110
|
export function getServerListenPort(): number | undefined {
|
|
26
111
|
const port = _serverRef?.port;
|
|
@@ -38,8 +123,9 @@ export function trackStreamLifetime(
|
|
|
38
123
|
body: ReadableStream<Uint8Array>,
|
|
39
124
|
ac: AbortController,
|
|
40
125
|
onDone?: () => void,
|
|
126
|
+
lease?: AdmissionLease,
|
|
41
127
|
): ReadableStream<Uint8Array> {
|
|
42
|
-
registerTurn(ac);
|
|
128
|
+
registerTurn(ac, lease);
|
|
43
129
|
const reader = body.getReader();
|
|
44
130
|
let closed = false;
|
|
45
131
|
const finish = () => {
|
|
@@ -73,25 +159,69 @@ export async function drainAndShutdown(
|
|
|
73
159
|
): Promise<void> {
|
|
74
160
|
const s = server ?? _serverRef;
|
|
75
161
|
draining = true;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
162
|
+
beginBackgroundShellShutdown();
|
|
163
|
+
try {
|
|
164
|
+
const deadline = Date.now() + timeoutMs;
|
|
165
|
+
while (admittedTurns.size > 0 && Date.now() < deadline) {
|
|
166
|
+
await Bun.sleep(100);
|
|
167
|
+
}
|
|
168
|
+
if (admittedTurns.size > 0) {
|
|
169
|
+
console.warn(`⚠️ Aborting ${admittedTurns.size} in-flight turn(s) after ${timeoutMs}ms deadline`);
|
|
170
|
+
abortAndReleaseAllTurns(new Error("server shutdown"));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const shellDrain = await Promise.allSettled([terminateAllBackgroundShells()]);
|
|
174
|
+
const shellResult = shellDrain[0]!;
|
|
175
|
+
if (shellResult.status === "rejected") {
|
|
176
|
+
console.warn("[cursor] background shell drain failed", { rejected: 1 });
|
|
177
|
+
} else if (shellResult.value.unresolved > 0 || shellResult.value.killFailures > 0) {
|
|
178
|
+
console.warn("[cursor] background shell drain incomplete", shellResult.value);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Debounced replay-state snapshot may still be pending; flush so the last completed turn's
|
|
182
|
+
// previous_response_id chain survives the restart this shutdown is usually part of.
|
|
183
|
+
const responseStateFlush = await Promise.allSettled([flushResponseState()]);
|
|
184
|
+
if (responseStateFlush[0]?.status === "rejected") {
|
|
185
|
+
console.warn("[responses] state flush during shutdown failed");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Tear down opt-in storage policy timers / worker / live-config sink so they cannot fire after stop.
|
|
189
|
+
// Await worker thread exit: on Windows, a still-exiting Bun Worker under
|
|
190
|
+
// `bun test --isolate` panics the whole process at the next realm reclaim.
|
|
191
|
+
// Abort each job independently so one wedged join cannot skip the other,
|
|
192
|
+
// then drain leftovers; failures must not prevent `server.stop`.
|
|
193
|
+
stopStorageCleanupScheduler();
|
|
194
|
+
stopStateStoreSweeper();
|
|
195
|
+
cancelQueuedStorageWorkerSpawns();
|
|
196
|
+
const shutdownJoins = await Promise.allSettled([
|
|
197
|
+
abortStorageCleanupPolicyJobAsync(),
|
|
198
|
+
abortRestoreTrashJobAsync(),
|
|
199
|
+
]);
|
|
200
|
+
for (const result of shutdownJoins) {
|
|
201
|
+
if (result.status === "rejected") {
|
|
202
|
+
console.warn(
|
|
203
|
+
"[storage] worker abort during shutdown failed:",
|
|
204
|
+
result.reason instanceof Error ? result.reason.message : result.reason,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
await drainStorageWorkers();
|
|
210
|
+
} catch (err) {
|
|
211
|
+
console.warn(
|
|
212
|
+
"[storage] worker drain during shutdown failed:",
|
|
213
|
+
err instanceof Error ? err.message : err,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
setStorageCleanupPolicyLiveSink(null);
|
|
217
|
+
setStorageCleanupPolicyJobLiveApply(null);
|
|
218
|
+
} finally {
|
|
219
|
+
try {
|
|
220
|
+
// Bun's Server.stop returns Promise<void>; fire-and-forget races the next
|
|
221
|
+
// isolate reclaim / follow-on listen the same way unterminated Workers did.
|
|
222
|
+
if (s) await s.stop(true);
|
|
223
|
+
} finally {
|
|
224
|
+
draining = false;
|
|
84
225
|
}
|
|
85
|
-
activeTurns.clear();
|
|
86
226
|
}
|
|
87
|
-
// Debounced replay-state snapshot may still be pending; flush so the last completed turn's
|
|
88
|
-
// previous_response_id chain survives the restart this shutdown is usually part of.
|
|
89
|
-
await flushResponseState();
|
|
90
|
-
// Tear down opt-in storage policy timers / worker / live-config sink so they cannot fire after stop.
|
|
91
|
-
stopStorageCleanupScheduler();
|
|
92
|
-
abortStorageCleanupPolicyJob();
|
|
93
|
-
setStorageCleanupPolicyLiveSink(null);
|
|
94
|
-
setStorageCleanupPolicyJobLiveApply(null);
|
|
95
|
-
s?.stop(true);
|
|
96
|
-
draining = false;
|
|
97
227
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
codexAutoStartEnabled,
|
|
8
8
|
hasOwnProvider,
|
|
9
9
|
isValidProviderName,
|
|
10
|
+
loadConfig,
|
|
10
11
|
multiAgentGuidanceEnabled,
|
|
11
12
|
providerBaseUrlConfigError,
|
|
12
13
|
providerHeadersConfigError,
|
|
@@ -58,16 +59,68 @@ import { applySystemEnvToggle } from "../system-env";
|
|
|
58
59
|
|
|
59
60
|
import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels, fetchGrokCandidateModels, buildClaudeDesktopState } from "./shared";
|
|
60
61
|
import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
|
|
62
|
+
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
const GROK_APPLY_JOIN_MS = 120_000;
|
|
65
|
+
export const GROK_APPLY_TERMINAL_MS = 10 * 60_000;
|
|
66
|
+
const grokApplyEncoder = new TextEncoder();
|
|
67
|
+
let grokApplyFlight: { startedAt: number; promise: Promise<unknown>; bytes: number } | null = null;
|
|
68
|
+
let grokApplyHighWaterBytes = 0;
|
|
69
|
+
let grokApplyTestHooks: { now?: () => number; run?: () => Promise<unknown> } | null = null;
|
|
70
|
+
|
|
71
|
+
class GrokApplyBusyError extends Error {}
|
|
72
|
+
|
|
73
|
+
export function grokApplyFlightSnapshot(): { currentBytes: number; highWaterBytes: number; active: number } {
|
|
74
|
+
return {
|
|
75
|
+
currentBytes: grokApplyFlight?.bytes ?? 0,
|
|
76
|
+
highWaterBytes: grokApplyHighWaterBytes,
|
|
77
|
+
active: grokApplyFlight ? 1 : 0,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function runGrokApplyFlight(): Promise<unknown> {
|
|
82
|
+
const at = grokApplyTestHooks?.now?.() ?? Date.now();
|
|
83
|
+
const current = grokApplyFlight;
|
|
84
|
+
if (current) {
|
|
85
|
+
const age = at - current.startedAt;
|
|
86
|
+
if (age < GROK_APPLY_JOIN_MS) return current.promise;
|
|
87
|
+
if (age <= GROK_APPLY_TERMINAL_MS) return Promise.reject(new GrokApplyBusyError("grok_apply_busy"));
|
|
88
|
+
// A permanently hung operation must not monopolize the singleton forever. Its
|
|
89
|
+
// eventual finally is identity-checked, so it cannot clear a replacement flight.
|
|
90
|
+
if (grokApplyFlight === current) grokApplyFlight = null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const flight = { startedAt: at, promise: Promise.resolve() as Promise<unknown>, bytes: 0 };
|
|
94
|
+
flight.promise = (grokApplyTestHooks?.run ?? (async () => {
|
|
95
|
+
const [{ syncGrokConfig }, { readRuntimePort }] = await Promise.all([
|
|
96
|
+
import("../../grok/sync"),
|
|
97
|
+
import("../../config"),
|
|
98
|
+
]);
|
|
99
|
+
const currentConfig = loadConfig();
|
|
100
|
+
const runtime = readRuntimePort(process.pid);
|
|
101
|
+
const port = runtime?.port ?? currentConfig.port;
|
|
102
|
+
const hostname = runtime?.hostname ?? currentConfig.hostname;
|
|
103
|
+
flight.bytes = grokApplyEncoder.encode(JSON.stringify(currentConfig)).byteLength
|
|
104
|
+
+ grokApplyEncoder.encode(hostname ?? "").byteLength;
|
|
105
|
+
grokApplyHighWaterBytes = Math.max(grokApplyHighWaterBytes, flight.bytes);
|
|
106
|
+
return syncGrokConfig(port, currentConfig, hostname !== undefined ? { hostname } : {});
|
|
107
|
+
}))().finally(() => {
|
|
108
|
+
if (grokApplyFlight === flight) grokApplyFlight = null;
|
|
109
|
+
});
|
|
110
|
+
grokApplyFlight = flight;
|
|
111
|
+
return flight.promise;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function runGrokApplyFlightForTests(): Promise<unknown> {
|
|
115
|
+
return runGrokApplyFlight();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function setGrokApplyFlightTestHooks(
|
|
119
|
+
hooks: { now?: () => number; run?: () => Promise<unknown> } | null,
|
|
120
|
+
): void {
|
|
121
|
+
grokApplyTestHooks = hooks;
|
|
122
|
+
grokApplyFlight = null;
|
|
123
|
+
grokApplyHighWaterBytes = 0;
|
|
71
124
|
}
|
|
72
125
|
import type { ManagementContext } from "./context";
|
|
73
126
|
|
|
@@ -80,12 +133,12 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
80
133
|
if (config.claudeCode?.desktopAutoApply === false) return;
|
|
81
134
|
if (!config.claudeCode?.desktopProfile) return;
|
|
82
135
|
const { writeDesktop3pConfig } = await import("../../claude/desktop-3p");
|
|
83
|
-
const {
|
|
136
|
+
const { filterCatalogVisibleModels, desktopVisibleNativeSlugs } = await import("../../codex/catalog");
|
|
84
137
|
const allModels = await fetchAllModels(config);
|
|
85
138
|
const routed = filterCatalogVisibleModels(allModels, config).map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow }));
|
|
86
139
|
const result = writeDesktop3pConfig(
|
|
87
140
|
config.port ?? 10100,
|
|
88
|
-
[...
|
|
141
|
+
[...desktopVisibleNativeSlugs(config)],
|
|
89
142
|
routed,
|
|
90
143
|
config.apiKeys?.[0]?.key,
|
|
91
144
|
"static",
|
|
@@ -131,7 +184,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
131
184
|
agentsMaxDepth?: unknown;
|
|
132
185
|
subagentDeveloperInstructions?: unknown;
|
|
133
186
|
};
|
|
134
|
-
try { body = await req
|
|
187
|
+
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
135
188
|
const wantsFlag = body.enabled !== undefined;
|
|
136
189
|
const wantsThreads = body.maxConcurrentThreadsPerSession !== undefined;
|
|
137
190
|
const wantsMode = body.multiAgentMode !== undefined;
|
|
@@ -275,7 +328,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
275
328
|
}
|
|
276
329
|
if (url.pathname === "/api/injection-model" && req.method === "PUT") {
|
|
277
330
|
let parsedBody: unknown;
|
|
278
|
-
try { parsedBody = await req
|
|
331
|
+
try { parsedBody = await readManagementJsonBody(req); } catch (error) {
|
|
332
|
+
rethrowManagementBodyTooLarge(error);
|
|
279
333
|
return jsonResponse({ error: "invalid JSON body" }, 400);
|
|
280
334
|
}
|
|
281
335
|
if (!parsedBody || typeof parsedBody !== "object" || Array.isArray(parsedBody)) {
|
|
@@ -374,7 +428,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
374
428
|
}
|
|
375
429
|
if (url.pathname === "/api/effort-caps" && req.method === "PUT") {
|
|
376
430
|
let body: { effortCap?: unknown; subagentEffortCap?: unknown };
|
|
377
|
-
try { body = await req
|
|
431
|
+
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
378
432
|
const { isCodexReasoningEffort } = await import("../../reasoning-effort");
|
|
379
433
|
for (const key of ["effortCap", "subagentEffortCap"] as const) {
|
|
380
434
|
if (!(key in body)) continue;
|
|
@@ -410,7 +464,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
410
464
|
}
|
|
411
465
|
if (url.pathname === "/api/subagent-models" && req.method === "PUT") {
|
|
412
466
|
let body: { models?: unknown };
|
|
413
|
-
try { body = await req
|
|
467
|
+
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
414
468
|
const chosen = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string").slice(0, 5) : [];
|
|
415
469
|
config.subagentModels = chosen;
|
|
416
470
|
const { saveConfigPreservingClaudeCode: save } = await import("../../config");
|
|
@@ -444,8 +498,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
444
498
|
if (url.pathname === "/api/subagent-model-fallback" && req.method === "PUT") {
|
|
445
499
|
let body: { models?: unknown; pollMs?: unknown };
|
|
446
500
|
try {
|
|
447
|
-
body = await req
|
|
448
|
-
} catch {
|
|
501
|
+
body = await readManagementJsonBody(req);
|
|
502
|
+
} catch (error) {
|
|
503
|
+
rethrowManagementBodyTooLarge(error);
|
|
449
504
|
return jsonResponse({ error: "invalid JSON body" }, 400);
|
|
450
505
|
}
|
|
451
506
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
@@ -513,7 +568,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
513
568
|
// injectGrokConfig, through the apply route below — this route cannot touch that file.
|
|
514
569
|
if (url.pathname === "/api/grok/selection" && req.method === "PUT") {
|
|
515
570
|
let body: { excluded?: unknown };
|
|
516
|
-
try { body = await req
|
|
571
|
+
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
517
572
|
const raw = body.excluded;
|
|
518
573
|
if (!Array.isArray(raw) || raw.some(entry => typeof entry !== "string" || entry.length === 0)) {
|
|
519
574
|
return jsonResponse({ error: "excluded must be an array of model ids" }, 400);
|
|
@@ -533,20 +588,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
533
588
|
// duplicated here. Accepts no body: every input comes from persisted state.
|
|
534
589
|
if (url.pathname === "/api/grok/apply" && req.method === "POST") {
|
|
535
590
|
try {
|
|
536
|
-
const
|
|
537
|
-
const { readRuntimePort } = await import("../../config");
|
|
538
|
-
// The host/port the proxy ACTUALLY bound — not the request authority (caller-
|
|
539
|
-
// influenced) and not config.hostname, which sync.ts warns may have drifted.
|
|
540
|
-
// `ocx ensure` passes live.hostname for the same reason; the runtime-port record
|
|
541
|
-
// is the in-process equivalent, written at startup.
|
|
542
|
-
const runtime = readRuntimePort(process.pid);
|
|
543
|
-
const port = runtime?.port ?? config.port;
|
|
544
|
-
const hostname = runtime?.hostname ?? config.hostname;
|
|
545
|
-
const result = await queueGrokApply(() => syncGrokConfig(
|
|
546
|
-
port,
|
|
547
|
-
config,
|
|
548
|
-
hostname !== undefined ? { hostname } : {},
|
|
549
|
-
));
|
|
591
|
+
const result = await runGrokApplyFlight() as Awaited<ReturnType<typeof import("../../grok/sync")["syncGrokConfig"]>>;
|
|
550
592
|
// A policy skip (non-loopback, no ~/.grok) is not a server error: report it as a
|
|
551
593
|
// result the page can explain rather than a 500 the user cannot act on.
|
|
552
594
|
return jsonResponse({
|
|
@@ -556,6 +598,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
556
598
|
...(result.skippedReason ? { skippedReason: result.skippedReason } : {}),
|
|
557
599
|
}, result.ok ? 200 : 500);
|
|
558
600
|
} catch (error) {
|
|
601
|
+
if (error instanceof GrokApplyBusyError) return jsonResponse({ error: "grok_apply_busy" }, 409);
|
|
559
602
|
return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
560
603
|
}
|
|
561
604
|
}
|
|
@@ -572,7 +615,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
572
615
|
}
|
|
573
616
|
if (url.pathname === "/api/claude-desktop" && req.method === "PUT") {
|
|
574
617
|
let body: { profile?: unknown };
|
|
575
|
-
try { body = await req
|
|
618
|
+
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
576
619
|
try {
|
|
577
620
|
const { parseDesktopProfile, reconcileDesktopProfile } = await import("../../claude/desktop-profile");
|
|
578
621
|
const parsed = parseDesktopProfile(body.profile);
|
|
@@ -607,7 +650,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
607
650
|
config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: state.profile };
|
|
608
651
|
saveConfigPreservingClaudeCode(config);
|
|
609
652
|
const { writeDesktop3pConfig } = await import("../../claude/desktop-3p");
|
|
610
|
-
const {
|
|
653
|
+
const { desktopVisibleNativeSlugs } = await import("../../codex/catalog");
|
|
611
654
|
const routed = state.models
|
|
612
655
|
.filter(model => model.available && !model.route.startsWith("native/"))
|
|
613
656
|
.map(model => {
|
|
@@ -616,7 +659,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
616
659
|
});
|
|
617
660
|
const result = writeDesktop3pConfig(
|
|
618
661
|
Number(url.port) || config.port,
|
|
619
|
-
[...
|
|
662
|
+
[...desktopVisibleNativeSlugs(config)],
|
|
620
663
|
routed,
|
|
621
664
|
config.apiKeys?.[0]?.key,
|
|
622
665
|
"static",
|
|
@@ -770,7 +813,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
770
813
|
// regardless on 2.1.207). PUT keeps validating them so hand-written configs
|
|
771
814
|
// and older GUIs stay safe; GUI saves omit them and the spread preserves them.
|
|
772
815
|
let parsedBody: unknown;
|
|
773
|
-
try { parsedBody = await req
|
|
816
|
+
try { parsedBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
774
817
|
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
|
775
818
|
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
776
819
|
const prototype = Object.getPrototypeOf(value);
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
currentUsageLogRevision,
|
|
3
|
+
readUsageSnapshotForManagement,
|
|
4
|
+
usageLogRevisionKey,
|
|
5
|
+
type PersistedUsageEntry,
|
|
6
|
+
} from "../../usage/log";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Per-key usage as the API tab renders it.
|
|
10
|
+
*
|
|
11
|
+
* A discriminated union rather than numbers with a flag beside them: when two
|
|
12
|
+
* config entries share an id there IS no per-key total, and an optional marker
|
|
13
|
+
* next to `requests7d: 7` invites a consumer to render the 7 anyway.
|
|
14
|
+
*/
|
|
15
|
+
export type ApiKeyUsage =
|
|
16
|
+
| { ambiguous: true }
|
|
17
|
+
| { ambiguous?: false; requests7d: number; totalRequests: number; lastUsedAt?: string };
|
|
18
|
+
|
|
19
|
+
export interface ApiKeyUsageSnapshot {
|
|
20
|
+
rollup: Map<string, ApiKeyUsage>;
|
|
21
|
+
historyTruncated?: true;
|
|
22
|
+
/**
|
|
23
|
+
* Earliest row carrying a recognized `admissionKind`. A property of the DATA
|
|
24
|
+
* SET, not of a key, so it is singular and lives beside the map: it is what
|
|
25
|
+
* lets the GUI tell "this key was used zero times" from "nothing is
|
|
26
|
+
* attributable yet". Keyed on the kind rather than on `apiKeyId`, because an
|
|
27
|
+
* environment or loopback row is attributed traffic with no configured key.
|
|
28
|
+
*/
|
|
29
|
+
attributionSince?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A timestamp we can actually do date arithmetic with.
|
|
36
|
+
*
|
|
37
|
+
* `usage.jsonl` is hand-editable and JSON permits numbers outside the Date
|
|
38
|
+
* range: `1e309` survives normalization and then throws `RangeError` from
|
|
39
|
+
* `toISOString()`. Since the caller catches to protect key management, one bad
|
|
40
|
+
* row would have zeroed the rollup for EVERY key — active keys reported as
|
|
41
|
+
* unused is exactly the wrong answer to hand someone deciding what to delete.
|
|
42
|
+
*/
|
|
43
|
+
function usableTimestamp(value: unknown): number | null {
|
|
44
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
45
|
+
return Number.isNaN(new Date(value).getTime()) ? null : value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Pure: one pass over an already-read snapshot, so it is unit-testable without
|
|
50
|
+
* touching the filesystem.
|
|
51
|
+
*
|
|
52
|
+
* Rows are bucketed only when `admissionKind === "configured"`. Keying on
|
|
53
|
+
* `apiKeyId` alone would let a hand-edited entry whose id is `loopback` absorb
|
|
54
|
+
* traffic it never admitted.
|
|
55
|
+
*/
|
|
56
|
+
export function rollupApiKeyUsage(
|
|
57
|
+
entries: PersistedUsageEntry[],
|
|
58
|
+
configuredIds: string[],
|
|
59
|
+
now: number = Date.now(),
|
|
60
|
+
): ApiKeyUsageSnapshot {
|
|
61
|
+
const duplicated = new Set<string>();
|
|
62
|
+
const seen = new Set<string>();
|
|
63
|
+
for (const id of configuredIds) {
|
|
64
|
+
if (seen.has(id)) duplicated.add(id);
|
|
65
|
+
seen.add(id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const totals = new Map<string, { requests7d: number; totalRequests: number; lastUsedAt?: string }>();
|
|
69
|
+
let attributionSince: number | undefined;
|
|
70
|
+
const cutoff = now - SEVEN_DAYS_MS;
|
|
71
|
+
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (!entry.admissionKind) continue;
|
|
74
|
+
const timestamp = usableTimestamp(entry.timestamp);
|
|
75
|
+
if (timestamp !== null && (attributionSince === undefined || timestamp < attributionSince)) {
|
|
76
|
+
attributionSince = timestamp;
|
|
77
|
+
}
|
|
78
|
+
if (entry.admissionKind !== "configured" || !entry.apiKeyId) continue;
|
|
79
|
+
|
|
80
|
+
const bucket = totals.get(entry.apiKeyId) ?? { requests7d: 0, totalRequests: 0 };
|
|
81
|
+
// The request happened even if its clock reading is unusable, so it still
|
|
82
|
+
// counts toward the total; only the time-based fields are skipped.
|
|
83
|
+
bucket.totalRequests += 1;
|
|
84
|
+
if (timestamp !== null) {
|
|
85
|
+
if (timestamp >= cutoff) bucket.requests7d += 1;
|
|
86
|
+
const iso = new Date(timestamp).toISOString();
|
|
87
|
+
if (!bucket.lastUsedAt || iso > bucket.lastUsedAt) bucket.lastUsedAt = iso;
|
|
88
|
+
}
|
|
89
|
+
totals.set(entry.apiKeyId, bucket);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const rollup = new Map<string, ApiKeyUsage>();
|
|
93
|
+
for (const id of configuredIds) {
|
|
94
|
+
if (duplicated.has(id)) {
|
|
95
|
+
rollup.set(id, { ambiguous: true });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
rollup.set(id, totals.get(id) ?? { requests7d: 0, totalRequests: 0 });
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
rollup,
|
|
102
|
+
...(attributionSince !== undefined ? { attributionSince: new Date(attributionSince).toISOString() } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Rollup cache keyed by the exact usage-log revision, mirroring the /api/usage
|
|
108
|
+
* summary cache. Without it, every key-list read reparses an append-only log
|
|
109
|
+
* that only ever grows — and the GUI fetches this route on mount and after every
|
|
110
|
+
* create/rename/delete. The compact rollup is a handful of counters per key, so
|
|
111
|
+
* caching it costs nothing; a new row changes the revision and invalidates it.
|
|
112
|
+
*/
|
|
113
|
+
let rollupCache: { revisionKey: string; expiresAt: number; snapshot: ApiKeyUsageSnapshot } | null = null;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The rollup is a function of the log AND of the clock: a request ages out of
|
|
117
|
+
* the seven-day window with no write to bump the file revision, so a purely
|
|
118
|
+
* revision-keyed entry would report a stale `requests7d` indefinitely.
|
|
119
|
+
*
|
|
120
|
+
* A minute is the whole rule. Deriving the exact next-transition instant would
|
|
121
|
+
* mean tracking the OLDEST counted request per key, which the compact rollup
|
|
122
|
+
* deliberately does not keep — and a count that can be at most 60s stale is
|
|
123
|
+
* already far tighter than the window it describes.
|
|
124
|
+
*/
|
|
125
|
+
const ROLLUP_CACHE_TTL_MS = 60_000;
|
|
126
|
+
|
|
127
|
+
/** Test seam: the cache is module state and would otherwise leak between cases. */
|
|
128
|
+
export function clearApiKeyUsageCacheForTests(): void {
|
|
129
|
+
rollupCache = null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Reads the durable usage snapshot the way /api/usage does, then rolls it up.
|
|
134
|
+
*
|
|
135
|
+
* Never throws: an unreadable snapshot yields empty rollups and no
|
|
136
|
+
* `attributionSince`. Key management working matters more than usage numbers
|
|
137
|
+
* being present, and the GUI already treats an absent field as "no data".
|
|
138
|
+
*/
|
|
139
|
+
export async function readApiKeyUsageRollup(configuredIds: string[], maxReadBytes?: number): Promise<ApiKeyUsageSnapshot> {
|
|
140
|
+
// JSON rather than a joined string: ids are only validated as non-empty
|
|
141
|
+
// strings, so `["a\0b","c"]` and `["a","b\0c"]` join to the same value and one
|
|
142
|
+
// config could be served the other's cached rollup.
|
|
143
|
+
const idsKey = JSON.stringify([configuredIds, maxReadBytes]);
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
try {
|
|
146
|
+
const observedKey = `${usageLogRevisionKey(currentUsageLogRevision())}|${idsKey}`;
|
|
147
|
+
if (rollupCache?.revisionKey === observedKey && now < rollupCache.expiresAt) {
|
|
148
|
+
return rollupCache.snapshot;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const snapshot = await readUsageSnapshotForManagement(maxReadBytes);
|
|
152
|
+
const rolled = {
|
|
153
|
+
...rollupApiKeyUsage(snapshot.entries, configuredIds, now),
|
|
154
|
+
...(snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated ? { historyTruncated: true as const } : {}),
|
|
155
|
+
};
|
|
156
|
+
rollupCache = {
|
|
157
|
+
revisionKey: `${usageLogRevisionKey(snapshot.revision)}|${idsKey}`,
|
|
158
|
+
expiresAt: now + ROLLUP_CACHE_TTL_MS,
|
|
159
|
+
snapshot: rolled,
|
|
160
|
+
};
|
|
161
|
+
return rolled;
|
|
162
|
+
} catch {
|
|
163
|
+
const rollup = new Map<string, ApiKeyUsage>();
|
|
164
|
+
for (const id of configuredIds) rollup.set(id, { requests7d: 0, totalRequests: 0 });
|
|
165
|
+
return { rollup };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { OcxConfig } from "../../types";
|
|
2
|
+
import { jsonResponse } from "../auth-cors";
|
|
3
|
+
import {
|
|
4
|
+
DecompressedBodyTooLargeError,
|
|
5
|
+
readBoundedJsonRequestBody,
|
|
6
|
+
} from "../request-decompress";
|
|
7
|
+
|
|
8
|
+
export const MANAGEMENT_JSON_BODY_MAX_BYTES = 4 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
export function readManagementJsonBody<T = unknown>(req: Request): Promise<T> {
|
|
11
|
+
return readBoundedJsonRequestBody(req, MANAGEMENT_JSON_BODY_MAX_BYTES) as Promise<T>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function managementBodyTooLargeResponse(
|
|
15
|
+
error: unknown,
|
|
16
|
+
req: Request,
|
|
17
|
+
config: OcxConfig,
|
|
18
|
+
): Response | null {
|
|
19
|
+
return error instanceof DecompressedBodyTooLargeError
|
|
20
|
+
? jsonResponse({ error: "request body too large" }, 413, req, config)
|
|
21
|
+
: null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function rethrowManagementBodyTooLarge(error: unknown): void {
|
|
25
|
+
if (error instanceof DecompressedBodyTooLargeError) throw error;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function readManagementJsonBodyOr<T>(req: Request, fallback: T): Promise<unknown | T> {
|
|
29
|
+
try {
|
|
30
|
+
return await readManagementJsonBody(req);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
rethrowManagementBodyTooLarge(error);
|
|
33
|
+
return fallback;
|
|
34
|
+
}
|
|
35
|
+
}
|