@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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-time resolution of the optional quotaResetNotify config section.
|
|
3
|
+
*
|
|
4
|
+
* Cached, because loadConfig is a readFileSync plus a full configSchema.safeParse with no
|
|
5
|
+
* memoization and the enable check runs once per pooled response — a config parse per request
|
|
6
|
+
* for a feature nobody enabled.
|
|
7
|
+
*
|
|
8
|
+
* Keyed on the config file's mtime and size, NOT on captureConfigGeneration. The generation
|
|
9
|
+
* counter only advances when state-store reconciliation runs
|
|
10
|
+
* (src/lib/state-store-sweeper.ts:149, reached from reconcileLiveStateStores on
|
|
11
|
+
* account/provider changes), so editing quotaResetNotify alone would never bump it and the
|
|
12
|
+
* cached answer would stay stale until an unrelated account edit happened to occur. A short
|
|
13
|
+
* TTL bounds the stat call so the hot path does not stat on every single write.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { statSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { loadConfig } from "../config";
|
|
19
|
+
import { getConfigDir } from "../config/paths";
|
|
20
|
+
import type { QuotaResetKind } from "./reset-detector";
|
|
21
|
+
|
|
22
|
+
export type ResolvedQuotaResetNotify = {
|
|
23
|
+
readonly enabled: boolean;
|
|
24
|
+
readonly kinds: ReadonlySet<QuotaResetKind>;
|
|
25
|
+
/** 0 means passive-only: observe live refreshes, never poll. */
|
|
26
|
+
readonly pollMs: number;
|
|
27
|
+
readonly webhookUrl?: string;
|
|
28
|
+
readonly allowPrivateNetwork: boolean;
|
|
29
|
+
readonly timeoutMs: number;
|
|
30
|
+
readonly command?: readonly string[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const ALL_KINDS: ReadonlySet<QuotaResetKind> = new Set(["scheduled", "surprise"]);
|
|
34
|
+
const DEFAULT_POLL_SECONDS = 900;
|
|
35
|
+
/**
|
|
36
|
+
* Matches MIN_INTERVAL_MS in ./reset-poller. The two floors must move together: the resolver's
|
|
37
|
+
* value now reaches setInterval, so a lower floor here would be silently overridden by the
|
|
38
|
+
* poller and an accepted config value would not be the cadence that runs.
|
|
39
|
+
*/
|
|
40
|
+
const MIN_POLL_SECONDS = 600;
|
|
41
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
42
|
+
const MAX_TIMEOUT_MS = 30_000;
|
|
43
|
+
|
|
44
|
+
const DISABLED: ResolvedQuotaResetNotify = Object.freeze({
|
|
45
|
+
enabled: false,
|
|
46
|
+
kinds: ALL_KINDS,
|
|
47
|
+
pollMs: 0,
|
|
48
|
+
allowPrivateNetwork: false,
|
|
49
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
type RawNotify = {
|
|
53
|
+
enabled?: unknown;
|
|
54
|
+
kinds?: unknown;
|
|
55
|
+
pollSeconds?: unknown;
|
|
56
|
+
webhookUrl?: unknown;
|
|
57
|
+
allowPrivateNetwork?: unknown;
|
|
58
|
+
timeoutMs?: unknown;
|
|
59
|
+
command?: unknown;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function positiveInt(value: unknown, fallback: number, min: number, max: number): number {
|
|
63
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
64
|
+
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function nonBlankStrings(value: unknown): readonly string[] | undefined {
|
|
68
|
+
if (!Array.isArray(value)) return undefined;
|
|
69
|
+
const out = value.filter((item): item is string => typeof item === "string" && item.trim() !== "");
|
|
70
|
+
return out.length > 0 ? out : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function resolveQuotaResetNotify(raw: unknown): ResolvedQuotaResetNotify {
|
|
74
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return DISABLED;
|
|
75
|
+
const value = raw as RawNotify;
|
|
76
|
+
|
|
77
|
+
const webhookUrl = typeof value.webhookUrl === "string" && value.webhookUrl.trim() !== ""
|
|
78
|
+
? value.webhookUrl.trim()
|
|
79
|
+
: undefined;
|
|
80
|
+
const command = nonBlankStrings(value.command);
|
|
81
|
+
|
|
82
|
+
// An "enabled" subsystem with nowhere to send is a misconfiguration. Reporting it as off
|
|
83
|
+
// keeps the default-OFF guarantee honest: no sink means no timer and no observation.
|
|
84
|
+
const enabled = value.enabled === true && (webhookUrl !== undefined || command !== undefined);
|
|
85
|
+
if (!enabled) return DISABLED;
|
|
86
|
+
|
|
87
|
+
const requestedKinds = Array.isArray(value.kinds)
|
|
88
|
+
? value.kinds.filter((kind): kind is QuotaResetKind => kind === "scheduled" || kind === "surprise")
|
|
89
|
+
: [];
|
|
90
|
+
const kinds: ReadonlySet<QuotaResetKind> = requestedKinds.length > 0
|
|
91
|
+
? new Set(requestedKinds)
|
|
92
|
+
: ALL_KINDS;
|
|
93
|
+
|
|
94
|
+
const pollSeconds = value.pollSeconds === 0
|
|
95
|
+
? 0
|
|
96
|
+
: positiveInt(value.pollSeconds, DEFAULT_POLL_SECONDS, MIN_POLL_SECONDS, 24 * 60 * 60);
|
|
97
|
+
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
enabled: true,
|
|
100
|
+
kinds,
|
|
101
|
+
pollMs: pollSeconds * 1_000,
|
|
102
|
+
...(webhookUrl !== undefined ? { webhookUrl } : {}),
|
|
103
|
+
allowPrivateNetwork: value.allowPrivateNetwork === true,
|
|
104
|
+
timeoutMs: positiveInt(value.timeoutMs, DEFAULT_TIMEOUT_MS, 100, MAX_TIMEOUT_MS),
|
|
105
|
+
...(command !== undefined ? { command } : {}),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Bounds how often the hot path stats the config file. */
|
|
110
|
+
const STAT_TTL_MS = 5_000;
|
|
111
|
+
|
|
112
|
+
type Cached = {
|
|
113
|
+
checkedAt: number;
|
|
114
|
+
signature: string;
|
|
115
|
+
resolved: ResolvedQuotaResetNotify;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
let cached: Cached | null = null;
|
|
119
|
+
|
|
120
|
+
/** mtime + size of the config file. Cheap, and changes on any edit including an in-place one. */
|
|
121
|
+
function configSignature(): string {
|
|
122
|
+
try {
|
|
123
|
+
const stat = statSync(join(getConfigDir(), "config.json"));
|
|
124
|
+
return `${stat.mtimeMs}:${stat.size}`;
|
|
125
|
+
} catch {
|
|
126
|
+
return "absent";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function currentQuotaResetNotify(): ResolvedQuotaResetNotify {
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
if (cached && now - cached.checkedAt < STAT_TTL_MS) return cached.resolved;
|
|
133
|
+
|
|
134
|
+
const signature = configSignature();
|
|
135
|
+
if (cached && cached.signature === signature) {
|
|
136
|
+
cached.checkedAt = now;
|
|
137
|
+
return cached.resolved;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let resolved = DISABLED;
|
|
141
|
+
try {
|
|
142
|
+
resolved = resolveQuotaResetNotify(
|
|
143
|
+
(loadConfig() as { quotaResetNotify?: unknown }).quotaResetNotify,
|
|
144
|
+
);
|
|
145
|
+
} catch {
|
|
146
|
+
// An unreadable config must not enable a notifier, and must not throw on a quota write.
|
|
147
|
+
}
|
|
148
|
+
cached = { checkedAt: now, signature, resolved };
|
|
149
|
+
return resolved;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function isQuotaResetNotificationEnabled(): boolean {
|
|
153
|
+
return currentQuotaResetNotify().enabled;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function resolveQuotaResetPollMs(): number {
|
|
157
|
+
return currentQuotaResetNotify().pollMs;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function resetQuotaResetNotifyCacheForTests(): void {
|
|
161
|
+
cached = null;
|
|
162
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a quota snapshot becomes a delivered reset notification.
|
|
3
|
+
*
|
|
4
|
+
* Kept behind a lazy import from both quota subsystems: src/codex/quota.ts and
|
|
5
|
+
* src/providers/quota.ts are both statically reachable from
|
|
6
|
+
* src/server/responses/core.ts, so a static edge here would load this module, its config
|
|
7
|
+
* resolution, and its sink registry into every install — the failure AGENTS.md documents for
|
|
8
|
+
* Compatibility Lab, where a six-hop chain pulled ~69 modules onto the core path.
|
|
9
|
+
*
|
|
10
|
+
* Never throws. A detection or delivery failure must not break the quota write that
|
|
11
|
+
* triggered it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
detectQuotaResets,
|
|
16
|
+
quotaAccountTag,
|
|
17
|
+
type QuotaResetEvent,
|
|
18
|
+
type QuotaWindowObservation,
|
|
19
|
+
} from "./reset-detector";
|
|
20
|
+
import {
|
|
21
|
+
claimQuotaReset,
|
|
22
|
+
forgetLastObservedWindows,
|
|
23
|
+
quotaResetAccountSalt,
|
|
24
|
+
recordQuotaResetEvent,
|
|
25
|
+
swapLastObservedWindows,
|
|
26
|
+
} from "./reset-seen-store";
|
|
27
|
+
|
|
28
|
+
export type QuotaResetSink = (event: QuotaResetEvent) => void;
|
|
29
|
+
|
|
30
|
+
let sink: QuotaResetSink | null = null;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Install the delivery sink. Core owns the slot; wp4 registers into it at activation.
|
|
34
|
+
*
|
|
35
|
+
* Until something registers, detection still runs its bookkeeping but delivers nowhere,
|
|
36
|
+
* which is what keeps the subsystem inert on a default install.
|
|
37
|
+
*/
|
|
38
|
+
export function setQuotaResetSink(next: QuotaResetSink | null): void {
|
|
39
|
+
sink = next;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when a sink is installed. Used by the seams to skip work entirely. */
|
|
43
|
+
export function hasQuotaResetSink(): boolean {
|
|
44
|
+
return sink !== null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Observe one committed snapshot for a (scope, account) pair.
|
|
49
|
+
*
|
|
50
|
+
* The baseline comes from the detector's own persisted last-seen map, not from the caller:
|
|
51
|
+
* see swapLastObservedWindows for why neither upstream cache can supply one.
|
|
52
|
+
*/
|
|
53
|
+
export function observeQuotaSnapshot(input: {
|
|
54
|
+
readonly scope: string;
|
|
55
|
+
readonly accountKey: string;
|
|
56
|
+
readonly windows: ReadonlyArray<QuotaWindowObservation>;
|
|
57
|
+
readonly now?: number;
|
|
58
|
+
}): QuotaResetEvent[] {
|
|
59
|
+
try {
|
|
60
|
+
if (!sink) return [];
|
|
61
|
+
if (input.windows.length === 0) return [];
|
|
62
|
+
const now = input.now ?? Date.now();
|
|
63
|
+
const accountTag = quotaAccountTag(input.accountKey, quotaResetAccountSalt());
|
|
64
|
+
// Stamp the observation time as it becomes the baseline. The detector needs the gap
|
|
65
|
+
// between two observations to tell a rolling window's natural decay from a real reset,
|
|
66
|
+
// and only this layer knows when the snapshot was taken.
|
|
67
|
+
const stamped = input.windows.map(window => ({ ...window, observedAt: now }));
|
|
68
|
+
const previous = swapLastObservedWindows(input.scope, accountTag, stamped);
|
|
69
|
+
if (!previous) return [];
|
|
70
|
+
|
|
71
|
+
const detected = detectQuotaResets({
|
|
72
|
+
scope: input.scope,
|
|
73
|
+
accountTag,
|
|
74
|
+
previous,
|
|
75
|
+
next: input.windows,
|
|
76
|
+
now,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const delivered: QuotaResetEvent[] = [];
|
|
80
|
+
for (const event of detected) {
|
|
81
|
+
// Claim BEFORE dispatch. A sink that fails must not cause a re-notification storm on
|
|
82
|
+
// the next poll, and two observers racing one transition must produce one notification.
|
|
83
|
+
if (!claimQuotaReset(event.key, now, event.resetAt)) continue;
|
|
84
|
+
recordQuotaResetEvent(event);
|
|
85
|
+
delivered.push(event);
|
|
86
|
+
try {
|
|
87
|
+
sink(event);
|
|
88
|
+
} catch {
|
|
89
|
+
// A sink is best-effort by contract; its failure is recorded by the sink layer.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return delivered;
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Forget the baseline for a (scope, accountKey) whose quota row was deliberately cleared.
|
|
100
|
+
*
|
|
101
|
+
* Lives here rather than in the store because the account TAG is derived from the account
|
|
102
|
+
* key with the per-install salt, and callers hold the key, not the tag. Runs regardless of
|
|
103
|
+
* whether a sink is installed: a stale baseline written while notifications were enabled
|
|
104
|
+
* must not survive to fire a false reset after they are re-enabled.
|
|
105
|
+
*
|
|
106
|
+
* Never throws, for the same reason observeQuotaSnapshot does not: this is called from
|
|
107
|
+
* sign-out and reauth paths that must complete.
|
|
108
|
+
*/
|
|
109
|
+
export function forgetQuotaBaseline(input: {
|
|
110
|
+
readonly scope: string;
|
|
111
|
+
readonly accountKey?: string;
|
|
112
|
+
}): void {
|
|
113
|
+
try {
|
|
114
|
+
if (input.accountKey === undefined) {
|
|
115
|
+
forgetLastObservedWindows(input.scope);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
forgetLastObservedWindows(
|
|
119
|
+
input.scope,
|
|
120
|
+
quotaAccountTag(input.accountKey, quotaResetAccountSalt()),
|
|
121
|
+
);
|
|
122
|
+
} catch {
|
|
123
|
+
// Best-effort: a failure here can only cost a re-baseline.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in idle refresh so a reset is noticed while nobody is looking.
|
|
3
|
+
*
|
|
4
|
+
* This is load-bearing, not a convenience. fetchProviderQuotaReports has exactly one caller
|
|
5
|
+
* — the GET /api/provider-quotas route — so with no dashboard open and no CLI invocation it
|
|
6
|
+
* never runs, no second snapshot exists, and an overnight reset passes unobserved. That
|
|
7
|
+
* overnight case is the whole reason the subsystem exists.
|
|
8
|
+
*
|
|
9
|
+
* Shape follows src/storage/policy-scheduler.ts: a module-singleton unref'd interval whose
|
|
10
|
+
* config gate lives in the callee, so toggling `enabled` takes effect on the next tick
|
|
11
|
+
* without a restart (the rationale spelled out at src/oauth/token-guardian.ts:276).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const DEFAULT_INTERVAL_MS = 15 * 60_000;
|
|
15
|
+
/**
|
|
16
|
+
* Above the 5-minute provider cache TTL and the 10-minute per-account TTL, so one tick costs
|
|
17
|
+
* at most one upstream probe per window rather than hammering a quota endpoint that
|
|
18
|
+
* rate-limits (src/providers/quota.ts:1425 records an observed 429 under repeated probing).
|
|
19
|
+
*/
|
|
20
|
+
export const MIN_INTERVAL_MS = 10 * 60_000;
|
|
21
|
+
|
|
22
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
23
|
+
let detachShutdownHook: (() => void) | null = null;
|
|
24
|
+
/** The bounded cadence the live timer was created with, so a tick can notice config drift. */
|
|
25
|
+
let liveIntervalMs: number | null = null;
|
|
26
|
+
/**
|
|
27
|
+
* Bumped by every start and stop. A tick captures it on entry and re-checks before publishing,
|
|
28
|
+
* so a probe still in flight when the poller stops cannot publish into the next generation.
|
|
29
|
+
*/
|
|
30
|
+
let generation = 0;
|
|
31
|
+
/** setInterval does not skip a firing while the previous callback is still awaiting. */
|
|
32
|
+
let inFlight = false;
|
|
33
|
+
|
|
34
|
+
/** Number of ticks that have run. Test-only observability; carries no quota data. */
|
|
35
|
+
let tickCount = 0;
|
|
36
|
+
|
|
37
|
+
function boundedInterval(value: number): number {
|
|
38
|
+
return Math.max(MIN_INTERVAL_MS, Math.floor(value));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Re-arm the timer when the operator changed the cadence since it was created. */
|
|
42
|
+
function restartIfCadenceChanged(configured: number): void {
|
|
43
|
+
if (timer === null || boundedInterval(configured) === liveIntervalMs) return;
|
|
44
|
+
stopQuotaResetPoller();
|
|
45
|
+
startQuotaResetPoller(configured);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function tick(): Promise<void> {
|
|
49
|
+
// An interval firing while the previous probe is still awaiting would double the upstream
|
|
50
|
+
// load precisely when it is already slow.
|
|
51
|
+
if (inFlight) return;
|
|
52
|
+
inFlight = true;
|
|
53
|
+
const entryGeneration = generation;
|
|
54
|
+
try {
|
|
55
|
+
const { isQuotaResetNotificationEnabled, resolveQuotaResetPollMs } = await import(
|
|
56
|
+
"./reset-notify-config"
|
|
57
|
+
);
|
|
58
|
+
// Re-sync the sink on every tick, before the enable check bails out. This is what makes
|
|
59
|
+
// enabling or disabling notifications take effect without a restart: an install that starts
|
|
60
|
+
// with the section absent has no sink, and the seams therefore skip all work, so something
|
|
61
|
+
// has to notice the operator turned it on. Cheap — the resolver is mtime-cached.
|
|
62
|
+
const { syncQuotaResetActivation } = await import("./reset-activation");
|
|
63
|
+
await syncQuotaResetActivation();
|
|
64
|
+
if (!isQuotaResetNotificationEnabled()) return;
|
|
65
|
+
const configured = resolveQuotaResetPollMs();
|
|
66
|
+
if (configured === 0) return;
|
|
67
|
+
// A stop or restart landed while the config resolved: this tick no longer owns the timer,
|
|
68
|
+
// so it must neither count as a probe nor adopt a cadence for a generation that is gone.
|
|
69
|
+
if (entryGeneration !== generation) return;
|
|
70
|
+
// Adopt a changed cadence without a restart, which is why the config gate lives in the
|
|
71
|
+
// callee at all. Only while this tick still owns the timer.
|
|
72
|
+
restartIfCadenceChanged(configured);
|
|
73
|
+
tickCount += 1;
|
|
74
|
+
const [{ loadConfig }, { fetchProviderQuotaReports }] = await Promise.all([
|
|
75
|
+
import("../config"),
|
|
76
|
+
import("../providers/quota"),
|
|
77
|
+
]);
|
|
78
|
+
// A stop or restart landed while this probe was in flight: the result belongs to a
|
|
79
|
+
// generation that no longer owns the timer, so it must not publish.
|
|
80
|
+
if (entryGeneration !== generation) return;
|
|
81
|
+
// Forced: an unforced call would be served from the 5-minute cache and produce no new
|
|
82
|
+
// observation at all.
|
|
83
|
+
await fetchProviderQuotaReports(loadConfig(), true);
|
|
84
|
+
} catch {
|
|
85
|
+
// A failed probe is not an error worth surfacing: the next tick tries again.
|
|
86
|
+
} finally {
|
|
87
|
+
inFlight = false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Idempotent. A second call while running is a no-op, matching startStorageCleanupScheduler. */
|
|
92
|
+
export function startQuotaResetPoller(intervalMs = DEFAULT_INTERVAL_MS): void {
|
|
93
|
+
if (timer) return;
|
|
94
|
+
const bounded = boundedInterval(intervalMs);
|
|
95
|
+
generation += 1;
|
|
96
|
+
liveIntervalMs = bounded;
|
|
97
|
+
timer = setInterval(() => void tick(), bounded);
|
|
98
|
+
// Never keep the process alive for a quota probe.
|
|
99
|
+
timer.unref?.();
|
|
100
|
+
void import("../lib/optional-shutdown-hooks")
|
|
101
|
+
.then(hooks => {
|
|
102
|
+
detachShutdownHook = hooks.registerOptionalShutdownHook(
|
|
103
|
+
"quota-reset-poller",
|
|
104
|
+
stopQuotaResetPoller,
|
|
105
|
+
);
|
|
106
|
+
})
|
|
107
|
+
.catch(() => {
|
|
108
|
+
// Without the hook the unref'd timer still cannot delay exit.
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function stopQuotaResetPoller(): void {
|
|
113
|
+
if (timer) {
|
|
114
|
+
clearInterval(timer);
|
|
115
|
+
timer = null;
|
|
116
|
+
}
|
|
117
|
+
liveIntervalMs = null;
|
|
118
|
+
generation += 1;
|
|
119
|
+
detachShutdownHook?.();
|
|
120
|
+
detachShutdownHook = null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function isQuotaResetPollerRunning(): boolean {
|
|
124
|
+
return timer !== null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Adopt the operator's configured cadence at startup.
|
|
129
|
+
*
|
|
130
|
+
* The caller starts the poller synchronously with the default interval, because resolving the
|
|
131
|
+
* config here would mean a static edge to ../config from a module the quota boundary guard
|
|
132
|
+
* requires to have none. Resolving it through import() keeps that edge dynamic, at the cost of
|
|
133
|
+
* the timer running at the default for the few microtasks before this settles.
|
|
134
|
+
*/
|
|
135
|
+
export async function syncQuotaResetPollerCadence(): Promise<void> {
|
|
136
|
+
const { resolveQuotaResetPollMs } = await import("./reset-notify-config");
|
|
137
|
+
const configured = resolveQuotaResetPollMs();
|
|
138
|
+
// 0 is passive-only: tick() already returns before probing, and the timer stays unref'd.
|
|
139
|
+
if (configured === 0) return;
|
|
140
|
+
restartIfCadenceChanged(configured);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Test-only: run one tick synchronously rather than waiting out the interval. */
|
|
144
|
+
export async function runQuotaResetPollerTickForTests(): Promise<void> {
|
|
145
|
+
await tick();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function quotaResetPollerTickCountForTests(): number {
|
|
149
|
+
return tickCount;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Test-only: the bounded cadence the live timer is running at, or null when stopped. */
|
|
153
|
+
export function quotaResetPollerIntervalForTests(): number | null {
|
|
154
|
+
return liveIntervalMs;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function resetQuotaResetPollerForTests(): void {
|
|
158
|
+
stopQuotaResetPoller();
|
|
159
|
+
tickCount = 0;
|
|
160
|
+
}
|