@bitkyc08/opencodex 2.55.0 → 2.57.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/bin/ocx.mjs +10 -0
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
- package/gui/dist/index.html +2 -2
- package/package.json +4 -3
- package/src/adapters/base.ts +21 -0
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
- package/src/adapters/command-code.ts +1 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro/payload.ts +17 -3
- package/src/adapters/kiro/reasoning.ts +70 -7
- package/src/adapters/kiro/stream.ts +8 -2
- package/src/adapters/kiro/wire.ts +2 -1
- package/src/adapters/kiro-events.ts +21 -13
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +495 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +40 -1452
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +642 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +58 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +630 -0
- package/src/bridge/sse.ts +1462 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +18 -0
- package/src/cli/account-main.ts +1 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/combo.ts +10 -1
- package/src/cli/index.ts +48 -5
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +4 -4
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +184 -35
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +566 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +431 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +252 -35
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +305 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +714 -0
- package/src/codex/catalog/routed-gather.ts +895 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/cli-install-provenance.ts +7 -1
- package/src/codex/convergence.ts +7 -2
- package/src/codex/desktop-app/types.ts +11 -2
- package/src/codex/desktop-app/windows.ts +5 -5
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +567 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +89 -1444
- package/src/codex/lineage.ts +458 -0
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/pool-refresh-backoff.ts +161 -0
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +285 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +780 -0
- package/src/codex/routing/thread-affinity.ts +586 -0
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +370 -2271
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/codex/warmup.ts +1 -1
- package/src/combos/failover.ts +85 -0
- package/src/combos/request.ts +17 -10
- package/src/combos/types.ts +23 -2
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/pending-teardown.ts +31 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +649 -121
- package/src/images/loop.ts +1 -1
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +198 -23
- package/src/lib/spend-reservation-ledger.ts +958 -0
- package/src/lib/state-store-registrations.ts +6 -2
- package/src/lib/test-home-guard.ts +85 -1
- package/src/lib/upstream-retry.ts +132 -21
- package/src/lib/windows-elevation.ts +76 -14
- package/src/lib/workflow-budget.ts +553 -30
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +16 -0
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1228 -0
- package/src/providers/registry/entries-extended.ts +1213 -0
- package/src/providers/registry/model-seeds.ts +912 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +456 -0
- package/src/routing/probe-lease.ts +613 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +601 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +339 -0
- package/src/server/index.ts +45 -2552
- package/src/server/inspection-tee.ts +107 -0
- package/src/server/live.ts +46 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +372 -4
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +307 -0
- package/src/server/responses/adapter-continuation.ts +540 -0
- package/src/server/responses/adapter-delivery.ts +208 -0
- package/src/server/responses/adapter-dispatch.ts +1042 -0
- package/src/server/responses/codex-ws-wire.ts +5 -0
- package/src/server/responses/collaboration.ts +74 -4
- package/src/server/responses/combo-session-recall.ts +68 -8
- package/src/server/responses/compact.ts +113 -17
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +529 -0
- package/src/server/responses/core-codex-account.ts +907 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +787 -0
- package/src/server/responses/core-errors.ts +170 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +298 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/passthrough-delivery.ts +869 -0
- package/src/server/responses/passthrough-dispatch.ts +1494 -0
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +1080 -0
- package/src/server/responses/request-send-budget.ts +259 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +803 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +476 -0
- package/src/server/responses/sidecar-execution.ts +463 -0
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/server/workflow-refusal.ts +84 -0
- package/src/service/windows-ops.ts +210 -16
- package/src/service/windows-scheduler.ts +28 -21
- package/src/service.ts +1 -1
- package/src/types/config.ts +34 -1
- package/src/types/request.ts +8 -5
- package/src/types/tools.ts +24 -0
- package/src/types.ts +2 -0
- package/src/update/index.ts +10 -0
- package/src/update/stop-contract.d.mts +1 -0
- package/src/update/stop-contract.mjs +19 -0
- package/src/update/stop-decision.d.mts +1 -1
- package/src/update/stop-decision.mjs +12 -3
- package/src/usage/log.ts +147 -1
- package/src/usage/summary.ts +171 -21
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/vision/describe.ts +5 -5
- package/src/web-search/anthropic-executor.ts +1 -1
- package/src/web-search/exa-executor.ts +1 -1
- package/src/web-search/executor.ts +1 -1
- package/src/web-search/gemini-executor.ts +1 -1
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/ollama-executor.ts +1 -1
- package/src/web-search/parse.ts +67 -14
- package/src/web-search/passthrough-bridge.ts +64 -31
- package/src/web-search/xai-executor.ts +1 -1
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Half-open recovery for a held account, and the pool-wide retry/probe budget
|
|
3
|
+
* that sits above it (#4546, wp3 follow-up).
|
|
4
|
+
*
|
|
5
|
+
* The transient hold (#4616) keeps a thread's binding while its account serves a
|
|
6
|
+
* 5xx streak, and detours requests to a healthy sibling. What it cannot answer is
|
|
7
|
+
* whether the held account is actually back: a soft-avoided account receives no
|
|
8
|
+
* traffic, so the two-success clearing rule can only fire through the "held"
|
|
9
|
+
* fallback, which hands the failing account back to every pinned thread at once.
|
|
10
|
+
* This module is the bounded trial that closes that gap -- a single-holder probe
|
|
11
|
+
* lease keyed on the health domain (the quota-cooldown domain already has its own
|
|
12
|
+
* lease in src/codex/routing.ts and is a different thing).
|
|
13
|
+
*
|
|
14
|
+
* Three rules the lease enforces:
|
|
15
|
+
*
|
|
16
|
+
* - While an account is held, exactly one in-flight probe may test it. Every
|
|
17
|
+
* other request keeps the remembered detour, so a failed probe costs the
|
|
18
|
+
* caller nothing -- the detour's identity is never dropped to run the trial.
|
|
19
|
+
* - The lease has a deadline and is released on success, failure, or expiry. A
|
|
20
|
+
* response that arrives after its lease was lost is STALE: it must not
|
|
21
|
+
* overwrite a newer binding or a newer failure state, so every lease carries
|
|
22
|
+
* the generation it was issued under and a settle that fails the fence
|
|
23
|
+
* mutates nothing.
|
|
24
|
+
* - When every candidate is held the caller gets a typed "binding remembered,
|
|
25
|
+
* dispatch withheld" outcome -- not a send to an account already known to be
|
|
26
|
+
* failing.
|
|
27
|
+
*
|
|
28
|
+
* The pool-wide limiter exists because per-request send budgets do not prevent
|
|
29
|
+
* a retry storm: thousands of requests each staying inside their own allowance
|
|
30
|
+
* still compose into an unbounded rate against an already-failing upstream.
|
|
31
|
+
* Recovery dispatches (retries and probes, never the initial send of a new
|
|
32
|
+
* request) are admitted only while they stay under a ratio of observed initial
|
|
33
|
+
* sends in a sliding window -- the standard overload-guidance shape.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** How long a granted probe may be in flight before its lease is forfeit. */
|
|
37
|
+
export const TRANSIENT_PROBE_LEASE_MS = 30_000;
|
|
38
|
+
/**
|
|
39
|
+
* Minimum spacing between probes of the same held account. Without it every
|
|
40
|
+
* request that follows a settled probe becomes the next probe, which is the
|
|
41
|
+
* same storm the single-holder rule exists to bound -- just serialized.
|
|
42
|
+
*/
|
|
43
|
+
export const TRANSIENT_PROBE_INTERVAL_MS = 15_000;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Grace kept on top of an entry's pacing and lease deadlines before it may be forgotten.
|
|
47
|
+
* Inside it a late settle can still answer "expired" rather than "stale", which is the
|
|
48
|
+
* distinction the settle contract exists to report.
|
|
49
|
+
*/
|
|
50
|
+
const PROBE_STATE_RETENTION_MS = 60_000;
|
|
51
|
+
/**
|
|
52
|
+
* Hard ceiling on remembered accounts. Pacing state is per account id, and account ids churn
|
|
53
|
+
* with configuration: without a ceiling a long-lived proxy accumulates one entry per id it
|
|
54
|
+
* ever probed. Above the ceiling the entries whose pacing lapses soonest are dropped, which
|
|
55
|
+
* at worst lets one dormant account be probed earlier than its interval; an entry holding a
|
|
56
|
+
* LIVE lease is never dropped, because that would hand out a second concurrent probe and
|
|
57
|
+
* break the single-holder rule the lease exists to enforce.
|
|
58
|
+
*/
|
|
59
|
+
export const MAX_TRANSIENT_PROBE_STATES = 1_024;
|
|
60
|
+
/** Below this the map is too small to be worth scanning on a grant. */
|
|
61
|
+
const PROBE_STATE_SWEEP_THRESHOLD = 64;
|
|
62
|
+
/**
|
|
63
|
+
* Eviction target once the ceiling is reached. Clearing a block at a time keeps the ordering
|
|
64
|
+
* pass off the common grant path: it runs once per block of new accounts instead of once per
|
|
65
|
+
* grant forever after the first time the ceiling is touched.
|
|
66
|
+
*/
|
|
67
|
+
const PROBE_STATE_EVICTION_LOW_WATER = Math.floor(MAX_TRANSIENT_PROBE_STATES * 0.9);
|
|
68
|
+
|
|
69
|
+
export interface TransientProbeLease {
|
|
70
|
+
readonly accountId: string;
|
|
71
|
+
readonly leaseId: string;
|
|
72
|
+
/** Epoch the lease was issued under; a settle must match the CURRENT epoch. */
|
|
73
|
+
readonly generation: number;
|
|
74
|
+
readonly expiresAt: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type TransientProbeOutcome = "recovered" | "failed";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What a settle did to the lease.
|
|
81
|
+
*
|
|
82
|
+
* - `applied`: the probe still held the lease inside its deadline; the caller
|
|
83
|
+
* may act on the outcome (clear the hold, or record the fresh failure).
|
|
84
|
+
* - `stale`: the lease was already lost -- expired and re-issued, or invalidated
|
|
85
|
+
* by newer authoritative state. The result is dropped; nothing is overwritten.
|
|
86
|
+
* - `expired`: the probe finished after its own deadline. The lease is dead
|
|
87
|
+
* either way; this answer exists so the caller can tell "lost a race" from
|
|
88
|
+
* "ran long".
|
|
89
|
+
*/
|
|
90
|
+
export type TransientProbeSettle = "applied" | "stale" | "expired";
|
|
91
|
+
|
|
92
|
+
interface AccountProbeState {
|
|
93
|
+
/** Bumped on every lease grant and every external invalidation. */
|
|
94
|
+
generation: number;
|
|
95
|
+
leaseId?: string;
|
|
96
|
+
leaseExpiresAt?: number;
|
|
97
|
+
lastProbeAt?: number;
|
|
98
|
+
/**
|
|
99
|
+
* Moment this account's pacing interval lapses, recorded at grant time from the interval
|
|
100
|
+
* that grant actually used. Kept alongside `lastProbeAt` so cleanup honours a caller's
|
|
101
|
+
* longer interval instead of assuming the default.
|
|
102
|
+
*/
|
|
103
|
+
pacedUntil?: number;
|
|
104
|
+
lastOutcome?: TransientProbeOutcome;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const probeStates = new Map<string, AccountProbeState>();
|
|
108
|
+
let probeLeaseSeq = 0;
|
|
109
|
+
|
|
110
|
+
function probeStateFor(accountId: string): AccountProbeState {
|
|
111
|
+
let state = probeStates.get(accountId);
|
|
112
|
+
if (!state) {
|
|
113
|
+
state = { generation: 0 };
|
|
114
|
+
probeStates.set(accountId, state);
|
|
115
|
+
}
|
|
116
|
+
return state;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function liveLease(state: AccountProbeState, now: number): boolean {
|
|
120
|
+
return state.leaseId !== undefined && state.leaseExpiresAt !== undefined && state.leaseExpiresAt > now;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Moment an entry stops carrying anything a future decision can read: its pacing interval and
|
|
125
|
+
* any unsettled lease deadline, plus the grace above.
|
|
126
|
+
*/
|
|
127
|
+
function probeStateRetiresAt(state: AccountProbeState): number {
|
|
128
|
+
return Math.max(state.pacedUntil ?? 0, state.leaseExpiresAt ?? 0) + PROBE_STATE_RETENTION_MS;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Bound the remembered accounts. Called on the one path that can grow the map -- a grant is
|
|
133
|
+
* the only insertion -- so the ceiling holds without a timer.
|
|
134
|
+
*
|
|
135
|
+
* The first pass drops only entries that can no longer change an answer: no live lease, the
|
|
136
|
+
* pacing interval lapsed, and the grace elapsed. Re-creating such an entry later yields the
|
|
137
|
+
* same decisions it would have produced, and a late settle against it still cannot be applied
|
|
138
|
+
* because lease ids are issued from a monotonic counter and never repeat.
|
|
139
|
+
*/
|
|
140
|
+
function sweepProbeStates(now: number): void {
|
|
141
|
+
if (probeStates.size <= PROBE_STATE_SWEEP_THRESHOLD) return;
|
|
142
|
+
for (const [accountId, state] of probeStates) {
|
|
143
|
+
if (liveLease(state, now)) continue;
|
|
144
|
+
if (now >= probeStateRetiresAt(state)) probeStates.delete(accountId);
|
|
145
|
+
}
|
|
146
|
+
if (probeStates.size <= MAX_TRANSIENT_PROBE_STATES) return;
|
|
147
|
+
// Still over the ceiling with nothing retired: churn is faster than the retention window.
|
|
148
|
+
// Evict in retirement order so the entries closest to meaningless go first, and never one
|
|
149
|
+
// holding a live lease.
|
|
150
|
+
const evictable = Array.from(probeStates)
|
|
151
|
+
.filter(([, state]) => !liveLease(state, now))
|
|
152
|
+
.sort((a, b) => probeStateRetiresAt(a[1]) - probeStateRetiresAt(b[1]));
|
|
153
|
+
let excess = probeStates.size - PROBE_STATE_EVICTION_LOW_WATER;
|
|
154
|
+
for (const [accountId] of evictable) {
|
|
155
|
+
if (excess <= 0) break;
|
|
156
|
+
probeStates.delete(accountId);
|
|
157
|
+
excess -= 1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Grant the single in-flight probe for a held account, or null when another
|
|
163
|
+
* probe is already out or the pacing interval has not elapsed. The grant bumps
|
|
164
|
+
* the epoch, so a result from any earlier lease is stale the moment it lands.
|
|
165
|
+
*/
|
|
166
|
+
export function tryAcquireTransientProbe(
|
|
167
|
+
accountId: string,
|
|
168
|
+
now = Date.now(),
|
|
169
|
+
options?: { leaseMs?: number; minIntervalMs?: number },
|
|
170
|
+
): TransientProbeLease | null {
|
|
171
|
+
const state = probeStateFor(accountId);
|
|
172
|
+
if (liveLease(state, now)) return null;
|
|
173
|
+
const interval = options?.minIntervalMs ?? TRANSIENT_PROBE_INTERVAL_MS;
|
|
174
|
+
if (state.lastProbeAt !== undefined && now - state.lastProbeAt < interval) return null;
|
|
175
|
+
const leaseMs = options?.leaseMs ?? TRANSIENT_PROBE_LEASE_MS;
|
|
176
|
+
const leaseId = `tprobe-${(probeLeaseSeq += 1).toString(36)}`;
|
|
177
|
+
const expiresAt = now + Math.max(1, leaseMs);
|
|
178
|
+
state.generation += 1;
|
|
179
|
+
state.leaseId = leaseId;
|
|
180
|
+
state.leaseExpiresAt = expiresAt;
|
|
181
|
+
state.lastProbeAt = now;
|
|
182
|
+
state.pacedUntil = now + Math.max(0, interval);
|
|
183
|
+
// After the grant, not before it: the entry this call just wrote holds a live lease and is
|
|
184
|
+
// therefore the one entry the sweep may never touch, so the ceiling is a real ceiling
|
|
185
|
+
// rather than "the ceiling plus whatever was inserted after the scan".
|
|
186
|
+
sweepProbeStates(now);
|
|
187
|
+
return {
|
|
188
|
+
accountId,
|
|
189
|
+
leaseId,
|
|
190
|
+
generation: state.generation,
|
|
191
|
+
expiresAt,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Side-effect-free mirror of {@link tryAcquireTransientProbe} eligibility. */
|
|
196
|
+
export function canAcquireTransientProbe(
|
|
197
|
+
accountId: string,
|
|
198
|
+
now = Date.now(),
|
|
199
|
+
options?: { minIntervalMs?: number },
|
|
200
|
+
): boolean {
|
|
201
|
+
const state = probeStates.get(accountId);
|
|
202
|
+
if (!state) return true;
|
|
203
|
+
if (liveLease(state, now)) return false;
|
|
204
|
+
const interval = options?.minIntervalMs ?? TRANSIENT_PROBE_INTERVAL_MS;
|
|
205
|
+
return state.lastProbeAt === undefined || now - state.lastProbeAt >= interval;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Report a probe's outcome. Only the current lease holder inside its deadline
|
|
210
|
+
* applies: anything else is a late answer from a probe that already lost, and
|
|
211
|
+
* dropping it is what keeps it from overwriting a newer binding or a newer
|
|
212
|
+
* failure state. An applied settle clears the lease so the next probe is paced
|
|
213
|
+
* by the interval, not by the expiry.
|
|
214
|
+
*/
|
|
215
|
+
export function settleTransientProbe(
|
|
216
|
+
lease: TransientProbeLease,
|
|
217
|
+
outcome: TransientProbeOutcome,
|
|
218
|
+
now = Date.now(),
|
|
219
|
+
): TransientProbeSettle {
|
|
220
|
+
const state = probeStates.get(lease.accountId);
|
|
221
|
+
if (!state || state.leaseId !== lease.leaseId || state.generation !== lease.generation) {
|
|
222
|
+
return "stale";
|
|
223
|
+
}
|
|
224
|
+
// `>=`, matching liveLease: at exactly the deadline the lease is already gone, so applying
|
|
225
|
+
// the outcome there would let a probe act on a lease the grant path would refuse to
|
|
226
|
+
// recognise -- two answers to the same instant.
|
|
227
|
+
if (now >= lease.expiresAt) return "expired";
|
|
228
|
+
state.leaseId = undefined;
|
|
229
|
+
state.leaseExpiresAt = undefined;
|
|
230
|
+
state.lastOutcome = outcome;
|
|
231
|
+
return "applied";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Hand a lease back with no outcome -- the probe never reached upstream, so
|
|
236
|
+
* there is nothing to record. Only the holder may release; a stale lease is
|
|
237
|
+
* already dead and needs no cleanup.
|
|
238
|
+
*/
|
|
239
|
+
export function releaseTransientProbe(lease: TransientProbeLease): void {
|
|
240
|
+
const state = probeStates.get(lease.accountId);
|
|
241
|
+
if (!state || state.leaseId !== lease.leaseId || state.generation !== lease.generation) return;
|
|
242
|
+
state.leaseId = undefined;
|
|
243
|
+
state.leaseExpiresAt = undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Fence the epoch against newer authoritative state. A fresh failure recorded
|
|
248
|
+
* through the ordinary outcome path, or a binding that moved on, must not be
|
|
249
|
+
* overwritten by a probe result that was issued before it -- bumping the epoch
|
|
250
|
+
* makes every outstanding lease stale without waiting for its deadline.
|
|
251
|
+
*/
|
|
252
|
+
export function invalidateTransientProbe(accountId: string): void {
|
|
253
|
+
const state = probeStates.get(accountId);
|
|
254
|
+
if (!state) return;
|
|
255
|
+
state.generation += 1;
|
|
256
|
+
state.leaseId = undefined;
|
|
257
|
+
state.leaseExpiresAt = undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface TransientProbeDiagnostics {
|
|
261
|
+
readonly held: boolean;
|
|
262
|
+
readonly generation: number;
|
|
263
|
+
readonly leaseId?: string;
|
|
264
|
+
readonly leaseExpiresAt?: number;
|
|
265
|
+
readonly lastProbeAt?: number;
|
|
266
|
+
readonly lastOutcome?: TransientProbeOutcome;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Current lease state for one account, for diagnostics. Never mutates. */
|
|
270
|
+
export function transientProbeDiagnostics(accountId: string, now = Date.now()): TransientProbeDiagnostics {
|
|
271
|
+
const state = probeStates.get(accountId);
|
|
272
|
+
if (!state) return { held: false, generation: 0 };
|
|
273
|
+
return {
|
|
274
|
+
held: liveLease(state, now),
|
|
275
|
+
generation: state.generation,
|
|
276
|
+
...(state.leaseId !== undefined ? { leaseId: state.leaseId, leaseExpiresAt: state.leaseExpiresAt } : {}),
|
|
277
|
+
...(state.lastProbeAt !== undefined ? { lastProbeAt: state.lastProbeAt } : {}),
|
|
278
|
+
...(state.lastOutcome !== undefined ? { lastOutcome: state.lastOutcome } : {}),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Test seam: lease state is module-global and must not leak between cases. */
|
|
283
|
+
export function clearTransientProbeLeasesForTests(): void {
|
|
284
|
+
probeStates.clear();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* How many accounts currently carry probe state. Diagnostic, and the assertion surface for
|
|
289
|
+
* the {@link MAX_TRANSIENT_PROBE_STATES} bound.
|
|
290
|
+
*/
|
|
291
|
+
export function transientProbeStateCount(): number {
|
|
292
|
+
return probeStates.size;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* What a request may do while its bound account is held.
|
|
297
|
+
*
|
|
298
|
+
* - `probe`: this caller holds the lease and may send ONE trial to the held
|
|
299
|
+
* account.
|
|
300
|
+
* - `detour`: a probe is already out (or was refused); keep the remembered
|
|
301
|
+
* detour. The detour's identity survives the whole probing window -- a failed
|
|
302
|
+
* trial must not cost the caller its working route.
|
|
303
|
+
* - `withheld`: every candidate is held. The binding is remembered and dispatch
|
|
304
|
+
* is refused; `retryAt` is the earliest moment a probe could next go out.
|
|
305
|
+
* Sending anyway here is exactly the "must not send, sends anyway" defect the
|
|
306
|
+
* hold was added to close.
|
|
307
|
+
*/
|
|
308
|
+
export type HeldAccountDispatch =
|
|
309
|
+
| { kind: "probe"; lease: TransientProbeLease }
|
|
310
|
+
| { kind: "detour"; accountId: string }
|
|
311
|
+
| { kind: "withheld"; boundAccountId: string; detourAccountId?: string; retryAt: number };
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Decide what a request bound to a held account may do this turn. The probe is
|
|
315
|
+
* tried first -- somebody has to find out whether the account is back, and the
|
|
316
|
+
* lease guarantees it is exactly one somebody. Everyone else keeps the detour,
|
|
317
|
+
* and a caller with no detour left is told to wait rather than sent at an
|
|
318
|
+
* account already known to be failing.
|
|
319
|
+
*/
|
|
320
|
+
export function resolveHeldAccountDispatch(input: {
|
|
321
|
+
boundAccountId: string;
|
|
322
|
+
detourAccountId?: string;
|
|
323
|
+
now?: number;
|
|
324
|
+
leaseMs?: number;
|
|
325
|
+
minProbeIntervalMs?: number;
|
|
326
|
+
backpressure?: PoolBackpressureLimiter;
|
|
327
|
+
}): HeldAccountDispatch {
|
|
328
|
+
const now = input.now ?? Date.now();
|
|
329
|
+
const limiter = input.backpressure ?? sharedPoolBackpressure();
|
|
330
|
+
// The lease check runs before the budget charge: a probe another holder already has out is
|
|
331
|
+
// not a dispatch, and charging the pool for it would shrink the recovery budget by phantom
|
|
332
|
+
// sends. Between the check and the grant there is no await, so eligibility cannot change.
|
|
333
|
+
if (
|
|
334
|
+
canAcquireTransientProbe(input.boundAccountId, now, {
|
|
335
|
+
...(input.minProbeIntervalMs !== undefined ? { minIntervalMs: input.minProbeIntervalMs } : {}),
|
|
336
|
+
})
|
|
337
|
+
&& limiter.tryPermitProbeDispatch(now)
|
|
338
|
+
) {
|
|
339
|
+
const lease = tryAcquireTransientProbe(input.boundAccountId, now, {
|
|
340
|
+
...(input.leaseMs !== undefined ? { leaseMs: input.leaseMs } : {}),
|
|
341
|
+
...(input.minProbeIntervalMs !== undefined ? { minIntervalMs: input.minProbeIntervalMs } : {}),
|
|
342
|
+
});
|
|
343
|
+
if (lease) return { kind: "probe", lease };
|
|
344
|
+
}
|
|
345
|
+
if (input.detourAccountId !== undefined && input.detourAccountId !== input.boundAccountId) {
|
|
346
|
+
return { kind: "detour", accountId: input.detourAccountId };
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
kind: "withheld",
|
|
350
|
+
boundAccountId: input.boundAccountId,
|
|
351
|
+
...(input.detourAccountId !== undefined ? { detourAccountId: input.detourAccountId } : {}),
|
|
352
|
+
// Both bounds, not just the probe pacing. A request refused by the RATIO has no probe state
|
|
353
|
+
// of its own yet, so `nextProbeAt` answered `now` and the refusal told the caller to try
|
|
354
|
+
// again immediately -- a withheld dispatch that busy-loops is the same load as the dispatch
|
|
355
|
+
// it refused. The limiter is the only thing that knows when its window moves.
|
|
356
|
+
retryAt: Math.max(
|
|
357
|
+
nextProbeAt(input.boundAccountId, now, input.minProbeIntervalMs),
|
|
358
|
+
limiter.nextRecoveryAt(now),
|
|
359
|
+
),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Earliest moment a probe of this account could next be granted. */
|
|
364
|
+
function nextProbeAt(accountId: string, now: number, minIntervalMs?: number): number {
|
|
365
|
+
const state = probeStates.get(accountId);
|
|
366
|
+
if (!state) return now;
|
|
367
|
+
const interval = minIntervalMs ?? TRANSIENT_PROBE_INTERVAL_MS;
|
|
368
|
+
const paced = state.lastProbeAt !== undefined ? state.lastProbeAt + interval : now;
|
|
369
|
+
const leased = liveLease(state, now) ? state.leaseExpiresAt! : now;
|
|
370
|
+
return Math.max(paced, leased);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/* ------------------------------------------------------------------ */
|
|
374
|
+
/* Pool-wide recovery backpressure */
|
|
375
|
+
/* ------------------------------------------------------------------ */
|
|
376
|
+
|
|
377
|
+
export interface PoolBackpressurePolicy {
|
|
378
|
+
/** Sliding window the ratio is measured over. */
|
|
379
|
+
readonly windowMs: number;
|
|
380
|
+
/**
|
|
381
|
+
* Recovery dispatches (retries + probes) admitted per observed initial send.
|
|
382
|
+
* 0.2 is the standard overload-guidance budget: at most one recovery send for
|
|
383
|
+
* every five new requests.
|
|
384
|
+
*/
|
|
385
|
+
readonly maxRetryRatio: number;
|
|
386
|
+
/**
|
|
387
|
+
* Floor under the ratio so a quiet pool can still recover: with almost no
|
|
388
|
+
* traffic a strict ratio admits nothing, which would wedge every held
|
|
389
|
+
* account behind a probe that can never run.
|
|
390
|
+
*/
|
|
391
|
+
readonly minRecoveryAllowance: number;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export const DEFAULT_POOL_BACKPRESSURE_POLICY: PoolBackpressurePolicy = {
|
|
395
|
+
windowMs: 10_000,
|
|
396
|
+
maxRetryRatio: 0.2,
|
|
397
|
+
minRecoveryAllowance: 3,
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
export interface PoolBackpressureState {
|
|
401
|
+
readonly windowMs: number;
|
|
402
|
+
readonly initialSends: number;
|
|
403
|
+
readonly recoveryDispatches: number;
|
|
404
|
+
/** Dispatches admitted under the current window's allowance. */
|
|
405
|
+
readonly allowance: number;
|
|
406
|
+
/** Lifetime refusals, including windows already rotated out. */
|
|
407
|
+
readonly refusedTotal: number;
|
|
408
|
+
readonly ratioLimit: number;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export interface PoolBackpressureLimiter {
|
|
412
|
+
/** A new request's FIRST send. Always recorded, never refused. */
|
|
413
|
+
recordInitialSend(now?: number): void;
|
|
414
|
+
/** Admit one retry dispatch, or refuse when the window's ratio is spent. */
|
|
415
|
+
tryPermitRetryDispatch(now?: number): boolean;
|
|
416
|
+
/** Admit one probe dispatch under the same shared recovery budget. */
|
|
417
|
+
tryPermitProbeDispatch(now?: number): boolean;
|
|
418
|
+
/**
|
|
419
|
+
* Earliest moment this limiter could admit another recovery dispatch.
|
|
420
|
+
*
|
|
421
|
+
* A refusal has to hand back a time, or the caller has nothing to wait on and busy-loops
|
|
422
|
+
* against a pool that is already failing -- which is the load this limiter exists to remove.
|
|
423
|
+
* `now` when the allowance is not spent; otherwise the moment the oldest bucket still inside
|
|
424
|
+
* the window falls out of it, which is strictly in the future and is a real change point
|
|
425
|
+
* rather than a guess.
|
|
426
|
+
*/
|
|
427
|
+
nextRecoveryAt(now?: number): number;
|
|
428
|
+
state(now?: number): PoolBackpressureState;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const BACKPRESSURE_BUCKETS = 10;
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Ratio limiter over a bucketed sliding window. Buckets give a sliding answer
|
|
435
|
+
* without keeping per-event state: the window is the sum of the buckets whose
|
|
436
|
+
* span falls inside it, and grant/refuse decisions read that sum.
|
|
437
|
+
*/
|
|
438
|
+
export function createPoolBackpressureLimiter(
|
|
439
|
+
policy: PoolBackpressurePolicy = DEFAULT_POOL_BACKPRESSURE_POLICY,
|
|
440
|
+
): PoolBackpressureLimiter {
|
|
441
|
+
const bucketMs = Math.max(1, Math.floor(policy.windowMs / BACKPRESSURE_BUCKETS));
|
|
442
|
+
const buckets: Array<{ start: number; initials: number; recoveries: number }> = [];
|
|
443
|
+
let refusedTotal = 0;
|
|
444
|
+
|
|
445
|
+
function bucketFor(now: number): { start: number; initials: number; recoveries: number } {
|
|
446
|
+
const start = Math.floor(now / bucketMs) * bucketMs;
|
|
447
|
+
const last = buckets[buckets.length - 1];
|
|
448
|
+
if (last && last.start === start) return last;
|
|
449
|
+
while (buckets.length > 0 && buckets[0]!.start <= start - policy.windowMs) buckets.shift();
|
|
450
|
+
const bucket = { start, initials: 0, recoveries: 0 };
|
|
451
|
+
buckets.push(bucket);
|
|
452
|
+
return bucket;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function totals(now: number): { initials: number; recoveries: number } {
|
|
456
|
+
let initials = 0;
|
|
457
|
+
let recoveries = 0;
|
|
458
|
+
for (const bucket of buckets) {
|
|
459
|
+
if (bucket.start <= now - policy.windowMs) continue;
|
|
460
|
+
initials += bucket.initials;
|
|
461
|
+
recoveries += bucket.recoveries;
|
|
462
|
+
}
|
|
463
|
+
return { initials, recoveries };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function allowanceFor(initials: number): number {
|
|
467
|
+
return Math.max(policy.minRecoveryAllowance, Math.floor(initials * policy.maxRetryRatio));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function tryPermit(now: number): boolean {
|
|
471
|
+
const bucket = bucketFor(now);
|
|
472
|
+
const { initials, recoveries } = totals(now);
|
|
473
|
+
if (recoveries + 1 > allowanceFor(initials)) {
|
|
474
|
+
refusedTotal += 1;
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
bucket.recoveries += 1;
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function nextRecoveryAt(now: number): number {
|
|
482
|
+
const { initials, recoveries } = totals(now);
|
|
483
|
+
if (recoveries + 1 <= allowanceFor(initials)) return now;
|
|
484
|
+
// The window has to move before another recovery fits. The earliest that can happen is the
|
|
485
|
+
// moment the oldest bucket still inside it leaves, and every such bucket started after
|
|
486
|
+
// `now - windowMs`, so the answer is always strictly in the future.
|
|
487
|
+
for (const bucket of buckets) {
|
|
488
|
+
if (bucket.start <= now - policy.windowMs) continue;
|
|
489
|
+
return bucket.start + policy.windowMs;
|
|
490
|
+
}
|
|
491
|
+
return now + policy.windowMs;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
recordInitialSend(now = Date.now()): void {
|
|
496
|
+
bucketFor(now).initials += 1;
|
|
497
|
+
},
|
|
498
|
+
tryPermitRetryDispatch(now = Date.now()): boolean {
|
|
499
|
+
return tryPermit(now);
|
|
500
|
+
},
|
|
501
|
+
tryPermitProbeDispatch(now = Date.now()): boolean {
|
|
502
|
+
return tryPermit(now);
|
|
503
|
+
},
|
|
504
|
+
nextRecoveryAt(now = Date.now()): number {
|
|
505
|
+
return nextRecoveryAt(now);
|
|
506
|
+
},
|
|
507
|
+
state(now = Date.now()): PoolBackpressureState {
|
|
508
|
+
const { initials, recoveries } = totals(now);
|
|
509
|
+
return {
|
|
510
|
+
windowMs: policy.windowMs,
|
|
511
|
+
initialSends: initials,
|
|
512
|
+
recoveryDispatches: recoveries,
|
|
513
|
+
allowance: allowanceFor(initials),
|
|
514
|
+
refusedTotal,
|
|
515
|
+
ratioLimit: policy.maxRetryRatio,
|
|
516
|
+
};
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
let sharedLimiter: PoolBackpressureLimiter | undefined;
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The process-wide limiter every recovery dispatch shares. A per-request
|
|
525
|
+
* limiter cannot see the storm, which is the entire reason this layer exists.
|
|
526
|
+
*/
|
|
527
|
+
export function sharedPoolBackpressure(): PoolBackpressureLimiter {
|
|
528
|
+
sharedLimiter ??= createPoolBackpressureLimiter();
|
|
529
|
+
return sharedLimiter;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Point the shared limiter at a different policy. The ceiling is deliberately
|
|
534
|
+
* configurable here and not yet plumbed into OcxConfig -- the wiring lane owns
|
|
535
|
+
* that seam; this is the knob it turns.
|
|
536
|
+
*/
|
|
537
|
+
export function configureSharedPoolBackpressure(policy: PoolBackpressurePolicy): void {
|
|
538
|
+
sharedLimiter = createPoolBackpressureLimiter(policy);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Test seam: the shared limiter is module-global. */
|
|
542
|
+
export function resetSharedPoolBackpressureForTests(): void {
|
|
543
|
+
sharedLimiter = undefined;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Forget every account's probe pacing AND the shared recovery window.
|
|
548
|
+
*
|
|
549
|
+
* Called when the pool's routing state is reset wholesale -- a roster change, a config reload,
|
|
550
|
+
* an account removal. Both halves describe a pool that no longer exists: pacing is keyed on
|
|
551
|
+
* account ids that may be gone, and the window's buckets count sends made by a roster that
|
|
552
|
+
* changed underneath them. Keeping either across such a reset lets one context's recovery
|
|
553
|
+
* decisions govern the next one, which is also how it leaks between test files.
|
|
554
|
+
*
|
|
555
|
+
* This is the production reset. The two `ForTests` seams above stay separate because a test
|
|
556
|
+
* frequently wants exactly one half of it.
|
|
557
|
+
*/
|
|
558
|
+
export function clearPoolRecoveryState(): void {
|
|
559
|
+
probeStates.clear();
|
|
560
|
+
sharedLimiter = undefined;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* What one physical send IS, as far as the recovery window is concerned.
|
|
565
|
+
*
|
|
566
|
+
* The window measures recovery traffic against observed demand, so it needs the distinction
|
|
567
|
+
* made where the send happens -- and the transport wrapper cannot make it. That layer sees a
|
|
568
|
+
* URL and an init; whether this is a conversation's first attempt, its third retry, or the one
|
|
569
|
+
* trial admitted against a held account is knowledge only the caller has. So the caller names
|
|
570
|
+
* it, and the classification lives here with the window rather than in the transport, which
|
|
571
|
+
* owns no routing policy and has an enforced import boundary saying so.
|
|
572
|
+
*
|
|
573
|
+
* - `initial`: a new request's first send. Recorded, never refused -- it is the denominator,
|
|
574
|
+
* and refusing it would make this a throughput cap rather than a recovery bound.
|
|
575
|
+
* - `retry`: a re-send of a request that already reached upstream once. Admitted only while
|
|
576
|
+
* recovery traffic stays under its ratio of observed demand.
|
|
577
|
+
* - `probe`: the half-open trial against a held account. It ALREADY paid at selection, inside
|
|
578
|
+
* {@link resolveHeldAccountDispatch}; charging it again would bill one send twice and shrink
|
|
579
|
+
* the very budget it was admitted from.
|
|
580
|
+
*/
|
|
581
|
+
export type PoolRecoveryDispatchClass = "initial" | "retry" | "probe";
|
|
582
|
+
|
|
583
|
+
export interface PoolRecoveryDispatchDecision {
|
|
584
|
+
readonly admitted: boolean;
|
|
585
|
+
/**
|
|
586
|
+
* Earliest moment another recovery dispatch could be admitted. `now` when the send was
|
|
587
|
+
* admitted; otherwise a real change point strictly in the future, so a refused caller has
|
|
588
|
+
* something to wait on instead of busy-looping against a pool that is already failing.
|
|
589
|
+
*/
|
|
590
|
+
readonly retryAt: number;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Admit one physical send against the process-wide recovery window.
|
|
595
|
+
*
|
|
596
|
+
* Per-request send budgets cannot see a storm: thousands of requests each staying inside their
|
|
597
|
+
* own allowance still compose into an unbounded rate against one failing upstream. This is the
|
|
598
|
+
* layer above them, and it is shared by construction.
|
|
599
|
+
*/
|
|
600
|
+
export function classifyPoolRecoveryDispatch(
|
|
601
|
+
dispatchClass: PoolRecoveryDispatchClass,
|
|
602
|
+
now = Date.now(),
|
|
603
|
+
limiter: PoolBackpressureLimiter = sharedPoolBackpressure(),
|
|
604
|
+
): PoolRecoveryDispatchDecision {
|
|
605
|
+
if (dispatchClass === "initial") {
|
|
606
|
+
limiter.recordInitialSend(now);
|
|
607
|
+
return { admitted: true, retryAt: now };
|
|
608
|
+
}
|
|
609
|
+
if (dispatchClass === "probe") return { admitted: true, retryAt: now };
|
|
610
|
+
return limiter.tryPermitRetryDispatch(now)
|
|
611
|
+
? { admitted: true, retryAt: now }
|
|
612
|
+
: { admitted: false, retryAt: limiter.nextRecoveryAt(now) };
|
|
613
|
+
}
|
|
@@ -170,7 +170,9 @@ async function handleChatCompletionsWithBudget(
|
|
|
170
170
|
if (chatBody.tools !== undefined) parts.push(JSON.stringify(chatBody.tools));
|
|
171
171
|
logCtx.usageLogInputTokens = Math.max(1, estimateTokens(parts.join("\n"), requestedModel));
|
|
172
172
|
}
|
|
173
|
-
|
|
173
|
+
// Combos must enter the Responses routing path so child selection, forced default
|
|
174
|
+
// effort, failover, and per-attempt telemetry run before any native Chat send.
|
|
175
|
+
if (!route.combo && !effortRow && isNativeChatRouteEligible(route, chatBody, config)) chatNativeRoute = route;
|
|
174
176
|
} catch (err) {
|
|
175
177
|
if (err instanceof UnknownRoutingPolicyError) {
|
|
176
178
|
logCtx.requestedModel = requestedModel;
|