@bitkyc08/opencodex 2.87.0 → 2.89.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-BnRSujOq.js +58 -0
- package/gui/dist/assets/App-dnkJ85te.css +1 -0
- package/gui/dist/assets/{Tray-DCd-A1az.js → Tray-7z1COnmz.js} +1 -1
- package/gui/dist/assets/index-uchD-ngf.js +94 -0
- package/gui/dist/assets/{quota-summary-ChvmKGOk.js → quota-summary-BY_10IEz.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic/cache-retention.ts +117 -0
- package/src/adapters/anthropic/passthrough.ts +7 -4
- package/src/adapters/anthropic-rejection-memo.ts +71 -0
- package/src/adapters/anthropic-replay.ts +135 -0
- package/src/adapters/anthropic-tool-catalog.ts +11 -0
- package/src/adapters/anthropic.ts +86 -48
- package/src/adapters/coding-agent/protocol.ts +6 -0
- package/src/adapters/google-http.ts +3 -2
- package/src/adapters/google-progress-history.ts +53 -0
- package/src/adapters/google-vertex-endpoint.ts +67 -0
- package/src/adapters/google.ts +52 -30
- package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
- package/src/adapters/openai-responses/passthrough.ts +17 -12
- package/src/adapters/openai-responses/reasoning.ts +68 -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 +17 -3
- package/src/bridge/response-json.ts +4 -4
- package/src/bridge/sse.ts +17 -10
- package/src/claude/inbound-content-options.ts +66 -0
- package/src/claude/inbound.ts +12 -26
- package/src/claude/outbound.ts +18 -5
- 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/cross-home-owner.ts +16 -2
- package/src/cli/help.ts +1 -1
- package/src/cli/loopback-family.ts +31 -0
- 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-pause.ts +6 -1
- package/src/codex/account-runtime-state.ts +5 -0
- package/src/codex/account-store.ts +30 -3
- package/src/codex/auth-api/account-list.ts +3 -0
- package/src/codex/auth-api/main-account-probe.ts +3 -2
- package/src/codex/auth-context.ts +9 -4
- 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-gather.ts +3 -1
- package/src/codex/catalog/routed-removal.ts +4 -4
- 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.ts +6 -2
- package/src/codex/native-residue.ts +2 -3
- package/src/codex/quota.ts +26 -2
- package/src/codex/websocket-activity.ts +161 -0
- package/src/codex/websocket-registry.ts +25 -1
- package/src/combos/failover.ts +2 -1
- 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/opencode-zen-upgrade.ts +9 -8
- package/src/config/persist-unlocked.ts +10 -1
- 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 +411 -175
- package/src/images/loop.ts +101 -27
- 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 +68 -0
- package/src/lib/decision-quota-sink.ts +76 -0
- package/src/lib/request-failure-model.ts +2 -0
- package/src/lib/state-store-registrations.ts +25 -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-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/generic-account-failover.ts +7 -4
- package/src/oauth/index.ts +5 -35
- package/src/oauth/store.ts +35 -8
- package/src/oauth/types.ts +2 -0
- package/src/plugins/upstream-hooks.ts +14 -1
- package/src/protocols/encoders/adapter-events.ts +5 -5
- package/src/protocols/encoders/messages.ts +2 -2
- 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 +5 -0
- package/src/providers/key-failover.ts +11 -11
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/opencode-zen-rate-limit.ts +12 -73
- package/src/providers/quota/account-cache.ts +15 -1
- package/src/providers/quota/anthropic-account-quota.ts +2 -1
- 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 +39 -6
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- 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 +3 -0
- package/src/providers/quota-types.ts +6 -1
- package/src/providers/quota.ts +3 -2
- package/src/providers/registry/entries-extended.ts +8 -52
- package/src/providers/registry/model-seeds.ts +1 -1
- package/src/providers/registry.ts +1 -2
- package/src/providers/stale-vision-classification-migration.ts +1 -1
- package/src/responses/parser.ts +18 -10
- package/src/responses/reasoning-replay-cache.ts +50 -4
- package/src/responses/web-search-replay.ts +138 -0
- package/src/routing/analytics.ts +7 -15
- 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 +28 -13
- package/src/server/claude-messages.ts +4 -2
- package/src/server/index/live-sideband.ts +3 -2
- package/src/server/index/optional-listeners.ts +6 -0
- package/src/server/index/serve-options.ts +11 -5
- package/src/server/index/websocket-handler.ts +18 -4
- package/src/server/index.ts +7 -7
- package/src/server/lifecycle.ts +13 -1
- package/src/server/management/combo-routes.ts +17 -2
- package/src/server/management/oauth-account-routes.ts +10 -1
- 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 +34 -7
- package/src/server/messages-shared.ts +4 -1
- package/src/server/request-log.ts +7 -2
- package/src/server/responses/account-auto-pause-recovery.ts +26 -0
- package/src/server/responses/adapter-continuation.ts +34 -18
- package/src/server/responses/adapter-delivery.ts +53 -6
- package/src/server/responses/adapter-dispatch.ts +104 -29
- package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
- package/src/server/responses/combo-session-recall.ts +25 -0
- package/src/server/responses/combo-stream-preflight.ts +6 -3
- package/src/server/responses/combo-tool-loop-pin.ts +70 -0
- package/src/server/responses/compact.ts +8 -5
- package/src/server/responses/compaction-recovery.ts +2 -0
- package/src/server/responses/context-overflow.ts +141 -2
- package/src/server/responses/core-codex-account.ts +9 -3
- package/src/server/responses/core-combo.ts +34 -7
- package/src/server/responses/core-lifetime.ts +3 -2
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/core-replay.ts +5 -1
- package/src/server/responses/core.ts +12 -9
- package/src/server/responses/empty-completion-guard.ts +2 -0
- package/src/server/responses/fetch-helpers.ts +35 -4
- package/src/server/responses/jev-model-invoke.ts +13 -14
- package/src/server/responses/native-steering-log.ts +2 -2
- package/src/server/responses/passthrough-delivery.ts +3 -2
- package/src/server/responses/passthrough-dispatch.ts +30 -33
- package/src/server/responses/passthrough-execution.ts +3 -2
- package/src/server/responses/passthrough-observations.ts +38 -0
- package/src/server/responses/request-compression.ts +98 -0
- package/src/server/responses/request-prepare.ts +1 -0
- package/src/server/responses/request-transport.ts +32 -3
- package/src/server/responses/response-effects.ts +1 -0
- package/src/server/responses/response-tool-ownership.ts +21 -0
- package/src/server/responses/run-turn-execution.ts +2 -0
- package/src/server/responses/sidecar-execution.ts +59 -10
- package/src/server/responses/sidecar-send-budget.ts +2 -2
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- 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/server/ws-bridge.ts +2 -0
- package/src/service/definition.ts +9 -1
- package/src/types/account-auto-pause.ts +15 -0
- package/src/types/config.ts +23 -1
- package/src/types/opencode-zen.ts +9 -3
- package/src/types/provider.ts +9 -5
- package/src/types/request.ts +30 -4
- package/src/types/wire.ts +4 -2
- 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 +16 -3
- package/src/usage/telemetry-contract.ts +1 -0
- package/src/web-search/anthropic-history.ts +59 -0
- package/src/web-search/loop.ts +212 -73
- package/src/web-search/progress-stream.ts +14 -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-ZfYbLHkB.js +0 -58
- package/gui/dist/assets/App-wT3mXqFz.css +0 -1
- package/gui/dist/assets/index-CfYDMj34.js +0 -94
|
@@ -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
|
}
|
|
@@ -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
|
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isAuthorizedProviderExecutedCall, type ProviderExecutedCallType } from "../server/responses-undeclared-tool-guard";
|
|
3
|
+
|
|
4
|
+
// Logical recency avoids clock adjustments and ties between messages in one millisecond.
|
|
5
|
+
let activityOrder = 0;
|
|
6
|
+
const MAX_PENDING_RESULTS = 1024;
|
|
7
|
+
const MAX_RESULT_ID_LENGTH = 1024;
|
|
8
|
+
const resultTypes: Readonly<Record<string, string>> = {
|
|
9
|
+
function_call: "function_call_output",
|
|
10
|
+
custom_tool_call: "custom_tool_call_output",
|
|
11
|
+
local_shell_call: "function_call_output",
|
|
12
|
+
shell_call: "shell_call_output",
|
|
13
|
+
computer_call: "computer_call_output",
|
|
14
|
+
apply_patch_call: "apply_patch_call_output",
|
|
15
|
+
tool_search_call: "tool_search_output",
|
|
16
|
+
mcp_approval_request: "mcp_approval_response",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
20
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resultKey(type: string, id: string): string {
|
|
24
|
+
return createHash("sha256").update(JSON.stringify([type, id])).digest("hex");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Content-free activity owned by one admitted incoming Responses socket. */
|
|
28
|
+
export class CodexWebSocketActivity {
|
|
29
|
+
lastUsed = ++activityOrder;
|
|
30
|
+
closing = false;
|
|
31
|
+
private turns = 0;
|
|
32
|
+
private pendingResults = new Set<string>();
|
|
33
|
+
private returnedResults = new Set<string>();
|
|
34
|
+
// Hashed identities only; null marks a route-authorized provider-owned call.
|
|
35
|
+
private argumentCalls = new Map<string, { type: string; result: string | null }>();
|
|
36
|
+
// Unknown/oversized identities cannot safely prove that client work finished.
|
|
37
|
+
private untrackedResults = false;
|
|
38
|
+
|
|
39
|
+
get idle(): boolean {
|
|
40
|
+
return !this.closing && this.turns === 0 && this.pendingResults.size === 0 && !this.untrackedResults;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
touch(): void {
|
|
44
|
+
this.lastUsed = ++activityOrder;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Each turn retains its own busy reference, including superseded async cleanup. */
|
|
48
|
+
beginTurn(input: unknown): () => void {
|
|
49
|
+
this.turns += 1;
|
|
50
|
+
this.touch();
|
|
51
|
+
this.acceptResults(input);
|
|
52
|
+
// Ordinary creates start a new response even when its upstream omits response.created.
|
|
53
|
+
// Prior results settle old waits, but cannot suppress new calls reusing their IDs.
|
|
54
|
+
this.returnedResults.clear();
|
|
55
|
+
this.argumentCalls.clear();
|
|
56
|
+
let finished = false;
|
|
57
|
+
return () => {
|
|
58
|
+
if (finished) return;
|
|
59
|
+
finished = true;
|
|
60
|
+
this.turns -= 1;
|
|
61
|
+
if (this.turns === 0) this.touch();
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Called only after admission or native control accepts the client's results. */
|
|
66
|
+
acceptResults(input: unknown): void {
|
|
67
|
+
if (this.untrackedResults) return;
|
|
68
|
+
if (Array.isArray(input)) {
|
|
69
|
+
for (const item of input) {
|
|
70
|
+
if (!record(item) || typeof item.type !== "string") continue;
|
|
71
|
+
const id = item.type === "mcp_approval_response" ? item.approval_request_id : item.call_id;
|
|
72
|
+
if (typeof id === "string" && id.length <= MAX_RESULT_ID_LENGTH) {
|
|
73
|
+
// Ordinary Codex shell results use function_call_output; native steering
|
|
74
|
+
// also accepts its explicit local_shell_call_output continuation stub.
|
|
75
|
+
const type = item.type === "local_shell_call_output" ? "function_call_output" : item.type;
|
|
76
|
+
const key = resultKey(type, id);
|
|
77
|
+
if (this.pendingResults.delete(key)) {
|
|
78
|
+
if (this.returnedResults.size >= MAX_PENDING_RESULTS) { this.protectUntracked(); return; }
|
|
79
|
+
else this.returnedResults.add(key);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Preserve tool waits even when the response terminal omits its output array. */
|
|
87
|
+
observePayload(payload: string, providerExecuted?: ReadonlySet<ProviderExecutedCallType>): void {
|
|
88
|
+
if (this.untrackedResults) return;
|
|
89
|
+
let frame: unknown;
|
|
90
|
+
try { frame = JSON.parse(payload); } catch { return; }
|
|
91
|
+
if (!record(frame)) return;
|
|
92
|
+
// Native injection can return a result before the same terminal repeats its call.
|
|
93
|
+
if (frame.type === "response.created") {
|
|
94
|
+
this.returnedResults.clear();
|
|
95
|
+
this.argumentCalls.clear();
|
|
96
|
+
}
|
|
97
|
+
if (frame.type === "response.output_item.added") this.rememberArgumentCall(frame.item, providerExecuted);
|
|
98
|
+
if (frame.type === "response.function_call_arguments.done" || frame.type === "response.custom_tool_call_input.done") {
|
|
99
|
+
const type = frame.type === "response.function_call_arguments.done" ? "function_call" : "custom_tool_call";
|
|
100
|
+
const call = typeof frame.item_id === "string" && frame.item_id.length <= MAX_RESULT_ID_LENGTH
|
|
101
|
+
? this.argumentCalls.get(resultKey("item", frame.item_id)) : undefined;
|
|
102
|
+
if (!call || call.type !== type) this.protectUntracked();
|
|
103
|
+
else if (call.result !== null) this.observeResultKey(call.result);
|
|
104
|
+
}
|
|
105
|
+
if (frame.type === "response.output_item.done") this.observeOutput(frame.item, providerExecuted);
|
|
106
|
+
if (["response.completed", "response.incomplete", "response.failed"].includes(String(frame.type))
|
|
107
|
+
&& record(frame.response) && Array.isArray(frame.response.output)) {
|
|
108
|
+
for (const item of frame.response.output) this.observeOutput(item, providerExecuted);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private rememberArgumentCall(item: unknown, providerExecuted?: ReadonlySet<ProviderExecutedCallType>): void {
|
|
113
|
+
if (!record(item) || (item.type !== "function_call" && item.type !== "custom_tool_call")) return;
|
|
114
|
+
if (typeof item.id !== "string" || !item.id.length || item.id.length > MAX_RESULT_ID_LENGTH) return;
|
|
115
|
+
const key = resultKey("item", item.id);
|
|
116
|
+
const hosted = providerExecuted && isAuthorizedProviderExecutedCall(item, providerExecuted);
|
|
117
|
+
if (!hosted && (typeof item.call_id !== "string" || !item.call_id.length || item.call_id.length > MAX_RESULT_ID_LENGTH)) {
|
|
118
|
+
if (this.argumentCalls.has(key)) this.protectUntracked();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const result = hosted ? null : resultKey(resultTypes[item.type]!, item.call_id as string);
|
|
122
|
+
const previous = this.argumentCalls.get(key);
|
|
123
|
+
if (previous) {
|
|
124
|
+
if (previous.type !== item.type || previous.result !== result) this.protectUntracked();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (this.argumentCalls.size >= MAX_PENDING_RESULTS) { this.protectUntracked(); return; }
|
|
128
|
+
// An unfinished argument stream alone does not prove the client started work.
|
|
129
|
+
this.argumentCalls.set(key, { type: item.type, result });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private observeOutput(item: unknown, providerExecuted?: ReadonlySet<ProviderExecutedCallType>): void {
|
|
133
|
+
if (this.untrackedResults) return;
|
|
134
|
+
if (!record(item) || typeof item.type !== "string") return;
|
|
135
|
+
// Hosted execution may publish both the call and its result in the output stream.
|
|
136
|
+
this.acceptResults([item]);
|
|
137
|
+
if (!Object.hasOwn(resultTypes, item.type)) return;
|
|
138
|
+
if (providerExecuted && isAuthorizedProviderExecutedCall(item, providerExecuted)) return;
|
|
139
|
+
if (item.type === "tool_search_call" && item.execution !== "client") return;
|
|
140
|
+
const id = item.type === "mcp_approval_request" ? item.id
|
|
141
|
+
: item.type === "local_shell_call" || item.type === "tool_search_call" ? item.call_id ?? item.id : item.call_id;
|
|
142
|
+
if (typeof id !== "string" || !id.length || id.length > MAX_RESULT_ID_LENGTH) {
|
|
143
|
+
this.protectUntracked();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.observeResultKey(resultKey(resultTypes[item.type]!, id));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private observeResultKey(key: string): void {
|
|
150
|
+
if (this.pendingResults.has(key) || this.returnedResults.has(key)) return;
|
|
151
|
+
if (this.pendingResults.size >= MAX_PENDING_RESULTS) this.protectUntracked();
|
|
152
|
+
else this.pendingResults.add(key);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private protectUntracked(): void {
|
|
156
|
+
this.untrackedResults = true;
|
|
157
|
+
this.pendingResults.clear();
|
|
158
|
+
this.returnedResults.clear();
|
|
159
|
+
this.argumentCalls.clear();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -1,23 +1,47 @@
|
|
|
1
1
|
import type { ServerWebSocket } from "bun";
|
|
2
2
|
import type { WsData } from "../server/ws-bridge";
|
|
3
3
|
import { createAdmissionGate, type AdmissionMetrics, type AdmissionReservation } from "../lib/admission";
|
|
4
|
+
import { CodexWebSocketActivity } from "./websocket-activity";
|
|
4
5
|
|
|
5
6
|
export const MAX_TRACKED_CODEX_WEBSOCKETS = 128;
|
|
6
7
|
const websocketGate = createAdmissionGate("codex_websockets", MAX_TRACKED_CODEX_WEBSOCKETS);
|
|
8
|
+
const admittedSockets = new Set<ServerWebSocket<WsData>>();
|
|
9
|
+
|
|
10
|
+
function reclaimOldestIdleSocket(): void {
|
|
11
|
+
let oldest: ServerWebSocket<WsData> | undefined;
|
|
12
|
+
for (const ws of admittedSockets) {
|
|
13
|
+
const activity = ws.data.responsesActivity;
|
|
14
|
+
if (!activity?.idle || ws.data.nativeControl || ws.readyState !== 1 || ws.getBufferedAmount() !== 0) continue;
|
|
15
|
+
if (!oldest || activity.lastUsed < oldest.data.responsesActivity!.lastUsed) oldest = ws;
|
|
16
|
+
}
|
|
17
|
+
if (!oldest) return;
|
|
18
|
+
// Fence late frames before closing. Only the actual close callback releases admission.
|
|
19
|
+
oldest.data.responsesActivity!.closing = true;
|
|
20
|
+
try {
|
|
21
|
+
oldest.close(1001, "Idle Responses connection reclaimed");
|
|
22
|
+
} catch {
|
|
23
|
+
// A failed/delayed close never manufactures a free slot or retries another victim.
|
|
24
|
+
if (oldest.readyState === 1) oldest.data.responsesActivity!.closing = false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
7
27
|
|
|
8
28
|
export function tryReserveCodexWebSocket(): AdmissionReservation<ServerWebSocket<WsData>> | null {
|
|
29
|
+
if (websocketGate.metrics().active >= MAX_TRACKED_CODEX_WEBSOCKETS) reclaimOldestIdleSocket();
|
|
9
30
|
const gateLease = websocketGate.tryAcquire();
|
|
10
31
|
if (!gateLease) return null;
|
|
11
32
|
let socket: ServerWebSocket<WsData> | undefined;
|
|
12
33
|
let active = true;
|
|
13
34
|
return {
|
|
14
35
|
bind(value) {
|
|
15
|
-
if (!active) return;
|
|
36
|
+
if (!active || socket) return;
|
|
16
37
|
socket = value;
|
|
38
|
+
socket.data.responsesActivity = new CodexWebSocketActivity();
|
|
39
|
+
admittedSockets.add(socket);
|
|
17
40
|
},
|
|
18
41
|
release() {
|
|
19
42
|
if (!active) return;
|
|
20
43
|
active = false;
|
|
44
|
+
if (socket) admittedSockets.delete(socket);
|
|
21
45
|
socket = undefined;
|
|
22
46
|
gateLease.release();
|
|
23
47
|
},
|
package/src/combos/failover.ts
CHANGED
|
@@ -666,8 +666,9 @@ function isDefiniteContextOverflowMessage(text: string): boolean {
|
|
|
666
666
|
* arbitrary field that happens to sit in the first 500 bytes authorize a hop, so it fails closed.
|
|
667
667
|
*
|
|
668
668
|
* Only the exact proxy wrapper is unwrapped, within a fixed envelope budget and 16,384 characters.
|
|
669
|
+
* Exported for the sidecar loops, which classify their own provider failures with the same rule.
|
|
669
670
|
*/
|
|
670
|
-
function isDefiniteContextOverflow(status: number, message: string): boolean {
|
|
671
|
+
export function isDefiniteContextOverflow(status: number, message: string): boolean {
|
|
671
672
|
if (!CONTEXT_VERDICT_STATUSES.has(status) && status < 500) return false;
|
|
672
673
|
if (message.length > 16_384) return false;
|
|
673
674
|
let text = message.trim();
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { boundedJevQuotaPayload, jevQuotaCandidates, withJevQuotaSummary } from "./jev-quota-route";
|
|
2
|
+
import { JEV_QUOTA_INSTRUCTION } from "./jev-quota";
|
|
3
|
+
import { serializeJevModelRequest, type JevModelRequestBody } from "./jev-model-request";
|
|
1
4
|
import {
|
|
2
5
|
buildJevState,
|
|
3
6
|
candidatesFitRequestBounds,
|
|
@@ -25,11 +28,14 @@ export interface JevModelInvokeRequest {
|
|
|
25
28
|
instructions: string;
|
|
26
29
|
input: string;
|
|
27
30
|
signal: AbortSignal;
|
|
31
|
+
/** Budget-only fallback, selected by the detached invoker before admission or dispatch. */
|
|
32
|
+
withoutQuota?: Pick<JevModelRequestBody, "instructions" | "input">;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
export interface JevModelInvokeResult {
|
|
31
36
|
text: string;
|
|
32
37
|
usage?: Record<string, number>;
|
|
38
|
+
quotaOmitted?: true;
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
export type JevModelInvoke = (request: JevModelInvokeRequest) => Promise<JevModelInvokeResult>;
|
|
@@ -69,29 +75,45 @@ export function parseJevModelChoice(text: string, allowed: ReadonlySet<string>):
|
|
|
69
75
|
return choice;
|
|
70
76
|
}
|
|
71
77
|
|
|
78
|
+
/** Resolve a JEV decision through the decision model, falling back with a gate reason on any failure; quota tiers are advisory input only and never widen the allowlist. */
|
|
72
79
|
export async function resolveJevModelDecision(
|
|
73
80
|
options: ResolveJevDecisionOptions & { decisionModel: string; invokeModel: JevModelInvoke },
|
|
74
81
|
): Promise<JevDecision> {
|
|
75
82
|
const now = options.now ?? Date.now;
|
|
76
83
|
const startedAt = now();
|
|
84
|
+
let candidates = options.candidates;
|
|
85
|
+
/** Build the fallback decision for a failure gate, attaching the quota summary for the candidates in scope. */
|
|
77
86
|
const failed = (gate: Exclude<JevDecision["gate"], "apply">): JevDecision =>
|
|
78
|
-
fallbackDecision(options.fallback, gate, Math.max(0, now() - startedAt), "model");
|
|
87
|
+
withJevQuotaSummary(fallbackDecision(options.fallback, gate, Math.max(0, now() - startedAt), "model"), candidates);
|
|
79
88
|
|
|
80
89
|
if (options.signal?.aborted) throw options.signal.reason;
|
|
81
90
|
if (options.candidates.length === 0) return failed("no_choices");
|
|
82
91
|
if (!candidatesFitRequestBounds(options.candidates)) return failed("invalid");
|
|
83
92
|
|
|
84
93
|
let routeOptions: JevRouteOptionDescriptor[];
|
|
85
|
-
let input
|
|
94
|
+
let input = "";
|
|
95
|
+
let instructions = JEV_MODEL_INSTRUCTIONS;
|
|
96
|
+
let withoutQuota: JevModelInvokeRequest["withoutQuota"];
|
|
86
97
|
try {
|
|
87
98
|
routeOptions = jevRouteOptions(options.candidates);
|
|
88
99
|
if (routeOptions.length > JEV_MODEL_MAX_OPTIONS) return failed("invalid");
|
|
89
100
|
const state = buildJevState(options.body, options.candidates);
|
|
90
101
|
if (!hasJevDecisionState(state)) return failed("no_state");
|
|
91
|
-
|
|
92
|
-
|
|
102
|
+
const payload = boundedJevQuotaPayload(jevQuotaCandidates(options), rows => {
|
|
103
|
+
instructions = JEV_MODEL_INSTRUCTIONS + (rows.some(row => row.quota) ? " " + JEV_QUOTA_INSTRUCTION : "");
|
|
104
|
+
input = buildJevModelPrompt(state, rows);
|
|
105
|
+
if (options.decisionQuotaSignals !== true) return instructions + input;
|
|
106
|
+
const request = { model: options.decisionModel, instructions, input };
|
|
107
|
+
return serializeJevModelRequest(request);
|
|
108
|
+
}, JEV_MAX_REQUEST_BYTES);
|
|
109
|
+
candidates = payload.candidates;
|
|
110
|
+
if (new TextEncoder().encode(payload.body).byteLength > JEV_MAX_REQUEST_BYTES) {
|
|
93
111
|
return failed("invalid");
|
|
94
112
|
}
|
|
113
|
+
if (candidates.some(row => row.quota)) {
|
|
114
|
+
withoutQuota = { instructions: JEV_MODEL_INSTRUCTIONS,
|
|
115
|
+
input: buildJevModelPrompt(state, candidates.map(({ quota: _quota, ...row }) => row)) };
|
|
116
|
+
}
|
|
95
117
|
} catch {
|
|
96
118
|
return failed("invalid");
|
|
97
119
|
}
|
|
@@ -99,7 +121,8 @@ export async function resolveJevModelDecision(
|
|
|
99
121
|
const timeoutSignal = AbortSignal.timeout(jevDecisionTimeoutMs(options.timeoutMs));
|
|
100
122
|
const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
|
|
101
123
|
try {
|
|
102
|
-
const result = await options.invokeModel({ model: options.decisionModel, instructions
|
|
124
|
+
const result = await options.invokeModel({ model: options.decisionModel, instructions, input, signal, ...(withoutQuota ? { withoutQuota } : {}) });
|
|
125
|
+
if (result.quotaOmitted) candidates = candidates.map(({ quota: _quota, ...row }) => row);
|
|
103
126
|
if (options.signal?.aborted) throw options.signal.reason;
|
|
104
127
|
if (timeoutSignal.aborted) return failed("timeout");
|
|
105
128
|
let choice: string;
|
|
@@ -111,14 +134,14 @@ export async function resolveJevModelDecision(
|
|
|
111
134
|
const selected = routeOptions.find(option => option.key === choice)!;
|
|
112
135
|
const usage = jevUsage({ usage: result.usage });
|
|
113
136
|
if (options.signal?.aborted) throw options.signal.reason;
|
|
114
|
-
return {
|
|
137
|
+
return withJevQuotaSummary({
|
|
115
138
|
backend: "model",
|
|
116
139
|
targetKey: selected.targetKey,
|
|
117
140
|
effort: selected.effort,
|
|
118
141
|
gate: "apply",
|
|
119
142
|
latencyMs: Math.max(0, now() - startedAt),
|
|
120
143
|
...(usage ? { usage } : {}),
|
|
121
|
-
};
|
|
144
|
+
}, candidates);
|
|
122
145
|
} catch (error) {
|
|
123
146
|
if (options.signal?.aborted) throw options.signal.reason;
|
|
124
147
|
if (timeoutSignal.aborted || (error instanceof Error && error.name === "TimeoutError")) return failed("timeout");
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The exact detached Responses envelope, shared by quota sizing and the invoker. */
|
|
2
|
+
export const JEV_MODEL_MAX_OUTPUT_TOKENS = 1024;
|
|
3
|
+
export interface JevModelRequestBody {
|
|
4
|
+
model: string;
|
|
5
|
+
instructions: string;
|
|
6
|
+
input: string;
|
|
7
|
+
}
|
|
8
|
+
/** Serialize the streaming decision-model request body, adding the reasoning effort only when one is given. */
|
|
9
|
+
export function serializeJevModelRequest(request: JevModelRequestBody, effort?: string): string {
|
|
10
|
+
return JSON.stringify({
|
|
11
|
+
model: request.model,
|
|
12
|
+
stream: true,
|
|
13
|
+
store: false,
|
|
14
|
+
instructions: request.instructions,
|
|
15
|
+
input: [{ role: "user", content: [{ type: "input_text", text: request.input }] }],
|
|
16
|
+
tools: [],
|
|
17
|
+
max_output_tokens: JEV_MODEL_MAX_OUTPUT_TOKENS,
|
|
18
|
+
...(effort ? { reasoning: { effort } } : {}),
|
|
19
|
+
});
|
|
20
|
+
}
|