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.
- package/CHANGELOG.md +12 -0
- package/README.md +11 -2
- package/docs/capacity-admission.md +147 -0
- package/docs/development-container.md +77 -0
- package/package.json +1 -1
- package/scripts/verify-package.js +8 -0
- package/src/absolute-deadline.js +6 -3
- package/src/activity-log-status.js +291 -0
- package/src/activity-log.js +91 -3
- package/src/cli.js +153 -8
- package/src/docker-api.js +8 -0
- package/src/isolated-runtime-client.js +124 -4
- package/src/isolated-runtime.js +145 -11
- package/src/jsonl-record-spool.js +178 -0
- package/src/kimi-oauth-store.js +16 -0
- package/src/provider-capacity-codex.js +176 -0
- package/src/provider-capacity-kimi.js +105 -0
- package/src/provider-capacity.js +292 -0
- package/src/providers/index.js +110 -6
- package/src/providers/kimi.js +30 -0
- package/src/run-worker.js +286 -71
- package/src/telegram-ingress/core.js +51 -22
|
@@ -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
|
+
}
|