@cortexkit/opencode-openai-auth 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +14 -0
- package/dist/commands.d.ts +49 -0
- package/dist/core/accounts.d.ts +227 -0
- package/dist/core/atomic-write.d.ts +1 -0
- package/dist/core/backoff.d.ts +53 -0
- package/dist/core/cachekeep.d.ts +95 -0
- package/dist/core/oauth.d.ts +126 -0
- package/dist/core/provider.d.ts +59 -0
- package/dist/core/quota-manager.d.ts +136 -0
- package/dist/core/refresh-all-quota.d.ts +52 -0
- package/dist/core/refresh-file-lock.d.ts +15 -0
- package/dist/index-0s5tftne.js +3 -0
- package/dist/index-6qjg8qfw.js +2 -0
- package/dist/index-dz7tcrxb.js +2 -0
- package/dist/index-eks9xm6s.js +1 -0
- package/dist/index-hrg6bdgb.js +2 -0
- package/dist/index-jshhys8t.js +2 -0
- package/dist/index-m9dtkj3q.js +92 -0
- package/dist/index-sgfft5d3.js +2 -0
- package/dist/index.d.ts +3 -19
- package/dist/index.js +118 -124
- package/dist/logger.d.ts +11 -1
- package/dist/quota-normalize-qh5n0p7v.js +1 -0
- package/dist/quota-normalize.d.ts +33 -0
- package/dist/rpc/notifications.d.ts +5 -0
- package/dist/rpc/port-file.d.ts +12 -0
- package/dist/rpc/port-file.js +1 -0
- package/dist/rpc/protocol.d.ts +20 -0
- package/dist/rpc/protocol.js +0 -0
- package/dist/rpc/rpc-client.d.ts +7 -0
- package/dist/rpc/rpc-client.js +1 -0
- package/dist/rpc/rpc-dir.d.ts +3 -0
- package/dist/rpc/rpc-dir.js +1 -0
- package/dist/rpc/rpc-server.d.ts +13 -0
- package/dist/sidebar-state.d.ts +83 -0
- package/dist/sidebar-state.js +1 -0
- package/dist/token-util-sqwtzxz0.js +1 -0
- package/dist/token-zzzp7w2y.js +1 -0
- package/dist/tui/command-dialogs.d.ts +14 -0
- package/dist/tui-preferences.d.ts +40 -0
- package/dist/tui-preferences.js +6 -0
- package/dist/tui.d.ts +9 -0
- package/dist/util/open-url.d.ts +6 -0
- package/dist/version.d.ts +1 -1
- package/dist/ws-pool.d.ts +8 -0
- package/dist/ws.d.ts +4 -0
- package/package.json +25 -4
- package/src/rpc/notifications.ts +54 -0
- package/src/rpc/port-file.ts +70 -0
- package/src/rpc/protocol.ts +31 -0
- package/src/rpc/rpc-client.ts +78 -0
- package/src/rpc/rpc-dir.ts +22 -0
- package/src/rpc/rpc-server.ts +129 -0
- package/src/sidebar-state.ts +312 -0
- package/src/tui/command-dialogs.tsx +742 -0
- package/src/tui-preferences.ts +328 -0
- package/src/tui.tsx +837 -0
- package/dist/index-6z8w05g9.js +0 -2
- package/dist/index-xqe8xm96.js +0 -2
- package/dist/token-util-fs3z8kkp.js +0 -1
- package/dist/token-yyjm093c.js +0 -1
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { homedir, tmpdir } from 'node:os'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const RPC_DIR_ENV = 'OPENCODE_OPENAI_AUTH_RPC_DIR'
|
|
6
|
+
|
|
7
|
+
// Both processes must resolve the SAME dir from the SAME project directory.
|
|
8
|
+
export function getRpcDir(projectDirectory: string): string {
|
|
9
|
+
const override = process.env[RPC_DIR_ENV]?.trim()
|
|
10
|
+
// A relative override is anchored to projectDirectory (shared by both processes)
|
|
11
|
+
// so server and TUI halves always resolve the same dir. An absolute override is
|
|
12
|
+
// used as-is (resolve(base, absolute) returns the absolute path unchanged).
|
|
13
|
+
if (override) return resolve(projectDirectory, override)
|
|
14
|
+
const hash = createHash('sha256')
|
|
15
|
+
.update(projectDirectory)
|
|
16
|
+
.digest('hex')
|
|
17
|
+
.slice(0, 16)
|
|
18
|
+
const base = process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state')
|
|
19
|
+
return join(base, 'cortexkit', 'openai-auth', 'rpc', hash)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { tmpdir }
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { unlink } from 'node:fs/promises'
|
|
3
|
+
import {
|
|
4
|
+
createServer,
|
|
5
|
+
type IncomingMessage,
|
|
6
|
+
type ServerResponse,
|
|
7
|
+
} from 'node:http'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { createLogger } from '../logger'
|
|
10
|
+
import type { drainNotifications } from './notifications'
|
|
11
|
+
import { writePortFile } from './port-file'
|
|
12
|
+
import type { ApplyRequest, ApplyResult } from './protocol'
|
|
13
|
+
|
|
14
|
+
const log = createLogger('rpc')
|
|
15
|
+
|
|
16
|
+
export interface RpcServerHandle {
|
|
17
|
+
port: number
|
|
18
|
+
token: string
|
|
19
|
+
stop: () => Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RpcServerOptions {
|
|
23
|
+
dir: string
|
|
24
|
+
drain: typeof drainNotifications
|
|
25
|
+
apply: (request: ApplyRequest) => Promise<ApplyResult>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const chunks: Buffer[] = []
|
|
31
|
+
let size = 0
|
|
32
|
+
req.on('data', (chunk: Buffer) => {
|
|
33
|
+
size += chunk.length
|
|
34
|
+
if (size > 1_000_000) {
|
|
35
|
+
req.destroy()
|
|
36
|
+
reject(new Error('body too large'))
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
chunks.push(chunk)
|
|
40
|
+
})
|
|
41
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
42
|
+
req.on('error', reject)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tokenOk(header: string | undefined, token: string): boolean {
|
|
47
|
+
if (!header?.startsWith('Bearer ')) return false
|
|
48
|
+
const got = Buffer.from(header.slice(7))
|
|
49
|
+
const want = Buffer.from(token)
|
|
50
|
+
return got.length === want.length && timingSafeEqual(got, want)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function startRpcServer(
|
|
54
|
+
options: RpcServerOptions,
|
|
55
|
+
): Promise<RpcServerHandle> {
|
|
56
|
+
const token = randomBytes(32).toString('hex')
|
|
57
|
+
const server = createServer((req, res) => {
|
|
58
|
+
void dispatch(req, res)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
async function dispatch(req: IncomingMessage, res: ServerResponse) {
|
|
62
|
+
const json = (status: number, value: unknown) => {
|
|
63
|
+
// Guard against writing to a socket that was destroyed (e.g. when
|
|
64
|
+
// readBody rejected after req.destroy() on an oversized body).
|
|
65
|
+
if (res.headersSent || res.writableEnded || res.destroyed) return
|
|
66
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
67
|
+
res.end(JSON.stringify(value))
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const url = req.url ?? ''
|
|
71
|
+
if (req.method === 'GET' && url === '/health')
|
|
72
|
+
return json(200, { ok: true })
|
|
73
|
+
if (req.method !== 'POST' || !url.startsWith('/rpc/'))
|
|
74
|
+
return json(404, { error: 'not found' })
|
|
75
|
+
if (!tokenOk(req.headers.authorization, token))
|
|
76
|
+
return json(401, { error: 'unauthorized' })
|
|
77
|
+
const method = url.slice('/rpc/'.length)
|
|
78
|
+
const body = await readBody(req)
|
|
79
|
+
const params = JSON.parse(body || '{}') as Record<string, unknown>
|
|
80
|
+
if (method === 'pending-notifications') {
|
|
81
|
+
const messages = options.drain(
|
|
82
|
+
Number(params.lastReceivedId ?? 0),
|
|
83
|
+
typeof params.sessionId === 'string' ? params.sessionId : undefined,
|
|
84
|
+
)
|
|
85
|
+
return json(200, { messages })
|
|
86
|
+
}
|
|
87
|
+
if (method === 'apply') {
|
|
88
|
+
const result = await options.apply(params as unknown as ApplyRequest)
|
|
89
|
+
return json(200, result)
|
|
90
|
+
}
|
|
91
|
+
return json(404, { error: 'unknown method' })
|
|
92
|
+
} catch (error) {
|
|
93
|
+
json(500, {
|
|
94
|
+
error: error instanceof Error ? error.message : String(error),
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const port = await new Promise<number>((resolve, reject) => {
|
|
100
|
+
server.on('error', reject)
|
|
101
|
+
server.listen(0, '127.0.0.1', () => {
|
|
102
|
+
const addr = server.address()
|
|
103
|
+
if (addr && typeof addr === 'object') resolve(addr.port)
|
|
104
|
+
else reject(new Error('no port'))
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
server.unref()
|
|
108
|
+
try {
|
|
109
|
+
await writePortFile(options.dir, { port, token, pid: process.pid })
|
|
110
|
+
log.debug('rpc server pid', {
|
|
111
|
+
pid: process.pid,
|
|
112
|
+
rpcPort: port,
|
|
113
|
+
})
|
|
114
|
+
} catch (error) {
|
|
115
|
+
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
116
|
+
throw error
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
port,
|
|
121
|
+
token,
|
|
122
|
+
async stop() {
|
|
123
|
+
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
124
|
+
await unlink(join(options.dir, `port-${process.pid}.json`)).catch(
|
|
125
|
+
() => {},
|
|
126
|
+
)
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
export interface QuotaWindow {
|
|
2
|
+
usedPercent: number
|
|
3
|
+
remainingPercent: number
|
|
4
|
+
resetsAt?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface AccountQuota {
|
|
8
|
+
primary?: QuotaWindow
|
|
9
|
+
secondary?: QuotaWindow
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SidebarAccountState {
|
|
13
|
+
id: string
|
|
14
|
+
label: string | undefined
|
|
15
|
+
quota: AccountQuota | null
|
|
16
|
+
killed: boolean
|
|
17
|
+
enabled: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SidebarState {
|
|
21
|
+
main: {
|
|
22
|
+
quota: AccountQuota | null
|
|
23
|
+
killed: boolean
|
|
24
|
+
quotaBackedOff?: boolean
|
|
25
|
+
quotaBackoffUntil?: number
|
|
26
|
+
refreshBackedOff?: boolean
|
|
27
|
+
refreshBackoffUntil?: number
|
|
28
|
+
}
|
|
29
|
+
fallbacks: SidebarAccountState[]
|
|
30
|
+
activeId: string | undefined
|
|
31
|
+
route: string
|
|
32
|
+
planType?: string
|
|
33
|
+
credits?: number
|
|
34
|
+
lastUpdated: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
38
|
+
import { tmpdir } from 'node:os'
|
|
39
|
+
import { dirname, join } from 'node:path'
|
|
40
|
+
import { createLogger } from './logger'
|
|
41
|
+
|
|
42
|
+
const logSb = createLogger('sidebar')
|
|
43
|
+
|
|
44
|
+
const STATE_FILE_ENV = 'OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE'
|
|
45
|
+
const DEFAULT_STATE_DIR = join(tmpdir(), 'opencode-openai-auth')
|
|
46
|
+
const DEFAULT_STATE_FILE = join(DEFAULT_STATE_DIR, 'sidebar-state.json')
|
|
47
|
+
|
|
48
|
+
export function getSidebarStateFile(): string {
|
|
49
|
+
return process.env[STATE_FILE_ENV] || DEFAULT_STATE_FILE
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const DEFAULT_SIDEBAR_STATE: SidebarState = {
|
|
53
|
+
main: { quota: null, killed: false },
|
|
54
|
+
fallbacks: [],
|
|
55
|
+
activeId: undefined,
|
|
56
|
+
route: 'main',
|
|
57
|
+
lastUpdated: 0,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Normalize an arbitrary parsed value into a well-formed SidebarState.
|
|
62
|
+
*
|
|
63
|
+
* JSON.parse + `as SidebarState` is an unchecked cast — a partial, old, or
|
|
64
|
+
* malformed state file passes through and the TUI's `state().main.quota` /
|
|
65
|
+
* `state().fallbacks.filter(...)` throw at runtime. This helper guarantees
|
|
66
|
+
* every required field is present and correctly typed before the value leaves
|
|
67
|
+
* the I/O boundary, so a bad file can never crash the host TUI.
|
|
68
|
+
*/
|
|
69
|
+
export function normalizeSidebarState(raw: unknown): SidebarState {
|
|
70
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
71
|
+
return DEFAULT_SIDEBAR_STATE
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const r = raw as Record<string, unknown>
|
|
75
|
+
|
|
76
|
+
// main — must be an object with at least quota and killed
|
|
77
|
+
const rawMain = r.main
|
|
78
|
+
let main: SidebarState['main']
|
|
79
|
+
if (
|
|
80
|
+
rawMain !== null &&
|
|
81
|
+
typeof rawMain === 'object' &&
|
|
82
|
+
!Array.isArray(rawMain)
|
|
83
|
+
) {
|
|
84
|
+
const m = rawMain as Record<string, unknown>
|
|
85
|
+
main = {
|
|
86
|
+
quota: ('quota' in m ? m.quota : null) as AccountQuota | null,
|
|
87
|
+
killed: typeof m.killed === 'boolean' ? m.killed : false,
|
|
88
|
+
// Preserve optional backoff fields if present
|
|
89
|
+
...(typeof m.quotaBackedOff === 'boolean'
|
|
90
|
+
? { quotaBackedOff: m.quotaBackedOff }
|
|
91
|
+
: {}),
|
|
92
|
+
...(typeof m.quotaBackoffUntil === 'number'
|
|
93
|
+
? { quotaBackoffUntil: m.quotaBackoffUntil }
|
|
94
|
+
: {}),
|
|
95
|
+
...(typeof m.refreshBackedOff === 'boolean'
|
|
96
|
+
? { refreshBackedOff: m.refreshBackedOff }
|
|
97
|
+
: {}),
|
|
98
|
+
...(typeof m.refreshBackoffUntil === 'number'
|
|
99
|
+
? { refreshBackoffUntil: m.refreshBackoffUntil }
|
|
100
|
+
: {}),
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
main = { quota: null, killed: false }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// fallbacks — must be an array; keep entries that are objects with a string
|
|
107
|
+
// id, and normalize each entry's inner fields so the TUI never reads a
|
|
108
|
+
// wrong-typed value (e.g. a string `enabled`) off a malformed file.
|
|
109
|
+
const rawFallbacks = r.fallbacks
|
|
110
|
+
const fallbacks: SidebarAccountState[] = Array.isArray(rawFallbacks)
|
|
111
|
+
? rawFallbacks
|
|
112
|
+
.filter(
|
|
113
|
+
(entry): entry is Record<string, unknown> =>
|
|
114
|
+
entry !== null &&
|
|
115
|
+
typeof entry === 'object' &&
|
|
116
|
+
!Array.isArray(entry) &&
|
|
117
|
+
typeof (entry as Record<string, unknown>).id === 'string',
|
|
118
|
+
)
|
|
119
|
+
.map((e) => ({
|
|
120
|
+
id: e.id as string,
|
|
121
|
+
label: typeof e.label === 'string' ? e.label : undefined,
|
|
122
|
+
quota: ('quota' in e ? e.quota : null) as AccountQuota | null,
|
|
123
|
+
killed: typeof e.killed === 'boolean' ? e.killed : false,
|
|
124
|
+
enabled: typeof e.enabled === 'boolean' ? e.enabled : true,
|
|
125
|
+
}))
|
|
126
|
+
: []
|
|
127
|
+
|
|
128
|
+
// activeId — string or undefined
|
|
129
|
+
const activeId = typeof r.activeId === 'string' ? r.activeId : undefined
|
|
130
|
+
|
|
131
|
+
// route — string, default 'main'
|
|
132
|
+
const route =
|
|
133
|
+
typeof r.route === 'string' ? r.route : DEFAULT_SIDEBAR_STATE.route
|
|
134
|
+
|
|
135
|
+
// lastUpdated — number, default 0
|
|
136
|
+
const lastUpdated = typeof r.lastUpdated === 'number' ? r.lastUpdated : 0
|
|
137
|
+
|
|
138
|
+
// Optional top-level fields
|
|
139
|
+
const planType = typeof r.planType === 'string' ? r.planType : undefined
|
|
140
|
+
const credits = typeof r.credits === 'number' ? r.credits : undefined
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
main,
|
|
144
|
+
fallbacks,
|
|
145
|
+
activeId,
|
|
146
|
+
route,
|
|
147
|
+
lastUpdated,
|
|
148
|
+
...(planType !== undefined ? { planType } : {}),
|
|
149
|
+
...(credits !== undefined ? { credits } : {}),
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function getSidebarState(): Promise<SidebarState> {
|
|
154
|
+
try {
|
|
155
|
+
const raw = await readFile(getSidebarStateFile(), 'utf8')
|
|
156
|
+
return normalizeSidebarState(JSON.parse(raw))
|
|
157
|
+
} catch {
|
|
158
|
+
return DEFAULT_SIDEBAR_STATE
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Serialization chain: concurrent calls are queued so a stale background
|
|
163
|
+
// write cannot land after a newer one and corrupt the file.
|
|
164
|
+
let sidebarWriteChain: Promise<void> = Promise.resolve()
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Write sidebar state to disk, serialized through a promise chain so
|
|
168
|
+
* concurrent callers never interleave or let a stale write land last.
|
|
169
|
+
*
|
|
170
|
+
* @param state The state to persist.
|
|
171
|
+
* @param file Explicit path override — callers that bind the path at init
|
|
172
|
+
* time (e.g. the index.ts loader) pass this so late callbacks
|
|
173
|
+
* always write to the path that was current when the loader ran,
|
|
174
|
+
* even if the env changes underneath them during tests.
|
|
175
|
+
* Defaults to getSidebarStateFile() (per-call resolution).
|
|
176
|
+
*/
|
|
177
|
+
export function setSidebarState(
|
|
178
|
+
state: SidebarState,
|
|
179
|
+
file = getSidebarStateFile(),
|
|
180
|
+
): Promise<void> {
|
|
181
|
+
sidebarWriteChain = sidebarWriteChain
|
|
182
|
+
.then(() => doWriteSidebarState(state, file))
|
|
183
|
+
.catch(() => {})
|
|
184
|
+
return sidebarWriteChain
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function doWriteSidebarState(
|
|
188
|
+
state: SidebarState,
|
|
189
|
+
file: string,
|
|
190
|
+
): Promise<void> {
|
|
191
|
+
try {
|
|
192
|
+
await mkdir(dirname(file), { recursive: true })
|
|
193
|
+
await writeFile(file, JSON.stringify(state), 'utf8')
|
|
194
|
+
} catch (e) {
|
|
195
|
+
logSb.warn('sidebar write failed', {
|
|
196
|
+
pid: process.pid,
|
|
197
|
+
error: e instanceof Error ? e.message : String(e),
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Await all pending sidebar writes. Tests call this before restoring env
|
|
204
|
+
* vars in teardown so no in-flight write can re-resolve getSidebarStateFile()
|
|
205
|
+
* after the env is changed.
|
|
206
|
+
*/
|
|
207
|
+
export function drainSidebarWrites(): Promise<void> {
|
|
208
|
+
return sidebarWriteChain
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Resolve the currently-active account from activeId for the collapsed sidebar
|
|
212
|
+
// view. activeId === 'main' (or undefined/unmatched/disabled) → the main
|
|
213
|
+
// account; otherwise the enabled fallback whose id matches.
|
|
214
|
+
export function resolveActiveAccount(state: SidebarState): {
|
|
215
|
+
id: string
|
|
216
|
+
name: string
|
|
217
|
+
quota: AccountQuota | null
|
|
218
|
+
killed: boolean
|
|
219
|
+
} {
|
|
220
|
+
const activeId = state.activeId
|
|
221
|
+
if (activeId && activeId !== 'main') {
|
|
222
|
+
const fallback = state.fallbacks.find(
|
|
223
|
+
(account) => account.enabled && account.id === activeId,
|
|
224
|
+
)
|
|
225
|
+
if (fallback) {
|
|
226
|
+
return {
|
|
227
|
+
id: fallback.id,
|
|
228
|
+
name: fallback.label ?? fallback.id,
|
|
229
|
+
quota: fallback.quota,
|
|
230
|
+
killed: fallback.killed,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
id: 'main',
|
|
236
|
+
name: 'main',
|
|
237
|
+
quota: state.main.quota,
|
|
238
|
+
killed: state.main.killed,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function getCollapsedQuotaSummary(quota: AccountQuota | null): {
|
|
243
|
+
primaryUsedPercent: number | null
|
|
244
|
+
secondaryUsedPercent: number | null
|
|
245
|
+
text: string | null
|
|
246
|
+
} {
|
|
247
|
+
const primaryUsedPercent = quota?.primary?.usedPercent ?? null
|
|
248
|
+
const secondaryUsedPercent = quota?.secondary?.usedPercent ?? null
|
|
249
|
+
if (primaryUsedPercent == null && secondaryUsedPercent == null) {
|
|
250
|
+
return { primaryUsedPercent, secondaryUsedPercent, text: null }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
primaryUsedPercent,
|
|
255
|
+
secondaryUsedPercent,
|
|
256
|
+
text: `5h: ${primaryUsedPercent == null ? '\u2014' : `${Math.round(primaryUsedPercent)}%`} 7d: ${secondaryUsedPercent == null ? '\u2014' : `${Math.round(secondaryUsedPercent)}%`}`,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export const FIVE_HOUR_MS = 5 * 60 * 60 * 1000
|
|
261
|
+
export const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000
|
|
262
|
+
|
|
263
|
+
const PACING_MIN_ELAPSED_MS = 5 * 60 * 1000
|
|
264
|
+
const PACING_MIN_ELAPSED_FRACTION = 0.01
|
|
265
|
+
const ON_PACE_DELTA = 1
|
|
266
|
+
|
|
267
|
+
export interface QuotaPacing {
|
|
268
|
+
pacePercent: number
|
|
269
|
+
deltaPercent: number
|
|
270
|
+
state: 'deficit' | 'reserve' | 'on-pace'
|
|
271
|
+
runsOutAt: string | null
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Even-burn pacing for a quota window. The window start is inferred from the
|
|
275
|
+
// reset timestamp minus the window length. Two metrics: deltaPercent compares
|
|
276
|
+
// usage against a uniform burn-down (positive = deficit), and runsOutAt
|
|
277
|
+
// projects the current average burn rate forward — null means the window
|
|
278
|
+
// lasts until reset at that rate. Returns null when there is no reset
|
|
279
|
+
// timestamp or the elapsed time is too small to give a meaningful rate.
|
|
280
|
+
export function computeQuotaPacing(
|
|
281
|
+
window: QuotaWindow,
|
|
282
|
+
windowMs: number,
|
|
283
|
+
now: number,
|
|
284
|
+
): QuotaPacing | null {
|
|
285
|
+
if (!window.resetsAt) return null
|
|
286
|
+
const resetsAt = new Date(window.resetsAt).getTime()
|
|
287
|
+
if (!Number.isFinite(resetsAt)) return null
|
|
288
|
+
const start = resetsAt - windowMs
|
|
289
|
+
const elapsed = now - start
|
|
290
|
+
if (elapsed < PACING_MIN_ELAPSED_MS) return null
|
|
291
|
+
if (elapsed < windowMs * PACING_MIN_ELAPSED_FRACTION) return null
|
|
292
|
+
if (elapsed >= windowMs) return null
|
|
293
|
+
|
|
294
|
+
const used = window.usedPercent
|
|
295
|
+
const pacePercent = Math.min(Math.max((elapsed / windowMs) * 100, 0), 100)
|
|
296
|
+
const deltaPercent = used - pacePercent
|
|
297
|
+
const state =
|
|
298
|
+
Math.abs(deltaPercent) < ON_PACE_DELTA
|
|
299
|
+
? 'on-pace'
|
|
300
|
+
: deltaPercent > 0
|
|
301
|
+
? 'deficit'
|
|
302
|
+
: 'reserve'
|
|
303
|
+
|
|
304
|
+
let runsOutAt: string | null = null
|
|
305
|
+
if (used > 0) {
|
|
306
|
+
const msToFull = (elapsed * 100) / used
|
|
307
|
+
const runOut = start + msToFull
|
|
308
|
+
if (runOut < resetsAt) runsOutAt = new Date(runOut).toISOString()
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { pacePercent, deltaPercent, state, runsOutAt }
|
|
312
|
+
}
|