threadwire 0.1.13 → 0.1.15

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.
@@ -0,0 +1,176 @@
1
+ // @ts-check
2
+
3
+ import {spawn} from "node:child_process"
4
+ import {providerExecutable} from "./providers/executable.js"
5
+ import {CapacityProbeError, DEFAULT_CAPACITY_TIMEOUT_MS, normalizeCodexRateLimits} from "./provider-capacity.js"
6
+
7
+ const DEFAULT_EXECUTABLE = "/opt/data/libexec/threadwire/codex"
8
+ const MAX_LINE_BYTES = 65_536
9
+ const MAX_LINES = 1_024
10
+
11
+ /**
12
+ * Probe live Codex account capacity through `codex app-server` JSON-RPC over
13
+ * stdio: `initialize` followed by `account/rateLimits/read`. The child is
14
+ * always killed, its stderr is consumed but never forwarded, and server error
15
+ * detail (which can carry account or credential material) is never propagated —
16
+ * failures classify with fixed, credential-free messages.
17
+ * @param {{
18
+ * env?: NodeJS.ProcessEnv,
19
+ * spawnImplementation?: typeof spawn | undefined,
20
+ * timeoutMs?: number
21
+ * }} options
22
+ * @returns {Promise<import("./provider-capacity.js").CapacityWindows>}
23
+ */
24
+ export async function probeCodexCapacity(options) {
25
+ const environment = options.env ?? process.env
26
+ const spawnImplementation = options.spawnImplementation ?? spawn
27
+ const timeoutMs = options.timeoutMs ?? DEFAULT_CAPACITY_TIMEOUT_MS
28
+ const executable = providerExecutable("THREADWIRE_CODEX_BIN", DEFAULT_EXECUTABLE, environment)
29
+ const result = await appServerExchange(spawnImplementation, executable, environment, timeoutMs)
30
+ return normalizeCodexRateLimits(result)
31
+ }
32
+
33
+ /**
34
+ * Run the bounded two-request JSON-RPC exchange and resolve with the raw
35
+ * `account/rateLimits/read` result for fail-closed normalization.
36
+ * @param {typeof spawn} spawnImplementation
37
+ * @param {string} executable
38
+ * @param {NodeJS.ProcessEnv} environment
39
+ * @param {number} timeoutMs
40
+ * @returns {Promise<unknown>}
41
+ */
42
+ function appServerExchange(spawnImplementation, executable, environment, timeoutMs) {
43
+ return new Promise((resolve, reject) => {
44
+ /** @type {ReturnType<typeof spawn>} */
45
+ let child
46
+ try {
47
+ child = spawnImplementation(executable, ["app-server"], {env: environment, stdio: ["pipe", "pipe", "pipe"]})
48
+ } catch {
49
+ reject(new CapacityProbeError("unavailable", "Codex app-server is unavailable"))
50
+ return
51
+ }
52
+ let settled = false
53
+ let buffer = ""
54
+ let lines = 0
55
+ let phase = "initialize"
56
+ // The timer is always cleared by finish, so it never outlives the exchange.
57
+ const timer = setTimeout(() => {
58
+ fail(new CapacityProbeError("unavailable", "Codex app-server capacity probe timed out"))
59
+ }, timeoutMs)
60
+
61
+ /**
62
+ * @param {(value: unknown) => void} settle
63
+ * @param {unknown} value
64
+ */
65
+ function finish(settle, value) {
66
+ if (settled) return
67
+ settled = true
68
+ clearTimeout(timer)
69
+ child.kill()
70
+ settle(value)
71
+ }
72
+
73
+ /** @param {CapacityProbeError} error */
74
+ function fail(error) {
75
+ finish(reject, error)
76
+ }
77
+
78
+ /** @param {Record<string, unknown>} request */
79
+ function write(request) {
80
+ try {
81
+ child.stdin?.write(`${JSON.stringify(request)}\n`)
82
+ } catch {
83
+ fail(new CapacityProbeError("unavailable", "Codex app-server is unavailable"))
84
+ }
85
+ }
86
+
87
+ /** @param {string} line */
88
+ function handleLine(line) {
89
+ if (line.trim().length === 0) return
90
+ /** @type {unknown} */
91
+ let message
92
+ try {
93
+ message = JSON.parse(line)
94
+ } catch {
95
+ fail(new CapacityProbeError("protocol", "Codex app-server emitted a malformed record"))
96
+ return
97
+ }
98
+ if (!isRecord(message) || (message.id !== 1 && message.id !== 2)) return
99
+ if (message.error !== undefined) {
100
+ if (message.id === 1 || phase === "initialize") {
101
+ fail(new CapacityProbeError("protocol", "Codex app-server initialize failed"))
102
+ } else if (isAuthRejection(message.error)) {
103
+ fail(new CapacityProbeError("auth", "Codex rate-limits credential was rejected"))
104
+ } else {
105
+ fail(new CapacityProbeError("protocol", "Codex rate-limits request failed"))
106
+ }
107
+ return
108
+ }
109
+ if (message.id === 1) {
110
+ if (phase !== "initialize") {
111
+ fail(new CapacityProbeError("protocol", "Codex app-server answered out of order"))
112
+ return
113
+ }
114
+ phase = "read"
115
+ write({jsonrpc: "2.0", id: 2, method: "account/rateLimits/read"})
116
+ return
117
+ }
118
+ if (phase !== "read") {
119
+ fail(new CapacityProbeError("protocol", "Codex app-server answered out of order"))
120
+ return
121
+ }
122
+ finish(resolve, message.result)
123
+ }
124
+
125
+ child.on("error", () => fail(new CapacityProbeError("unavailable", "Codex app-server is unavailable")))
126
+ child.on("close", () => fail(new CapacityProbeError("unavailable", "Codex app-server exited before answering")))
127
+ child.stdin?.on("error", () => {})
128
+ child.stderr?.on("data", () => {})
129
+ child.stdout?.on("data", (chunk) => {
130
+ if (settled) return
131
+ buffer += /** @type {Buffer} */ (chunk).toString("utf8")
132
+ let index = buffer.indexOf("\n")
133
+ while (index >= 0 && !settled) {
134
+ const line = buffer.slice(0, index)
135
+ buffer = buffer.slice(index + 1)
136
+ lines += 1
137
+ if (line.length > MAX_LINE_BYTES || lines > MAX_LINES) {
138
+ fail(new CapacityProbeError("protocol", "Codex app-server exceeded the bounded exchange"))
139
+ return
140
+ }
141
+ handleLine(line)
142
+ index = buffer.indexOf("\n")
143
+ }
144
+ if (!settled && buffer.length > MAX_LINE_BYTES) {
145
+ fail(new CapacityProbeError("protocol", "Codex app-server exceeded the bounded exchange"))
146
+ }
147
+ })
148
+
149
+ write({
150
+ jsonrpc: "2.0",
151
+ id: 1,
152
+ method: "initialize",
153
+ params: {clientInfo: {name: "threadwire-capacity", version: "1"}}
154
+ })
155
+ })
156
+ }
157
+
158
+ /**
159
+ * A JSON-RPC error whose server message indicates an authentication or login
160
+ * failure classifies as `auth`; everything else stays `protocol`. The server
161
+ * message itself is only inspected, never propagated.
162
+ * @param {unknown} value
163
+ * @returns {boolean}
164
+ */
165
+ function isAuthRejection(value) {
166
+ if (!isRecord(value) || typeof value.message !== "string") return false
167
+ return /auth|login|unauthorized|token/iu.test(value.message)
168
+ }
169
+
170
+ /**
171
+ * @param {unknown} value
172
+ * @returns {value is Record<string, unknown>}
173
+ */
174
+ function isRecord(value) {
175
+ return typeof value === "object" && value !== null && !Array.isArray(value)
176
+ }
@@ -0,0 +1,105 @@
1
+ // @ts-check
2
+
3
+ import {isAbsolute, join} from "node:path"
4
+ import {readResponseCapped} from "./absolute-deadline.js"
5
+ import {openKimiOAuthStore} from "./kimi-oauth-store.js"
6
+ import {CapacityProbeError, DEFAULT_CAPACITY_TIMEOUT_MS, normalizeKimiUsages} from "./provider-capacity.js"
7
+
8
+ const USAGES_URL = "https://api.kimi.com/coding/v1/usages"
9
+ const MAX_USAGES_BYTES = 65_536
10
+ const RE_LOGIN_MESSAGE = "Kimi OAuth credential was rejected; re-login required"
11
+ const REFRESH_REQUIRED_MESSAGE = "Kimi OAuth credential requires refresh"
12
+
13
+ /**
14
+ * Probe live Kimi account capacity through the official usages endpoint. The
15
+ * OAuth bearer is loaded through the hardened credential store's non-mutating
16
+ * `getCurrentAccessToken` and used in-process only: the probe never refreshes,
17
+ * persists, or tombstones credentials, and the token is never printed or
18
+ * embedded in errors. All failures are classified with fixed, credential-free
19
+ * messages.
20
+ * @param {{
21
+ * env?: NodeJS.ProcessEnv,
22
+ * fetchImplementation?: typeof fetch | undefined,
23
+ * timeoutMs?: number
24
+ * }} options
25
+ * @returns {Promise<import("./provider-capacity.js").CapacityWindows>}
26
+ */
27
+ export async function probeKimiCapacity(options) {
28
+ const environment = options.env ?? process.env
29
+ const fetchImplementation = options.fetchImplementation ?? fetch
30
+ const timeoutMs = options.timeoutMs ?? DEFAULT_CAPACITY_TIMEOUT_MS
31
+ const file = resolveCredentialFile(environment)
32
+ const signal = AbortSignal.timeout(timeoutMs)
33
+ /** @type {string} */
34
+ let token
35
+ try {
36
+ const store = await openKimiOAuthStore({file})
37
+ token = await store.getCurrentAccessToken()
38
+ } catch (error) {
39
+ if (error instanceof Error && error.message === RE_LOGIN_MESSAGE) {
40
+ throw new CapacityProbeError("auth", RE_LOGIN_MESSAGE)
41
+ }
42
+ if (error instanceof Error && error.message === REFRESH_REQUIRED_MESSAGE) {
43
+ throw new CapacityProbeError("unavailable", REFRESH_REQUIRED_MESSAGE)
44
+ }
45
+ throw new CapacityProbeError("auth", "Kimi OAuth credential file is unavailable")
46
+ }
47
+ /** @type {Response} */
48
+ let response
49
+ try {
50
+ response = await fetchImplementation(USAGES_URL, {
51
+ method: "GET",
52
+ redirect: "error",
53
+ headers: {accept: "application/json", authorization: `Bearer ${token}`},
54
+ signal
55
+ })
56
+ } catch {
57
+ throw new CapacityProbeError("unavailable", "Kimi usages request is unavailable")
58
+ }
59
+ if (response.status === 401 || response.status === 403) {
60
+ throw new CapacityProbeError("auth", "Kimi usages credential was rejected")
61
+ }
62
+ if (!response.ok) throw new CapacityProbeError("unavailable", "Kimi usages endpoint is unavailable")
63
+ const contentType = response.headers.get("content-type")
64
+ if (contentType === null || !contentType.toLowerCase().includes("application/json")) {
65
+ throw new CapacityProbeError("protocol", "Kimi usages response has an unexpected content type")
66
+ }
67
+ /** @type {unknown} */
68
+ let parsed
69
+ try {
70
+ const bytes = await readResponseCapped(response, MAX_USAGES_BYTES, signal)
71
+ parsed = JSON.parse(bytes.toString("utf8"))
72
+ } catch {
73
+ throw new CapacityProbeError("protocol", "Kimi usages response is not valid bounded JSON")
74
+ }
75
+ return normalizeKimiUsages(parsed)
76
+ }
77
+
78
+ /**
79
+ * Resolve the Kimi credential file: a non-empty absolute
80
+ * `THREADWIRE_KIMI_OAUTH_FILE` explicitly overrides; otherwise the installed
81
+ * Kimi CLI's canonical `${KIMI_CODE_HOME}/credentials/kimi-code.json` store;
82
+ * otherwise the vendor default `${HOME}/.kimi-code/credentials/kimi-code.json`.
83
+ * Missing, relative, or NUL-containing candidates never qualify, and with no
84
+ * usable candidate the probe fails closed without logging any path.
85
+ * @param {NodeJS.ProcessEnv} environment
86
+ * @returns {string}
87
+ */
88
+ function resolveCredentialFile(environment) {
89
+ const override = usablePath(environment.THREADWIRE_KIMI_OAUTH_FILE)
90
+ if (override !== undefined) return override
91
+ const codeHome = usablePath(environment.KIMI_CODE_HOME)
92
+ if (codeHome !== undefined) return join(codeHome, "credentials", "kimi-code.json")
93
+ const home = usablePath(environment.HOME)
94
+ if (home !== undefined) return join(home, ".kimi-code", "credentials", "kimi-code.json")
95
+ throw new CapacityProbeError("auth", "Kimi OAuth credential file is not configured")
96
+ }
97
+
98
+ /**
99
+ * @param {string | undefined} value
100
+ * @returns {string | undefined}
101
+ */
102
+ function usablePath(value) {
103
+ if (value === undefined || value.length === 0 || value.includes("\0") || !isAbsolute(value)) return undefined
104
+ return value
105
+ }
@@ -0,0 +1,292 @@
1
+ // @ts-check
2
+
3
+ /** @type {CapacityProvider[]} */
4
+ export const CAPACITY_PROVIDERS = ["codex", "kimi"]
5
+ export const DEFAULT_SHORT_RESERVE_PERCENT = 20
6
+ export const DEFAULT_LONG_RESERVE_PERCENT = 10
7
+ export const DEFAULT_CAPACITY_TIMEOUT_MS = 15_000
8
+
9
+ const MAX_EPOCH_SECONDS = 4_102_444_800
10
+ const MAX_WINDOW_MINUTES = 525_600
11
+ const KIMI_LONG_WINDOW_SECONDS = 604_800
12
+ const MAX_KIMI_LIMIT_WINDOWS = 8
13
+ const MAX_QUOTA_DIGITS = 15
14
+ const MAX_RESET_TIME_LENGTH = 64
15
+ const ISO_RESET_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/u
16
+
17
+ /** @typedef {"codex" | "kimi"} CapacityProvider */
18
+ /** @typedef {"unavailable" | "auth" | "protocol"} CapacityFailureKind */
19
+ /** @typedef {{usedPercent: number, remainingPercent: number, resetAt: string, windowDurationSeconds: number}} CapacityWindow */
20
+ /** @typedef {{short: CapacityWindow, long?: CapacityWindow}} CapacityWindows */
21
+ /** @typedef {{provider: CapacityProvider, status: "ok", windows: CapacityWindows} | {provider: CapacityProvider, status: CapacityFailureKind, error: string}} CapacityCandidate */
22
+ /** @typedef {{provider: CapacityProvider, reason: string}} RejectedCandidate */
23
+ /** @typedef {{provider: CapacityProvider, score: number}} CapacitySelection */
24
+ /** @typedef {{shortReservePercent: number, longReservePercent: number}} CapacityReserves */
25
+ /** @typedef {(request: {env: NodeJS.ProcessEnv, timeoutMs: number}) => Promise<CapacityWindows>} CapacityProbeFunction */
26
+
27
+ /** A fixed-message, credential-free probe failure classified for the caller. */
28
+ export class CapacityProbeError extends Error {
29
+ /**
30
+ * @param {CapacityFailureKind} kind
31
+ * @param {string} message
32
+ */
33
+ constructor(kind, message) {
34
+ super(message)
35
+ this.name = "CapacityProbeError"
36
+ this.kind = kind
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Normalize a Codex `account/rateLimits/read` JSON-RPC result into the canonical
42
+ * short/long window form. Only the quota projection is parsed: `rateLimits`
43
+ * must carry a `primary` window and a nullable `secondary`; unrelated result or
44
+ * `rateLimits` metadata (limit identifiers/names, credits, plan type) is
45
+ * neither read nor propagated. Any quota shape drift fails closed as a protocol
46
+ * error; raw response content is never carried into the error.
47
+ * @param {unknown} value
48
+ * @returns {CapacityWindows}
49
+ */
50
+ export function normalizeCodexRateLimits(value) {
51
+ if (!isRecord(value) || !isRecord(value.rateLimits)) throw codexProtocolError()
52
+ const rateLimits = value.rateLimits
53
+ const primary = rateLimits.primary
54
+ if (!isRecord(primary)) throw codexProtocolError()
55
+ const short = codexWindow(primary)
56
+ const secondary = rateLimits.secondary
57
+ if (secondary === null) return {short}
58
+ if (!isRecord(secondary)) throw codexProtocolError()
59
+ const long = codexWindow(secondary)
60
+ if (long.windowDurationSeconds < short.windowDurationSeconds) throw codexProtocolError()
61
+ return {short, long}
62
+ }
63
+
64
+ /**
65
+ * Normalize a Kimi `GET /coding/v1/usages` body into the canonical short/long
66
+ * window form. Only the quota projection is parsed: top-level `usage` is the
67
+ * long (weekly) account quota and the shortest `limits[]` window is `short`;
68
+ * every returned window is validated but intermediate windows are ignored.
69
+ * Unrelated top-level fields are neither read nor propagated. Quota numbers
70
+ * are strict non-negative decimal strings with `used + remaining === limit`;
71
+ * remaining percent is floored so normalization never overstates capacity.
72
+ * @param {unknown} value
73
+ * @returns {CapacityWindows}
74
+ */
75
+ export function normalizeKimiUsages(value) {
76
+ if (!isRecord(value) || !Array.isArray(value.limits)
77
+ || value.limits.length < 1 || value.limits.length > MAX_KIMI_LIMIT_WINDOWS) {
78
+ throw kimiProtocolError()
79
+ }
80
+ const windows = value.limits.map(kimiLimitsWindow)
81
+ const long = kimiQuotaWindow(value.usage, KIMI_LONG_WINDOW_SECONDS)
82
+ const [first, ...rest] = windows
83
+ if (first === undefined) throw kimiProtocolError()
84
+ let short = first
85
+ for (const window of rest) {
86
+ if (window.windowDurationSeconds < short.windowDurationSeconds) short = window
87
+ }
88
+ if (long.windowDurationSeconds <= short.windowDurationSeconds) throw kimiProtocolError()
89
+ return {short, long}
90
+ }
91
+
92
+ /**
93
+ * Deterministically select one admission candidate: reject candidates below the
94
+ * short/long reserves, score survivors by their most constrained window, and
95
+ * order by score descending, long-window remaining descending, then provider
96
+ * name ascending. Pure: no I/O, no clock.
97
+ * @param {CapacityCandidate[]} candidates
98
+ * @param {CapacityReserves} reserves
99
+ * @returns {{selection: CapacitySelection | null, rejected: RejectedCandidate[]}}
100
+ */
101
+ export function selectProvider(candidates, reserves) {
102
+ /** @type {RejectedCandidate[]} */
103
+ const rejected = []
104
+ /** @type {{provider: CapacityProvider, score: number, longRemaining: number}[]} */
105
+ const survivors = []
106
+ for (const candidate of candidates) {
107
+ if (candidate.status !== "ok") {
108
+ rejected.push({provider: candidate.provider, reason: candidate.status})
109
+ continue
110
+ }
111
+ const shortRemaining = candidate.windows.short.remainingPercent
112
+ const longRemaining = candidate.windows.long?.remainingPercent ?? 100
113
+ if (shortRemaining < reserves.shortReservePercent) {
114
+ rejected.push({provider: candidate.provider, reason: "short-reserve"})
115
+ continue
116
+ }
117
+ if (longRemaining < reserves.longReservePercent) {
118
+ rejected.push({provider: candidate.provider, reason: "long-reserve"})
119
+ continue
120
+ }
121
+ survivors.push({provider: candidate.provider, score: Math.min(shortRemaining, longRemaining), longRemaining})
122
+ }
123
+ survivors.sort((a, b) => (
124
+ b.score - a.score
125
+ || b.longRemaining - a.longRemaining
126
+ || (a.provider < b.provider ? -1 : 1)
127
+ ))
128
+ const winner = survivors[0]
129
+ return {
130
+ selection: winner === undefined ? null : {provider: winner.provider, score: winner.score},
131
+ rejected
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Create the live capacity probe. The per-provider probe functions are wired by
137
+ * the caller (keeping this module import-cycle-free); failures are converted
138
+ * into per-provider candidates with fixed, credential-free messages and never
139
+ * mask the other provider.
140
+ * @param {{
141
+ * env?: NodeJS.ProcessEnv,
142
+ * probes: Record<CapacityProvider, CapacityProbeFunction>
143
+ * }} options
144
+ * @returns {{probe: (provider: CapacityProvider, timeoutMs?: number) => Promise<CapacityCandidate>}}
145
+ */
146
+ export function createCapacityProbe(options) {
147
+ const environment = options.env ?? process.env
148
+ return {
149
+ async probe(provider, timeoutMs = DEFAULT_CAPACITY_TIMEOUT_MS) {
150
+ try {
151
+ const windows = await options.probes[provider]({env: environment, timeoutMs})
152
+ return {provider, status: "ok", windows}
153
+ } catch (error) {
154
+ if (error instanceof CapacityProbeError) return {provider, status: error.kind, error: error.message}
155
+ return {provider, status: "unavailable", error: "Capacity probe failed"}
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Validate one quota window: exactly `usedPercent`, `windowDurationMins`, and
163
+ * `resetsAt` (epoch seconds, normalized to the canonical `resetAt` output
164
+ * field); any missing, obsolete, or extra key fails closed.
165
+ * @param {Record<string, unknown>} value
166
+ * @returns {CapacityWindow}
167
+ */
168
+ function codexWindow(value) {
169
+ if (!exactKeys(value, ["usedPercent", "windowDurationMins", "resetsAt"])
170
+ || typeof value.usedPercent !== "number" || !Number.isFinite(value.usedPercent)
171
+ || value.usedPercent < 0 || value.usedPercent > 100
172
+ || typeof value.windowDurationMins !== "number" || !Number.isSafeInteger(value.windowDurationMins)
173
+ || value.windowDurationMins < 1 || value.windowDurationMins > MAX_WINDOW_MINUTES) {
174
+ throw codexProtocolError()
175
+ }
176
+ return {
177
+ usedPercent: value.usedPercent,
178
+ remainingPercent: 100 - value.usedPercent,
179
+ resetAt: isoResetAt(value.resetsAt, codexProtocolError),
180
+ windowDurationSeconds: value.windowDurationMins * 60
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Validate one `limits[]` entry: exact `window`/`detail` keys, minute-unit
186
+ * duration, and a fully validated quota detail.
187
+ * @param {unknown} value
188
+ * @returns {CapacityWindow}
189
+ */
190
+ function kimiLimitsWindow(value) {
191
+ if (!isRecord(value) || !exactKeys(value, ["window", "detail"]) || !isRecord(value.window)
192
+ || !exactKeys(value.window, ["duration", "timeUnit"]) || value.window.timeUnit !== "TIME_UNIT_MINUTE"
193
+ || typeof value.window.duration !== "number" || !Number.isSafeInteger(value.window.duration)
194
+ || value.window.duration < 1 || value.window.duration > MAX_WINDOW_MINUTES) {
195
+ throw kimiProtocolError()
196
+ }
197
+ return kimiQuotaWindow(value.detail, value.window.duration * 60)
198
+ }
199
+
200
+ /**
201
+ * Validate one quota object (`usage` or `detail`): exact keys, strict
202
+ * non-negative decimal-string integers with `used + remaining === limit`, a
203
+ * positive limit, and a bounded ISO-8601 reset time.
204
+ * @param {unknown} value
205
+ * @param {number} windowDurationSeconds
206
+ * @returns {CapacityWindow}
207
+ */
208
+ function kimiQuotaWindow(value, windowDurationSeconds) {
209
+ if (!isRecord(value) || !exactKeys(value, ["limit", "used", "remaining", "resetTime"])) {
210
+ throw kimiProtocolError()
211
+ }
212
+ const limit = quotaValue(value.limit)
213
+ const used = quotaValue(value.used)
214
+ const remaining = quotaValue(value.remaining)
215
+ if (limit < 1 || used + remaining !== limit) throw kimiProtocolError()
216
+ const remainingPercent = Math.floor((remaining / limit) * 100)
217
+ return {
218
+ usedPercent: 100 - remainingPercent,
219
+ remainingPercent,
220
+ resetAt: isoResetTime(value.resetTime),
221
+ windowDurationSeconds
222
+ }
223
+ }
224
+
225
+ /**
226
+ * A strict non-negative decimal-string integer, digit-bounded so the sum of
227
+ * two values always stays a safe integer.
228
+ * @param {unknown} value
229
+ * @returns {number}
230
+ */
231
+ function quotaValue(value) {
232
+ if (typeof value !== "string" || !/^\d+$/u.test(value) || value.length > MAX_QUOTA_DIGITS) {
233
+ throw kimiProtocolError()
234
+ }
235
+ return Number(value)
236
+ }
237
+
238
+ /**
239
+ * @param {unknown} value
240
+ * @returns {string}
241
+ */
242
+ function isoResetTime(value) {
243
+ if (typeof value !== "string" || value.length < 1 || value.length > MAX_RESET_TIME_LENGTH || !ISO_RESET_TIME.test(value)) {
244
+ throw kimiProtocolError()
245
+ }
246
+ const milliseconds = Date.parse(value)
247
+ if (!Number.isFinite(milliseconds) || milliseconds < 1 || milliseconds > MAX_EPOCH_SECONDS * 1000) {
248
+ throw kimiProtocolError()
249
+ }
250
+ return new Date(milliseconds).toISOString()
251
+ }
252
+
253
+ /**
254
+ * @param {unknown} value
255
+ * @param {() => CapacityProbeError} errorFactory
256
+ * @returns {string}
257
+ */
258
+ function isoResetAt(value, errorFactory) {
259
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > MAX_EPOCH_SECONDS) {
260
+ throw errorFactory()
261
+ }
262
+ return new Date(value * 1000).toISOString()
263
+ }
264
+
265
+ /** @returns {CapacityProbeError} */
266
+ function codexProtocolError() {
267
+ return new CapacityProbeError("protocol", "Codex rate-limits response has an unexpected shape")
268
+ }
269
+
270
+ /** @returns {CapacityProbeError} */
271
+ function kimiProtocolError() {
272
+ return new CapacityProbeError("protocol", "Kimi usages response has an unexpected shape")
273
+ }
274
+
275
+ /**
276
+ * @param {unknown} value
277
+ * @returns {value is Record<string, unknown>}
278
+ */
279
+ function isRecord(value) {
280
+ return typeof value === "object" && value !== null && !Array.isArray(value)
281
+ }
282
+
283
+ /**
284
+ * @param {Record<string, unknown>} value
285
+ * @param {readonly string[]} expected
286
+ * @returns {boolean}
287
+ */
288
+ function exactKeys(value, expected) {
289
+ const actual = Object.keys(value).sort()
290
+ const wanted = [...expected].sort()
291
+ return actual.length === wanted.length && actual.every((key, index) => key === wanted[index])
292
+ }