@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.
Files changed (63) hide show
  1. package/README.md +27 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +14 -0
  4. package/dist/commands.d.ts +49 -0
  5. package/dist/core/accounts.d.ts +227 -0
  6. package/dist/core/atomic-write.d.ts +1 -0
  7. package/dist/core/backoff.d.ts +53 -0
  8. package/dist/core/cachekeep.d.ts +95 -0
  9. package/dist/core/oauth.d.ts +126 -0
  10. package/dist/core/provider.d.ts +59 -0
  11. package/dist/core/quota-manager.d.ts +136 -0
  12. package/dist/core/refresh-all-quota.d.ts +52 -0
  13. package/dist/core/refresh-file-lock.d.ts +15 -0
  14. package/dist/index-0s5tftne.js +3 -0
  15. package/dist/index-6qjg8qfw.js +2 -0
  16. package/dist/index-dz7tcrxb.js +2 -0
  17. package/dist/index-eks9xm6s.js +1 -0
  18. package/dist/index-hrg6bdgb.js +2 -0
  19. package/dist/index-jshhys8t.js +2 -0
  20. package/dist/index-m9dtkj3q.js +92 -0
  21. package/dist/index-sgfft5d3.js +2 -0
  22. package/dist/index.d.ts +3 -19
  23. package/dist/index.js +118 -124
  24. package/dist/logger.d.ts +11 -1
  25. package/dist/quota-normalize-qh5n0p7v.js +1 -0
  26. package/dist/quota-normalize.d.ts +33 -0
  27. package/dist/rpc/notifications.d.ts +5 -0
  28. package/dist/rpc/port-file.d.ts +12 -0
  29. package/dist/rpc/port-file.js +1 -0
  30. package/dist/rpc/protocol.d.ts +20 -0
  31. package/dist/rpc/protocol.js +0 -0
  32. package/dist/rpc/rpc-client.d.ts +7 -0
  33. package/dist/rpc/rpc-client.js +1 -0
  34. package/dist/rpc/rpc-dir.d.ts +3 -0
  35. package/dist/rpc/rpc-dir.js +1 -0
  36. package/dist/rpc/rpc-server.d.ts +13 -0
  37. package/dist/sidebar-state.d.ts +83 -0
  38. package/dist/sidebar-state.js +1 -0
  39. package/dist/token-util-sqwtzxz0.js +1 -0
  40. package/dist/token-zzzp7w2y.js +1 -0
  41. package/dist/tui/command-dialogs.d.ts +14 -0
  42. package/dist/tui-preferences.d.ts +40 -0
  43. package/dist/tui-preferences.js +6 -0
  44. package/dist/tui.d.ts +9 -0
  45. package/dist/util/open-url.d.ts +6 -0
  46. package/dist/version.d.ts +1 -1
  47. package/dist/ws-pool.d.ts +8 -0
  48. package/dist/ws.d.ts +4 -0
  49. package/package.json +25 -4
  50. package/src/rpc/notifications.ts +54 -0
  51. package/src/rpc/port-file.ts +70 -0
  52. package/src/rpc/protocol.ts +31 -0
  53. package/src/rpc/rpc-client.ts +78 -0
  54. package/src/rpc/rpc-dir.ts +22 -0
  55. package/src/rpc/rpc-server.ts +129 -0
  56. package/src/sidebar-state.ts +312 -0
  57. package/src/tui/command-dialogs.tsx +742 -0
  58. package/src/tui-preferences.ts +328 -0
  59. package/src/tui.tsx +837 -0
  60. package/dist/index-6z8w05g9.js +0 -2
  61. package/dist/index-xqe8xm96.js +0 -2
  62. package/dist/token-util-fs3z8kkp.js +0 -1
  63. 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
+ }