@bitkyc08/opencodex 2.52.0-preview.20260912 → 2.53.0-preview.20260913
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/index-BBOZWGB6.css +1 -0
- package/gui/dist/assets/index-D7ynYo2K.js +128 -0
- package/gui/dist/index.html +2 -2
- package/native/remote-workspace-helper/Cargo.lock +130 -0
- package/native/remote-workspace-helper/Cargo.toml +24 -0
- package/native/remote-workspace-helper/src/main.rs +49 -0
- package/native/remote-workspace-helper/src/protocol.rs +246 -0
- package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
- package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
- package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
- package/package.json +6 -1
- package/src/adapters/anthropic-image-normalize.ts +30 -2
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/base.ts +8 -2
- package/src/adapters/cursor/cursor-errors.ts +12 -0
- package/src/adapters/cursor/thread-continuity.ts +93 -0
- package/src/adapters/cursor.ts +104 -73
- package/src/adapters/devin/cloud-direct/chat.ts +312 -23
- package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
- package/src/adapters/devin/live-models.ts +70 -3
- package/src/adapters/devin.ts +281 -21
- package/src/adapters/google-wire-compiler.ts +14 -6
- package/src/adapters/google.ts +22 -8
- package/src/adapters/kiro/adapter.ts +316 -0
- package/src/adapters/kiro/conversation.ts +136 -0
- package/src/adapters/kiro/payload.ts +432 -0
- package/src/adapters/kiro/reasoning.ts +56 -0
- package/src/adapters/kiro/stream.ts +1153 -0
- package/src/adapters/kiro/usage.ts +223 -0
- package/src/adapters/kiro/wire.ts +76 -0
- package/src/adapters/kiro.ts +8 -2319
- package/src/adapters/mimo-free.ts +1 -1
- package/src/adapters/openai-chat-images.ts +101 -0
- package/src/adapters/openai-chat.ts +201 -181
- package/src/adapters/openai-responses.ts +92 -224
- package/src/adapters/registry.ts +0 -7
- package/src/adapters/run-turn-queue.ts +13 -6
- package/src/bridge.ts +14 -15
- package/src/chat/inbound.ts +29 -4
- package/src/chat/outbound.ts +145 -107
- package/src/claude/desktop-profile.ts +4 -6
- package/src/cli/account-api.ts +14 -0
- package/src/cli/account-extended.ts +1 -1
- package/src/cli/account-history.ts +60 -0
- package/src/cli/account-main.ts +80 -0
- package/src/cli/account.ts +11 -3
- package/src/cli/capabilities.ts +113 -0
- package/src/cli/catalog.ts +109 -0
- package/src/cli/dispatch.ts +9 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +2 -2
- package/src/cli/observe.ts +28 -1
- package/src/cli/opencode.ts +42 -8
- package/src/cli/provider-runtime.ts +11 -1
- package/src/cli/provider.ts +22 -2
- package/src/cli/registry.ts +21 -0
- package/src/cli/remote-workspace.ts +154 -0
- package/src/cli/status.ts +39 -7
- package/src/cli/usage-report.ts +14 -2
- package/src/client/hub-client.ts +34 -0
- package/src/client/hub-state.ts +9 -1
- package/src/codex/account-store.ts +78 -0
- package/src/codex/auth-api.ts +81 -54
- package/src/codex/auth-context.ts +45 -16
- package/src/codex/catalog/effort.ts +1 -1
- package/src/codex/catalog/metadata.ts +3 -6
- package/src/codex/catalog/native-models.ts +4 -4
- package/src/codex/catalog/parsing.ts +2 -20
- package/src/codex/catalog/provider-fetch.ts +10 -1
- package/src/codex/catalog/remote.ts +233 -0
- package/src/codex/catalog/sync.ts +403 -35
- package/src/codex/convergence.ts +1 -1
- package/src/codex/history-manifest.ts +36 -0
- package/src/codex/history-provider.ts +32 -5
- package/src/codex/inject.ts +9 -0
- package/src/codex/main-account.ts +113 -0
- package/src/codex/main-device-reauth-api.ts +89 -0
- package/src/codex/main-device-reauth.ts +217 -0
- package/src/codex/native-residue.ts +9 -2
- package/src/codex/quota-auto-refresh.ts +3 -2
- package/src/codex/quota-capacity.ts +98 -0
- package/src/codex/quota-history.ts +160 -0
- package/src/codex/quota-types.ts +8 -0
- package/src/codex/quota.ts +118 -91
- package/src/codex/refresh.ts +2 -1
- package/src/codex/routing.ts +90 -17
- package/src/codex/sync.ts +33 -4
- package/src/combos/request.ts +19 -1
- package/src/config/multi-agent-surface.ts +61 -0
- package/src/config/provider-validation.ts +176 -0
- package/src/config.ts +213 -11
- package/src/generated/compatibility-version.json +436 -168
- package/src/images/loop.ts +119 -36
- package/src/lib/admission.ts +12 -6
- package/src/lib/redact.ts +7 -0
- package/src/lib/translator-budget.ts +4 -3
- package/src/lib/windows-atomic-replace.ts +1 -0
- package/src/lib/windows-elevation.ts +1 -1
- package/src/oauth/chatgpt-device.ts +62 -5
- package/src/oauth/devin/cli-import.ts +130 -0
- package/src/oauth/devin.ts +63 -8
- package/src/oauth/index.ts +29 -14
- package/src/oauth/kiro.ts +18 -6
- package/src/oauth/login-cli.ts +9 -1
- package/src/oauth/meta-muse-device.ts +464 -0
- package/src/oauth/meta-muse.ts +123 -32
- package/src/oauth/pool-kernel.ts +9 -0
- package/src/oauth/pool-settings-capability.ts +2 -2
- package/src/oauth/store.ts +57 -0
- package/src/oauth/types.ts +31 -0
- package/src/providers/derive.ts +13 -3
- package/src/providers/devin-cli-authmode-migration.ts +57 -35
- package/src/providers/devin-provider-merge-migration.ts +240 -0
- package/src/providers/muse-key-quota.ts +117 -0
- package/src/providers/muse-subscription-usage.ts +14 -2
- package/src/providers/openai-sidecar.ts +25 -3
- package/src/providers/opencode-zen-rate-limit.ts +58 -0
- package/src/providers/provider-id-rewrite.ts +20 -5
- package/src/providers/quota-types.ts +12 -0
- package/src/providers/quota.ts +143 -102
- package/src/providers/reasoning-metadata.ts +543 -0
- package/src/providers/registry.ts +80 -49
- package/src/reasoning-effort.ts +26 -2
- package/src/remote/hub-usage.ts +32 -0
- package/src/remote-control/index.ts +192 -41
- package/src/remote-control/workspace-activation.ts +9 -0
- package/src/remote-control/workspace-agent-connection.ts +366 -0
- package/src/remote-control/workspace-claude-runtime.ts +243 -0
- package/src/remote-control/workspace-codex-runtime.ts +531 -0
- package/src/remote-control/workspace-codex-sandbox.ts +115 -0
- package/src/remote-control/workspace-command-runner.ts +748 -0
- package/src/remote-control/workspace-coordinator.ts +231 -0
- package/src/remote-control/workspace-device.ts +585 -0
- package/src/remote-control/workspace-executable.ts +43 -0
- package/src/remote-control/workspace-executor.ts +397 -0
- package/src/remote-control/workspace-hub.ts +519 -0
- package/src/remote-control/workspace-pi-runtime.ts +382 -0
- package/src/remote-control/workspace-process.ts +129 -0
- package/src/remote-control/workspace-rpc.ts +304 -0
- package/src/remote-control/workspace-runtime.ts +60 -0
- package/src/remote-control/workspace-secret-store.ts +39 -0
- package/src/remote-control/workspace-sessions.ts +799 -0
- package/src/remote-control/workspace-tool-bridge.ts +192 -0
- package/src/responses/code-mode-helper-compat.ts +22 -3
- package/src/responses/hosted-tool-policy.ts +0 -1
- package/src/responses/muse-tool-name-alias.ts +379 -0
- package/src/responses/plaintext-v2-agent-messages.ts +902 -0
- package/src/router.ts +7 -0
- package/src/routing/compatibility/behavior.ts +0 -1
- package/src/server/audio-client.ts +64 -0
- package/src/server/audio-dictation.ts +91 -0
- package/src/server/audio-live.ts +185 -0
- package/src/server/audio-transcriptions.ts +183 -0
- package/src/server/audio-upstream.ts +153 -0
- package/src/server/auth-cors.ts +61 -2
- package/src/server/chat-completions.ts +1 -1
- package/src/server/chat-native-sse.ts +92 -48
- package/src/server/chat-native.ts +37 -15
- package/src/server/hub-usage.ts +57 -0
- package/src/server/images.ts +4 -0
- package/src/server/index.ts +722 -57
- package/src/server/lifecycle.ts +5 -6
- package/src/server/live-call-bindings.ts +60 -0
- package/src/server/live.ts +12 -1
- package/src/server/management/agent-settings-routes.ts +25 -4
- package/src/server/management/api-access.ts +37 -0
- package/src/server/management/api-key-usage.ts +7 -2
- package/src/server/management/config-routes.ts +1 -18
- package/src/server/management/context.ts +15 -0
- package/src/server/management/logs-usage-routes.ts +2 -0
- package/src/server/management/oauth-account-routes.ts +39 -12
- package/src/server/management/provider-routes.ts +125 -2
- package/src/server/management/remote-workspace-routes.ts +140 -0
- package/src/server/management/route-registry.ts +15 -0
- package/src/server/management/usage-aggregate-cache.ts +14 -15
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management-api.ts +23 -0
- package/src/server/ports.ts +17 -0
- package/src/server/relay-eager.ts +4 -1
- package/src/server/relay.ts +70 -10
- package/src/server/request-decompress.ts +6 -3
- package/src/server/responses/agent-task-recovery.ts +25 -32
- package/src/server/responses/codex-auth-error.ts +11 -0
- package/src/server/responses/codex-ws-exchange.ts +52 -3
- package/src/server/responses/codex-ws-wire.ts +55 -0
- package/src/server/responses/compact.ts +9 -1
- package/src/server/responses/core.ts +337 -73
- package/src/server/responses/encrypted-payload.ts +45 -2
- package/src/server/responses/ws-upstream.ts +4 -1
- package/src/server/responses-self-named-namespace-scrub.ts +1 -3
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- package/src/server/search.ts +3 -0
- package/src/server/sse-payload-rewrite.ts +136 -51
- package/src/server/ws-bridge.ts +35 -1
- package/src/service/cli.ts +372 -0
- package/src/service/diagnostics.ts +340 -0
- package/src/service/guards.ts +303 -0
- package/src/service/health.ts +222 -0
- package/src/service/launchd.ts +853 -0
- package/src/service/orchestration.ts +617 -0
- package/src/service/repair.ts +334 -0
- package/src/service/state.ts +363 -0
- package/src/service/systemd.ts +229 -0
- package/src/service/windows-ops.ts +690 -0
- package/src/service/windows-scheduler.ts +769 -0
- package/src/service/windows-taskxml.ts +613 -0
- package/src/service.ts +22 -5550
- package/src/storage/cleanup/db.ts +258 -0
- package/src/storage/cleanup/execute.ts +358 -0
- package/src/storage/cleanup/paths.ts +189 -0
- package/src/storage/cleanup/pending.ts +140 -0
- package/src/storage/cleanup/preview.ts +292 -0
- package/src/storage/cleanup/reconcile.ts +347 -0
- package/src/storage/cleanup/restore.ts +932 -0
- package/src/storage/cleanup/satellite.ts +474 -0
- package/src/storage/cleanup/staging.ts +129 -0
- package/src/storage/cleanup/types.ts +98 -0
- package/src/storage/cleanup.ts +49 -3127
- package/src/types/accounts.ts +2 -0
- package/src/types/config.ts +13 -12
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +2 -0
- package/src/types/tools.ts +17 -5
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +127 -0
- package/src/usage/log.ts +58 -1
- package/src/vision/eligibility.ts +13 -2
- package/src/web-search/loop.ts +56 -3
- package/gui/dist/assets/index-D_t6sCWs.js +0 -115
- package/gui/dist/assets/index-EdoPnm9_.css +0 -1
- package/src/adapters/devin-cli/acp.ts +0 -204
- package/src/adapters/devin-cli/adapter.ts +0 -345
- package/src/adapters/devin-cli/binary.ts +0 -69
- package/src/adapters/devin-cli/models.ts +0 -57
- package/src/oauth/devin-cli.ts +0 -149
- package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { QuotaHistorySample, QuotaHistoryWindow } from "./quota-history";
|
|
2
|
+
import type { PersistedUsageAttempt, PersistedUsageEntry } from "../usage/log";
|
|
3
|
+
|
|
4
|
+
export const CAPACITY_ASSUMPTIONS = [
|
|
5
|
+
"Quota percentages can be rounded or delayed.",
|
|
6
|
+
"Only retained valid proxy usage rows are observed; external usage is unknown.",
|
|
7
|
+
"Account log labels are assumed stable within each observation interval.",
|
|
8
|
+
"This low-confidence effective-token estimate is not a provider token limit or lower bound.",
|
|
9
|
+
] as const;
|
|
10
|
+
export const CAPACITY_REASONS = ["insufficient_intervals", "ledger_unavailable", "ledger_truncated", "identity_unavailable", "identity_changed", "ambiguous_usage"] as const;
|
|
11
|
+
export type CapacityReason = typeof CAPACITY_REASONS[number];
|
|
12
|
+
export function parseCapacityReason(value: unknown): CapacityReason | undefined {
|
|
13
|
+
return CAPACITY_REASONS.find(reason => reason === value);
|
|
14
|
+
}
|
|
15
|
+
export interface CodexCapacityResult {
|
|
16
|
+
status: "estimated" | "insufficient-evidence";
|
|
17
|
+
estimates: Array<{ window: QuotaHistoryWindow["window"]; estimatedTokens: number; sampleCount: number; confidence: "low" }>;
|
|
18
|
+
reason?: CapacityReason;
|
|
19
|
+
assumptions: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
export function insufficientCodexCapacity(reason: CapacityReason): CodexCapacityResult {
|
|
22
|
+
return { status: "insufficient-evidence", estimates: [], reason, assumptions: [...CAPACITY_ASSUMPTIONS] };
|
|
23
|
+
}
|
|
24
|
+
const nonnegative = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
25
|
+
|
|
26
|
+
function reportedTokens(attempt: PersistedUsageAttempt): number | undefined {
|
|
27
|
+
if (attempt.sendCount !== 1 || attempt.usageStatus !== "reported" || attempt.locallyAnswered === true
|
|
28
|
+
|| !attempt.usage || attempt.usage.estimated === true
|
|
29
|
+
|| !nonnegative(attempt.usage.inputTokens) || !nonnegative(attempt.usage.outputTokens)) return undefined;
|
|
30
|
+
const total = attempt.usage.totalTokens ?? attempt.usage.inputTokens + attempt.usage.outputTokens;
|
|
31
|
+
return nonnegative(total) ? total : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Informational inference over raw same-window observations, never an account-selection input. */
|
|
35
|
+
export function estimateCodexQuotaCapacity(
|
|
36
|
+
observations: ReadonlyArray<Omit<QuotaHistorySample, "credentialGeneration">>,
|
|
37
|
+
entries: readonly PersistedUsageEntry[],
|
|
38
|
+
label: string,
|
|
39
|
+
sharedQuotaModel: (model: string) => boolean,
|
|
40
|
+
): CodexCapacityResult {
|
|
41
|
+
if (entries.length > 10_000) return insufficientCodexCapacity("ledger_truncated");
|
|
42
|
+
const requests = new Map<string, PersistedUsageEntry>();
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
const previous = requests.get(entry.requestId);
|
|
45
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(entry)) return insufficientCodexCapacity("ambiguous_usage");
|
|
46
|
+
requests.set(entry.requestId, entry);
|
|
47
|
+
}
|
|
48
|
+
const sorted = [...observations].sort((a, b) => a.observedAt - b.observedAt);
|
|
49
|
+
const estimates: CodexCapacityResult["estimates"] = [];
|
|
50
|
+
for (const windowName of ["short", "weekly", "monthly"] as const) {
|
|
51
|
+
const points = sorted.flatMap(row => {
|
|
52
|
+
const window = row.windows.find(candidate => candidate.family === "account" && candidate.window === windowName);
|
|
53
|
+
return window ? [{ ...window, at: row.observedAt, source: row.source }] : [];
|
|
54
|
+
});
|
|
55
|
+
const samples: number[] = [];
|
|
56
|
+
for (let index = 1; index < points.length; index++) {
|
|
57
|
+
const left = points[index - 1], right = points[index];
|
|
58
|
+
const delta = right.usedPercent - left.usedPercent;
|
|
59
|
+
if (left.source !== right.source || !nonnegative(left.resetAtMs) || left.resetAtMs !== right.resetAtMs
|
|
60
|
+
|| left.resetAtMs <= right.at || left.windowSeconds !== right.windowSeconds
|
|
61
|
+
|| left.monthlyIsPrimaryWindow !== right.monthlyIsPrimaryWindow
|
|
62
|
+
|| right.at <= left.at || delta < 1 || delta > 100 || !Number.isFinite(delta)) continue;
|
|
63
|
+
let tokens = 0;
|
|
64
|
+
let valid = true;
|
|
65
|
+
for (const entry of requests.values()) {
|
|
66
|
+
if (!nonnegative(entry.timestamp) || !nonnegative(entry.durationMs)) continue;
|
|
67
|
+
const end = entry.timestamp + entry.durationMs;
|
|
68
|
+
if (!Number.isFinite(end) || entry.timestamp <= left.at || end > right.at) continue;
|
|
69
|
+
// Untimed or absent physical-attempt evidence cannot be reconstructed from parent totals.
|
|
70
|
+
if (!entry.attempts?.length) continue;
|
|
71
|
+
const attempts = new Map<number, PersistedUsageAttempt>();
|
|
72
|
+
for (const attempt of entry.attempts) {
|
|
73
|
+
const prior = attempts.get(attempt.ordinal);
|
|
74
|
+
if (prior && JSON.stringify(prior) !== JSON.stringify(attempt)) { valid = false; break; }
|
|
75
|
+
attempts.set(attempt.ordinal, attempt);
|
|
76
|
+
}
|
|
77
|
+
if (!valid) break;
|
|
78
|
+
for (const attempt of attempts.values()) {
|
|
79
|
+
if (attempt.accountLogLabel !== label || attempt.adapter !== "openai-responses" || !sharedQuotaModel(attempt.model)) continue;
|
|
80
|
+
const reported = reportedTokens(attempt);
|
|
81
|
+
if (reported === undefined) continue;
|
|
82
|
+
tokens += reported;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const inferred = tokens * 100 / delta;
|
|
86
|
+
if (valid && tokens > 0 && Number.isFinite(inferred) && inferred > 0) samples.push(inferred);
|
|
87
|
+
}
|
|
88
|
+
if (samples.length) {
|
|
89
|
+
samples.sort((a, b) => a - b);
|
|
90
|
+
const middle = Math.floor(samples.length / 2);
|
|
91
|
+
const median = samples.length % 2 ? samples[middle] : samples[middle - 1] / 2 + samples[middle] / 2;
|
|
92
|
+
const estimatedTokens = Math.round(median);
|
|
93
|
+
if (estimatedTokens > 0) estimates.push({ window: windowName, estimatedTokens, sampleCount: samples.length, confidence: "low" });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return estimates.length ? { status: "estimated", estimates, assumptions: [...CAPACITY_ASSUMPTIONS] }
|
|
97
|
+
: insufficientCodexCapacity("insufficient_intervals");
|
|
98
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { isValidCodexAccountId } from "./account-id";
|
|
2
|
+
import type { PoolQuotaWriter } from "./quota-types";
|
|
3
|
+
|
|
4
|
+
export const QUOTA_HISTORY_LIMITS = { perAccount: 200, accounts: 64, samples: 4096, bytes: 2 * 1024 * 1024, ageMs: 30 * 86400_000 } as const;
|
|
5
|
+
export interface QuotaHistoryWindow {
|
|
6
|
+
family: "account" | "spark";
|
|
7
|
+
window: "short" | "weekly" | "monthly";
|
|
8
|
+
usedPercent: number;
|
|
9
|
+
resetAtMs?: number;
|
|
10
|
+
windowSeconds?: number;
|
|
11
|
+
monthlyIsPrimaryWindow?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface QuotaHistorySample {
|
|
14
|
+
observedAt: number;
|
|
15
|
+
source: "wham" | "response-header";
|
|
16
|
+
credentialGeneration: number;
|
|
17
|
+
windows: QuotaHistoryWindow[];
|
|
18
|
+
}
|
|
19
|
+
type Envelope = { identity: string; samples: QuotaHistorySample[] };
|
|
20
|
+
type Bucket = Envelope & { costs: number[]; overhead: number };
|
|
21
|
+
const encoder = new TextEncoder();
|
|
22
|
+
const byteSize = (value: unknown) => encoder.encode(JSON.stringify(value)).byteLength;
|
|
23
|
+
const identityPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
24
|
+
const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
25
|
+
const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
26
|
+
|
|
27
|
+
/** Reconstruct allowlisted data at the disk boundary; malformed windows cannot be partial evidence. */
|
|
28
|
+
function parseSample(value: unknown, now: number): QuotaHistorySample | undefined {
|
|
29
|
+
if (!record(value) || !finite(value.observedAt) || value.observedAt > now
|
|
30
|
+
|| value.observedAt < now - QUOTA_HISTORY_LIMITS.ageMs
|
|
31
|
+
|| !Number.isSafeInteger(value.credentialGeneration) || (value.credentialGeneration as number) < 0
|
|
32
|
+
|| (value.source !== "wham" && value.source !== "response-header")
|
|
33
|
+
|| !Array.isArray(value.windows) || !value.windows.length || value.windows.length > 5) return undefined;
|
|
34
|
+
const windows: QuotaHistoryWindow[] = [];
|
|
35
|
+
const seen = new Set<string>();
|
|
36
|
+
for (const item of value.windows) {
|
|
37
|
+
if (!record(item) || (item.family !== "account" && item.family !== "spark")
|
|
38
|
+
|| (item.window !== "short" && item.window !== "weekly" && item.window !== "monthly")
|
|
39
|
+
|| (item.family === "spark" && item.window === "monthly")
|
|
40
|
+
|| !finite(item.usedPercent) || item.usedPercent > 100) return undefined;
|
|
41
|
+
const key = `${item.family}:${item.window}`;
|
|
42
|
+
if (seen.has(key)) return undefined;
|
|
43
|
+
seen.add(key);
|
|
44
|
+
if ((item.resetAtMs !== undefined && !finite(item.resetAtMs))
|
|
45
|
+
|| (item.windowSeconds !== undefined && (!finite(item.windowSeconds) || item.windowSeconds === 0))
|
|
46
|
+
|| (item.monthlyIsPrimaryWindow !== undefined && typeof item.monthlyIsPrimaryWindow !== "boolean")) return undefined;
|
|
47
|
+
windows.push({ family: item.family, window: item.window, usedPercent: item.usedPercent,
|
|
48
|
+
...(item.resetAtMs !== undefined ? { resetAtMs: item.resetAtMs as number } : {}),
|
|
49
|
+
...(item.windowSeconds !== undefined ? { windowSeconds: item.windowSeconds as number } : {}),
|
|
50
|
+
...(item.monthlyIsPrimaryWindow === true && item.family === "account" && item.window === "monthly" ? { monthlyIsPrimaryWindow: true } : {}),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return { observedAt: value.observedAt, source: value.source, credentialGeneration: value.credentialGeneration as number, windows };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Bounded in-process observations. The quota cache owns persistence and credential admission. */
|
|
57
|
+
export class CodexQuotaHistory {
|
|
58
|
+
private accounts = new Map<string, Bucket>();
|
|
59
|
+
private bytes = 32;
|
|
60
|
+
private count = 0;
|
|
61
|
+
|
|
62
|
+
append(writer: PoolQuotaWriter, sample: QuotaHistorySample, now = Date.now()): void {
|
|
63
|
+
if (!isValidCodexAccountId(writer.accountId) || !identityPattern.test(writer.historyIdentity)
|
|
64
|
+
|| sample.credentialGeneration !== writer.credentialGeneration) return;
|
|
65
|
+
const parsed = parseSample(sample, now);
|
|
66
|
+
if (!parsed) return;
|
|
67
|
+
let bucket = this.accounts.get(writer.accountId);
|
|
68
|
+
if (bucket && bucket.identity !== writer.historyIdentity) { this.clear(writer.accountId); bucket = undefined; }
|
|
69
|
+
if (!bucket) {
|
|
70
|
+
const overhead = byteSize(writer.accountId) + byteSize({ identity: writer.historyIdentity, samples: [] }) + 8;
|
|
71
|
+
bucket = { identity: writer.historyIdentity, samples: [], costs: [], overhead };
|
|
72
|
+
this.accounts.set(writer.accountId, bucket);
|
|
73
|
+
this.bytes += overhead;
|
|
74
|
+
}
|
|
75
|
+
const index = bucket.samples.findIndex(row => row.observedAt > parsed.observedAt);
|
|
76
|
+
const position = index < 0 ? bucket.samples.length : index;
|
|
77
|
+
const cost = byteSize(parsed) + 1;
|
|
78
|
+
bucket.samples.splice(position, 0, parsed);
|
|
79
|
+
bucket.costs.splice(position, 0, cost);
|
|
80
|
+
this.bytes += cost;
|
|
81
|
+
this.count++;
|
|
82
|
+
while (bucket.samples.length > QUOTA_HISTORY_LIMITS.perAccount) this.dropFirst(writer.accountId);
|
|
83
|
+
this.prune(now);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
read(accountId: string, identity: string | undefined, now = Date.now(), limit: number = QUOTA_HISTORY_LIMITS.perAccount): { samples: QuotaHistorySample[]; truncated: boolean } {
|
|
87
|
+
this.prune(now);
|
|
88
|
+
const bucket = this.accounts.get(accountId);
|
|
89
|
+
if (!identity || !bucket) return { samples: [], truncated: false };
|
|
90
|
+
if (bucket.identity !== identity) { this.clear(accountId); return { samples: [], truncated: false }; }
|
|
91
|
+
const capped = Math.max(1, Math.min(QUOTA_HISTORY_LIMITS.perAccount, Math.trunc(limit)));
|
|
92
|
+
return { samples: structuredClone(bucket.samples.slice(-capped)), truncated: bucket.samples.length > capped };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
clear(accountId?: string): number {
|
|
96
|
+
if (accountId === undefined) {
|
|
97
|
+
const count = this.accounts.size;
|
|
98
|
+
this.accounts.clear(); this.count = 0; this.bytes = 32;
|
|
99
|
+
return count;
|
|
100
|
+
}
|
|
101
|
+
const bucket = this.accounts.get(accountId);
|
|
102
|
+
if (!bucket) return 0;
|
|
103
|
+
this.bytes -= bucket.overhead + bucket.costs.reduce((a, b) => a + b, 0);
|
|
104
|
+
this.count -= bucket.samples.length;
|
|
105
|
+
this.accounts.delete(accountId);
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
reconcile(ids: ReadonlySet<string>): number {
|
|
110
|
+
let removed = 0;
|
|
111
|
+
for (const id of this.accounts.keys()) if (!ids.has(id)) removed += this.clear(id);
|
|
112
|
+
return removed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
serialize(now = Date.now()): { version: 1; accounts: Record<string, Envelope> } {
|
|
116
|
+
this.prune(now);
|
|
117
|
+
return { version: 1, accounts: Object.fromEntries([...this.accounts].map(([id, bucket]) => [id,
|
|
118
|
+
{ identity: bucket.identity, samples: structuredClone(bucket.samples) }])) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
hydrate(value: unknown, now = Date.now()): void {
|
|
122
|
+
this.clear();
|
|
123
|
+
if (!record(value) || value.version !== 1 || !record(value.accounts) || byteSize(value) > QUOTA_HISTORY_LIMITS.bytes) return;
|
|
124
|
+
const entries = Object.entries(value.accounts);
|
|
125
|
+
if (entries.length > QUOTA_HISTORY_LIMITS.accounts) return;
|
|
126
|
+
let count = 0;
|
|
127
|
+
for (const [id, envelope] of entries) {
|
|
128
|
+
if (!isValidCodexAccountId(id) || !record(envelope) || typeof envelope.identity !== "string"
|
|
129
|
+
|| !identityPattern.test(envelope.identity) || !Array.isArray(envelope.samples)
|
|
130
|
+
|| envelope.samples.length > QUOTA_HISTORY_LIMITS.perAccount) return;
|
|
131
|
+
count += envelope.samples.length;
|
|
132
|
+
if (count > QUOTA_HISTORY_LIMITS.samples) return;
|
|
133
|
+
}
|
|
134
|
+
for (const [accountId, raw] of entries) {
|
|
135
|
+
const envelope = raw as Envelope;
|
|
136
|
+
for (const sample of envelope.samples) {
|
|
137
|
+
const parsed = parseSample(sample, now);
|
|
138
|
+
if (parsed) this.append({ accountId, historyIdentity: envelope.identity, credentialGeneration: parsed.credentialGeneration }, parsed, now);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private dropFirst(id: string): void {
|
|
144
|
+
const bucket = this.accounts.get(id)!;
|
|
145
|
+
this.bytes -= bucket.costs.shift()!;
|
|
146
|
+
bucket.samples.shift(); this.count--;
|
|
147
|
+
if (!bucket.samples.length) { this.bytes -= bucket.overhead; this.accounts.delete(id); }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private prune(now: number): void {
|
|
151
|
+
for (const [id, bucket] of this.accounts) {
|
|
152
|
+
while (bucket.samples.length && bucket.samples[0].observedAt < now - QUOTA_HISTORY_LIMITS.ageMs) this.dropFirst(id);
|
|
153
|
+
}
|
|
154
|
+
while (this.accounts.size > QUOTA_HISTORY_LIMITS.accounts || this.count > QUOTA_HISTORY_LIMITS.samples || this.bytes > QUOTA_HISTORY_LIMITS.bytes) {
|
|
155
|
+
const first = [...this.accounts].sort(([a, x], [b, y]) => x.samples[0].observedAt - y.samples[0].observedAt || a.localeCompare(b))[0];
|
|
156
|
+
if (!first) break;
|
|
157
|
+
this.dropFirst(first[0]);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/codex/quota-types.ts
CHANGED
|
@@ -49,3 +49,11 @@ export type WhamUsageResponse = {
|
|
|
49
49
|
rate_limit_reset_credits?: { available_count: number } | null;
|
|
50
50
|
additional_rate_limits?: WhamAdditionalRateLimit[] | null;
|
|
51
51
|
};
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
/** Captured from the exact dispatched pool credential; never a management API field. */
|
|
55
|
+
export interface PoolQuotaWriter {
|
|
56
|
+
accountId: string;
|
|
57
|
+
credentialGeneration: number;
|
|
58
|
+
historyIdentity: string;
|
|
59
|
+
}
|
package/src/codex/quota.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { closeSync, constants as fsConstants, existsSync, fstatSync, openSync, readSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { atomicWriteFile, getConfigDir } from "../config";
|
|
4
4
|
import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
|
|
@@ -6,10 +6,12 @@ import { isThirtyDayOnlyCodexPlan } from "./plan";
|
|
|
6
6
|
import { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
|
|
7
7
|
import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaWriter } from "./main-account-cache";
|
|
8
8
|
|
|
9
|
-
import
|
|
9
|
+
import { CodexQuotaHistory, QUOTA_HISTORY_LIMITS, type QuotaHistoryWindow } from "./quota-history";
|
|
10
|
+
import { isPoolQuotaWriterLive, poolQuotaHistoryIdentity } from "./account-store";
|
|
11
|
+
import type { PoolQuotaWriter, StoredAccountQuota, WhamUsageResponse, WhamUsageWindow } from "./quota-types";
|
|
10
12
|
export type { StoredAccountQuota, WhamUsageResponse } from "./quota-types";
|
|
11
13
|
|
|
12
|
-
/** Disk snapshot
|
|
14
|
+
/** Disk snapshot: quota, private non-secret publication UUIDs and policy identity; never token-derived fingerprints. */
|
|
13
15
|
const QUOTA_CACHE_FILENAME = "codex-quota-cache.json";
|
|
14
16
|
/** Keep last-known bars across restarts; WHAM still refreshes on TTL in live/prime paths. */
|
|
15
17
|
const QUOTA_DISK_MAX_AGE_MS = 6 * 60 * 60_000;
|
|
@@ -19,6 +21,7 @@ type QuotaDiskFile = {
|
|
|
19
21
|
version: 1;
|
|
20
22
|
quotas: Record<string, StoredAccountQuota>;
|
|
21
23
|
mainPolicyQuota?: MainPolicyQuota;
|
|
24
|
+
history?: ReturnType<CodexQuotaHistory["serialize"]>;
|
|
22
25
|
};
|
|
23
26
|
|
|
24
27
|
type MainPolicyQuota = { identityKey: string; quota: StoredAccountQuota };
|
|
@@ -61,6 +64,7 @@ export function resetAtToMs(resetAt: number): number {
|
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
const accountQuota = new Map<string, StoredAccountQuota>();
|
|
67
|
+
const quotaHistory = new CodexQuotaHistory();
|
|
64
68
|
let lastReconciledGeneration = 0;
|
|
65
69
|
let liveAccountIds = new Set<string>();
|
|
66
70
|
|
|
@@ -268,14 +272,22 @@ export function setAccountQuotaFromParsed(
|
|
|
268
272
|
writerGeneration = captureConfigGeneration(),
|
|
269
273
|
mainWriter?: MainQuotaWriter,
|
|
270
274
|
policyQuota: Omit<StoredAccountQuota, "updatedAt"> | null = quota,
|
|
275
|
+
historyEvidence?: QuotaObservationEvidence,
|
|
271
276
|
): void {
|
|
272
|
-
|
|
277
|
+
quota = withoutRetiredCodexQuota(quota);
|
|
278
|
+
policyQuota = withoutRetiredCodexQuota(policyQuota);
|
|
279
|
+
if (!quota || (!snapshotHasUsage(quota) && quota.resetCredits === undefined)) return;
|
|
273
280
|
if (!mayCommitAccountQuota(accountId, writerGeneration)) return;
|
|
274
281
|
const isMain = accountId === MAIN_CODEX_ACCOUNT_ID;
|
|
275
282
|
if (isMain && mainWriter && !isMainQuotaWriterLive(mainWriter)) return;
|
|
276
283
|
hydrateAccountQuotasFromDisk();
|
|
277
284
|
const legacyExisting = accountQuota.get(accountId);
|
|
278
285
|
const updatedAt = Date.now();
|
|
286
|
+
if (historyEvidence && historyEvidence.writer.accountId === accountId && isPoolQuotaWriterLive(historyEvidence.writer)) {
|
|
287
|
+
quotaHistory.append(historyEvidence.writer, { observedAt: historyEvidence.observedAt, source: historyEvidence.source,
|
|
288
|
+
credentialGeneration: historyEvidence.writer.credentialGeneration, windows: historyWindows(historyEvidence.raw),
|
|
289
|
+
}, updatedAt);
|
|
290
|
+
}
|
|
279
291
|
// Legacy rotation keeps its existing carry behavior, but never inherits policy-only
|
|
280
292
|
// evidence that outlived its disk TTL. Policy has a separate, identity-checked base.
|
|
281
293
|
const next = mergeAccountQuota(quota, legacyExisting, updatedAt);
|
|
@@ -307,6 +319,8 @@ function mergeAccountQuota(
|
|
|
307
319
|
updatedAt: number,
|
|
308
320
|
policyEvidence = false,
|
|
309
321
|
): StoredAccountQuota {
|
|
322
|
+
quota = withoutRetiredCodexQuota(quota) ?? {};
|
|
323
|
+
existing = withoutRetiredCodexQuota(existing ?? null) ?? undefined;
|
|
310
324
|
const next: StoredAccountQuota = { updatedAt };
|
|
311
325
|
const creditsOnly = quota.resetCredits !== undefined && !snapshotHasUsage(quota);
|
|
312
326
|
|
|
@@ -415,7 +429,9 @@ function notifyCodexQuotaSnapshot(accountId: string, next: StoredAccountQuota):
|
|
|
415
429
|
// Copy before the boundary: `next` is the live map value and the following write mutates
|
|
416
430
|
// it, so an observation that read it after awaiting could see a later snapshot than the
|
|
417
431
|
// one it was called for.
|
|
418
|
-
const
|
|
432
|
+
const retained = withoutRetiredCodexQuota(next);
|
|
433
|
+
if (!retained) return;
|
|
434
|
+
const snapshot = { ...retained };
|
|
419
435
|
pendingObservation = pendingObservation
|
|
420
436
|
.then(async () => {
|
|
421
437
|
const observer = await import("../quota/reset-observer");
|
|
@@ -439,20 +455,25 @@ export function flushQuotaObservationsForTests(): Promise<void> {
|
|
|
439
455
|
return pendingObservation;
|
|
440
456
|
}
|
|
441
457
|
|
|
442
|
-
/**
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
* must write the SAME label so a header refresh replaces the WHAM reading instead of doubling it.
|
|
447
|
-
*/
|
|
448
|
-
const SPARK_SHORT_WINDOW_LABEL = "GPT-5.3-Codex-Spark 5h";
|
|
458
|
+
/** Exact retired model evidence, including account/provider-qualified selectors. */
|
|
459
|
+
export function isRetiredCodexSparkModel(modelId: string | undefined): boolean {
|
|
460
|
+
return modelId?.trim().toLowerCase().split("/").at(-1) === "gpt-5.3-codex-spark";
|
|
461
|
+
}
|
|
449
462
|
|
|
450
|
-
/**
|
|
451
|
-
function
|
|
452
|
-
|
|
463
|
+
/** Tombstone old cache/DTO labels without changing unrelated custom windows or stores. */
|
|
464
|
+
export function withoutRetiredCodexQuota<T extends Omit<StoredAccountQuota, "updatedAt"> | null>(quota: T): T | null {
|
|
465
|
+
if (!quota?.customWindows?.length) return quota;
|
|
466
|
+
const kept = quota.customWindows.filter(window =>
|
|
467
|
+
window.label !== "GPT-5.3-Codex-Spark 5h" && window.label !== "GPT-5.3-Codex-Spark Weekly");
|
|
468
|
+
if (kept.length === quota.customWindows.length) return quota;
|
|
469
|
+
const next = { ...quota };
|
|
470
|
+
if (kept.length > 0) next.customWindows = kept;
|
|
471
|
+
else delete next.customWindows;
|
|
472
|
+
return snapshotHasUsage(next) || next.resetCredits !== undefined ? next as T : null;
|
|
453
473
|
}
|
|
454
474
|
|
|
455
475
|
export function parseUpstreamQuotaHeaders(headers: Headers, options?: { modelId?: string }): Omit<StoredAccountQuota, "updatedAt"> | null {
|
|
476
|
+
if (isRetiredCodexSparkModel(options?.modelId)) return null;
|
|
456
477
|
const primaryRaw = headers.get("x-codex-primary-used-percent");
|
|
457
478
|
const secondaryRaw = headers.get("x-codex-secondary-used-percent");
|
|
458
479
|
const tertiaryRaw = headers.get("x-codex-tertiary-used-percent");
|
|
@@ -475,10 +496,6 @@ export function parseUpstreamQuotaHeaders(headers: Headers, options?: { modelId?
|
|
|
475
496
|
// it into weeklyPercent both discards the real weekly reading and leaves the account looking
|
|
476
497
|
// exhausted long after the burst window resets. Duration decides, exactly as parseUsageQuota
|
|
477
498
|
// already does for the WHAM payload — the two parsers must not disagree about the same data.
|
|
478
|
-
// One more attribution layer (#4122): on a Spark-family model response the sub-day primary is
|
|
479
|
-
// the MODEL-SPECIFIC limit, not an account window. Filing it as the account short tuple made
|
|
480
|
-
// one pool account display a 5h bar its identically-limited peers did not have, and fed a
|
|
481
|
-
// model limit to the account-policy readers (main-account hard lock, five-hour auto-refresh).
|
|
482
499
|
const primaryIsShort = isExplicitShortWindowMinutes(primaryWindowMinutes);
|
|
483
500
|
|
|
484
501
|
if (primaryIsMonthly) {
|
|
@@ -495,21 +512,10 @@ export function parseUpstreamQuotaHeaders(headers: Headers, options?: { modelId?
|
|
|
495
512
|
if (secondaryResetAt !== undefined) quota.weeklyResetAt = secondaryResetAt;
|
|
496
513
|
}
|
|
497
514
|
} else if (primaryIsShort) {
|
|
498
|
-
if (
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
percent: primaryPercent,
|
|
503
|
-
};
|
|
504
|
-
if (primaryResetAt !== undefined) sparkWindow.resetAt = primaryResetAt;
|
|
505
|
-
quota.customWindows = [sparkWindow];
|
|
506
|
-
}
|
|
507
|
-
} else {
|
|
508
|
-
if (primaryPercent !== undefined) quota.shortPercent = primaryPercent;
|
|
509
|
-
if (primaryResetAt !== undefined) quota.shortResetAt = primaryResetAt;
|
|
510
|
-
const minutes = windowMinutes_(primaryWindowMinutes);
|
|
511
|
-
if (minutes !== undefined) quota.shortWindowSeconds = Math.round(minutes * 60);
|
|
512
|
-
}
|
|
515
|
+
if (primaryPercent !== undefined) quota.shortPercent = primaryPercent;
|
|
516
|
+
if (primaryResetAt !== undefined) quota.shortResetAt = primaryResetAt;
|
|
517
|
+
const minutes = windowMinutes_(primaryWindowMinutes);
|
|
518
|
+
if (minutes !== undefined) quota.shortWindowSeconds = Math.round(minutes * 60);
|
|
513
519
|
// The burst window vacates the primary slot, so the weekly reading is the secondary — which
|
|
514
520
|
// is where it was all along. Without this the true weekly value is silently dropped.
|
|
515
521
|
if (secondaryPercent !== undefined) {
|
|
@@ -540,31 +546,17 @@ export function applyAccountQuotaFromUpstreamHeaders(
|
|
|
540
546
|
headers: Headers,
|
|
541
547
|
writerGeneration = captureConfigGeneration(),
|
|
542
548
|
mainWriter?: MainQuotaWriter,
|
|
543
|
-
options?: { modelId?: string },
|
|
549
|
+
options?: { modelId?: string; poolWriter?: PoolQuotaWriter },
|
|
544
550
|
): void {
|
|
545
551
|
const quota = parseUpstreamQuotaHeaders(headers, options);
|
|
546
552
|
if (!quota) return;
|
|
547
553
|
const policyQuota = [
|
|
548
554
|
"x-codex-primary-used-percent", "x-codex-secondary-used-percent", "x-codex-tertiary-used-percent",
|
|
549
555
|
].some(name => isInvalidPolicyUsagePercent(headers.get(name))) ? null : filterMainPolicyMonthlyQuota(quota);
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
let legacyQuota = quota;
|
|
555
|
-
if (quota.customWindows !== undefined) {
|
|
556
|
-
hydrateAccountQuotasFromDisk();
|
|
557
|
-
const existing = accountQuota.get(accountId)?.customWindows;
|
|
558
|
-
if (existing !== undefined) {
|
|
559
|
-
const incoming = new Map(quota.customWindows.map(window => [window.label, window]));
|
|
560
|
-
const merged = existing.map(window => incoming.get(window.label) ?? window);
|
|
561
|
-
for (const window of quota.customWindows) {
|
|
562
|
-
if (!existing.some(entry => entry.label === window.label)) merged.push(window);
|
|
563
|
-
}
|
|
564
|
-
legacyQuota = { ...quota, customWindows: merged };
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
setAccountQuotaFromParsed(accountId, legacyQuota, writerGeneration, mainWriter, policyQuota);
|
|
556
|
+
const validHistory = !["x-codex-primary-used-percent", "x-codex-secondary-used-percent", "x-codex-tertiary-used-percent"]
|
|
557
|
+
.some(name => isInvalidPolicyUsagePercent(headers.get(name)));
|
|
558
|
+
setAccountQuotaFromParsed(accountId, quota, writerGeneration, mainWriter, policyQuota,
|
|
559
|
+
options?.poolWriter && validHistory ? { writer: options.poolWriter, observedAt: Date.now(), source: "response-header", raw: quota } : undefined);
|
|
568
560
|
}
|
|
569
561
|
|
|
570
562
|
export function updateAccountQuota(
|
|
@@ -658,16 +650,19 @@ function hydrateAccountQuotasFromDisk(): void {
|
|
|
658
650
|
try {
|
|
659
651
|
const path = join(getConfigDir(), QUOTA_CACHE_FILENAME);
|
|
660
652
|
if (!existsSync(path)) return;
|
|
661
|
-
const raw =
|
|
653
|
+
const raw = readQuotaCacheBounded(path);
|
|
662
654
|
const parsed = JSON.parse(raw) as QuotaDiskFile;
|
|
663
655
|
if (!parsed || parsed.version !== 1 || !parsed.quotas || typeof parsed.quotas !== "object") return;
|
|
656
|
+
quotaHistory.hydrate(parsed.history);
|
|
664
657
|
// Policy evidence deliberately outlives the legacy six-hour rotation-cache TTL.
|
|
665
658
|
mainPolicyQuota = readMainPolicyQuota(parsed.mainPolicyQuota);
|
|
666
659
|
const now = Date.now();
|
|
667
660
|
for (const [accountId, quota] of Object.entries(parsed.quotas)) {
|
|
668
661
|
if (!quota || typeof quota !== "object" || typeof quota.updatedAt !== "number") continue;
|
|
669
662
|
if (now - quota.updatedAt > QUOTA_DISK_MAX_AGE_MS) continue;
|
|
670
|
-
|
|
663
|
+
const retained = withoutRetiredCodexQuota(quota);
|
|
664
|
+
if (!retained) continue;
|
|
665
|
+
if (!accountQuota.has(accountId)) accountQuota.set(accountId, retained);
|
|
671
666
|
}
|
|
672
667
|
} catch {
|
|
673
668
|
// Corrupt/missing cache must never block routing or the dashboard.
|
|
@@ -687,6 +682,7 @@ function schedulePersistAccountQuotas(): void {
|
|
|
687
682
|
version: 1,
|
|
688
683
|
quotas,
|
|
689
684
|
...(mainPolicyQuota ? { mainPolicyQuota } : {}),
|
|
685
|
+
history: quotaHistory.serialize(),
|
|
690
686
|
};
|
|
691
687
|
atomicWriteFile(join(getConfigDir(), QUOTA_CACHE_FILENAME), `${JSON.stringify(body)}\n`);
|
|
692
688
|
} catch {
|
|
@@ -735,6 +731,8 @@ function forgetCodexQuotaBaseline(accountId?: string): void {
|
|
|
735
731
|
}
|
|
736
732
|
|
|
737
733
|
export function clearAccountQuota(accountId?: string): void {
|
|
734
|
+
if (accountId) hydrateAccountQuotasFromDisk();
|
|
735
|
+
quotaHistory.clear(accountId);
|
|
738
736
|
if (accountId) {
|
|
739
737
|
hydrateAccountQuotasFromDisk();
|
|
740
738
|
accountQuota.delete(accountId);
|
|
@@ -762,7 +760,7 @@ export function clearAccountQuota(accountId?: string): void {
|
|
|
762
760
|
export function reconcileCodexQuotaAccounts(context: GenerationContext): number {
|
|
763
761
|
if (context.generation <= lastReconciledGeneration) return 0;
|
|
764
762
|
hydrateAccountQuotasFromDisk();
|
|
765
|
-
let removed =
|
|
763
|
+
let removed = quotaHistory.reconcile(context.codexAccountIds);
|
|
766
764
|
for (const accountId of accountQuota.keys()) {
|
|
767
765
|
if (context.codexAccountIds.has(accountId)) continue;
|
|
768
766
|
accountQuota.delete(accountId);
|
|
@@ -801,9 +799,6 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
|
|
|
801
799
|
: undefined;
|
|
802
800
|
|
|
803
801
|
if (!data.rate_limit) {
|
|
804
|
-
if (data.additional_rate_limits?.length) {
|
|
805
|
-
return parseUsageQuota({ ...data, rate_limit: {} });
|
|
806
|
-
}
|
|
807
802
|
return resetCredits !== undefined ? { resetCredits } : null;
|
|
808
803
|
}
|
|
809
804
|
|
|
@@ -866,39 +861,71 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
|
|
|
866
861
|
// A supplementary value (including fallback from an unreadable primary) is not proof.
|
|
867
862
|
if (primaryIsMonthly && primaryPercent !== undefined) quota.monthlyIsPrimaryWindow = true;
|
|
868
863
|
|
|
869
|
-
const spark = data.additional_rate_limits?.find(additional => {
|
|
870
|
-
const name = String(additional.limit_name ?? "").toLowerCase();
|
|
871
|
-
const feature = String(additional.metered_feature ?? "").toLowerCase();
|
|
872
|
-
return feature === "codex_bengalfox" || name.includes("gpt-5.3-codex-spark");
|
|
873
|
-
});
|
|
874
|
-
const sparkWindows = [spark?.rate_limit?.primary_window, spark?.rate_limit?.secondary_window]
|
|
875
|
-
.filter((window): window is WhamUsageWindow => !!window);
|
|
876
|
-
const sparkShort = sparkWindows.find(window => {
|
|
877
|
-
const percent = normalizeUsagePercent(window.used_percent);
|
|
878
|
-
return percent !== undefined && isExplicitShortWindow(window);
|
|
879
|
-
});
|
|
880
|
-
const sparkWeekly = sparkWindows.find(window => {
|
|
881
|
-
const percent = normalizeUsagePercent(window.used_percent);
|
|
882
|
-
const seconds = window.limit_window_seconds;
|
|
883
|
-
return percent !== undefined
|
|
884
|
-
&& !isExplicitShortWindow(window)
|
|
885
|
-
&& !isExplicitMonthlyWindow(window)
|
|
886
|
-
&& (seconds === undefined || seconds >= WEEKLY_WINDOW_MIN_SECONDS);
|
|
887
|
-
});
|
|
888
|
-
const sparkCustomWindows: Array<{ label: string; percent: number; resetAt?: number }> = [];
|
|
889
|
-
for (const [label, window] of [
|
|
890
|
-
[SPARK_SHORT_WINDOW_LABEL, sparkShort],
|
|
891
|
-
["GPT-5.3-Codex-Spark Weekly", sparkWeekly],
|
|
892
|
-
] as const) {
|
|
893
|
-
const percent = normalizeUsagePercent(window?.used_percent);
|
|
894
|
-
if (percent === undefined) continue;
|
|
895
|
-
const sparkWindow: { label: string; percent: number; resetAt?: number } = { label, percent };
|
|
896
|
-
const resetAt = normalizeResetAt(window?.reset_at);
|
|
897
|
-
if (resetAt !== undefined) sparkWindow.resetAt = resetAt;
|
|
898
|
-
sparkCustomWindows.push(sparkWindow);
|
|
899
|
-
}
|
|
900
|
-
if (sparkCustomWindows.length > 0) quota.customWindows = sparkCustomWindows;
|
|
901
864
|
if (resetCredits !== undefined) quota.resetCredits = resetCredits;
|
|
902
865
|
|
|
903
866
|
return hasKnownQuotaValue(quota) || resetCredits !== undefined ? quota : null;
|
|
904
867
|
}
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
export interface QuotaObservationEvidence {
|
|
871
|
+
writer: PoolQuotaWriter;
|
|
872
|
+
observedAt: number;
|
|
873
|
+
source: "wham" | "response-header";
|
|
874
|
+
raw: Omit<StoredAccountQuota, "updatedAt">;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Reject raw invalid readings before the compatibility parser clamps them into valid-looking bars. */
|
|
878
|
+
export function isValidWhamHistoryObservation(data: WhamUsageResponse): boolean {
|
|
879
|
+
const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window];
|
|
880
|
+
for (const limit of Array.isArray(data.additional_rate_limits) ? data.additional_rate_limits : []) {
|
|
881
|
+
if (limit && typeof limit === "object") windows.push(limit.rate_limit?.primary_window, limit.rate_limit?.secondary_window);
|
|
882
|
+
}
|
|
883
|
+
return !windows.some(window => isInvalidPolicyUsagePercent(window?.used_percent));
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function historyWindows(quota: Omit<StoredAccountQuota, "updatedAt">): QuotaHistoryWindow[] {
|
|
887
|
+
const windows: QuotaHistoryWindow[] = [];
|
|
888
|
+
for (const window of ["short", "weekly", "monthly"] as const) {
|
|
889
|
+
const percent = quota[`${window}Percent`];
|
|
890
|
+
const reset = quota[`${window}ResetAt`];
|
|
891
|
+
if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) continue;
|
|
892
|
+
windows.push({ family: "account", window, usedPercent: percent,
|
|
893
|
+
...(typeof reset === "number" && Number.isFinite(reset) && reset >= 0 ? { resetAtMs: resetAtToMs(reset) } : {}),
|
|
894
|
+
...(window === "short" && quota.shortWindowSeconds ? { windowSeconds: quota.shortWindowSeconds } : {}),
|
|
895
|
+
...(window === "monthly" && quota.monthlyIsPrimaryWindow ? { monthlyIsPrimaryWindow: true } : {}),
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
// No observation path emits retired Spark custom windows any more: the header parser refuses the
|
|
899
|
+
// retired model and the WHAM parser no longer derives its family, so recording one here could only
|
|
900
|
+
// resurrect evidence `withoutRetiredCodexQuota` tombstones on the way in. The `spark` family stays
|
|
901
|
+
// in the history schema so samples already persisted by older builds still parse.
|
|
902
|
+
return windows;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** A cache read is bounded even if a file grows between stat and read. */
|
|
906
|
+
function readQuotaCacheBounded(path: string): string {
|
|
907
|
+
const limit = 4 * 1024 * 1024;
|
|
908
|
+
const flags = fsConstants.O_RDONLY | (process.platform === "win32" ? 0 : fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW);
|
|
909
|
+
const fd = openSync(path, flags);
|
|
910
|
+
try {
|
|
911
|
+
const stat = fstatSync(fd);
|
|
912
|
+
if (!stat.isFile() || stat.size > limit) throw new Error("quota cache exceeds bounds");
|
|
913
|
+
const chunks: Buffer[] = [];
|
|
914
|
+
let total = 0;
|
|
915
|
+
while (total <= limit) {
|
|
916
|
+
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, limit + 1 - total));
|
|
917
|
+
const size = readSync(fd, chunk, 0, chunk.length, null);
|
|
918
|
+
if (!size) return Buffer.concat(chunks, total).toString("utf8");
|
|
919
|
+
chunks.push(chunk.subarray(0, size)); total += size;
|
|
920
|
+
}
|
|
921
|
+
throw new Error("quota cache exceeds bounds");
|
|
922
|
+
} finally { closeSync(fd); }
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** Cached pool observations only. An unavailable identity never authorizes publication or deletion. */
|
|
926
|
+
export function getAccountQuotaHistory(accountId: string, limit: number = QUOTA_HISTORY_LIMITS.perAccount) {
|
|
927
|
+
hydrateAccountQuotasFromDisk();
|
|
928
|
+
const result = quotaHistory.read(accountId, poolQuotaHistoryIdentity(accountId), Date.now(), limit);
|
|
929
|
+
return { observations: result.samples.map(({ credentialGeneration: _generation, ...sample }) => sample),
|
|
930
|
+
truncated: result.truncated, retention: { maxObservations: QUOTA_HISTORY_LIMITS.perAccount, maxAgeDays: 30 } };
|
|
931
|
+
}
|
package/src/codex/refresh.ts
CHANGED
|
@@ -13,6 +13,7 @@ export interface CodexCatalogRefreshResult {
|
|
|
13
13
|
catalogWritten: boolean;
|
|
14
14
|
cacheSynced: boolean;
|
|
15
15
|
comboOmissions: ComboCatalogOmission[];
|
|
16
|
+
refreshOutcome?: "committed" | "refused";
|
|
16
17
|
/** Desired OFF observed under K during the catalog commit; no cache write either. */
|
|
17
18
|
skippedReason?: "desired_disabled";
|
|
18
19
|
}
|
|
@@ -49,7 +50,7 @@ export async function refreshCodexModelCatalog(
|
|
|
49
50
|
const catalogExists = deps.existsSync(result.path);
|
|
50
51
|
const catalogWritten = result.catalogWritten === true;
|
|
51
52
|
const comboOmissions = result.comboOmissions ?? [];
|
|
52
|
-
if (result.skippedReason === "desired_disabled") {
|
|
53
|
+
if (result.skippedReason === "desired_disabled" || result.refreshOutcome === "refused") {
|
|
53
54
|
// The commit path observed OFF under K. Invalidate nothing: rewriting the
|
|
54
55
|
// models cache here would be exactly the routed-cache write the skip refused.
|
|
55
56
|
return { ...result, catalogExists, catalogWritten: false, cacheSynced: false, comboOmissions };
|