opencode-auth-load-balancer 0.2.1
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/LICENSE +21 -0
- package/README.md +289 -0
- package/dist/accounts.d.ts +20 -0
- package/dist/cli/status.d.ts +2 -0
- package/dist/fetch.d.ts +34 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +14829 -0
- package/dist/notify.d.ts +47 -0
- package/dist/pool/lock.d.ts +39 -0
- package/dist/pool/paths.d.ts +16 -0
- package/dist/pool/store.d.ts +51 -0
- package/dist/prime.d.ts +32 -0
- package/dist/providers/anthropic/adapter.d.ts +8 -0
- package/dist/providers/anthropic/cch.d.ts +20 -0
- package/dist/providers/anthropic/constants.d.ts +64 -0
- package/dist/providers/anthropic/fallback.d.ts +52 -0
- package/dist/providers/anthropic/oauth.d.ts +8 -0
- package/dist/providers/anthropic/transform.d.ts +23 -0
- package/dist/providers/anthropic/usage.d.ts +14 -0
- package/dist/providers/headers.d.ts +7 -0
- package/dist/providers/http-timeouts.d.ts +32 -0
- package/dist/providers/oauth-callback.d.ts +100 -0
- package/dist/providers/openai/adapter.d.ts +9 -0
- package/dist/providers/openai/constants.d.ts +18 -0
- package/dist/providers/openai/jwt.d.ts +5 -0
- package/dist/providers/openai/oauth.d.ts +12 -0
- package/dist/providers/openai/transform.d.ts +18 -0
- package/dist/providers/openai/usage.d.ts +16 -0
- package/dist/providers/pkce.d.ts +10 -0
- package/dist/providers/types.d.ts +122 -0
- package/dist/providers/usage-headers.d.ts +55 -0
- package/dist/providers/usage-http.d.ts +8 -0
- package/dist/refresh.d.ts +13 -0
- package/dist/scheduler/config.d.ts +46 -0
- package/dist/scheduler/score-core.d.ts +167 -0
- package/dist/scheduler/select.d.ts +41 -0
- package/dist/session.d.ts +15 -0
- package/dist/status.d.ts +55 -0
- package/dist/types.d.ts +101 -0
- package/dist/usage-merge.d.ts +23 -0
- package/dist/usage-refresh.d.ts +30 -0
- package/dist/util.d.ts +118 -0
- package/package.json +75 -0
- package/tui/auth-load-balancer-scoring.ts +344 -0
- package/tui/auth-load-balancer-tui.logic.ts +362 -0
- package/tui/auth-load-balancer-tui.ts +39 -0
- package/tui/auth-load-balancer-tui.view.tsx +327 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { PoolAccount, TokenSet, UsageSnapshot } from '../types';
|
|
2
|
+
export type FetchInput = string | URL | Request;
|
|
3
|
+
/**
|
|
4
|
+
* Error classification drives rotation:
|
|
5
|
+
* "account" — this account is rate-limited/over quota (429/402). Cool it down, try next.
|
|
6
|
+
* "auth" — this account's credential is bad (401/403). Cool it down, try next.
|
|
7
|
+
* "service" — provider-side/transient (5xx). Return as-is.
|
|
8
|
+
* "ok" — success or any other status (including 4xx client errors like
|
|
9
|
+
* 400/404): return the response as-is.
|
|
10
|
+
*/
|
|
11
|
+
export type ErrorClass = 'account' | 'auth' | 'service' | 'ok';
|
|
12
|
+
/**
|
|
13
|
+
* Shared HTTP-status → ErrorClass mapping. Both Anthropic and OpenAI adapters
|
|
14
|
+
* delegate to this single source of truth so a future status (e.g. 408, 425)
|
|
15
|
+
* being added in one provider but missed in the other cannot silently diverge
|
|
16
|
+
* rotation behavior between Claude and Codex.
|
|
17
|
+
*/
|
|
18
|
+
export declare function classifyHttpStatus(status: number): ErrorClass;
|
|
19
|
+
/**
|
|
20
|
+
* Parse a fetch input into a URL, or null when it is not a valid absolute URL.
|
|
21
|
+
* Shared by both providers' `rewriteUrl` so the `FetchInput → URL` prologue
|
|
22
|
+
* (string/URL vs Request handling, malformed-input bail) can never drift
|
|
23
|
+
* between Claude and Codex — same rationale as `classifyHttpStatus` above.
|
|
24
|
+
*/
|
|
25
|
+
export declare function urlFromInput(input: FetchInput): URL | null;
|
|
26
|
+
export interface AuthorizeRequest {
|
|
27
|
+
url: string;
|
|
28
|
+
verifier: string;
|
|
29
|
+
state: string;
|
|
30
|
+
redirectUri: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A request-body rewrite that downgrades the requested model to a cheaper
|
|
34
|
+
* fallback (e.g. Claude Opus → Sonnet) when the primary model's tier quota is
|
|
35
|
+
* exhausted on the chosen account. `body` is the rewritten JSON string to send;
|
|
36
|
+
* `fromModel`/`toModel` drive the user-facing switch toast/log. `fromTier` is
|
|
37
|
+
* the specific tier name (e.g. "opus", "fable") that triggered the downgrade,
|
|
38
|
+
* when the adapter can identify it — optional so providers/callers that don't
|
|
39
|
+
* supply it (OpenAI, or tests predating this field) stay unaffected; consumers
|
|
40
|
+
* that key off a tier (`notifyModelFallback`'s de-dupe window) fall back to
|
|
41
|
+
* their previous heuristic when it's absent.
|
|
42
|
+
*/
|
|
43
|
+
export interface ModelFallback {
|
|
44
|
+
body: string;
|
|
45
|
+
fromModel: string;
|
|
46
|
+
toModel: string;
|
|
47
|
+
fromTier?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Reactive-fallback plan returned from a rejected (429) response whose headers
|
|
51
|
+
* indicate a MODEL-TIER cap (not an account-wide limit). `tier` names the
|
|
52
|
+
* limited tier (e.g. "opus", "fable") and `resetAt` (epoch ms) is when it
|
|
53
|
+
* recovers — persisted as `PoolAccount.modelCooldownsUntil[tier]` so subsequent
|
|
54
|
+
* requests for that tier avoid the account without paying a rejected
|
|
55
|
+
* round-trip. `fallback` is the downgraded body the fetch loop adopts once the
|
|
56
|
+
* WHOLE pool proves limited for the tier.
|
|
57
|
+
*/
|
|
58
|
+
export interface ReactiveModelFallback {
|
|
59
|
+
tier: string;
|
|
60
|
+
resetAt: number;
|
|
61
|
+
fallback: ModelFallback;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A provider adapter encapsulates everything provider-specific: OAuth, request
|
|
65
|
+
* shaping (headers/body/url transforms), usage parsing, and error classification.
|
|
66
|
+
* The scheduler, pool store, and load-balanced fetch are all provider-agnostic and
|
|
67
|
+
* drive adapters through this interface.
|
|
68
|
+
*/
|
|
69
|
+
export interface ProviderAdapter {
|
|
70
|
+
/** opencode provider id, e.g. "anthropic" | "openai". */
|
|
71
|
+
readonly id: string;
|
|
72
|
+
/** Begin an authorization-code (PKCE) flow. */
|
|
73
|
+
authorize(): Promise<AuthorizeRequest>;
|
|
74
|
+
/** Exchange a pasted code/URL for tokens. Returns null on failure. */
|
|
75
|
+
exchange(input: string, verifier: string, redirectUri: string, state: string): Promise<TokenSet | null>;
|
|
76
|
+
/** Refresh an access token. Throws on failure (callers handle invalid_grant). */
|
|
77
|
+
refresh(refreshToken: string): Promise<TokenSet>;
|
|
78
|
+
/** Set auth + provider headers on an outgoing request for the chosen account. */
|
|
79
|
+
applyAuth(headers: Headers, account: PoolAccount): void;
|
|
80
|
+
/** Optionally rewrite the request URL (e.g. add ?beta=true, swap base URL). */
|
|
81
|
+
transformUrl(input: FetchInput): FetchInput;
|
|
82
|
+
/** Optionally rewrite a string request body (system-prompt spoof, tool prefixing). */
|
|
83
|
+
transformBody(body: string): string;
|
|
84
|
+
/** Optionally wrap the response (e.g. strip tool prefixes from the stream). */
|
|
85
|
+
transformResponse(response: Response): Response;
|
|
86
|
+
/**
|
|
87
|
+
* Parse usage from a normal inference response's headers (free, passive).
|
|
88
|
+
* Deliberately takes NO timestamp: the capture time is stamped downstream by
|
|
89
|
+
* `applyUsagePartial` (see the doc comments in each provider's usage.ts).
|
|
90
|
+
*/
|
|
91
|
+
parseUsageHeaders(headers: Headers): Partial<UsageSnapshot> | null;
|
|
92
|
+
/** Poll a dedicated usage endpoint for authoritative quota, if one exists. */
|
|
93
|
+
fetchUsage(account: PoolAccount, now: number): Promise<UsageSnapshot | null>;
|
|
94
|
+
/** Classify an HTTP status for rotation decisions. */
|
|
95
|
+
classifyError(status: number): ErrorClass;
|
|
96
|
+
/**
|
|
97
|
+
* The model TIER `body` asks for (the `modelCooldownsUntil` key the fetch
|
|
98
|
+
* loop consults BEFORE sending, to steer the request onto an account with
|
|
99
|
+
* tier headroom), or null when the body has no recognizable tier. Absent on
|
|
100
|
+
* providers without per-tier caps (OpenAI) — the fetch loop treats an absent
|
|
101
|
+
* hook as "no tier logic".
|
|
102
|
+
*/
|
|
103
|
+
requestModelTier?(body: string): string | null;
|
|
104
|
+
/**
|
|
105
|
+
* Pure body downgrade: rewrite `body`'s model ONE RUNG DOWN the fallback
|
|
106
|
+
* ladder — the best model of the next family below, chosen from `models`
|
|
107
|
+
* (the provider's catalog, e.g. opencode's configured model list) — or null
|
|
108
|
+
* when disabled / not downgradable. The fetch loop adopts it when EVERY
|
|
109
|
+
* candidate account is limited for the current body's tier — so the
|
|
110
|
+
* downgraded request can then reuse any of them, preferably the session's
|
|
111
|
+
* pinned account (keeping its prompt cache). Each call descends at least
|
|
112
|
+
* one family, so repeated adoption terminates.
|
|
113
|
+
*/
|
|
114
|
+
planModelFallback?(body: string, models: readonly string[]): ModelFallback | null;
|
|
115
|
+
/**
|
|
116
|
+
* REACTIVE classification (post-429): when a rejected response's headers
|
|
117
|
+
* indicate a MODEL-TIER cap (not account-wide) that `body`'s model can fall
|
|
118
|
+
* down the ladder from, return the tier + its `resetAt` to persist and the
|
|
119
|
+
* downgraded body. null = treat the 429 as a normal account rotation.
|
|
120
|
+
*/
|
|
121
|
+
planReactiveFallback?(res: Response, body: string, now: number, models: readonly string[]): ReactiveModelFallback | null;
|
|
122
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { UsageSnapshot, UsageWindow } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* Header names + scale for one provider's hourly/weekly rate-limit header pair.
|
|
4
|
+
* `divisor` maps the utilization header onto the normalized 0..1 fraction:
|
|
5
|
+
* 1 when the header already IS a fraction (Anthropic), 100 when it is a
|
|
6
|
+
* percent (Codex).
|
|
7
|
+
*/
|
|
8
|
+
export interface WindowPairHeaderSpec {
|
|
9
|
+
hourlyUtil: string;
|
|
10
|
+
hourlyReset: string;
|
|
11
|
+
weeklyUtil: string;
|
|
12
|
+
weeklyReset: string;
|
|
13
|
+
divisor: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Parse an hourly/weekly usage pair from response headers (free, on every
|
|
17
|
+
* response). Shared by both providers — the parse is structurally identical,
|
|
18
|
+
* only the header names and the utilization scale (`spec`) differ.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately NO `capturedAt` here: `applyUsagePartial` (fetch.ts) stamps its
|
|
21
|
+
* own timestamp, and ONLY when a weekly window arrived — the staleness gate
|
|
22
|
+
* `refreshUsageInBackground` relies on. A parser-side stamp would be dead data
|
|
23
|
+
* at best and, if a future consumer trusted it, would resurrect the
|
|
24
|
+
* weekly-less-partial-marks-fresh bug locked by plugin.test.ts.
|
|
25
|
+
*
|
|
26
|
+
* Returns null when no utilization header is present, AND when headers were
|
|
27
|
+
* present but NEITHER window parsed (e.g. a malformed utilization value) — the
|
|
28
|
+
* contract consumers gate on (`if (partial)`) is "null = nothing usable",
|
|
29
|
+
* never a truthy empty `{}`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseWindowPairHeaders(headers: Headers, spec: WindowPairHeaderSpec): Partial<UsageSnapshot> | null;
|
|
32
|
+
/**
|
|
33
|
+
* Build a single `UsageWindow` from ONE raw usage-endpoint window, sharing the
|
|
34
|
+
* three-step contract both providers' endpoint parsers need — only reached
|
|
35
|
+
* AFTER the caller's own shape guard confirmed the response body is
|
|
36
|
+
* recognizably the usage-endpoint shape (a genuinely broken/foreign body
|
|
37
|
+
* never reaches here; the caller discards the whole poll instead):
|
|
38
|
+
*
|
|
39
|
+
* - `w` absent (null/undefined): the server is deliberately reporting NO
|
|
40
|
+
* usage recorded in that window (e.g. right after an out-of-band quota
|
|
41
|
+
* reset wipes the record, or an idle account past the window) — a TRUE
|
|
42
|
+
* 0%, not "unknown". Synthesize a zero window (`resetAt` 0 = no reset
|
|
43
|
+
* scheduled yet) so dashboards render "0%" instead of "-" ("-" stays
|
|
44
|
+
* reserved for never-polled accounts). Scoring is unaffected either way:
|
|
45
|
+
* `utilOf`/`weeklyUrgency` already read a missing window as 0.
|
|
46
|
+
* - `w` present but its utilization field is missing/non-finite: unusable —
|
|
47
|
+
* return `null` (NOT 0%) so the scheduler never treats a malformed
|
|
48
|
+
* response as "full headroom" and ranks the account first.
|
|
49
|
+
* - otherwise: `clamp01(util / divisor)` plus the provider's own reset
|
|
50
|
+
* parser (`resetOf`) — the one piece that genuinely differs between
|
|
51
|
+
* providers (Anthropic's ISO/epoch-seconds/epoch-ms heuristic vs OpenAI's
|
|
52
|
+
* plain-seconds `secondsToMs`), so it stays a caller-supplied callback
|
|
53
|
+
* rather than something this helper tries to unify too.
|
|
54
|
+
*/
|
|
55
|
+
export declare function endpointWindowFrom<W>(w: W | null | undefined, utilOf: (w: W) => unknown, divisor: number, resetOf: (w: W) => number): UsageWindow | null;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTTP shell for the provider usage endpoints (Anthropic `/usage`,
|
|
3
|
+
* OpenAI `/wham/usage`): GET with a timeout, null on ANY failure — network
|
|
4
|
+
* throw/timeout, non-ok status (body cancelled so the connection is released),
|
|
5
|
+
* or a 200 whose body is not JSON. Providers keep only their own headers,
|
|
6
|
+
* shape guards, and window mapping; the transport contract lives here ONCE.
|
|
7
|
+
*/
|
|
8
|
+
export declare function fetchUsageJson<T>(url: string, headers: Record<string, string>, timeoutMs: number): Promise<T | null>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ProviderAdapter } from './providers/types';
|
|
2
|
+
import type { PoolAccount } from './types';
|
|
3
|
+
export declare function needsRefresh(account: PoolAccount, now: number): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Ensure the account has a fresh access token, refreshing if needed. The rotated
|
|
6
|
+
* refresh token is persisted immediately. On a genuine invalid_grant (the current
|
|
7
|
+
* token was revoked) the account is marked disabled and the error rethrown; a token
|
|
8
|
+
* merely superseded by a concurrent refresh is adopted, not disabled.
|
|
9
|
+
*
|
|
10
|
+
* Mutates the passed `account` in place so the caller sees the new token. Concurrent
|
|
11
|
+
* callers that reuse the in-flight refresh have their OWN local objects updated too.
|
|
12
|
+
*/
|
|
13
|
+
export declare function ensureAccessToken(adapter: ProviderAdapter, account: PoolAccount, now: number): Promise<string>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type ScoreConfig } from './score-core';
|
|
2
|
+
/**
|
|
3
|
+
* Tunable parameters for the account scheduler. All overridable via env vars. Extends
|
|
4
|
+
* the scoring knobs (`ScoreConfig`, the single source shared with the TUI view) with the
|
|
5
|
+
* session/migration parameters that only the server scheduler needs.
|
|
6
|
+
*/
|
|
7
|
+
export interface SchedulerConfig extends ScoreConfig {
|
|
8
|
+
/** Session->account assignments older than this (ms) are pruned. */
|
|
9
|
+
sessionTtlMs: number;
|
|
10
|
+
/**
|
|
11
|
+
* Cost gate for NON-forced switches (proactive `migrateAt` and `drainMigrate`):
|
|
12
|
+
* only switch when the outgoing request body is <= this many bytes, so a large
|
|
13
|
+
* conversation isn't re-sent onto a fresh account (which has no prompt cache and
|
|
14
|
+
* would burn a big chunk of its quota in one shot). <= 0 disables the gate, i.e.
|
|
15
|
+
* always allow the switch. Forced switches (hard exhaustion) ignore this gate;
|
|
16
|
+
* so does a PROACTIVE switch when the pin is within `IMMINENT_EXHAUSTION_BAND` of
|
|
17
|
+
* hard exhaustion (see select.ts) — a forced, still-ungated switch is coming next
|
|
18
|
+
* turn anyway and the re-sent conversation only grows, so moving now is cheaper.
|
|
19
|
+
* Drain switches always stay byte-gated.
|
|
20
|
+
*/
|
|
21
|
+
cheapSwitchMaxBytes: number;
|
|
22
|
+
/**
|
|
23
|
+
* Allow migrating a still-healthy session to drain another account whose weekly
|
|
24
|
+
* window is about to reset (use-it-or-lose-it). Off by default — when off, a
|
|
25
|
+
* healthy session only ever leaves on the proactive/forced thresholds above.
|
|
26
|
+
*/
|
|
27
|
+
drainMigrate: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* When drainMigrate is on, the candidate account's weekly urgency must exceed the
|
|
30
|
+
* pinned account's by at least this factor to justify breaking session affinity.
|
|
31
|
+
*/
|
|
32
|
+
drainMigrateMargin: number;
|
|
33
|
+
/**
|
|
34
|
+
* Max wall-clock time (ms) a single request may BLOCK waiting for the pool to recover
|
|
35
|
+
* when EVERY account is rate-limited (a 429/402 `account`-class cooldown). Rather than
|
|
36
|
+
* failing the turn abruptly, the load-balanced fetch sleeps until the soonest account's
|
|
37
|
+
* cooldown expires (honoring `Retry-After`) — bounded by this budget — then retries. A
|
|
38
|
+
* client abort (opencode cancelling the turn) interrupts the wait immediately. Must
|
|
39
|
+
* exceed `ACCOUNT_COOLDOWN_MS` (5 min) to cover a 429 that carries no `Retry-After`.
|
|
40
|
+
* <= 0 disables waiting (fail fast, the old behavior).
|
|
41
|
+
*/
|
|
42
|
+
maxWaitMs: number;
|
|
43
|
+
}
|
|
44
|
+
export declare const DEFAULT_CONFIG: SchedulerConfig;
|
|
45
|
+
/** Read overrides from env (OPENCODE_AUTH_LB_*), falling back to defaults. */
|
|
46
|
+
export declare function loadConfig(env?: NodeJS.ProcessEnv): SchedulerConfig;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure account-scoring core — the SINGLE SOURCE OF TRUTH for how the load balancer
|
|
3
|
+
* ranks accounts. Imported by the server scheduler AND the standalone TUI view, which
|
|
4
|
+
* loads a byte-identical copy installed alongside it
|
|
5
|
+
* (`tui/auth-load-balancer-scoring.ts`, kept in sync by a build step + a test). Keeping
|
|
6
|
+
* the formula in one place means the dashboard's ranking can never silently drift from
|
|
7
|
+
* what the scheduler actually picks (which it once did, as duplicated copies).
|
|
8
|
+
*
|
|
9
|
+
* This module MUST stay dependency-free (only `process.env`) so the TUI view can load a
|
|
10
|
+
* single copied file without resolving the rest of `src/`.
|
|
11
|
+
*/
|
|
12
|
+
/** Tunable scoring knobs (the subset of the scheduler config that affects ranking). */
|
|
13
|
+
export interface ScoreConfig {
|
|
14
|
+
/**
|
|
15
|
+
* How much the 5h-window headroom modulates the weekly-urgency score, in [0,1].
|
|
16
|
+
* 0 = ignore the 5h window; 1 = an account with no 5h headroom scores 0. Applied
|
|
17
|
+
* multiplicatively so it de-rates within the weekly ordering but never flips it.
|
|
18
|
+
*/
|
|
19
|
+
hourlyInfluence: number;
|
|
20
|
+
/** Floor for time-until-reset (ms). Caps how large urgency can grow near a reset. */
|
|
21
|
+
minResetMs: number;
|
|
22
|
+
/** Baseline horizon (ms) used when an account's weekly reset time is unknown/stale. */
|
|
23
|
+
weekWindowMs: number;
|
|
24
|
+
/**
|
|
25
|
+
* Hard exhaustion: utilization >= this excludes the account entirely while others
|
|
26
|
+
* have headroom, and forces a pinned session off it regardless of switch cost.
|
|
27
|
+
*/
|
|
28
|
+
exhaustedAt: number;
|
|
29
|
+
/**
|
|
30
|
+
* Soft threshold for the SHORT 5h window: proactively migrate a pinned session once
|
|
31
|
+
* its 5h utilization crosses this — before it hard-exhausts at `exhaustedAt`. Must be
|
|
32
|
+
* < exhaustedAt. (The WEEKLY window migrates at the higher `weeklyDrainTarget`.)
|
|
33
|
+
*/
|
|
34
|
+
migrateAt: number;
|
|
35
|
+
/**
|
|
36
|
+
* Weekly drain target (default 0.98): scoring treats weekly quota as "fully drained"
|
|
37
|
+
* at this utilization (the account stops being favored past it), and a pinned session
|
|
38
|
+
* proactively migrates once its WEEKLY util crosses it — so perishable weekly quota is
|
|
39
|
+
* drained close to full before the window resets. In (migrateAt, exhaustedAt].
|
|
40
|
+
*/
|
|
41
|
+
weeklyDrainTarget: number;
|
|
42
|
+
}
|
|
43
|
+
/** A single rate-limit window slice the scoring reads. */
|
|
44
|
+
export interface ScoreWindow {
|
|
45
|
+
utilization: number;
|
|
46
|
+
resetAt: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The slice of an account the scoring reads. The server's full `PoolAccount` and the
|
|
50
|
+
* TUI's parsed-pool account both satisfy this (the TUI normalizes its looser JSON into
|
|
51
|
+
* this shape before scoring).
|
|
52
|
+
*/
|
|
53
|
+
export interface ScoreAccount {
|
|
54
|
+
usage: {
|
|
55
|
+
hourly: ScoreWindow | null;
|
|
56
|
+
weekly: ScoreWindow | null;
|
|
57
|
+
};
|
|
58
|
+
cooldownUntil: number;
|
|
59
|
+
disabledReason: string | null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Weekly windows repeat on FIXED per-account anchors every 7 days (see
|
|
63
|
+
* usage-merge.ts). Exported so `usage-merge.ts` (an ordinary `src/` file with
|
|
64
|
+
* no dependency-free constraint) can import this single source instead of
|
|
65
|
+
* computing the same 604800000ms constant independently — `score-core.ts`
|
|
66
|
+
* itself still imports nothing, so the TUI's byte-identical copy stays
|
|
67
|
+
* dependency-free.
|
|
68
|
+
*/
|
|
69
|
+
export declare const WEEK_MS: number;
|
|
70
|
+
/** Scoring-config defaults (the scoring subset of the scheduler's DEFAULT_CONFIG). */
|
|
71
|
+
export declare const SCORE_DEFAULTS: ScoreConfig;
|
|
72
|
+
/**
|
|
73
|
+
* Parse one numeric env override: missing/empty → default, non-finite → default.
|
|
74
|
+
* The SINGLE parsing contract for every numeric `OPENCODE_AUTH_LB_*` knob — shared
|
|
75
|
+
* by `loadScoreConfig` below and the server-only `loadConfig` in
|
|
76
|
+
* `src/scheduler/config.ts`, so the two loaders can never silently diverge.
|
|
77
|
+
*/
|
|
78
|
+
export declare function readEnvNumber(env: Record<string, string | undefined>, key: string, dflt: number): number;
|
|
79
|
+
/** Read scoring overrides from env (OPENCODE_AUTH_LB_*), falling back to SCORE_DEFAULTS. */
|
|
80
|
+
export declare function loadScoreConfig(env?: Record<string, string | undefined>): ScoreConfig;
|
|
81
|
+
/**
|
|
82
|
+
* Effective utilization of a window RIGHT NOW (0..1). A missing window — or one that has
|
|
83
|
+
* already reset (see `isWindowExpired`) — contributes 0, so a stale 100% left over from
|
|
84
|
+
* an elapsed window never makes an account look busy or exhausted (which would exclude it
|
|
85
|
+
* from selection, so it would never be picked, never re-polled, and stay "full" forever).
|
|
86
|
+
*/
|
|
87
|
+
export declare function utilOf(window: ScoreWindow | null, now: number): number;
|
|
88
|
+
/**
|
|
89
|
+
* Effective utilization for DISPLAY: `null` when there is no window (dashboards render
|
|
90
|
+
* "-"), `0` once the window has reset (the stale stored value is discarded → "0%"),
|
|
91
|
+
* otherwise the stored utilization. Mirrors `utilOf`'s expiry rule but keeps the
|
|
92
|
+
* unknown-vs-zero distinction the dashboards need ("-" for no data, "0%" for a freshly
|
|
93
|
+
* reset window with full headroom).
|
|
94
|
+
*/
|
|
95
|
+
export declare function displayUtil(window: ScoreWindow | null, now: number): number | null;
|
|
96
|
+
/** The busiest window's utilization right now (whichever of 5h / weekly is higher). */
|
|
97
|
+
export declare function maxUtil(account: ScoreAccount, now: number): number;
|
|
98
|
+
/** An account is exhausted when ANY still-live window is at/over the exhaustion threshold. */
|
|
99
|
+
export declare function isExhausted(account: ScoreAccount, cfg: ScoreConfig, now: number): boolean;
|
|
100
|
+
/** Available = not disabled, not in cooldown, and not exhausted. */
|
|
101
|
+
export declare function isAvailable(account: ScoreAccount, cfg: ScoreConfig, now: number): boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Weekly "urgency" — how perishable an account's remaining weekly quota is. It favors
|
|
104
|
+
* draining the SOONEST-resetting account first (earliest-deadline-first), because unused
|
|
105
|
+
* weekly quota is LOST at the reset:
|
|
106
|
+
*
|
|
107
|
+
* drainable = max(0, weeklyDrainTarget - weeklyUtil) // quota left to safely burn
|
|
108
|
+
* urgency = drainable / (daysToReset + cushion)^2
|
|
109
|
+
*
|
|
110
|
+
* The SQUARED time term is the key: it approximates the cost of DELAYING — as a reset
|
|
111
|
+
* nears, the drain rate needed to avoid waste worsens nonlinearly, so a near-reset
|
|
112
|
+
* account outranks one that merely has more quota left but resets far away (which is what
|
|
113
|
+
* `remaining / time` failed to do). `drainable` caps the chase at `weeklyDrainTarget`
|
|
114
|
+
* (stop favoring an account near the top of its window) and the cushion prevents a blow-up
|
|
115
|
+
* at the reset instant.
|
|
116
|
+
*
|
|
117
|
+
* Imperfect reset metadata splits in three:
|
|
118
|
+
* - Window PRESENT but `resetAt === 0` (utilization known, anchor unknown — the
|
|
119
|
+
* post-quota-reset shape): weekly resets are FIXED per-account anchors, so
|
|
120
|
+
* "unknown" does NOT imply "a full window away" — it may be hours away, wasting a
|
|
121
|
+
* refillable budget every idle hour. Assume the reset is IMMINENT (`minResetMs`)
|
|
122
|
+
* so the account is ranked FIRST and serves one request ASAP; that response's
|
|
123
|
+
* headers reveal the real anchor and the score self-corrects immediately.
|
|
124
|
+
* (`drainable` still gates this: an unknown-anchor account with no headroom
|
|
125
|
+
* scores 0, so probing never routes to an exhausted account.)
|
|
126
|
+
* - Window PRESENT with an ELAPSED anchor (`resetAt` in the past — e.g. an idle
|
|
127
|
+
* account behind a failing usage endpoint): because the anchor is FIXED and
|
|
128
|
+
* repeats every 7 days, the TRUE next reset is `WEEK_MS - (elapsed % WEEK_MS)`
|
|
129
|
+
* away — roll it forward (same model as `rollWeeklyAnchorForward` in
|
|
130
|
+
* usage-merge.ts, inlined here to keep score-core dependency-free). An anchor
|
|
131
|
+
* that lapsed 6 days ago really resets in ~1 day and must rank accordingly,
|
|
132
|
+
* not as "a full window away".
|
|
133
|
+
* - Window MISSING entirely (never polled — utilization unknown too): keep the
|
|
134
|
+
* conservative full-window baseline. The free usage-endpoint poll resolves this
|
|
135
|
+
* case within seconds without routing any traffic to it.
|
|
136
|
+
*/
|
|
137
|
+
export declare function weeklyUrgency(account: ScoreAccount, cfg: ScoreConfig, now: number): number;
|
|
138
|
+
/**
|
|
139
|
+
* Score one candidate account (higher is better):
|
|
140
|
+
* score = weeklyUrgency * (1 - hourlyInfluence * hourlyPressure)
|
|
141
|
+
*
|
|
142
|
+
* The 5h window is only a SAFETY de-rate (avoid a short-window hard limit), NOT a quota
|
|
143
|
+
* objective — and it is RESET-AWARE: `hourlyPressure = hourlyUtil * (msToReset / 5h)`, so
|
|
144
|
+
* an account whose 5h window is busy but resets in minutes is barely penalized (its
|
|
145
|
+
* short-term pressure is about to clear), letting the perishable weekly urgency drive the
|
|
146
|
+
* choice. An unknown 5h reset is treated as a full window (full penalty).
|
|
147
|
+
*/
|
|
148
|
+
export declare function scoreAccount(account: ScoreAccount, cfg: ScoreConfig, now: number): number;
|
|
149
|
+
/**
|
|
150
|
+
* Sort comparator shared by `src/status.ts` and the TUI sidebar ranking pipeline, so the
|
|
151
|
+
* dashboard's *ordering* rule can never silently drift from what the server displays —
|
|
152
|
+
* the same single-source guarantee `scoreAccount` provides for the *formula*. Both
|
|
153
|
+
* pipelines map each account into `{ available, score, weeklyUtil }` (the per-pipeline
|
|
154
|
+
* `weeklyUtil` field is computed locally — `src/status.ts` uses the raw stored value to
|
|
155
|
+
* keep tie-breaking deterministic for cooling-down accounts, the TUI uses `utilOf` to
|
|
156
|
+
* discard a window that already reset) and pass that shape here:
|
|
157
|
+
*
|
|
158
|
+
* - available accounts come first (they're the only ones the scheduler will pick);
|
|
159
|
+
* - among available accounts, higher `score` wins (matches `selectAccount`'s pick);
|
|
160
|
+
* - among unavailable accounts, lower `weeklyUtil` wins — the "least bad cooling-down
|
|
161
|
+
* account" surfaces first so a forced fall-through stays predictable.
|
|
162
|
+
*/
|
|
163
|
+
export declare function compareRanked<T extends {
|
|
164
|
+
available: boolean;
|
|
165
|
+
score: number;
|
|
166
|
+
weeklyUtil: number;
|
|
167
|
+
}>(x: T, y: T): number;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { PoolAccount, PoolFile } from '../types';
|
|
2
|
+
import { type SchedulerConfig } from './config';
|
|
3
|
+
export interface Selection {
|
|
4
|
+
account: PoolAccount;
|
|
5
|
+
/** true when every account was exhausted/cooling-down and we picked the least-bad one. */
|
|
6
|
+
degraded: boolean;
|
|
7
|
+
/** true when the account came from this session's existing assignment (no switch). */
|
|
8
|
+
sticky: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Pick the best account for a provider by weekly urgency.
|
|
12
|
+
*
|
|
13
|
+
* 1. Restrict to the provider's non-disabled accounts, minus `exclude` (already
|
|
14
|
+
* tried this request).
|
|
15
|
+
* 2. Prefer the subset that is currently available (not exhausted / cooling down).
|
|
16
|
+
* 3. Among those, pick the highest urgency score.
|
|
17
|
+
* 4. If none are available, fall back to the least-bad account (lowest weekly
|
|
18
|
+
* utilization) and flag the selection as `degraded`.
|
|
19
|
+
*
|
|
20
|
+
* Returns null when the provider has no usable account left.
|
|
21
|
+
*/
|
|
22
|
+
export declare function selectAccount(accounts: PoolAccount[], providerID: string, now: number, cfg?: SchedulerConfig, exclude?: ReadonlySet<string>): Selection | null;
|
|
23
|
+
/**
|
|
24
|
+
* Session-aware selection. Balances prompt-cache stickiness against headroom safety
|
|
25
|
+
* and quota perishability:
|
|
26
|
+
*
|
|
27
|
+
* - No pin / pin excluded / pin unavailable (cooldown / disabled / hard-exhausted at
|
|
28
|
+
* `exhaustedAt`) -> FORCED switch to the best urgency pick.
|
|
29
|
+
* - Pin healthy but a NON-forced switch is worthwhile AND it's a cheap moment
|
|
30
|
+
* (small `requestBytes`):
|
|
31
|
+
* (a) proactive: pin crossed the soft `migrateAt` (~95%) and another account has
|
|
32
|
+
* more headroom -> move before slamming into the 100% wall (subagent safety);
|
|
33
|
+
* (b) drain (opt-in `drainMigrate`): another account's weekly reset is imminent
|
|
34
|
+
* and its urgency dominates the pin's by `drainMigrateMargin` -> switch to
|
|
35
|
+
* drain that use-it-or-lose-it quota.
|
|
36
|
+
* - Otherwise keep the pin (sticky), preserving its prompt cache.
|
|
37
|
+
*
|
|
38
|
+
* Forced switches ignore the cost gate; proactive/drain switches honor it so a large
|
|
39
|
+
* conversation isn't re-sent onto a fresh account all at once.
|
|
40
|
+
*/
|
|
41
|
+
export declare function selectForSession(pool: PoolFile, providerID: string, sessionKey: string | null, now: number, cfg?: SchedulerConfig, exclude?: ReadonlySet<string>, requestBytes?: number | (() => number)): Selection | null;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header carrying opencode's session id. Injected by the plugin's `chat.headers`
|
|
3
|
+
* hook (which has the real sessionID) and read back here in the load-balanced
|
|
4
|
+
* fetch. Stripped before the request leaves for the provider.
|
|
5
|
+
*/
|
|
6
|
+
export declare const SESSION_HEADER = "x-allb-session";
|
|
7
|
+
/**
|
|
8
|
+
* Derive a stable key identifying the conversation a request belongs to.
|
|
9
|
+
*
|
|
10
|
+
* Prefers the real opencode session id (via the SESSION_HEADER). Falls back to a
|
|
11
|
+
* hash of the request's stable prefix (system prompt + first user message), which
|
|
12
|
+
* is constant across the turns of one conversation. Returns null when neither is
|
|
13
|
+
* available (selection then proceeds without affinity).
|
|
14
|
+
*/
|
|
15
|
+
export declare function deriveSessionKey(headers: Headers, body: string | undefined): string | null;
|
package/dist/status.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type SchedulerConfig } from './scheduler/config';
|
|
2
|
+
import type { PoolFile } from './types';
|
|
3
|
+
interface AccountStatus {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
weeklyUtil: number | null;
|
|
7
|
+
hourlyUtil: number | null;
|
|
8
|
+
weeklyResetAt: number;
|
|
9
|
+
available: boolean;
|
|
10
|
+
cooldownUntil: number;
|
|
11
|
+
/** tier name → epoch ms until that model tier's cap resets (empty = none). */
|
|
12
|
+
modelCooldownsUntil: Record<string, number>;
|
|
13
|
+
disabledReason: string | null;
|
|
14
|
+
/** The account that most recently served a request for this provider. */
|
|
15
|
+
current: boolean;
|
|
16
|
+
/** 1 = next candidate the scheduler would pick. */
|
|
17
|
+
rank: number;
|
|
18
|
+
}
|
|
19
|
+
interface ProviderStatus {
|
|
20
|
+
providerID: string;
|
|
21
|
+
accounts: AccountStatus[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Build the ranked status model: per provider, the candidate accounts sorted in
|
|
25
|
+
* the order the scheduler would pick them (available accounts by urgency score,
|
|
26
|
+
* then unavailable ones by weekly utilization). Mirrors `selectAccount`'s ordering.
|
|
27
|
+
*/
|
|
28
|
+
export declare function buildStatus(pool: PoolFile, now: number, cfg?: SchedulerConfig): ProviderStatus[];
|
|
29
|
+
/** Read the live pool and build its ranked status. */
|
|
30
|
+
export declare function readStatus(now?: number, cfg?: SchedulerConfig): Promise<ProviderStatus[]>;
|
|
31
|
+
/**
|
|
32
|
+
* Render a utilization as `"45%"` (or `"-"` for null/undefined). Shared with
|
|
33
|
+
* `notify.ts` so the dashboard, the `auth_lb_status` tool, and the switch
|
|
34
|
+
* toast cannot silently disagree on the same account's percentage rounding.
|
|
35
|
+
* `tui/auth-load-balancer-tui.logic.ts` keeps its own copy
|
|
36
|
+
* (the TUI runtime cannot import `src/`).
|
|
37
|
+
*/
|
|
38
|
+
export declare function pct(u: number | null | undefined): string;
|
|
39
|
+
/**
|
|
40
|
+
* Approximate on-screen terminal width of a string, treating common East-Asian
|
|
41
|
+
* "wide" characters (Hangul, CJK Unified Ideographs + Extension blocks,
|
|
42
|
+
* Hiragana/Katakana, fullwidth forms) as 2 columns and everything else as 1.
|
|
43
|
+
* Account labels are user-editable free-form text (rename tool / TUI / a
|
|
44
|
+
* hand-edited pool file), and this project's own stated locale is `ko-KR` —
|
|
45
|
+
* a CJK/Hangul label is plausible, and `.length` (UTF-16 code units) undercounts
|
|
46
|
+
* each such character by one column, breaking `renderStatus`'s table alignment.
|
|
47
|
+
* Not a full Unicode-grapheme-cluster/emoji-aware implementation — a pragmatic,
|
|
48
|
+
* dependency-free improvement over the previous zero handling for the common
|
|
49
|
+
* CJK case. Degenerates to `.length` for pure-ASCII input.
|
|
50
|
+
*/
|
|
51
|
+
export declare function displayWidth(s: string): number;
|
|
52
|
+
export declare function providerName(providerID: string): string;
|
|
53
|
+
/** Render the status model as a compact text dashboard (for tools/commands/CLI). */
|
|
54
|
+
export declare function renderStatus(providers: ProviderStatus[], now?: number): string;
|
|
55
|
+
export {};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-agnostic data model shared by the scheduler, pool store, and adapters.
|
|
3
|
+
*
|
|
4
|
+
* Both Anthropic and OpenAI usage is normalized into the same shape so a single
|
|
5
|
+
* scheduler can rank accounts regardless of provider.
|
|
6
|
+
*/
|
|
7
|
+
/** A single rate-limit window. utilization in [0,1]; resetAt is epoch ms (0 = unknown). */
|
|
8
|
+
export interface UsageWindow {
|
|
9
|
+
utilization: number;
|
|
10
|
+
resetAt: number;
|
|
11
|
+
}
|
|
12
|
+
/** Normalized usage snapshot for one account. */
|
|
13
|
+
export interface UsageSnapshot {
|
|
14
|
+
/** Short rolling window (~5h for both Anthropic and Codex). */
|
|
15
|
+
hourly: UsageWindow | null;
|
|
16
|
+
/** Weekly (7-day rolling) window — the PRIMARY scheduling signal. */
|
|
17
|
+
weekly: UsageWindow | null;
|
|
18
|
+
/**
|
|
19
|
+
* epoch ms when the WEEKLY window was last captured (0 = never). Deliberately
|
|
20
|
+
* weekly-scoped: this is the staleness gate for the usage-endpoint re-poll
|
|
21
|
+
* (`refreshUsageInBackground`), and weekly is the primary signal that poll
|
|
22
|
+
* backfills — a response that updates only hourly must not mark the
|
|
23
|
+
* snapshot fresh, or an out-of-band weekly reset is never picked up.
|
|
24
|
+
*/
|
|
25
|
+
capturedAt: number;
|
|
26
|
+
}
|
|
27
|
+
export declare function emptyUsage(): UsageSnapshot;
|
|
28
|
+
/** Result of an OAuth authorization-code exchange or refresh. */
|
|
29
|
+
export interface TokenSet {
|
|
30
|
+
access: string;
|
|
31
|
+
refresh: string;
|
|
32
|
+
/** epoch ms expiry of the access token. */
|
|
33
|
+
expires: number;
|
|
34
|
+
/** Provider account id (e.g. chatgpt-account-id), when present. */
|
|
35
|
+
accountId?: string;
|
|
36
|
+
}
|
|
37
|
+
/** One pooled credential. */
|
|
38
|
+
export interface PoolAccount {
|
|
39
|
+
/** Stable internal id (uuid). */
|
|
40
|
+
id: string;
|
|
41
|
+
/** opencode provider id, e.g. "anthropic" | "openai". */
|
|
42
|
+
providerID: string;
|
|
43
|
+
/** User-facing label (email / nickname). Editable in the pool file. */
|
|
44
|
+
label: string;
|
|
45
|
+
access: string;
|
|
46
|
+
refresh: string;
|
|
47
|
+
/** Access-token expiry, epoch ms. */
|
|
48
|
+
expires: number;
|
|
49
|
+
/**
|
|
50
|
+
* Monotonic token-rotation generation. Bumped on every successful refresh so a
|
|
51
|
+
* cross-process refresher that lost the single-use-token race can tell its token
|
|
52
|
+
* was superseded — and adopt the winner's token instead of permanently disabling
|
|
53
|
+
* a now-valid account. Absent on legacy pool files (read as 0).
|
|
54
|
+
*/
|
|
55
|
+
tokenGen?: number;
|
|
56
|
+
/** Provider account id (e.g. chatgpt-account-id), or null. */
|
|
57
|
+
accountId: string | null;
|
|
58
|
+
usage: UsageSnapshot;
|
|
59
|
+
/** epoch ms; the account is skipped until this time. 0 = no cooldown. */
|
|
60
|
+
cooldownUntil: number;
|
|
61
|
+
/**
|
|
62
|
+
* Per MODEL-TIER cooldowns: tier name (e.g. "opus", "fable") → epoch ms until
|
|
63
|
+
* that tier's separate weekly cap resets. While an entry is `> now`, requests
|
|
64
|
+
* for that tier's models avoid this account (another account with tier
|
|
65
|
+
* headroom is preferred) and — when the WHOLE pool is tier-limited — are
|
|
66
|
+
* auto-downgraded to the fallback model (see
|
|
67
|
+
* `OPENCODE_AUTH_LB_ANTHROPIC_OPUS_FALLBACK_MODEL`) instead of cooling the
|
|
68
|
+
* account down: the account still serves every other model. Distinct from
|
|
69
|
+
* `cooldownUntil` (account-wide) and NOT a scheduling signal: scoring /
|
|
70
|
+
* `isAvailable` ignore it, so it never sidelines the account. Absent = no
|
|
71
|
+
* tier is known-exhausted. Anthropic-only; absent for OpenAI accounts.
|
|
72
|
+
*/
|
|
73
|
+
modelCooldownsUntil?: Record<string, number>;
|
|
74
|
+
/**
|
|
75
|
+
* LEGACY (pre-tier-map) Opus-only cooldown. Folded into
|
|
76
|
+
* `modelCooldownsUntil.opus` and deleted by the pool-store normalizer on
|
|
77
|
+
* every read; never written anymore. Kept in the type so old pool files
|
|
78
|
+
* parse without a cast.
|
|
79
|
+
*/
|
|
80
|
+
opusCooldownUntil?: number;
|
|
81
|
+
/** Non-null when the account needs manual re-login (e.g. revoked refresh token). */
|
|
82
|
+
disabledReason: string | null;
|
|
83
|
+
}
|
|
84
|
+
/** A conversation's sticky account assignment (preserves prompt cache across turns). */
|
|
85
|
+
export interface SessionAssignment {
|
|
86
|
+
accountId: string;
|
|
87
|
+
updatedAt: number;
|
|
88
|
+
}
|
|
89
|
+
export interface PoolFile {
|
|
90
|
+
version: 1;
|
|
91
|
+
accounts: PoolAccount[];
|
|
92
|
+
/**
|
|
93
|
+
* providerID -> account that most recently served a request. Drives the ▶
|
|
94
|
+
* "in use" marker in the status tool/CLI and the TUI bottom bar/sidebar;
|
|
95
|
+
* written on every fetch success and by `primeInUse` at startup. Never read
|
|
96
|
+
* by scheduling.
|
|
97
|
+
*/
|
|
98
|
+
lastSelected: Record<string, string>;
|
|
99
|
+
/** sessionKey -> assignment. Keeps a conversation pinned to one account. */
|
|
100
|
+
sessions: Record<string, SessionAssignment>;
|
|
101
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { UsageWindow } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Roll a FIXED weekly reset anchor forward to its next occurrence strictly after
|
|
4
|
+
* `now`. Weekly windows are anchored per account (verified live: Anthropic's
|
|
5
|
+
* promotional quota reset zeroed usage but the account's next reset stayed on
|
|
6
|
+
* its pre-reset anchor), so a reset time observed in the PAST still predicts
|
|
7
|
+
* every future reset — it repeats every 7 days. Returns 0 when there is no
|
|
8
|
+
* usable anchor (unknown stays unknown).
|
|
9
|
+
*/
|
|
10
|
+
export declare function rollWeeklyAnchorForward(anchor: number, now: number): number;
|
|
11
|
+
/**
|
|
12
|
+
* Merge helper for the weekly window: when an incoming window LOST the reset
|
|
13
|
+
* time (`resetAt === 0`) but we have previously seen this account's anchor,
|
|
14
|
+
* keep the anchor (rolled forward past `now`) instead of downgrading to
|
|
15
|
+
* "unknown". This happens on every out-of-band quota reset — the usage
|
|
16
|
+
* endpoint reports `resets_at: null` until usage restarts, and a header
|
|
17
|
+
* response can carry utilization without its reset — and losing the anchor
|
|
18
|
+
* used to make the scheduler assume a full week of headroom for an account
|
|
19
|
+
* whose (fixed) reset could be hours away. Incoming utilization always wins;
|
|
20
|
+
* only the reset metadata is preserved. An incoming window that carries its
|
|
21
|
+
* own reset time is returned untouched (fresh server data beats memory).
|
|
22
|
+
*/
|
|
23
|
+
export declare function preserveWeeklyAnchor(incoming: UsageWindow | null, stored: UsageWindow | null | undefined, now: number): UsageWindow | null;
|