@bitkyc08/opencodex 2.42.0 → 2.43.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/AGENTS_INSTALL.md +2 -2
- package/README.md +31 -0
- package/bin/ocx.mjs +10 -1
- package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
- package/gui/dist/assets/index-Djowl68T.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-codec.ts +304 -0
- package/src/adapters/anthropic-image-normalize.ts +8 -298
- package/src/adapters/anthropic.ts +30 -7
- package/src/adapters/command-code.ts +7 -5
- package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
- package/src/adapters/cursor/images.ts +36 -6
- package/src/adapters/cursor/live-transport.ts +7 -2
- package/src/adapters/cursor/native-exec-desktop.ts +2 -15
- package/src/adapters/cursor/protobuf-request.ts +13 -3
- package/src/adapters/cursor/tool-definitions.ts +5 -670
- package/src/adapters/cursor/tool-guidance.ts +236 -0
- package/src/adapters/cursor/tool-naming.ts +252 -0
- package/src/adapters/cursor/tool-schemas.ts +195 -0
- package/src/adapters/cursor/types.ts +6 -3
- package/src/adapters/exec-tool-result-normalize.ts +1 -1
- package/src/adapters/google-errors.ts +9 -1
- package/src/adapters/google.ts +1 -0
- package/src/adapters/kiro-calibration.ts +181 -0
- package/src/adapters/kiro.ts +135 -3
- package/src/adapters/openai-responses.ts +216 -26
- package/src/adapters/responses-code-mode.ts +59 -0
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/adapters/xai-schema-analysis.ts +86 -0
- package/src/adapters/xai-tool-schema.ts +2 -87
- package/src/adapters/xai-web-search.ts +1 -1
- package/src/bridge.ts +22 -10
- package/src/chat/inbound.ts +11 -3
- package/src/claude/inbound-content-options.ts +60 -0
- package/src/claude/inbound-model-options.ts +142 -0
- package/src/claude/inbound-records.ts +7 -0
- package/src/claude/inbound.ts +10 -202
- package/src/claude/model-info.ts +45 -0
- package/src/cli/account-auth.ts +21 -6
- package/src/cli/capabilities.ts +13 -2
- package/src/cli/claude.ts +232 -39
- package/src/cli/config-command.ts +9 -1
- package/src/cli/dispatch.ts +5 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/effort.ts +372 -0
- package/src/cli/export-command.ts +3 -9
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +13 -0
- package/src/cli/init.ts +4 -0
- package/src/cli/model-selection-guidance.ts +30 -0
- package/src/cli/models-runtime.ts +3 -2
- package/src/cli/models.ts +8 -3
- package/src/cli/opencode.ts +4 -1
- package/src/cli/provider-runtime.ts +65 -0
- package/src/cli/provider.ts +8 -0
- package/src/cli/registry.ts +16 -2
- package/src/cli/runtime-api.ts +3 -1
- package/src/cli/star-prompt.ts +22 -6
- package/src/cli/status-probes.ts +168 -0
- package/src/cli/status.ts +5 -168
- package/src/clients/config-export/constants.ts +69 -0
- package/src/clients/config-export/contracts.ts +154 -0
- package/src/clients/config-export/dsh.ts +132 -0
- package/src/clients/config-export/fast-models.ts +29 -0
- package/src/clients/config-export/mcode.ts +83 -0
- package/src/clients/config-export/model-metadata.ts +108 -0
- package/src/clients/config-export/omp.ts +104 -0
- package/src/clients/config-export/zcode.ts +92 -0
- package/src/clients/config-export.ts +18 -710
- package/src/codex/account-lifecycle.ts +20 -3
- package/src/codex/account-usability.ts +2 -0
- package/src/codex/auth-api.ts +170 -23
- package/src/codex/auth-context.ts +328 -24
- package/src/codex/catalog/effort.ts +30 -4
- package/src/codex/catalog/metadata.ts +39 -9
- package/src/codex/catalog/native-models.ts +53 -12
- package/src/codex/catalog/parsing.ts +119 -5
- package/src/codex/catalog/provider-fetch.ts +150 -20
- package/src/codex/catalog/reserve.ts +52 -0
- package/src/codex/catalog/sync.ts +89 -16
- package/src/codex/convergence-types.ts +1 -0
- package/src/codex/convergence.ts +2 -0
- package/src/codex/data/upstream-models.json +1 -1
- package/src/codex/forward-transport-headers.ts +25 -0
- package/src/codex/inject.ts +3 -28
- package/src/codex/legacy-config-keys.ts +68 -0
- package/src/codex/log-guard/inspect-schema.ts +137 -0
- package/src/codex/log-guard/inspect.ts +2 -134
- package/src/codex/loopback-target.ts +54 -0
- package/src/codex/main-account-cache.ts +63 -1
- package/src/codex/main-account-hard-lock.ts +52 -0
- package/src/codex/main-account.ts +3 -1
- package/src/codex/management-convergence.ts +3 -0
- package/src/codex/model-entitlements.ts +54 -4
- package/src/codex/project-config-warnings.ts +92 -2
- package/src/codex/prompt-layers/encoding.ts +80 -0
- package/src/codex/prompt-layers/paths.ts +54 -0
- package/src/codex/prompt-layers/revision.ts +55 -0
- package/src/codex/prompt-layers/toml-edit.ts +163 -0
- package/src/codex/prompt-layers/toml-read.ts +181 -0
- package/src/codex/prompt-layers.ts +14 -520
- package/src/codex/quota-auto-refresh-state.ts +16 -0
- package/src/codex/quota-auto-refresh.ts +219 -0
- package/src/codex/quota-types.ts +51 -0
- package/src/codex/quota.ts +252 -93
- package/src/codex/reserve-availability.ts +177 -0
- package/src/codex/routing.ts +28 -9
- package/src/codex/shim.ts +53 -11
- package/src/codex/subagent-model-fallback.ts +23 -3
- package/src/combos/failover.ts +125 -7
- package/src/combos/identifiers.ts +89 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/resolve.ts +80 -9
- package/src/combos/types.ts +20 -93
- package/src/config/subagent-models.ts +24 -0
- package/src/config.ts +156 -13
- package/src/generated/compatibility-version.json +454 -166
- package/src/generated/model-metadata.ts +1 -1
- package/src/integrations/journal.ts +65 -4
- package/src/integrations/store.ts +5 -0
- package/src/lab/events/limits.ts +4 -0
- package/src/lib/destination-policy.ts +31 -2
- package/src/lib/errors.ts +39 -0
- package/src/lib/provider-outbound.ts +69 -3
- package/src/lib/proxy-env.ts +22 -0
- package/src/lib/redact-folding.ts +176 -0
- package/src/lib/redact.ts +2 -175
- package/src/lib/state-store-sweeper.ts +20 -6
- package/src/lib/token-estimate.ts +94 -27
- package/src/lib/windows-user-principal.ts +53 -5
- package/src/oauth/anthropic-routing.ts +99 -3
- package/src/oauth/generic-account-failover.ts +36 -13
- package/src/oauth/index.ts +127 -26
- package/src/oauth/login-cli.ts +5 -0
- package/src/oauth/meta-muse.ts +117 -15
- package/src/oauth/pool-settings-capability.ts +15 -4
- package/src/providers/api-keys.ts +8 -10
- package/src/providers/default-aliases.ts +39 -0
- package/src/providers/derive.ts +10 -2
- package/src/providers/fastwire.ts +36 -7
- package/src/providers/initial-model-selection-runtime.ts +90 -0
- package/src/providers/initial-model-selection.ts +120 -0
- package/src/providers/key-failover.ts +134 -54
- package/src/providers/key-store.ts +11 -1
- package/src/providers/label.ts +1 -1
- package/src/providers/model-discovery.ts +76 -0
- package/src/providers/model-rename-startup.ts +72 -8
- package/src/providers/openai-sidecar.ts +17 -5
- package/src/providers/openai-tiers-destination.ts +102 -0
- package/src/providers/openai-tiers.ts +2 -99
- package/src/providers/opencode-go-transport.ts +41 -0
- package/src/providers/quota-key-accounts.ts +141 -0
- package/src/providers/quota-types.ts +9 -0
- package/src/providers/quota.ts +535 -104
- package/src/providers/registry.ts +60 -17
- package/src/providers/xai-responses-opt-in.ts +31 -5
- package/src/quota/reset-activation.ts +81 -0
- package/src/quota/reset-detector.ts +305 -0
- package/src/quota/reset-notify-config.ts +162 -0
- package/src/quota/reset-observer.ts +125 -0
- package/src/quota/reset-poller.ts +160 -0
- package/src/quota/reset-seen-store.ts +385 -0
- package/src/quota/reset-sinks.ts +199 -0
- package/src/quota/window-mapping.ts +106 -0
- package/src/responses/apply-patch-envelope.ts +46 -0
- package/src/responses/code-mode-helper-compat.ts +39 -1
- package/src/responses/custom-tool-compat.ts +10 -4
- package/src/responses/hosted-tool-policy.ts +12 -4
- package/src/responses/parser-content.ts +133 -0
- package/src/responses/parser-text-format.ts +24 -0
- package/src/responses/parser-tools.ts +188 -0
- package/src/responses/parser.ts +3 -326
- package/src/responses/state.ts +124 -28
- package/src/router.ts +48 -13
- package/src/routing/analytics.ts +1 -0
- package/src/routing/capability.ts +17 -4
- package/src/server/auth-cors.ts +7 -1
- package/src/server/background-lifecycle.ts +23 -1
- package/src/server/chat-completions.ts +25 -3
- package/src/server/claude-messages.ts +62 -5
- package/src/server/effort-row.ts +1 -1
- package/src/server/fast-row.ts +295 -0
- package/src/server/gui-static.ts +30 -4
- package/src/server/index.ts +107 -21
- package/src/server/management/agent-settings-routes.ts +2 -2
- package/src/server/management/combo-routes.ts +37 -9
- package/src/server/management/config-routes.ts +93 -2
- package/src/server/management/integration-routes.ts +108 -0
- package/src/server/management/model-routes.ts +13 -3
- package/src/server/management/model-rows.ts +20 -1
- package/src/server/management/native-integration-routes.ts +4 -1
- package/src/server/management/oauth-account-routes.ts +38 -10
- package/src/server/management/provider-routes.ts +34 -3
- package/src/server/management/quota-reset-routes.ts +57 -0
- package/src/server/management/route-registry.ts +7 -4
- package/src/server/management/shared.ts +19 -5
- package/src/server/management/system-routes.ts +3 -2
- package/src/server/management-api.ts +14 -2
- package/src/server/ports.ts +12 -2
- package/src/server/relay-eager.ts +38 -23
- package/src/server/relay.ts +4 -0
- package/src/server/request-log.ts +6 -0
- package/src/server/responses/codex-ws-correlation.ts +65 -0
- package/src/server/responses/codex-ws-exchange.ts +261 -0
- package/src/server/responses/codex-ws-metadata.ts +134 -0
- package/src/server/responses/codex-ws-pool.ts +162 -0
- package/src/server/responses/codex-ws-request.ts +87 -0
- package/src/server/responses/codex-ws-session.ts +93 -0
- package/src/server/responses/codex-ws-wire.ts +144 -0
- package/src/server/responses/collaboration.ts +41 -1
- package/src/server/responses/compact.ts +105 -12
- package/src/server/responses/core.ts +476 -57
- package/src/server/responses/fetch-helpers.ts +10 -3
- package/src/server/responses/input-admission.ts +16 -9
- package/src/server/responses/responses-field-backfill.ts +1 -1
- package/src/server/responses/ws-upstream.ts +34 -318
- package/src/server/responses-custom-tool-repair.ts +20 -4
- package/src/server/responses-undeclared-tool-guard.ts +100 -8
- package/src/server/safe-response-headers.ts +23 -0
- package/src/server/search.ts +9 -0
- package/src/server/subagent-models-startup.ts +27 -0
- package/src/server/system-env-shell.ts +238 -0
- package/src/server/system-env.ts +7 -234
- package/src/server/ws-bridge.ts +3 -25
- package/src/server/xai-responses-startup.ts +21 -0
- package/src/service-manager-probe.ts +1 -1
- package/src/service.ts +55 -16
- package/src/types/config.ts +108 -12
- package/src/types/provider.ts +36 -7
- package/src/types/tools.ts +26 -1
- package/src/types.ts +2 -0
- package/src/update/notify.ts +8 -2
- package/src/usage/cost.ts +38 -28
- package/src/usage/expected-prices.ts +34 -15
- package/src/usage/log.ts +2 -0
- package/src/usage/model-identity.ts +26 -0
- package/src/usage/summary.ts +15 -1
- package/src/vision/describe.ts +6 -0
- package/src/vision/image-rewrite.ts +108 -0
- package/src/vision/index.ts +19 -306
- package/src/vision/plan.ts +205 -0
- package/src/web-search/executor.ts +6 -0
- package/src/web-search/index.ts +8 -1
- package/gui/dist/assets/index-BU1tE0sr.js +0 -112
- package/gui/dist/assets/index-DL9-iS6J.css +0 -1
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable "already notified" ledger plus a bounded ring of recent reset events.
|
|
3
|
+
*
|
|
4
|
+
* Exactly-once has to hold across a restart, because the whole point of a surprise reset is
|
|
5
|
+
* that it happens while nobody is watching. An in-memory set would re-notify every reset
|
|
6
|
+
* whose window is still open the next time the proxy starts.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately NOT stored in config.json: this is high-frequency job state, and
|
|
9
|
+
* mutatePersistedConfig fails closed when the config did not come from a file.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
// Imported from the definition sites, NOT the ../config barrel. The barrel pulls 154 modules
|
|
15
|
+
// (~430 KB) including combos, account-store, pool-rotation and cursor discovery; these two
|
|
16
|
+
// cost 8. This store is reached from the once-per-pooled-response observation path, so the
|
|
17
|
+
// barrel would work directly against the boundary guard wp5 adds.
|
|
18
|
+
import { atomicWriteFile } from "../config/atomic-write";
|
|
19
|
+
import { getConfigDir } from "../config/paths";
|
|
20
|
+
import type { QuotaResetEvent, QuotaWindowObservation } from "./reset-detector";
|
|
21
|
+
|
|
22
|
+
const STATE_FILENAME = "quota-reset-state.json";
|
|
23
|
+
const PERSIST_DEBOUNCE_MS = 250;
|
|
24
|
+
/**
|
|
25
|
+
* Longest a pending write may be deferred by continued activity. See schedulePersist.
|
|
26
|
+
*/
|
|
27
|
+
const MAX_PERSIST_DEFERRAL_MS = 1_000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Age floor for pruning a claimed key.
|
|
31
|
+
*
|
|
32
|
+
* 90 days, not 30: a monthly window's key is legitimately older than a month while still
|
|
33
|
+
* current, and pruning it would let the same reset notify twice.
|
|
34
|
+
*/
|
|
35
|
+
const CLAIM_MAX_AGE_MS = 90 * 24 * 60 * 60_000;
|
|
36
|
+
const MAX_CLAIMS = 512;
|
|
37
|
+
/**
|
|
38
|
+
* Hard ceiling for the claim map.
|
|
39
|
+
*
|
|
40
|
+
* MAX_CLAIMS is the soft budget, honoured by evicting settled claims. When every claim is
|
|
41
|
+
* still live there is nothing safe to evict, so without a hard stop the map — and the JSON
|
|
42
|
+
* rewritten beside it — grows without limit. At this ceiling we evict the furthest-future
|
|
43
|
+
* deadline and accept one theoretical duplicate, which is strictly better than unbounded growth.
|
|
44
|
+
*/
|
|
45
|
+
const HARD_MAX_CLAIMS = 2 * MAX_CLAIMS;
|
|
46
|
+
const MAX_RING_EVENTS = 100;
|
|
47
|
+
/** One row per (scope, accountTag). A large pool plus several providers stays well inside this. */
|
|
48
|
+
const MAX_OBSERVED_SCOPES = 64;
|
|
49
|
+
|
|
50
|
+
type ClaimRecord = {
|
|
51
|
+
/** When the claim was made. */
|
|
52
|
+
at: number;
|
|
53
|
+
/** The window deadline this claim belongs to; a future value means the claim is live. */
|
|
54
|
+
resetAt?: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type StateFile = {
|
|
58
|
+
version: 1;
|
|
59
|
+
claims: Record<string, ClaimRecord>;
|
|
60
|
+
events: QuotaResetEvent[];
|
|
61
|
+
/** Last observed windows per "scope\u0000accountTag". Absent in files written before this field. */
|
|
62
|
+
observed?: Record<string, QuotaWindowObservation[]>;
|
|
63
|
+
/** Per-install salt for account tagging. Created once, then stable. */
|
|
64
|
+
accountSalt?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const claims = new Map<string, ClaimRecord>();
|
|
68
|
+
let ring: QuotaResetEvent[] = [];
|
|
69
|
+
const observed = new Map<string, QuotaWindowObservation[]>();
|
|
70
|
+
let hydrated = false;
|
|
71
|
+
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
72
|
+
/** When the currently pending debounced write was FIRST scheduled, or null if none is pending. */
|
|
73
|
+
let firstDeferredPersistAt: number | null = null;
|
|
74
|
+
let accountSalt: string | null = null;
|
|
75
|
+
|
|
76
|
+
function statePath(): string {
|
|
77
|
+
return join(getConfigDir(), STATE_FILENAME);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hydrate(): void {
|
|
81
|
+
if (hydrated) return;
|
|
82
|
+
hydrated = true;
|
|
83
|
+
try {
|
|
84
|
+
const path = statePath();
|
|
85
|
+
if (!existsSync(path)) return;
|
|
86
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as StateFile;
|
|
87
|
+
if (!parsed || parsed.version !== 1) return;
|
|
88
|
+
if (parsed.claims && typeof parsed.claims === "object") {
|
|
89
|
+
for (const [key, record] of Object.entries(parsed.claims)) {
|
|
90
|
+
if (!record || typeof record.at !== "number") continue;
|
|
91
|
+
claims.set(key, {
|
|
92
|
+
at: record.at,
|
|
93
|
+
...(typeof record.resetAt === "number" ? { resetAt: record.resetAt } : {}),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (Array.isArray(parsed.events)) ring = parsed.events.slice(-MAX_RING_EVENTS);
|
|
98
|
+
if (parsed.observed && typeof parsed.observed === "object") {
|
|
99
|
+
for (const [key, windows] of Object.entries(parsed.observed)) {
|
|
100
|
+
if (Array.isArray(windows)) observed.set(key, windows);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (typeof parsed.accountSalt === "string" && parsed.accountSalt.length >= 16) {
|
|
104
|
+
accountSalt = parsed.accountSalt;
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// A corrupt or partially written cache must never break a quota refresh. Starting empty
|
|
108
|
+
// risks one duplicate notification; throwing here would break the write that triggered us.
|
|
109
|
+
claims.clear();
|
|
110
|
+
ring = [];
|
|
111
|
+
observed.clear();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Per-install salt, created on first use and persisted.
|
|
117
|
+
*
|
|
118
|
+
* Without it an account tag is a bare unkeyed hash of an email — brute-forceable in tens of
|
|
119
|
+
* guesses, which turns the webhook payload into an account-identity oracle for whoever
|
|
120
|
+
* receives it. Salted, the tag stays stable for this install (so the persisted idempotence
|
|
121
|
+
* key still works across restarts) and is unlinkable to anyone without the salt.
|
|
122
|
+
*/
|
|
123
|
+
export function quotaResetAccountSalt(): string {
|
|
124
|
+
hydrate();
|
|
125
|
+
if (accountSalt) return accountSalt;
|
|
126
|
+
accountSalt = crypto.randomUUID().replaceAll("-", "");
|
|
127
|
+
persistNow();
|
|
128
|
+
return accountSalt;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function stateFileBody(): StateFile {
|
|
132
|
+
return {
|
|
133
|
+
version: 1,
|
|
134
|
+
claims: Object.fromEntries(claims),
|
|
135
|
+
events: ring,
|
|
136
|
+
observed: Object.fromEntries(observed),
|
|
137
|
+
...(accountSalt !== null ? { accountSalt } : {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Write immediately, for anything whose loss breaks correctness.
|
|
143
|
+
*
|
|
144
|
+
* A claim MUST NOT ride the debounce: the timer is unref'd, so a process exiting within
|
|
145
|
+
* 250 ms of claiming — the common case when detection runs on the last pooled request before
|
|
146
|
+
* shutdown — never writes it, and the next start re-notifies. That is precisely the
|
|
147
|
+
* across-a-restart guarantee this store exists for, and a shutdown hook would not cover SIGKILL.
|
|
148
|
+
*
|
|
149
|
+
* Returns whether the write actually landed. The error is still swallowed rather than thrown —
|
|
150
|
+
* a quota observation must not fail on a full disk — but a caller that treats success as
|
|
151
|
+
* "durably claimed" needs to be told the difference.
|
|
152
|
+
*/
|
|
153
|
+
function persistNow(): boolean {
|
|
154
|
+
if (persistTimer) {
|
|
155
|
+
clearTimeout(persistTimer);
|
|
156
|
+
persistTimer = null;
|
|
157
|
+
}
|
|
158
|
+
firstDeferredPersistAt = null;
|
|
159
|
+
try {
|
|
160
|
+
atomicWriteFile(statePath(), `${JSON.stringify(stateFileBody())}\n`);
|
|
161
|
+
return true;
|
|
162
|
+
} catch {
|
|
163
|
+
// Best-effort persistence only.
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Debounce with a MAXIMUM STALENESS cap.
|
|
170
|
+
*
|
|
171
|
+
* A plain re-arming debounce is starvable: it clears and re-sets the timer on every
|
|
172
|
+
* observation, so any write cadence faster than the debounce pushes the deadline out forever.
|
|
173
|
+
* Measured on the unfixed version: 75 observations at 40 ms intervals produced ZERO disk writes,
|
|
174
|
+
* and 200 at 10 ms also produced zero. A busy pooled install that is then SIGKILLed (container
|
|
175
|
+
* stop, OOM) loses its entire baseline and re-baselines on restart, silently missing any reset
|
|
176
|
+
* that spans the gap — which defeats the across-a-restart guarantee this store exists for.
|
|
177
|
+
*
|
|
178
|
+
* So the deferral is bounded: once a write has been pending for MAX_PERSIST_DEFERRAL_MS, the
|
|
179
|
+
* next call writes immediately instead of deferring again. At 0.29 ms per serialize-and-write,
|
|
180
|
+
* a forced write every second under sustained load costs nothing measurable.
|
|
181
|
+
*
|
|
182
|
+
* Claims deliberately do NOT use this path — they write synchronously, because an unref'd timer
|
|
183
|
+
* cannot be trusted to fire before process exit.
|
|
184
|
+
*/
|
|
185
|
+
function schedulePersist(): void {
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
if (firstDeferredPersistAt === null) firstDeferredPersistAt = now;
|
|
188
|
+
else if (now - firstDeferredPersistAt >= MAX_PERSIST_DEFERRAL_MS) {
|
|
189
|
+
// persistNow clears the timer and resets the window below.
|
|
190
|
+
persistNow();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (persistTimer) clearTimeout(persistTimer);
|
|
194
|
+
persistTimer = setTimeout(() => {
|
|
195
|
+
persistTimer = null;
|
|
196
|
+
firstDeferredPersistAt = null;
|
|
197
|
+
try {
|
|
198
|
+
atomicWriteFile(statePath(), `${JSON.stringify(stateFileBody())}\n`);
|
|
199
|
+
} catch {
|
|
200
|
+
// Best-effort persistence, matching the codex quota cache.
|
|
201
|
+
}
|
|
202
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
203
|
+
persistTimer.unref?.();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Drop claims that are both old and settled. A live deadline is never pruned.
|
|
208
|
+
*
|
|
209
|
+
* `now` comes from the wall clock, not from the caller's `at`: a backdated or clock-skewed
|
|
210
|
+
* claim must not change the retention of unrelated keys.
|
|
211
|
+
*/
|
|
212
|
+
function prune(now = Date.now()): void {
|
|
213
|
+
for (const [key, record] of claims) {
|
|
214
|
+
// A claim with no deadline is UNKNOWN, not settled. Clockless windows are the common case
|
|
215
|
+
// for credit-balance providers, so treating them as settled would make them the first
|
|
216
|
+
// thing evicted. They still age out below, just without that preference.
|
|
217
|
+
if (record.resetAt === undefined || record.resetAt > now) continue;
|
|
218
|
+
if (now - record.at <= CLAIM_MAX_AGE_MS) continue;
|
|
219
|
+
claims.delete(key);
|
|
220
|
+
}
|
|
221
|
+
for (const [key, record] of claims) {
|
|
222
|
+
if (record.resetAt !== undefined) continue;
|
|
223
|
+
if (now - record.at <= CLAIM_MAX_AGE_MS) continue;
|
|
224
|
+
claims.delete(key);
|
|
225
|
+
}
|
|
226
|
+
if (claims.size <= MAX_CLAIMS) return;
|
|
227
|
+
// Over budget: evict the oldest SETTLED claims only. Evicting a live one would trade a
|
|
228
|
+
// memory bound for a duplicate notification, which is the bug this store prevents.
|
|
229
|
+
const settled = [...claims.entries()]
|
|
230
|
+
.filter(([, record]) => record.resetAt === undefined || record.resetAt <= now)
|
|
231
|
+
.sort((left, right) => left[1].at - right[1].at);
|
|
232
|
+
for (const [key] of settled) {
|
|
233
|
+
if (claims.size <= MAX_CLAIMS) break;
|
|
234
|
+
claims.delete(key);
|
|
235
|
+
}
|
|
236
|
+
if (claims.size <= HARD_MAX_CLAIMS) return;
|
|
237
|
+
// Every remaining claim is live. Evict the furthest-future deadlines first: least likely to
|
|
238
|
+
// be re-observed soon, so a duplicate there is least disruptive.
|
|
239
|
+
const live = [...claims.entries()].sort(
|
|
240
|
+
(left, right) => (right[1].resetAt ?? 0) - (left[1].resetAt ?? 0),
|
|
241
|
+
);
|
|
242
|
+
for (const [key] of live) {
|
|
243
|
+
if (claims.size <= HARD_MAX_CLAIMS) break;
|
|
244
|
+
claims.delete(key);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Atomically claim a reset key. Returns true for the FIRST caller only.
|
|
250
|
+
*
|
|
251
|
+
* One synchronous check-and-set rather than a separate has/mark pair: a poller tick and a
|
|
252
|
+
* live pooled response can observe the same transition, and two callers that both read
|
|
253
|
+
* "unseen" would both notify. There is no await inside, so with Bun's single-threaded turn
|
|
254
|
+
* semantics this is indivisible with respect to other observers.
|
|
255
|
+
*/
|
|
256
|
+
export function claimQuotaReset(key: string, at: number, resetAt?: number): boolean {
|
|
257
|
+
hydrate();
|
|
258
|
+
if (claims.has(key)) return false;
|
|
259
|
+
claims.set(key, { at, ...(resetAt !== undefined ? { resetAt } : {}) });
|
|
260
|
+
prune();
|
|
261
|
+
// prune() can evict the claim just added — an already-past deadline older than
|
|
262
|
+
// CLAIM_MAX_AGE_MS qualifies — and an evicted claim is not a claim.
|
|
263
|
+
if (!claims.has(key)) return false;
|
|
264
|
+
// Synchronous: a lost claim means a duplicate notification after restart, and claims are
|
|
265
|
+
// rare (one per real reset), so the write cost is irrelevant.
|
|
266
|
+
// A failed write is reported rather than swallowed: the caller reads true as "safe to
|
|
267
|
+
// dispatch", and an unpersisted claim re-notifies on the next start.
|
|
268
|
+
return persistNow();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Read-only probe. Never used to gate a notification — see claimQuotaReset. */
|
|
272
|
+
export function hasSeenQuotaReset(key: string): boolean {
|
|
273
|
+
hydrate();
|
|
274
|
+
return claims.has(key);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function recordQuotaResetEvent(event: QuotaResetEvent): void {
|
|
278
|
+
hydrate();
|
|
279
|
+
ring.push(event);
|
|
280
|
+
if (ring.length > MAX_RING_EVENTS) ring = ring.slice(-MAX_RING_EVENTS);
|
|
281
|
+
schedulePersist();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Recent events, newest first. */
|
|
285
|
+
export function listRecentQuotaResetEvents(limit = MAX_RING_EVENTS): QuotaResetEvent[] {
|
|
286
|
+
hydrate();
|
|
287
|
+
const bounded = Math.max(1, Math.min(limit, MAX_RING_EVENTS));
|
|
288
|
+
return [...ring].reverse().slice(0, bounded);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function observedKey(scope: string, accountTag: string): string {
|
|
292
|
+
return `${scope}\u0000${accountTag}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Store the newly observed windows for one (scope, accountTag) and return what was there
|
|
297
|
+
* before, or undefined on the first ever observation.
|
|
298
|
+
*
|
|
299
|
+
* The detector owns this map rather than borrowing a caller's previous value, because
|
|
300
|
+
* neither upstream cache can supply one reliably. The provider report cache keys itself on a
|
|
301
|
+
* digest that INCLUDES quota values and updatedAt (src/providers/quota.ts:193 via :155), so
|
|
302
|
+
* its `previous` is empty precisely when a reset happened; and it is process-memory only, so
|
|
303
|
+
* it has no answer at all after a restart. This map is keyed by identity and persisted.
|
|
304
|
+
*/
|
|
305
|
+
export function swapLastObservedWindows(
|
|
306
|
+
scope: string,
|
|
307
|
+
accountTag: string,
|
|
308
|
+
windows: ReadonlyArray<QuotaWindowObservation>,
|
|
309
|
+
): ReadonlyArray<QuotaWindowObservation> | undefined {
|
|
310
|
+
hydrate();
|
|
311
|
+
const key = observedKey(scope, accountTag);
|
|
312
|
+
const previous = observed.get(key);
|
|
313
|
+
// Delete before set so the row moves to the END of the insertion order, making this a
|
|
314
|
+
// true LRU. Re-setting an existing key does NOT move it in a Map, so without the delete
|
|
315
|
+
// the eviction below removed the EARLIEST-INSERTED row — which on a real install is the
|
|
316
|
+
// long-lived codex account observed on every response, while 63 transient rows survived.
|
|
317
|
+
// Measured: the hottest scope was evicted and its next genuine scheduled reset was
|
|
318
|
+
// silently missed, because a re-baselined row has no previous value to diff against.
|
|
319
|
+
observed.delete(key);
|
|
320
|
+
observed.set(key, windows.map(window => ({ ...window })));
|
|
321
|
+
if (observed.size > MAX_OBSERVED_SCOPES) {
|
|
322
|
+
// Least-recently-observed first. Evicting only costs a re-baseline, never a duplicate
|
|
323
|
+
// notification, because the claim ledger is separate — but a re-baseline DOES cost the
|
|
324
|
+
// next reset on that row, which is why picking the genuinely abandoned row matters.
|
|
325
|
+
const oldest = observed.keys().next();
|
|
326
|
+
if (!oldest.done && oldest.value !== key) observed.delete(oldest.value);
|
|
327
|
+
}
|
|
328
|
+
schedulePersist();
|
|
329
|
+
return previous;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Forget the baseline for one (scope, accountTag), or for every scope when no tag is given.
|
|
334
|
+
*
|
|
335
|
+
* Called when a quota row is deliberately cleared — reauth, sign-out, account removal. The
|
|
336
|
+
* quota row and this baseline live in different files, so clearing only the former left the
|
|
337
|
+
* observer holding the pre-clear percentages: the first fresh write after a reauth of a used
|
|
338
|
+
* account then read as a surprise reset (measured 91% -> 0%). The existing regression test
|
|
339
|
+
* missed it because it also called resetQuotaResetStoreForTests(), which real reauth does
|
|
340
|
+
* not do — the test simulated something that never happens.
|
|
341
|
+
*
|
|
342
|
+
* Deliberately does NOT touch the claim ledger. A cleared row must not re-notify a reset it
|
|
343
|
+
* already reported, and claims are what prevent that.
|
|
344
|
+
*/
|
|
345
|
+
export function forgetLastObservedWindows(scope: string, accountTag?: string): void {
|
|
346
|
+
hydrate();
|
|
347
|
+
if (accountTag !== undefined) {
|
|
348
|
+
if (!observed.delete(observedKey(scope, accountTag))) return;
|
|
349
|
+
schedulePersist();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
// No tag: the caller cleared everything for this scope. The account tag is a salted hash,
|
|
353
|
+
// so it cannot be reversed — matching on the scope prefix is the only available form.
|
|
354
|
+
const prefix = `${scope}\u0000`;
|
|
355
|
+
let removed = false;
|
|
356
|
+
for (const key of [...observed.keys()]) {
|
|
357
|
+
if (key.startsWith(prefix)) removed = observed.delete(key) || removed;
|
|
358
|
+
}
|
|
359
|
+
if (removed) schedulePersist();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Test-only: forget in-memory state so the next call re-reads OPENCODEX_HOME. */
|
|
363
|
+
export function resetQuotaResetStoreForTests(): void {
|
|
364
|
+
claims.clear();
|
|
365
|
+
ring = [];
|
|
366
|
+
observed.clear();
|
|
367
|
+
hydrated = false;
|
|
368
|
+
accountSalt = null;
|
|
369
|
+
if (persistTimer) {
|
|
370
|
+
clearTimeout(persistTimer);
|
|
371
|
+
persistTimer = null;
|
|
372
|
+
}
|
|
373
|
+
firstDeferredPersistAt = null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Test-only: flush the debounced write immediately. */
|
|
377
|
+
export function flushQuotaResetStoreForTests(): void {
|
|
378
|
+
persistNow();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Test-only: claim-map size, for asserting retention bounds. Exposes no keys. */
|
|
382
|
+
export function claimCountForTests(): number {
|
|
383
|
+
hydrate();
|
|
384
|
+
return claims.size;
|
|
385
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivery sinks for a detected quota reset: an HTTP webhook and a local command.
|
|
3
|
+
*
|
|
4
|
+
* Both are best-effort and independently isolated. Neither ever throws to the caller, and one
|
|
5
|
+
* failing never suppresses the other — a reset notification is a courtesy, and a courtesy that
|
|
6
|
+
* can break the quota write that triggered it is a defect.
|
|
7
|
+
*
|
|
8
|
+
* There is deliberately NO retry. The wp3 observer claims the idempotence key before dispatch,
|
|
9
|
+
* so a retry here could only ever duplicate a delivery the ledger already considers done; and a
|
|
10
|
+
* reset notification is interesting only while it is fresh.
|
|
11
|
+
*
|
|
12
|
+
* Imported lazily by the activation path, never statically from a core file: this module
|
|
13
|
+
* reaches the destination policy and the config barrel, and
|
|
14
|
+
* tests/usage/quota-reset-core-boundary.test.ts enforces that none of that lands on the request path.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { signalWithTimeout } from "../lib/abort";
|
|
18
|
+
import { assertUrlResolvesPublic } from "../lib/destination-policy";
|
|
19
|
+
import { cancelResponseBodyBestEffort } from "../lib/upstream-retry";
|
|
20
|
+
import type { QuotaResetEvent } from "./reset-detector";
|
|
21
|
+
import type { ResolvedQuotaResetNotify } from "./reset-notify-config";
|
|
22
|
+
|
|
23
|
+
export type QuotaResetSinkName = "webhook" | "command";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Outcome of one delivery attempt.
|
|
27
|
+
*
|
|
28
|
+
* `reason` is a CLOSED UNION on purpose. An upstream body, a resolved address, or the webhook
|
|
29
|
+
* URL itself would all be sensitive — the URL is the credential for Slack and Discord — and this
|
|
30
|
+
* value reaches logs and the management API.
|
|
31
|
+
*/
|
|
32
|
+
export type QuotaResetDeliveryResult = {
|
|
33
|
+
readonly sink: QuotaResetSinkName;
|
|
34
|
+
readonly ok: boolean;
|
|
35
|
+
readonly reason?: "blocked-destination" | "timeout" | "http-error" | "spawn-failed";
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The delivered payload.
|
|
40
|
+
*
|
|
41
|
+
* Closed-union labels and numbers only: no email, no account id, no token, no filesystem path,
|
|
42
|
+
* no URL. `accountTag` is a salted hash whose salt never leaves the install.
|
|
43
|
+
*
|
|
44
|
+
* The TYPE is the enforcement. `bun run privacy:scan` reads repository text, not runtime output,
|
|
45
|
+
* so it cannot check this — stating the obligation in the type is what keeps a later field
|
|
46
|
+
* addition honest.
|
|
47
|
+
*/
|
|
48
|
+
type QuotaResetPayload = {
|
|
49
|
+
readonly type: "quota_reset";
|
|
50
|
+
readonly kind: QuotaResetEvent["kind"];
|
|
51
|
+
readonly scope: string;
|
|
52
|
+
readonly accountTag: string;
|
|
53
|
+
readonly window: string;
|
|
54
|
+
readonly percentBefore?: number;
|
|
55
|
+
readonly percentAfter?: number;
|
|
56
|
+
readonly previousResetAt?: number;
|
|
57
|
+
readonly resetAt?: number;
|
|
58
|
+
readonly detectedAt: number;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build the payload by NAMING every field, never by spreading the event.
|
|
63
|
+
*
|
|
64
|
+
* A spread would silently forward whatever the detector gains next — including the internal
|
|
65
|
+
* idempotence `key`, which encodes the scope and tag and has no business crossing a webhook
|
|
66
|
+
* boundary to a third party.
|
|
67
|
+
*/
|
|
68
|
+
function payloadFor(event: QuotaResetEvent): QuotaResetPayload {
|
|
69
|
+
return {
|
|
70
|
+
type: "quota_reset",
|
|
71
|
+
kind: event.kind,
|
|
72
|
+
scope: event.scope,
|
|
73
|
+
accountTag: event.accountTag,
|
|
74
|
+
window: event.window,
|
|
75
|
+
...(event.percentBefore !== undefined ? { percentBefore: event.percentBefore } : {}),
|
|
76
|
+
...(event.percentAfter !== undefined ? { percentAfter: event.percentAfter } : {}),
|
|
77
|
+
...(event.previousResetAt !== undefined ? { previousResetAt: event.previousResetAt } : {}),
|
|
78
|
+
...(event.resetAt !== undefined ? { resetAt: event.resetAt } : {}),
|
|
79
|
+
detectedAt: event.detectedAt,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Test seam for the payload contract, so a test cannot drift from what is actually sent. */
|
|
84
|
+
export function quotaResetPayloadForTests(event: QuotaResetEvent): unknown {
|
|
85
|
+
return payloadFor(event);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function deliverWebhook(
|
|
89
|
+
json: string,
|
|
90
|
+
config: ResolvedQuotaResetNotify,
|
|
91
|
+
): Promise<QuotaResetDeliveryResult> {
|
|
92
|
+
const url = config.webhookUrl;
|
|
93
|
+
if (url === undefined) return { sink: "webhook", ok: true };
|
|
94
|
+
|
|
95
|
+
// An operator-supplied URL is an SSRF surface: this process can reach loopback services and
|
|
96
|
+
// cloud metadata endpoints that the operator's browser cannot. The repository already owns
|
|
97
|
+
// this policy, so the check is reused rather than reinvented. Self-hosted receivers opt in.
|
|
98
|
+
if (!config.allowPrivateNetwork) {
|
|
99
|
+
try {
|
|
100
|
+
await assertUrlResolvesPublic(url);
|
|
101
|
+
} catch {
|
|
102
|
+
return { sink: "webhook", ok: false, reason: "blocked-destination" };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const timeout = signalWithTimeout(config.timeoutMs);
|
|
107
|
+
try {
|
|
108
|
+
const response = await fetch(url, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "content-type": "application/json" },
|
|
111
|
+
body: json,
|
|
112
|
+
// The destination check above validated THIS url. Following a redirect would send the
|
|
113
|
+
// payload somewhere unvalidated, so a hop is refused rather than re-validated: the
|
|
114
|
+
// operator can configure the final URL directly, which is the stance
|
|
115
|
+
// providerRedirectError already takes for provider traffic.
|
|
116
|
+
redirect: "manual",
|
|
117
|
+
signal: timeout.signal,
|
|
118
|
+
});
|
|
119
|
+
// Nothing reads the body, and an undrained response holds the connection open.
|
|
120
|
+
cancelResponseBodyBestEffort(response);
|
|
121
|
+
if (response.status >= 300 && response.status < 400) {
|
|
122
|
+
return { sink: "webhook", ok: false, reason: "blocked-destination" };
|
|
123
|
+
}
|
|
124
|
+
if (!response.ok) return { sink: "webhook", ok: false, reason: "http-error" };
|
|
125
|
+
return { sink: "webhook", ok: true };
|
|
126
|
+
} catch (error) {
|
|
127
|
+
const aborted = error instanceof Error
|
|
128
|
+
&& (error.name === "TimeoutError" || error.name === "AbortError");
|
|
129
|
+
return { sink: "webhook", ok: false, reason: aborted ? "timeout" : "http-error" };
|
|
130
|
+
} finally {
|
|
131
|
+
// Without this the timer keeps the event loop alive for up to timeoutMs after a fast
|
|
132
|
+
// response, which on a short-lived CLI invocation delays exit for no reason.
|
|
133
|
+
timeout.cleanup();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function deliverCommand(
|
|
138
|
+
json: string,
|
|
139
|
+
config: ResolvedQuotaResetNotify,
|
|
140
|
+
): Promise<QuotaResetDeliveryResult> {
|
|
141
|
+
const command = config.command;
|
|
142
|
+
if (command === undefined || command.length === 0) return { sink: "command", ok: true };
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
// argv form, NOT a shell string: an operator-supplied command must not become an injection
|
|
146
|
+
// surface, and there is no shell here to interpret metacharacters.
|
|
147
|
+
const proc = Bun.spawn([...command], {
|
|
148
|
+
// Encoded bytes, not a string. Bun 1.4.0 throws ERR_INVALID_ARG_TYPE on a plain string
|
|
149
|
+
// here, and no existing call site in this repository pipes stdin, so there was no
|
|
150
|
+
// in-repo precedent to copy.
|
|
151
|
+
stdin: new TextEncoder().encode(json),
|
|
152
|
+
// The event is already delivered by writing it to stdin; the command's own chatter is not
|
|
153
|
+
// ours to relay, and inheriting it would interleave into whatever is on the terminal.
|
|
154
|
+
stdout: "ignore",
|
|
155
|
+
stderr: "ignore",
|
|
156
|
+
});
|
|
157
|
+
const exitCode = await proc.exited;
|
|
158
|
+
if (exitCode !== 0) return { sink: "command", ok: false, reason: "spawn-failed" };
|
|
159
|
+
return { sink: "command", ok: true };
|
|
160
|
+
} catch {
|
|
161
|
+
// A missing binary or a permission error. The message could contain a filesystem path, so
|
|
162
|
+
// it is deliberately not carried through.
|
|
163
|
+
return { sink: "command", ok: false, reason: "spawn-failed" };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Deliver one event to every configured sink.
|
|
169
|
+
*
|
|
170
|
+
* Sinks run CONCURRENTLY and are settled independently, so a webhook that sits until its
|
|
171
|
+
* timeout does not delay the local command. Never rejects.
|
|
172
|
+
*/
|
|
173
|
+
export async function deliverQuotaResetEvent(
|
|
174
|
+
event: QuotaResetEvent,
|
|
175
|
+
config: ResolvedQuotaResetNotify,
|
|
176
|
+
): Promise<QuotaResetDeliveryResult[]> {
|
|
177
|
+
// Filtered here as well as at the sink registration, because this function is the public
|
|
178
|
+
// entry point and a caller should not be able to deliver a kind the operator excluded.
|
|
179
|
+
if (!config.kinds.has(event.kind)) return [];
|
|
180
|
+
|
|
181
|
+
const json = JSON.stringify(payloadFor(event));
|
|
182
|
+
const attempts: Array<Promise<QuotaResetDeliveryResult>> = [];
|
|
183
|
+
if (config.webhookUrl !== undefined) attempts.push(deliverWebhook(json, config));
|
|
184
|
+
if (config.command !== undefined && config.command.length > 0) {
|
|
185
|
+
attempts.push(deliverCommand(json, config));
|
|
186
|
+
}
|
|
187
|
+
if (attempts.length === 0) return [];
|
|
188
|
+
|
|
189
|
+
const settled = await Promise.allSettled(attempts);
|
|
190
|
+
return settled.map((result, index) => {
|
|
191
|
+
if (result.status === "fulfilled") return result.value;
|
|
192
|
+
// Both helpers catch internally, so this is unreachable in practice — but a rejected
|
|
193
|
+
// promise must still not propagate out of a best-effort notifier.
|
|
194
|
+
const sink: QuotaResetSinkName = index === 0 && config.webhookUrl !== undefined
|
|
195
|
+
? "webhook"
|
|
196
|
+
: "command";
|
|
197
|
+
return { sink, ok: false, reason: "spawn-failed" };
|
|
198
|
+
});
|
|
199
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider- and Codex-specific quota shapes mapped to the detector's neutral window list.
|
|
3
|
+
*
|
|
4
|
+
* Separate from the detector so the detector stays free of any dependency on either quota
|
|
5
|
+
* subsystem's types, and separate from the observer so the seams can build observations
|
|
6
|
+
* without loading the sink registry.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { QuotaWindowObservation } from "./reset-detector";
|
|
10
|
+
|
|
11
|
+
type CodexLikeQuota = {
|
|
12
|
+
shortPercent?: number;
|
|
13
|
+
shortResetAt?: number;
|
|
14
|
+
shortWindowSeconds?: number;
|
|
15
|
+
weeklyPercent?: number;
|
|
16
|
+
weeklyResetAt?: number;
|
|
17
|
+
monthlyPercent?: number;
|
|
18
|
+
monthlyResetAt?: number;
|
|
19
|
+
customWindows?: ReadonlyArray<{ label: string; percent: number; resetAt?: number }>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type ProviderLikeQuota = {
|
|
23
|
+
fiveHourPercent?: number;
|
|
24
|
+
fiveHourResetAt?: number;
|
|
25
|
+
weeklyPercent?: number;
|
|
26
|
+
weeklyResetAt?: number;
|
|
27
|
+
monthlyPercent?: number;
|
|
28
|
+
monthlyResetAt?: number;
|
|
29
|
+
customWindows?: ReadonlyArray<{ label: string; percent: number; resetAt?: number }>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function window(
|
|
33
|
+
label: string,
|
|
34
|
+
percent: number | undefined,
|
|
35
|
+
resetAt: number | undefined,
|
|
36
|
+
windowSeconds?: number,
|
|
37
|
+
): QuotaWindowObservation | null {
|
|
38
|
+
// A window with neither a percent nor a clock carries no information. Emitting it would
|
|
39
|
+
// only create a baseline that can never produce a transition.
|
|
40
|
+
if (percent === undefined && resetAt === undefined) return null;
|
|
41
|
+
return {
|
|
42
|
+
window: label,
|
|
43
|
+
...(percent !== undefined ? { percent } : {}),
|
|
44
|
+
...(resetAt !== undefined ? { resetAt } : {}),
|
|
45
|
+
...(typeof windowSeconds === "number" && Number.isFinite(windowSeconds) && windowSeconds > 0
|
|
46
|
+
? { windowSeconds }
|
|
47
|
+
: {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Nominal lengths for the fixed labels, in seconds.
|
|
53
|
+
*
|
|
54
|
+
* The detector uses a window length only to bound how much of a percent drop natural decay
|
|
55
|
+
* can explain in a ROLLING window. Upstream states a length for the short window
|
|
56
|
+
* (shortWindowSeconds) but not for the others, so these supply it. Weekly and monthly are
|
|
57
|
+
* calendar-anchored rather than rolling on every provider observed so far, but stating a
|
|
58
|
+
* length is still the conservative choice: it can only suppress a drop that is small relative
|
|
59
|
+
* to the elapsed share of a WEEK, which no genuine reset is.
|
|
60
|
+
*/
|
|
61
|
+
const NOMINAL_WINDOW_SECONDS: Readonly<Record<string, number>> = {
|
|
62
|
+
"5h": 5 * 3600,
|
|
63
|
+
weekly: 7 * 24 * 3600,
|
|
64
|
+
monthly: 30 * 24 * 3600,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function customWindows(
|
|
68
|
+
entries: ReadonlyArray<{ label: string; percent: number; resetAt?: number }> | undefined,
|
|
69
|
+
): QuotaWindowObservation[] {
|
|
70
|
+
if (!entries) return [];
|
|
71
|
+
const out: QuotaWindowObservation[] = [];
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (typeof entry?.label !== "string") continue;
|
|
74
|
+
const mapped = window(`custom:${entry.label}`, entry.percent, entry.resetAt);
|
|
75
|
+
if (mapped) out.push(mapped);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Windows absent from the snapshot are omitted, never zero-filled: absence is not 0%. */
|
|
81
|
+
export function codexWindowObservations(quota: CodexLikeQuota): QuotaWindowObservation[] {
|
|
82
|
+
const out: QuotaWindowObservation[] = [];
|
|
83
|
+
for (const mapped of [
|
|
84
|
+
// Prefer the length upstream states for the short window; fall back to the nominal 5h.
|
|
85
|
+
window("5h", quota.shortPercent, quota.shortResetAt, quota.shortWindowSeconds ?? NOMINAL_WINDOW_SECONDS["5h"]),
|
|
86
|
+
window("weekly", quota.weeklyPercent, quota.weeklyResetAt, NOMINAL_WINDOW_SECONDS["weekly"]),
|
|
87
|
+
window("monthly", quota.monthlyPercent, quota.monthlyResetAt, NOMINAL_WINDOW_SECONDS["monthly"]),
|
|
88
|
+
]) {
|
|
89
|
+
if (mapped) out.push(mapped);
|
|
90
|
+
}
|
|
91
|
+
out.push(...customWindows(quota.customWindows));
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function providerWindowObservations(quota: ProviderLikeQuota): QuotaWindowObservation[] {
|
|
96
|
+
const out: QuotaWindowObservation[] = [];
|
|
97
|
+
for (const mapped of [
|
|
98
|
+
window("5h", quota.fiveHourPercent, quota.fiveHourResetAt, NOMINAL_WINDOW_SECONDS["5h"]),
|
|
99
|
+
window("weekly", quota.weeklyPercent, quota.weeklyResetAt, NOMINAL_WINDOW_SECONDS["weekly"]),
|
|
100
|
+
window("monthly", quota.monthlyPercent, quota.monthlyResetAt, NOMINAL_WINDOW_SECONDS["monthly"]),
|
|
101
|
+
]) {
|
|
102
|
+
if (mapped) out.push(mapped);
|
|
103
|
+
}
|
|
104
|
+
out.push(...customWindows(quota.customWindows));
|
|
105
|
+
return out;
|
|
106
|
+
}
|