@bitkyc08/opencodex 2.88.0 → 2.90.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/bin/ocx.mjs +15 -4
- package/gui/dist/assets/App-DgxkE6Md.js +58 -0
- package/gui/dist/assets/App-dnkJ85te.css +1 -0
- package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
- package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic/cache-retention.ts +117 -0
- package/src/adapters/anthropic/passthrough.ts +7 -4
- package/src/adapters/anthropic.ts +10 -3
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +14 -2
- package/src/adapters/coding-agent/protocol.ts +6 -0
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-http.ts +3 -2
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google-vertex-endpoint.ts +67 -0
- package/src/adapters/google.ts +62 -37
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +22 -13
- package/src/adapters/openai-responses/reasoning.ts +68 -1
- package/src/adapters/openai-responses/transport.ts +13 -1
- package/src/adapters/registry.ts +1 -1
- package/src/adapters/upstream-http-error.ts +3 -2
- package/src/adapters/zed.ts +7 -4
- package/src/bridge/internal.ts +11 -1
- package/src/bridge/response-json.ts +2 -2
- package/src/bridge/sse.ts +10 -4
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/claude/inbound.ts +12 -26
- package/src/claude/outbound.ts +5 -0
- package/src/cli/account-api.ts +13 -1
- package/src/cli/account-extended.ts +17 -5
- package/src/cli/account-key-quota.ts +4 -1
- package/src/cli/account.ts +1 -1
- package/src/cli/capabilities-agents-routing.ts +45 -8
- package/src/cli/capabilities-base.ts +5 -5
- package/src/cli/capabilities-provider-models.ts +5 -4
- package/src/cli/combo-result.ts +8 -2
- package/src/cli/combo.ts +17 -0
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/cli/help.ts +1 -1
- package/src/cli/message-command.ts +4 -1
- package/src/cli/message-remote-args.ts +69 -0
- package/src/cli/message-remote-command.ts +77 -0
- package/src/cli/provider-runtime.ts +69 -1
- package/src/cli/registry.ts +3 -3
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-pause.ts +6 -1
- package/src/codex/account-runtime-state.ts +5 -0
- package/src/codex/account-store.ts +55 -9
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +13 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/main-account-probe.ts +3 -2
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +57 -10
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/auto-review.ts +2 -1
- package/src/codex/catalog/build-entries.ts +17 -7
- package/src/codex/catalog/derive-entry.ts +3 -1
- package/src/codex/catalog/metadata.ts +2 -0
- package/src/codex/catalog/model-visibility.ts +0 -2
- package/src/codex/catalog/ownership.ts +71 -0
- package/src/codex/catalog/parsing.ts +12 -3
- package/src/codex/catalog/retained-sync.ts +2 -1
- package/src/codex/catalog/routed-removal.ts +4 -4
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/convergence.ts +2 -1
- package/src/codex/low-quota-desktop-notifier.ts +125 -0
- package/src/codex/low-quota-protection.ts +57 -31
- package/src/codex/main-account-cache.ts +77 -3
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/main-account.ts +6 -2
- package/src/codex/native-residue.ts +2 -3
- package/src/codex/quota.ts +26 -2
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/combos/failover.ts +10 -2
- package/src/combos/jev-model-backend.ts +30 -7
- package/src/combos/jev-model-request.ts +20 -0
- package/src/combos/jev-quota-activation.ts +153 -0
- package/src/combos/jev-quota-config.ts +62 -0
- package/src/combos/jev-quota-route.ts +26 -0
- package/src/combos/jev-quota.ts +88 -0
- package/src/combos/jev.ts +25 -12
- package/src/combos/types.ts +27 -0
- package/src/compatibility/index.ts +3 -0
- package/src/compatibility/openai-responses.ts +8 -1
- package/src/compatibility/opengateway-responses.ts +61 -0
- package/src/config/account-auto-pause-revisions.ts +136 -0
- package/src/config/diagnostics.ts +13 -0
- package/src/config/live-reconcile.ts +9 -0
- package/src/config/persist-unlocked.ts +10 -1
- package/src/config/provider-validation.ts +7 -2
- package/src/config/removed-opencode-free.ts +135 -0
- package/src/config/schema/account-auto-pause.ts +14 -0
- package/src/config/schema/config-schema.ts +15 -0
- package/src/config/schema/leaf-validators.ts +7 -0
- package/src/config.ts +10 -3
- package/src/generated/compatibility-version.json +476 -216
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +34 -11
- package/src/lib/account-auto-pause-response.ts +16 -0
- package/src/lib/bun-runtime.ts +18 -1
- package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
- package/src/lib/bun-transpiler-cache-scope.mjs +82 -0
- package/src/lib/decision-quota-sink.ts +76 -0
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/state-store-registrations.ts +25 -0
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/link/ssh-argv.ts +3 -0
- package/src/messaging/envelope.ts +6 -3
- package/src/messaging/remote-auth.ts +83 -0
- package/src/messaging/remote-bridge.ts +270 -0
- package/src/messaging/remote-contract.ts +108 -0
- package/src/messaging/remote-enrollment-recovery.ts +59 -0
- package/src/messaging/remote-enrollment.ts +244 -0
- package/src/messaging/remote-envelope.ts +40 -0
- package/src/messaging/remote-files.ts +117 -0
- package/src/messaging/remote-owner.ts +169 -0
- package/src/messaging/remote-ports.ts +56 -0
- package/src/messaging/remote-process.ts +131 -0
- package/src/messaging/remote-replies.ts +98 -0
- package/src/messaging/remote-rpc-admission.ts +32 -0
- package/src/messaging/remote-send.ts +72 -0
- package/src/messaging/remote-store.ts +111 -0
- package/src/messaging/remote-tunnels.ts +65 -0
- package/src/messaging/rpc.ts +13 -1
- package/src/oauth/account-auto-pause-policy.ts +66 -0
- package/src/oauth/account-auto-pause.ts +171 -0
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/account-quota-rank.ts +2 -1
- package/src/oauth/anthropic-account-refusal.ts +6 -0
- package/src/oauth/anthropic-model-family.ts +8 -0
- package/src/oauth/anthropic-model-quota.ts +2 -6
- package/src/oauth/api-key-pool-preserve.ts +37 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/index.ts +5 -35
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/oauth/store.ts +35 -8
- package/src/oauth/types.ts +2 -0
- package/src/plugins/upstream-hooks.ts +14 -1
- package/src/providers/api-key-pause.ts +32 -0
- package/src/providers/api-key-resolve.ts +10 -4
- package/src/providers/api-key-selection.ts +6 -1
- package/src/providers/api-keys.ts +8 -4
- package/src/providers/claude-cli-usage.ts +25 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +21 -13
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +207 -0
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/opencode-zen-rate-limit.ts +6 -72
- package/src/providers/quota/account-cache.ts +15 -1
- package/src/providers/quota/anthropic-account-quota.ts +20 -7
- package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
- package/src/providers/quota/anthropic-decision-windows.ts +47 -0
- package/src/providers/quota/antigravity.ts +45 -8
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +15 -6
- package/src/providers/quota-decision-publication.ts +77 -0
- package/src/providers/quota-decision-snapshot.ts +145 -0
- package/src/providers/quota-routing-cache.ts +4 -1
- package/src/providers/quota-types.ts +6 -1
- package/src/providers/quota.ts +8 -3
- package/src/providers/registry/entries-extended.ts +14 -53
- package/src/providers/registry/model-seeds.ts +1 -1
- package/src/providers/registry.ts +1 -2
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/stale-vision-classification-migration.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/parser.ts +18 -10
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +147 -0
- package/src/routing/compatibility/behavior.ts +4 -1
- package/src/server/auth-cors.ts +7 -0
- package/src/server/background-lifecycle.ts +2 -1
- package/src/server/chat-native.ts +36 -20
- package/src/server/claude-messages.ts +2 -1
- package/src/server/index/optional-listeners.ts +6 -0
- package/src/server/index/serve-options.ts +8 -2
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/management/combo-routes.ts +17 -2
- package/src/server/management/oauth-account-routes.ts +12 -2
- package/src/server/management/provider-completion-test.ts +289 -0
- package/src/server/management/provider-routes.ts +9 -1
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/messages-native.ts +29 -4
- package/src/server/messages-shared.ts +4 -1
- package/src/server/request-log.ts +23 -9
- package/src/server/responses/account-auto-pause-recovery.ts +26 -0
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +79 -30
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-session-recall.ts +25 -0
- package/src/server/responses/combo-stream-preflight.ts +10 -3
- package/src/server/responses/combo-tool-loop-pin.ts +70 -0
- package/src/server/responses/compact.ts +10 -5
- package/src/server/responses/context-overflow.ts +141 -2
- package/src/server/responses/core-auth.ts +20 -0
- package/src/server/responses/core-codex-account.ts +28 -15
- package/src/server/responses/core-combo.ts +34 -5
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/core.ts +12 -9
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +10 -0
- package/src/server/responses/fetch-helpers.ts +65 -14
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/jev-model-invoke.ts +13 -14
- package/src/server/responses/main-hard-lock-routing.ts +33 -8
- package/src/server/responses/native-steering-log.ts +2 -2
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +5 -2
- package/src/server/responses/passthrough-dispatch.ts +42 -45
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/passthrough-observations.ts +38 -0
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-compression.ts +98 -0
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/request-transport.ts +92 -5
- package/src/server/responses/run-turn-execution.ts +2 -0
- package/src/server/responses/sidecar-execution.ts +16 -3
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/startup-action-control.ts +2 -2
- package/src/server/startup-health-cache.ts +2 -2
- package/src/server/windows-tray-control.ts +2 -2
- package/src/service/definition.ts +9 -1
- package/src/service/orchestration.ts +17 -0
- package/src/types/account-auto-pause.ts +15 -0
- package/src/types/config.ts +23 -1
- package/src/types/provider.ts +11 -5
- package/src/types/request.ts +36 -3
- package/src/types.ts +2 -0
- package/src/usage/cost.ts +17 -1
- package/src/usage/expected-prices.ts +3 -1
- package/src/usage/jev-stats.ts +4 -0
- package/src/usage/log.ts +2 -0
- package/src/usage/summary.ts +7 -0
- package/src/web-search/loop.ts +39 -13
- package/src/web-search/progress-stream.ts +4 -1
- package/src/web-search/run-turn-loop.ts +9 -3
- package/src/web-search/sidecar-search-replay.ts +96 -0
- package/gui/dist/assets/App-CHC89EXQ.js +0 -58
- package/gui/dist/assets/App-wT3mXqFz.css +0 -1
- package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
package/src/codex/convergence.ts
CHANGED
|
@@ -53,7 +53,7 @@ import {
|
|
|
53
53
|
orderForSubagents,
|
|
54
54
|
} from "./catalog/sync";
|
|
55
55
|
import { multiAgentV2EnabledFromConfigText } from "./features";
|
|
56
|
-
import { enforceCatalogSlugUniqueness, exactComboCatalogSlugs } from "./catalog/aggregation";
|
|
56
|
+
import { comboCatalogDescriptions, enforceCatalogSlugUniqueness, exactComboCatalogSlugs } from "./catalog/aggregation";
|
|
57
57
|
import {
|
|
58
58
|
isNativeAliasCatalogEntry,
|
|
59
59
|
accountBoundNativeOpenAiSlugs,
|
|
@@ -392,6 +392,7 @@ function prepareCatalog(
|
|
|
392
392
|
multiAgentV2Enabled,
|
|
393
393
|
keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true,
|
|
394
394
|
exactComboSlugs,
|
|
395
|
+
comboDescriptions: comboCatalogDescriptions(config),
|
|
395
396
|
hasPhysicalComboProvider,
|
|
396
397
|
includeNativeOpenAi,
|
|
397
398
|
accountBoundEntries,
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { isTestHomeGuardArmed } from "../lib/test-home-guard";
|
|
3
|
+
|
|
4
|
+
/** What a low-quota notice may tell the operating system. Never an email, raw id or credential. */
|
|
5
|
+
export type LowQuotaNotice = {
|
|
6
|
+
/** Pseudonymous `p<hex6>` log label of the pool account. */
|
|
7
|
+
accountLabel: string;
|
|
8
|
+
window: "short" | "weekly";
|
|
9
|
+
percentUsed: number;
|
|
10
|
+
threshold: number;
|
|
11
|
+
/** Epoch milliseconds of the window reset, when known. */
|
|
12
|
+
resetAt: number | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Resolves when the OS notifier accepted the notice; rejects on failure, timeout or abort. */
|
|
16
|
+
export type LowQuotaNoticeSink = (notice: LowQuotaNotice, signal: AbortSignal) => Promise<void>;
|
|
17
|
+
|
|
18
|
+
type SpawnedNotifier = { exited: Promise<number | null>; kill(): void };
|
|
19
|
+
export type LowQuotaNotifierIo = {
|
|
20
|
+
platform?: NodeJS.Platform;
|
|
21
|
+
/** Returns true when the absolute path is an existing executable candidate. */
|
|
22
|
+
exists?: (path: string) => boolean;
|
|
23
|
+
spawn?: (argv: string[]) => SpawnedNotifier;
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
/** Formats the reset time for display; defaults to the host locale. */
|
|
26
|
+
formatTime?: (epochMs: number) => string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const OSASCRIPT = "/usr/bin/osascript";
|
|
30
|
+
/** Literal system locations only: a PATH lookup would let any earlier PATH entry run as the proxy. */
|
|
31
|
+
const NOTIFY_SEND_CANDIDATES = ["/usr/bin/notify-send", "/usr/local/bin/notify-send", "/bin/notify-send"];
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
33
|
+
const TITLE = "OpenCodex quota";
|
|
34
|
+
|
|
35
|
+
export function formatLowQuotaNotice(notice: LowQuotaNotice, formatTime: (epochMs: number) => string = defaultFormatTime): { title: string; body: string } {
|
|
36
|
+
const window = notice.window === "short" ? "5-hour" : "weekly";
|
|
37
|
+
const reset = notice.resetAt === null ? "reset time unknown" : `resets ${formatTime(notice.resetAt)}`;
|
|
38
|
+
return {
|
|
39
|
+
title: TITLE,
|
|
40
|
+
body: `Account ${notice.accountLabel}: ${window} quota at ${Math.round(notice.percentUsed)}% (threshold ${notice.threshold}%), ${reset}.`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function defaultFormatTime(epochMs: number): string {
|
|
45
|
+
return new Date(epochMs).toLocaleString();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** notify-send bodies may be parsed as markup by the notification server. */
|
|
49
|
+
function escapeMarkup(text: string): string {
|
|
50
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Builds the fixed argv for the host notifier. The notice text is always a separate argv item:
|
|
55
|
+
* the AppleScript source is constant and reads its arguments, and notify-send stops option
|
|
56
|
+
* parsing at `--`, so no account or quota text is ever interpreted as script or flags.
|
|
57
|
+
*/
|
|
58
|
+
export function lowQuotaNotifierArgv(
|
|
59
|
+
platform: NodeJS.Platform,
|
|
60
|
+
exists: (path: string) => boolean,
|
|
61
|
+
title: string,
|
|
62
|
+
body: string,
|
|
63
|
+
): string[] | undefined {
|
|
64
|
+
if (platform === "darwin") {
|
|
65
|
+
if (!exists(OSASCRIPT)) return undefined;
|
|
66
|
+
return [OSASCRIPT, "-e", "on run argv", "-e", "display notification (item 2 of argv) with title (item 1 of argv)", "-e", "end run", title, body];
|
|
67
|
+
}
|
|
68
|
+
if (platform === "linux") {
|
|
69
|
+
const notifySend = NOTIFY_SEND_CANDIDATES.find(exists);
|
|
70
|
+
if (!notifySend) return undefined;
|
|
71
|
+
return [notifySend, "--app-name=OpenCodex", "--", title, escapeMarkup(body)];
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function defaultSpawn(argv: string[]): SpawnedNotifier {
|
|
77
|
+
const proc = Bun.spawn(argv, { stdin: "ignore", stdout: "ignore", stderr: "ignore", windowsHide: true });
|
|
78
|
+
return { exited: proc.exited, kill: () => { try { proc.kill(); } catch { /* already gone */ } } };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Returns a sink that hands notices to this host's notification service, or undefined when the
|
|
83
|
+
* platform has none we support (Windows and others stay log-only). An armed test process never
|
|
84
|
+
* reaches a real notifier unless a spawn is injected.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveLowQuotaDesktopSink(io: LowQuotaNotifierIo = {}): LowQuotaNoticeSink | undefined {
|
|
87
|
+
if (!io.spawn && isTestHomeGuardArmed()) return undefined;
|
|
88
|
+
const platform = io.platform ?? process.platform;
|
|
89
|
+
const exists = io.exists ?? existsSync;
|
|
90
|
+
const spawn = io.spawn ?? defaultSpawn;
|
|
91
|
+
const timeoutMs = io.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
92
|
+
const probe = lowQuotaNotifierArgv(platform, exists, TITLE, "");
|
|
93
|
+
if (!probe) return undefined;
|
|
94
|
+
return (notice, signal) => {
|
|
95
|
+
if (signal.aborted) return Promise.reject(new Error("low-quota notice cancelled"));
|
|
96
|
+
const { title, body } = formatLowQuotaNotice(notice, io.formatTime);
|
|
97
|
+
const argv = lowQuotaNotifierArgv(platform, exists, title, body);
|
|
98
|
+
if (!argv) return Promise.reject(new Error("low-quota notifier unavailable"));
|
|
99
|
+
let child: SpawnedNotifier;
|
|
100
|
+
try {
|
|
101
|
+
child = spawn(argv);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return Promise.reject(error);
|
|
104
|
+
}
|
|
105
|
+
return new Promise<void>((resolve, reject) => {
|
|
106
|
+
let settled = false;
|
|
107
|
+
const finish = (error?: Error) => {
|
|
108
|
+
if (settled) return;
|
|
109
|
+
settled = true;
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
signal.removeEventListener("abort", onAbort);
|
|
112
|
+
if (error) reject(error);
|
|
113
|
+
else resolve();
|
|
114
|
+
};
|
|
115
|
+
const onAbort = () => { child.kill(); finish(new Error("low-quota notice cancelled")); };
|
|
116
|
+
const timer = setTimeout(() => { child.kill(); finish(new Error("low-quota notifier timed out")); }, timeoutMs);
|
|
117
|
+
(timer as { unref?: () => void }).unref?.();
|
|
118
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
119
|
+
child.exited.then(
|
|
120
|
+
code => finish(code === 0 ? undefined : new Error(`low-quota notifier exited ${code}`)),
|
|
121
|
+
error => finish(error instanceof Error ? error : new Error("low-quota notifier failed")),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -4,15 +4,28 @@ import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause";
|
|
|
4
4
|
import { createLowQuotaEventLedger, type LowQuotaEvent } from "./low-quota-events";
|
|
5
5
|
import { registerLowQuotaObserver } from "./low-quota-observer";
|
|
6
6
|
import { resetAtToMs } from "./quota-types";
|
|
7
|
+
import { codexAccountLogLabel, fallbackCodexAccountLogLabel } from "./account-label";
|
|
8
|
+
import type { LowQuotaNotice, LowQuotaNoticeSink } from "./low-quota-desktop-notifier";
|
|
7
9
|
|
|
8
|
-
type Window = "short" | "weekly";
|
|
9
|
-
type Notice = { window: Window; percentUsed: number; threshold: number };
|
|
10
10
|
type Dependencies = {
|
|
11
11
|
persist?: (config: OcxConfig) => void | Promise<void>;
|
|
12
|
-
|
|
12
|
+
/** Injected sink; takes precedence over desktop delivery. */
|
|
13
|
+
notify?: (notice: LowQuotaNotice, signal: AbortSignal) => void | Promise<void>;
|
|
14
|
+
/** Resolved per notice, and only while the live policy opts into actions.desktop. */
|
|
15
|
+
desktopSink?: () => LowQuotaNoticeSink | undefined;
|
|
13
16
|
};
|
|
14
17
|
type EventBase = Omit<LowQuotaEvent, "timestamp" | "status" | "delivery">;
|
|
15
|
-
type Episode = {
|
|
18
|
+
type Episode = {
|
|
19
|
+
reset: string;
|
|
20
|
+
notice: "in-flight" | "logged" | "delivered" | "failed" | undefined;
|
|
21
|
+
pausedByUs: boolean;
|
|
22
|
+
noticeBase?: EventBase;
|
|
23
|
+
noticeAbort?: AbortController;
|
|
24
|
+
/** A failed notice may retry only after this time, so a broken notifier is not respawned per request. */
|
|
25
|
+
noticeRetryAt?: number;
|
|
26
|
+
/** The episode's log line was written; retries do not repeat it. */
|
|
27
|
+
warned?: boolean;
|
|
28
|
+
};
|
|
16
29
|
export type LowQuotaRegistration = (() => void) & {
|
|
17
30
|
hasPendingSave(): boolean;
|
|
18
31
|
flush(): Promise<void>;
|
|
@@ -20,6 +33,7 @@ export type LowQuotaRegistration = (() => void) & {
|
|
|
20
33
|
};
|
|
21
34
|
|
|
22
35
|
const RETRY_DELAYS_MS = [100, 250];
|
|
36
|
+
const NOTICE_RETRY_MS = 5 * 60_000;
|
|
23
37
|
const FLUSH_DEADLINE_MS = 500;
|
|
24
38
|
|
|
25
39
|
/** Each server owns its own policy, episode state and deferred writer. */
|
|
@@ -44,6 +58,12 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
|
|
|
44
58
|
if (delivery === "pause-save") pauseStatus.set(base, status);
|
|
45
59
|
ledger.publish({ ...base, delivery, status, timestamp: Date.now() });
|
|
46
60
|
}
|
|
61
|
+
/** Publishes one cancellation for an in-flight notice and stops its delivery. */
|
|
62
|
+
function cancelNotice(episode: Episode): void {
|
|
63
|
+
if (episode.notice === "in-flight" && episode.noticeBase) event(episode.noticeBase, "notice", "cancelled");
|
|
64
|
+
episode.noticeAbort?.abort();
|
|
65
|
+
episode.noticeAbort = undefined;
|
|
66
|
+
}
|
|
47
67
|
function cancelTimer(): void {
|
|
48
68
|
if (saveTimer) clearTimeout(saveTimer);
|
|
49
69
|
saveTimer = null;
|
|
@@ -132,9 +152,7 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
|
|
|
132
152
|
if (pauseStatus.get(base) === "pending") event(base, "pause-save", "cancelled");
|
|
133
153
|
pendingPauseEvents.delete(key);
|
|
134
154
|
}
|
|
135
|
-
for (const episode of episodes.values())
|
|
136
|
-
if (episode.notice === "in-flight" && episode.noticeBase) event(episode.noticeBase, "notice", "cancelled");
|
|
137
|
-
}
|
|
155
|
+
for (const episode of episodes.values()) cancelNotice(episode);
|
|
138
156
|
dirty = false;
|
|
139
157
|
autoPausedAccounts.clear();
|
|
140
158
|
unregister();
|
|
@@ -175,9 +193,7 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
|
|
|
175
193
|
const policy = config.codexPool?.lowQuotaProtection;
|
|
176
194
|
const nextKey = JSON.stringify(policy);
|
|
177
195
|
if (policyKey !== nextKey) {
|
|
178
|
-
for (const episode of episodes.values())
|
|
179
|
-
if (episode.notice === "in-flight" && episode.noticeBase) event(episode.noticeBase, "notice", "cancelled");
|
|
180
|
-
}
|
|
196
|
+
for (const episode of episodes.values()) cancelNotice(episode);
|
|
181
197
|
episodes.clear();
|
|
182
198
|
autoPausedAccounts.clear();
|
|
183
199
|
policyKey = nextKey;
|
|
@@ -187,7 +203,7 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
|
|
|
187
203
|
for (const id of autoPausedAccounts) if (!liveIds.has(id)) autoPausedAccounts.delete(id);
|
|
188
204
|
for (const [key, episode] of episodes) {
|
|
189
205
|
if (liveIds.has(key.split("\u0000")[0]!)) continue;
|
|
190
|
-
|
|
206
|
+
cancelNotice(episode);
|
|
191
207
|
episodes.delete(key);
|
|
192
208
|
}
|
|
193
209
|
if (!liveIds.has(accountId)) return;
|
|
@@ -203,7 +219,7 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
|
|
|
203
219
|
const key = `${accountId}\u0000${window}`;
|
|
204
220
|
if (percentUsed < policy.threshold) {
|
|
205
221
|
const prior = episodes.get(key);
|
|
206
|
-
if (prior
|
|
222
|
+
if (prior) cancelNotice(prior);
|
|
207
223
|
episodes.delete(key);
|
|
208
224
|
continue;
|
|
209
225
|
}
|
|
@@ -211,6 +227,7 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
|
|
|
211
227
|
const reset = resetAt === null ? "unknown" : String(resetAt);
|
|
212
228
|
let episode = episodes.get(key);
|
|
213
229
|
if (!episode || episode.reset !== reset) {
|
|
230
|
+
if (episode) cancelNotice(episode);
|
|
214
231
|
episode = { reset, notice: undefined, pausedByUs: false };
|
|
215
232
|
episodes.set(key, episode);
|
|
216
233
|
}
|
|
@@ -234,32 +251,41 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
|
|
|
234
251
|
scheduleSave();
|
|
235
252
|
}
|
|
236
253
|
// A manual resume leaves pausedByUs set until recovery or a new reset episode.
|
|
237
|
-
if (policy.actions.notify && episode.notice !== "in-flight" && episode.notice !== "logged" && episode.notice !== "delivered"
|
|
238
|
-
|
|
239
|
-
if (!
|
|
254
|
+
if (policy.actions.notify && episode.notice !== "in-flight" && episode.notice !== "logged" && episode.notice !== "delivered"
|
|
255
|
+
&& !(episode.notice === "failed" && (episode.noticeRetryAt ?? 0) > Date.now())) {
|
|
256
|
+
if (!episode.warned) {
|
|
257
|
+
episode.warned = true;
|
|
258
|
+
console.warn(`[codex-low-quota] ${window === "short" ? "5-hour" : "weekly"} quota reached ${percentUsed}% used (threshold ${policy.threshold}%)`);
|
|
259
|
+
}
|
|
260
|
+
const sink = deps.notify ?? (policy.actions.desktop === true ? deps.desktopSink?.() : undefined);
|
|
261
|
+
if (!sink) {
|
|
240
262
|
event(base, "notice", "logged");
|
|
241
263
|
episode.notice = "logged";
|
|
242
264
|
continue;
|
|
243
265
|
}
|
|
244
|
-
|
|
245
|
-
|
|
266
|
+
const account = config.codexAccounts?.find(candidate => candidate.id === accountId);
|
|
267
|
+
const notice: LowQuotaNotice = {
|
|
268
|
+
accountLabel: account ? codexAccountLogLabel(account) : fallbackCodexAccountLogLabel(accountId),
|
|
269
|
+
window, percentUsed, threshold: policy.threshold, resetAt,
|
|
270
|
+
};
|
|
271
|
+
const abort = new AbortController();
|
|
272
|
+
const current = episode;
|
|
273
|
+
current.notice = "in-flight";
|
|
274
|
+
current.noticeBase = base;
|
|
275
|
+
current.noticeAbort = abort;
|
|
246
276
|
event(base, "notice", "pending");
|
|
277
|
+
const settle = (status: "delivered" | "failed") => {
|
|
278
|
+
if (closed || abort.signal.aborted || episodes.get(key) !== current) return;
|
|
279
|
+
event(base, "notice", status);
|
|
280
|
+
current.notice = status;
|
|
281
|
+
current.noticeRetryAt = status === "failed" ? Date.now() + NOTICE_RETRY_MS : undefined;
|
|
282
|
+
current.noticeBase = undefined;
|
|
283
|
+
current.noticeAbort = undefined;
|
|
284
|
+
};
|
|
247
285
|
try {
|
|
248
|
-
void Promise.resolve(
|
|
249
|
-
if (closed || episodes.get(key) !== episode) return;
|
|
250
|
-
event(base, "notice", "delivered");
|
|
251
|
-
episode.notice = "delivered";
|
|
252
|
-
episode.noticeBase = undefined;
|
|
253
|
-
}, () => {
|
|
254
|
-
if (closed || episodes.get(key) !== episode) return;
|
|
255
|
-
event(base, "notice", "failed");
|
|
256
|
-
episode.notice = "failed";
|
|
257
|
-
episode.noticeBase = undefined;
|
|
258
|
-
});
|
|
286
|
+
void Promise.resolve(sink(notice, abort.signal)).then(() => settle("delivered"), () => settle("failed"));
|
|
259
287
|
} catch {
|
|
260
|
-
|
|
261
|
-
episode.notice = "failed";
|
|
262
|
-
episode.noticeBase = undefined;
|
|
288
|
+
settle("failed");
|
|
263
289
|
}
|
|
264
290
|
}
|
|
265
291
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getConfigDir } from "../config/paths";
|
|
2
|
+
import { notifyMainRosterChanged, type MainDecisionRow } from "../lib/decision-quota-sink";
|
|
1
3
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
4
|
import { codexCredentialMutationEpoch } from "./credential-mutation-epoch";
|
|
3
5
|
import type { StoredAccountQuota } from "./quota-types";
|
|
@@ -22,11 +24,33 @@ let observedMainQuotaIdentityKey: string | undefined;
|
|
|
22
24
|
const mainQuotaCredentialKey = randomBytes(32);
|
|
23
25
|
let mainQuotaCredential: { bearerHmac: Buffer; writer: MainQuotaWriter } | undefined;
|
|
24
26
|
let mainQuotaCredentialGeneration = 0;
|
|
27
|
+
let mainDecisionCredentialUsable = true;
|
|
28
|
+
// Advisory-only generation for the decision row and its writers. It follows every core credential
|
|
29
|
+
// generation change and additionally advances when physical presence is lost, so absence retires
|
|
30
|
+
// advisory evidence without touching the core credential binding that policy checks rely on.
|
|
31
|
+
let mainDecisionGeneration = 0;
|
|
32
|
+
// Advisory-only: the retained credential was re-observed since presence was last lost. Absence and
|
|
33
|
+
// presence-forgetting clear it, so the decision row stays unusable until a fresh owned observation.
|
|
34
|
+
let mainDecisionCredentialFresh = false;
|
|
35
|
+
// Config root that owns the observed main identity/credential for advisory decision evidence only.
|
|
36
|
+
// Stamped at identity/credential observations while a sink is active; other transitions never claim a root.
|
|
37
|
+
let mainDecisionRoot: string | undefined;
|
|
38
|
+
/** Existing physical credential owner supplies terminal grant/usability observations. */
|
|
39
|
+
export function observeMainDecisionCredentialUsable(usable: boolean): void {
|
|
40
|
+
mainDecisionCredentialUsable = usable;
|
|
41
|
+
publishMainDecisionRoster();
|
|
42
|
+
}
|
|
25
43
|
|
|
26
44
|
/** Process-local transition fence; no credential material or persisted identity. */
|
|
27
45
|
export function getMainQuotaCredentialGeneration(): number { return mainQuotaCredentialGeneration; }
|
|
28
46
|
|
|
29
47
|
export type MainQuotaWriter = Readonly<{ identityKey: string; identityGeneration: number }>;
|
|
48
|
+
const decisionWriterGenerations = new WeakMap<MainQuotaWriter, number>();
|
|
49
|
+
/** Return the credential generation a main quota writer was captured under, or undefined once that writer is stale or its credential generation has moved. */
|
|
50
|
+
export function mainDecisionQuotaWriterGeneration(writer: MainQuotaWriter): number | undefined {
|
|
51
|
+
const generation = decisionWriterGenerations.get(writer);
|
|
52
|
+
return generation === mainDecisionGeneration && isMainQuotaWriterLive(writer) && mainDecisionRoot === getConfigDir() ? generation : undefined;
|
|
53
|
+
}
|
|
30
54
|
|
|
31
55
|
function mainQuotaIdentityKey(accountId: string): string {
|
|
32
56
|
return createHash("sha256").update("opencodex-main-quota-v1\0").update(accountId).digest("hex");
|
|
@@ -38,16 +62,22 @@ export function observeMainQuotaIdentity(accountId: string): void {
|
|
|
38
62
|
const identityKey = mainQuotaIdentityKey(accountId);
|
|
39
63
|
if (identityKey === observedMainQuotaIdentityKey) return;
|
|
40
64
|
observedMainQuotaIdentityKey = identityKey;
|
|
65
|
+
mainDecisionCredentialUsable = true;
|
|
41
66
|
mainAccountIdentityGeneration += 1;
|
|
42
67
|
mainQuotaCredential = undefined;
|
|
43
68
|
mainQuotaCredentialGeneration += 1;
|
|
69
|
+
mainDecisionGeneration += 1;
|
|
70
|
+
publishMainDecisionRoster(true);
|
|
44
71
|
}
|
|
45
72
|
|
|
73
|
+
/** Capture a main quota writer for an account id only when it matches the observed physical identity, remembering the credential generation it was captured under. */
|
|
46
74
|
export function captureMainQuotaWriter(accountId: string): MainQuotaWriter | undefined {
|
|
47
75
|
if (!accountId) return undefined;
|
|
48
76
|
const identityKey = mainQuotaIdentityKey(accountId);
|
|
49
77
|
if (identityKey !== observedMainQuotaIdentityKey) return undefined;
|
|
50
|
-
|
|
78
|
+
const writer = { identityKey, identityGeneration: mainAccountIdentityGeneration };
|
|
79
|
+
if (mainQuotaCredential) decisionWriterGenerations.set(writer, mainDecisionGeneration);
|
|
80
|
+
return writer;
|
|
51
81
|
}
|
|
52
82
|
|
|
53
83
|
/** Credential material must come from an already-owned read, never an incoming request. */
|
|
@@ -56,9 +86,13 @@ export function observeMainQuotaCredential(accessToken: string, accountId: strin
|
|
|
56
86
|
if (!accessToken || !writer) return undefined;
|
|
57
87
|
const bearerHmac = createHmac("sha256", mainQuotaCredentialKey).update(accessToken).digest();
|
|
58
88
|
if (!mainQuotaCredential || !isMainQuotaWriterLive(mainQuotaCredential.writer)
|
|
59
|
-
|| !timingSafeEqual(bearerHmac, mainQuotaCredential.bearerHmac)) mainQuotaCredentialGeneration += 1;
|
|
89
|
+
|| !timingSafeEqual(bearerHmac, mainQuotaCredential.bearerHmac)) { mainQuotaCredentialGeneration += 1; mainDecisionGeneration += 1; }
|
|
60
90
|
mainQuotaCredential = { bearerHmac, writer };
|
|
61
|
-
|
|
91
|
+
mainDecisionCredentialFresh = true;
|
|
92
|
+
publishMainDecisionRoster(true);
|
|
93
|
+
const result = { ...writer };
|
|
94
|
+
decisionWriterGenerations.set(result, mainDecisionGeneration);
|
|
95
|
+
return result;
|
|
62
96
|
}
|
|
63
97
|
|
|
64
98
|
export function matchesMainQuotaCredential(accessToken: string, effectiveAccountId: string | undefined): boolean {
|
|
@@ -140,11 +174,14 @@ export function setMainAccountInfoCache(value: CachedMainAccountInfo): void {
|
|
|
140
174
|
};
|
|
141
175
|
}
|
|
142
176
|
|
|
177
|
+
/** Drop the cached main-account info and invalidate the observed identity and credential generation, then republish the main decision roster as unusable. */
|
|
143
178
|
export function clearMainAccountInfoCache(): void {
|
|
144
179
|
cachedMainAccountInfo = null;
|
|
145
180
|
mainAccountIdentityGeneration += 1;
|
|
146
181
|
mainQuotaCredential = undefined;
|
|
147
182
|
mainQuotaCredentialGeneration += 1;
|
|
183
|
+
mainDecisionGeneration += 1;
|
|
184
|
+
publishMainDecisionRoster();
|
|
148
185
|
}
|
|
149
186
|
|
|
150
187
|
/** Last physical credential presence observed while native-main ownership was held. */
|
|
@@ -152,10 +189,47 @@ export function getMainAccountCredentialPresence(): boolean | null {
|
|
|
152
189
|
return cachedMainCredentialPresence;
|
|
153
190
|
}
|
|
154
191
|
|
|
192
|
+
/** Record whether the main credential is physically present. The core credential binding is left intact (a failed read must not change policy matching); losing presence only retires advisory decision evidence. */
|
|
155
193
|
export function setMainAccountCredentialPresence(present: boolean): void {
|
|
194
|
+
if (!present && cachedMainCredentialPresence !== false) { mainDecisionGeneration += 1; mainDecisionCredentialFresh = false; }
|
|
156
195
|
cachedMainCredentialPresence = present;
|
|
196
|
+
publishMainDecisionRoster();
|
|
157
197
|
}
|
|
158
198
|
|
|
199
|
+
/** Forget the last observed main credential presence; retires advisory decision evidence only, leaving the core credential binding intact. */
|
|
159
200
|
export function clearMainAccountCredentialPresence(): void {
|
|
160
201
|
cachedMainCredentialPresence = null;
|
|
202
|
+
mainDecisionGeneration += 1;
|
|
203
|
+
mainDecisionCredentialFresh = false;
|
|
204
|
+
publishMainDecisionRoster();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Build the single `__main__` decision row from in-memory fields. With no retained credential the
|
|
209
|
+
* row is a known-unusable main (nothing to misattribute), as before. A retained credential counts only
|
|
210
|
+
* for the config root that observed it: for any other root the row is undefined (unknown) and the
|
|
211
|
+
* claimed root is forgotten, so returning to the old root never resurrects it without a new owned
|
|
212
|
+
* observation.
|
|
213
|
+
*/
|
|
214
|
+
function mainDecisionRow(): MainDecisionRow | undefined {
|
|
215
|
+
if (mainQuotaCredential === undefined) return { generation: mainDecisionGeneration, usable: false };
|
|
216
|
+
if (mainDecisionRoot === undefined || mainDecisionRoot !== getConfigDir()) {
|
|
217
|
+
mainDecisionRoot = undefined;
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
return { generation: mainDecisionGeneration,
|
|
221
|
+
usable: mainDecisionCredentialFresh && mainDecisionCredentialUsable && cachedMainCredentialPresence !== false };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Hand the main decision row to the opt-in decision-quota slot (a no-op unless JEV quota signals are active); identity/credential observations claim the current root. */
|
|
225
|
+
function publishMainDecisionRoster(claimsRoot = false): void {
|
|
226
|
+
notifyMainRosterChanged(() => {
|
|
227
|
+
if (claimsRoot) mainDecisionRoot = getConfigDir();
|
|
228
|
+
return mainDecisionRow();
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Activation replay for the JEV quota owner: the current row, or undefined when the observed credential belongs to another config root. */
|
|
233
|
+
export function peekMainDecisionRow(): MainDecisionRow | undefined {
|
|
234
|
+
return mainDecisionRow();
|
|
161
235
|
}
|
|
@@ -121,3 +121,17 @@ export function getMainAccountHardLockStatus(
|
|
|
121
121
|
export function isMainAccountHardLocked(config: PolicyConfig, now = Date.now()): boolean {
|
|
122
122
|
return getMainAccountHardLockStatus(config, now).state === "blocked";
|
|
123
123
|
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Scheduling evidence only: the latest blocking reset even after it has passed, so recovery can
|
|
127
|
+
* notice a revised reset that expired between sweeps. Never an admission or unlock signal.
|
|
128
|
+
*/
|
|
129
|
+
export function latestMainBlockingResetAt(config: PolicyConfig): number | undefined {
|
|
130
|
+
if (!isMainAccountHardLockEnabled(config)) return undefined;
|
|
131
|
+
const quota = getMainPolicyQuota();
|
|
132
|
+
if (!quota) return undefined;
|
|
133
|
+
const thresholds = resolveMainAccountHardLockThresholds(config);
|
|
134
|
+
const resets = governingWindows(quota)
|
|
135
|
+
.filter(w => validPercent(w.percent) && w.percent >= thresholds[w.kind]).map(w => resetTimestamp(w.resetAt));
|
|
136
|
+
return resets.length > 0 && resets.every(r => r !== undefined) ? Math.max(...(resets as number[])) : undefined;
|
|
137
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { observeMainDecisionCredentialUsable } from "./main-account-cache";
|
|
1
2
|
import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure } from "./chatgpt-refresh-failure";
|
|
2
3
|
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
@@ -245,10 +246,12 @@ export function getMainAccountCredentialStatus(now = Date.now()): MainAccountCre
|
|
|
245
246
|
const current = readMainAuthJsonCredential();
|
|
246
247
|
const refreshGrantRejected = credentialRefreshGrantRejected(current);
|
|
247
248
|
const hasRefreshGrant = !!current?.refreshToken && !refreshGrantRejected;
|
|
249
|
+
const usable = !refreshGrantRejected && (hasRefreshGrant || mainAccessTokenFresh(current?.accessToken, now, 0));
|
|
250
|
+
observeMainDecisionCredentialUsable(usable);
|
|
248
251
|
return {
|
|
249
252
|
refreshGrantRejected,
|
|
250
253
|
hasRefreshGrant,
|
|
251
|
-
usable
|
|
254
|
+
usable,
|
|
252
255
|
};
|
|
253
256
|
}
|
|
254
257
|
|
|
@@ -444,6 +447,7 @@ export function beginNativeMainReauth(): {
|
|
|
444
447
|
};
|
|
445
448
|
}
|
|
446
449
|
|
|
450
|
+
/** Resolve the main account access token from the owned auth.json read, refreshing through the token endpoint when needed, or return null when no usable credential exists. */
|
|
447
451
|
async function resolveMainAccountToken(
|
|
448
452
|
dependencies: NativeMainRefreshDependencies = {},
|
|
449
453
|
rejectedAccessToken?: string,
|
|
@@ -546,7 +550,7 @@ async function resolveMainAccountToken(
|
|
|
546
550
|
if (signal.aborted) throw signal.reason;
|
|
547
551
|
if (reason === "reauth") {
|
|
548
552
|
assertMainAuthJsonSnapshotUnchanged(locked);
|
|
549
|
-
markMainRefreshGrantRejected(mainRefreshGrantKey(locked)!);
|
|
553
|
+
markMainRefreshGrantRejected(mainRefreshGrantKey(locked)!); observeMainDecisionCredentialUsable(false);
|
|
550
554
|
}
|
|
551
555
|
throw new MainAccountTokenRefreshError(reason, { cause });
|
|
552
556
|
}
|
|
@@ -17,6 +17,7 @@ import { Database } from "bun:sqlite";
|
|
|
17
17
|
|
|
18
18
|
import { getConfigDir } from "../config";
|
|
19
19
|
import { catalogHasRoutedEntries, parseCatalogJson } from "./catalog/parsing";
|
|
20
|
+
import { hasOcxRoutedAuthorship } from "./catalog/ownership";
|
|
20
21
|
import { codexHistoryBackupId, legacyCodexHistoryBackupId, validateCodexHistoryBackupManifest } from "./history-manifest";
|
|
21
22
|
import {
|
|
22
23
|
hasInjectedCodexRouting,
|
|
@@ -79,7 +80,6 @@ const PROFILE_FILE_NAME = basename(CODEX_PROFILE_PATH);
|
|
|
79
80
|
const CATALOG_FILE_NAME = basename(DEFAULT_CATALOG_PATH);
|
|
80
81
|
const MODELS_CACHE_FILE_NAME = basename(CODEX_MODELS_CACHE_PATH);
|
|
81
82
|
const JOURNAL_FILE_NAME = "opencodex-journal.json";
|
|
82
|
-
const ROUTED_CATALOG_DESCRIPTION_PREFIX = "Routed via opencodex → ";
|
|
83
83
|
const MAX_ROLLOUT_INSPECTION_BYTES = 64 * 1024 * 1024;
|
|
84
84
|
const ROLLOUT_READ_CHUNK_BYTES = 64 * 1024;
|
|
85
85
|
// Bound one observation across every referenced rollout, not just each file.
|
|
@@ -339,8 +339,7 @@ function classifyProfile(path: string): NativeRoutedResidueResult {
|
|
|
339
339
|
}
|
|
340
340
|
|
|
341
341
|
function isOcxRoutedCatalogEntry(entry: Record<string, unknown>): boolean {
|
|
342
|
-
return
|
|
343
|
-
&& entry.description.startsWith(ROUTED_CATALOG_DESCRIPTION_PREFIX);
|
|
342
|
+
return hasOcxRoutedAuthorship(entry);
|
|
344
343
|
}
|
|
345
344
|
|
|
346
345
|
function classifyCatalogLike(
|
package/src/codex/quota.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { bindRawCodexDecisionResets, publishCodexDecisionQuota } from "../providers/quota-decision-publication";
|
|
2
|
+
import { clearDecisionAccountQuotas, decisionQuotaSinkActive } from "../lib/decision-quota-sink";
|
|
1
3
|
import { parseCodexCredits } from "./credits";
|
|
2
4
|
import { forgetMainAccountUsage, observeMainAccountUsage, type FreshWindow } from "./main-account-external-usage";
|
|
3
5
|
import { closeSync, constants as fsConstants, existsSync, fstatSync, openSync, readSync, unlinkSync } from "node:fs";
|
|
@@ -8,7 +10,7 @@ import { isThirtyDayOnlyCodexPlan } from "./plan";
|
|
|
8
10
|
import { stampCodexQuotaUsageObservation } from "./quota-observation-freshness";
|
|
9
11
|
import { observeCodexLowQuota } from "./low-quota-observer";
|
|
10
12
|
import { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
|
|
11
|
-
import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaWriter } from "./main-account-cache";
|
|
13
|
+
import { getObservedMainQuotaIdentityKey, mainDecisionQuotaWriterGeneration, isMainQuotaWriterLive, type MainQuotaWriter } from "./main-account-cache";
|
|
12
14
|
|
|
13
15
|
import { CodexQuotaHistory, QUOTA_HISTORY_LIMITS, type QuotaHistoryWindow } from "./quota-history";
|
|
14
16
|
import { isPoolQuotaWriterLive, poolQuotaHistoryIdentity } from "./account-store";
|
|
@@ -301,6 +303,9 @@ export function setAccountQuotaFromParsed(
|
|
|
301
303
|
// writer: when capture failed before dispatch, an absent writer must fail closed instead
|
|
302
304
|
// of reading as a writer-free legacy/login observation.
|
|
303
305
|
poolRequest = false,
|
|
306
|
+
decisionObservationPartial = true,
|
|
307
|
+
// Advisory publication is explicit opt-in evidence: display or policy data never becomes it by default.
|
|
308
|
+
decisionRaw: Omit<StoredAccountQuota, "updatedAt"> | null = null,
|
|
304
309
|
): void {
|
|
305
310
|
quota = withoutRetiredCodexQuota(quota);
|
|
306
311
|
policyQuota = withoutRetiredCodexQuota(policyQuota);
|
|
@@ -315,12 +320,16 @@ export function setAccountQuotaFromParsed(
|
|
|
315
320
|
? historyEvidence.writer.accountId === accountId && isPoolQuotaWriterLive(historyEvidence.writer)
|
|
316
321
|
: !poolRequest;
|
|
317
322
|
if (historyEvidence && livePoolEvidence) {
|
|
323
|
+
publishCodexDecisionQuota(accountId, historyEvidence.writer.credentialGeneration, historyEvidence.raw, historyEvidence.observedAt, "codex", historyEvidence.source === "response-header");
|
|
318
324
|
quotaHistory.append(historyEvidence.writer, { observedAt: historyEvidence.observedAt, source: historyEvidence.source,
|
|
319
325
|
credentialGeneration: historyEvidence.writer.credentialGeneration, windows: historyWindows(historyEvidence.raw),
|
|
320
326
|
}, updatedAt);
|
|
321
327
|
}
|
|
322
328
|
// Rotation shares validated main-window retirement, but never inherits policy-only
|
|
323
329
|
// evidence that outlived its disk TTL. Policy has a separate, identity-checked base.
|
|
330
|
+
const mainDecisionGeneration = mainWriter && mainDecisionQuotaWriterGeneration(mainWriter);
|
|
331
|
+
if (isMain && mainDecisionGeneration !== undefined && decisionRaw)
|
|
332
|
+
publishCodexDecisionQuota(accountId, mainDecisionGeneration, decisionRaw, updatedAt, "codex-main", decisionObservationPartial);
|
|
324
333
|
const displayQuota: MainPolicyQuotaObservation = isMain && mainWriter && policyQuota?.shortWindowAbsent === true
|
|
325
334
|
? { ...quota, shortWindowAbsent: true }
|
|
326
335
|
: quota;
|
|
@@ -544,6 +553,7 @@ export function withoutRetiredCodexQuota<T extends Omit<StoredAccountQuota, "upd
|
|
|
544
553
|
return snapshotHasUsage(next) || snapshotHasCredits(next) ? next as T : null;
|
|
545
554
|
}
|
|
546
555
|
|
|
556
|
+
/** Parse `x-codex-*` rate-limit headers into a quota observation, binding the raw reset values so decision evidence can keep invalid resets distinct from the display fields. */
|
|
547
557
|
export function parseUpstreamQuotaHeaders(headers: Headers, options?: { modelId?: string }): Omit<StoredAccountQuota, "updatedAt"> | null {
|
|
548
558
|
if (isRetiredCodexSparkModel(options?.modelId)) return null;
|
|
549
559
|
const primaryRaw = headers.get("x-codex-primary-used-percent");
|
|
@@ -610,9 +620,15 @@ export function parseUpstreamQuotaHeaders(headers: Headers, options?: { modelId?
|
|
|
610
620
|
if (tertiaryResetAt !== undefined) quota.monthlyResetAt = tertiaryResetAt;
|
|
611
621
|
}
|
|
612
622
|
|
|
623
|
+
if (decisionQuotaSinkActive()) bindRawCodexDecisionResets(quota, {
|
|
624
|
+
short: primaryResetRaw,
|
|
625
|
+
weekly: primaryIsMonthly || primaryIsShort || primaryPercent === undefined ? secondaryResetRaw : primaryResetRaw,
|
|
626
|
+
monthly: primaryIsMonthly && primaryPercent !== undefined ? primaryResetRaw : tertiaryResetRaw,
|
|
627
|
+
}, Date.now());
|
|
613
628
|
return hasKnownQuotaValue(quota) ? quota : null;
|
|
614
629
|
}
|
|
615
630
|
|
|
631
|
+
/** Apply upstream rate-limit headers for an account: publish display and policy quota and, only for a valid observation, the advisory decision evidence. */
|
|
616
632
|
export function applyAccountQuotaFromUpstreamHeaders(
|
|
617
633
|
accountId: string,
|
|
618
634
|
headers: Headers,
|
|
@@ -629,7 +645,7 @@ export function applyAccountQuotaFromUpstreamHeaders(
|
|
|
629
645
|
.some(name => isInvalidPolicyUsagePercent(headers.get(name)));
|
|
630
646
|
setAccountQuotaFromParsed(accountId, quota, writerGeneration, mainWriter, policyQuota,
|
|
631
647
|
options?.poolWriter && validHistory ? { writer: options.poolWriter, observedAt: Date.now(), source: "response-header", raw: quota } : undefined,
|
|
632
|
-
options?.poolResponse === true);
|
|
648
|
+
options?.poolResponse === true, true, validHistory ? quota : null);
|
|
633
649
|
}
|
|
634
650
|
|
|
635
651
|
export function updateAccountQuota(
|
|
@@ -822,7 +838,10 @@ function forgetCodexQuotaBaseline(accountId?: string): void {
|
|
|
822
838
|
});
|
|
823
839
|
}
|
|
824
840
|
|
|
841
|
+
/** Clear stored quota, advisory decision evidence and history for one account, or for all accounts when no id is given. */
|
|
825
842
|
export function clearAccountQuota(accountId?: string): void {
|
|
843
|
+
clearDecisionAccountQuotas("codex", accountId);
|
|
844
|
+
if (!accountId || accountId === MAIN_CODEX_ACCOUNT_ID) clearDecisionAccountQuotas("codex-main");
|
|
826
845
|
if (!accountId || accountId === MAIN_CODEX_ACCOUNT_ID) forgetMainAccountUsage();
|
|
827
846
|
if (accountId) hydrateAccountQuotasFromDisk();
|
|
828
847
|
quotaHistory.clear(accountId);
|
|
@@ -1009,6 +1028,11 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
|
|
|
1009
1028
|
|
|
1010
1029
|
if (resetCredits !== undefined) quota.resetCredits = resetCredits;
|
|
1011
1030
|
|
|
1031
|
+
if (decisionQuotaSinkActive()) bindRawCodexDecisionResets(quota, {
|
|
1032
|
+
short: primaryWindow?.reset_at,
|
|
1033
|
+
weekly: primaryIsMonthly || weeklyCandidatePercent === undefined ? secondaryWindow?.reset_at : primaryWindow?.reset_at,
|
|
1034
|
+
monthly: primaryIsMonthly && primaryPercent !== undefined ? primaryWindow?.reset_at : tertiaryWindow?.reset_at,
|
|
1035
|
+
}, Date.now());
|
|
1012
1036
|
return hasKnownQuotaValue(quota) || snapshotHasCredits(quota) ? quota : null;
|
|
1013
1037
|
}
|
|
1014
1038
|
|
|
@@ -6,6 +6,7 @@ import { isThirtyDayOnlyCodexPlan } from "../plan";
|
|
|
6
6
|
import { hasSpendableCodexCredits, isTerminalShortWindow, type CodexSpendableCredits } from "../quota-types";
|
|
7
7
|
import type { CodexQuotaScope } from "./health-store";
|
|
8
8
|
import type { TransientProbeGrant } from "./thread-affinity";
|
|
9
|
+
import type { MainQuotaDispatch } from "../main-account-cache";
|
|
9
10
|
|
|
10
11
|
export { TERMINAL_SHORT_WINDOW_FRESHNESS_MS } from "../quota-types";
|
|
11
12
|
|
|
@@ -99,6 +100,10 @@ export type CodexUpstreamOutcomeMeta = {
|
|
|
99
100
|
* means the caller cannot supply lineage and the historical unfenced handling stands.
|
|
100
101
|
*/
|
|
101
102
|
credentialGeneration?: number;
|
|
103
|
+
/** Main credential proof captured before dispatch; null means no owned proof was available. */
|
|
104
|
+
mainFailureWindowDispatch?: MainQuotaDispatch | null;
|
|
105
|
+
/** Pool sample provenance retained by a terminal callback even if its context is reused. */
|
|
106
|
+
failureWindowCredentialGeneration?: number;
|
|
102
107
|
};
|
|
103
108
|
|
|
104
109
|
export function computeCodexUsageScore(quota: {
|