@young1lin/dsh-gpt-sub 0.1.0

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 (56) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +268 -0
  4. package/client.js +908 -0
  5. package/cordis.patch.yml +44 -0
  6. package/lib/index.d.ts +52 -0
  7. package/lib/index.d.ts.map +1 -0
  8. package/lib/index.js +581 -0
  9. package/lib/index.js.map +1 -0
  10. package/lib/jwt.d.ts +15 -0
  11. package/lib/jwt.d.ts.map +1 -0
  12. package/lib/jwt.js +29 -0
  13. package/lib/jwt.js.map +1 -0
  14. package/lib/proxy-config.d.ts +91 -0
  15. package/lib/proxy-config.d.ts.map +1 -0
  16. package/lib/proxy-config.js +213 -0
  17. package/lib/proxy-config.js.map +1 -0
  18. package/lib/proxy-probe.d.ts +47 -0
  19. package/lib/proxy-probe.d.ts.map +1 -0
  20. package/lib/proxy-probe.js +51 -0
  21. package/lib/proxy-probe.js.map +1 -0
  22. package/lib/proxy-routing.d.ts +111 -0
  23. package/lib/proxy-routing.d.ts.map +1 -0
  24. package/lib/proxy-routing.js +171 -0
  25. package/lib/proxy-routing.js.map +1 -0
  26. package/lib/quota-route.d.ts +85 -0
  27. package/lib/quota-route.d.ts.map +1 -0
  28. package/lib/quota-route.js +105 -0
  29. package/lib/quota-route.js.map +1 -0
  30. package/lib/reset-credits.d.ts +64 -0
  31. package/lib/reset-credits.d.ts.map +1 -0
  32. package/lib/reset-credits.js +84 -0
  33. package/lib/reset-credits.js.map +1 -0
  34. package/lib/token-store.d.ts +107 -0
  35. package/lib/token-store.d.ts.map +1 -0
  36. package/lib/token-store.js +228 -0
  37. package/lib/token-store.js.map +1 -0
  38. package/lib/types.d.ts +18 -0
  39. package/lib/types.d.ts.map +1 -0
  40. package/lib/types.js +2 -0
  41. package/lib/types.js.map +1 -0
  42. package/lib/usage.d.ts +92 -0
  43. package/lib/usage.d.ts.map +1 -0
  44. package/lib/usage.js +106 -0
  45. package/lib/usage.js.map +1 -0
  46. package/package.json +82 -0
  47. package/src/index.ts +685 -0
  48. package/src/jwt.ts +26 -0
  49. package/src/proxy-config.ts +217 -0
  50. package/src/proxy-probe.ts +81 -0
  51. package/src/proxy-routing.ts +205 -0
  52. package/src/quota-route.ts +163 -0
  53. package/src/reset-credits.ts +128 -0
  54. package/src/token-store.ts +309 -0
  55. package/src/types.ts +17 -0
  56. package/src/usage.ts +154 -0
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Rate-limit reset credits: list the redeemable credits on the account and
3
+ * consume one, against the same wham host the usage endpoint lives on.
4
+ *
5
+ * Mirrors the codex CLI's backend-client contract
6
+ * (codex-rs/backend-client/src/client/rate_limit_resets.rs): GET
7
+ * .../rate-limit-reset-credits lists credits, POST .../consume redeems one
8
+ * with a caller-chosen idempotency key (`redeem_request_id`) and an optional
9
+ * `credit_id`. Same Bearer auth as every other wham call.
10
+ *
11
+ * @module dsh-gpt-sub/reset-credits
12
+ */
13
+
14
+ import { fetch as undiciFetch, type Dispatcher } from 'undici'
15
+
16
+ /** One redeemable credit as the list endpoint reports it. */
17
+ export interface ResetCredit {
18
+ id: string
19
+ reset_type: string
20
+ status: string
21
+ granted_at: string
22
+ expires_at?: string
23
+ title?: string
24
+ description?: string
25
+ }
26
+
27
+ /** The list endpoint's reply. */
28
+ export interface ResetCreditsDetails {
29
+ credits: ResetCredit[]
30
+ available_count: number
31
+ }
32
+
33
+ /** The consume endpoint's machine-readable outcomes. */
34
+ export type ConsumeCode = 'reset' | 'nothing_to_reset' | 'no_credit' | 'already_redeemed'
35
+
36
+ /** The consume endpoint's reply. */
37
+ export interface ConsumeReply {
38
+ code: ConsumeCode
39
+ windows_reset?: number
40
+ }
41
+
42
+ /** The list endpoint, on the same host as the model calls. */
43
+ const CREDITS_URL = 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits'
44
+
45
+ /** The consume endpoint, beside the list one. */
46
+ const CONSUME_URL = CREDITS_URL + '/consume'
47
+
48
+ /**
49
+ * Decode a wham JSON reply or throw with the status attached.
50
+ *
51
+ * @param response - the undici response.
52
+ * @param what - names the endpoint in error messages.
53
+ * @returns the parsed body.
54
+ */
55
+ async function decode<T>(response: Response, what: string): Promise<T> {
56
+ const body = await response.text()
57
+ if (!response.ok) {
58
+ throw new Error(`${what} answered ${String(response.status)}: ${body.slice(0, 200)}`)
59
+ }
60
+ try {
61
+ return JSON.parse(body) as T
62
+ } catch {
63
+ throw new Error(`${what} did not answer with JSON`)
64
+ }
65
+ }
66
+
67
+ /**
68
+ * List the account's rate-limit reset credits.
69
+ *
70
+ * @param accessToken - a live Codex access token.
71
+ * @param dispatcher - undici dispatcher; omit to use the global one.
72
+ * @param fetchImpl - injectable fetch, for tests.
73
+ * @param signal - abort signal bounding the call.
74
+ * @returns the parsed credit list.
75
+ */
76
+ export async function listResetCredits(
77
+ accessToken: string,
78
+ dispatcher?: Dispatcher,
79
+ fetchImpl: typeof undiciFetch = undiciFetch,
80
+ signal?: AbortSignal,
81
+ ): Promise<ResetCreditsDetails> {
82
+ const response = await fetchImpl(CREDITS_URL, {
83
+ headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json' },
84
+ ...(dispatcher === undefined ? {} : { dispatcher }),
85
+ ...(signal === undefined ? {} : { signal }),
86
+ })
87
+ return decode<ResetCreditsDetails>(response, 'reset credits list')
88
+ }
89
+
90
+ /**
91
+ * Consume one rate-limit reset credit, resetting the eligible window.
92
+ *
93
+ * @param accessToken - a live Codex access token.
94
+ * @param redeemRequestId - caller-chosen idempotency key; retries with the same
95
+ * key cannot double-redeem.
96
+ * @param creditId - consume this specific credit; omit for any available one.
97
+ * @param dispatcher - undici dispatcher; omit to use the global one.
98
+ * @param fetchImpl - injectable fetch, for tests.
99
+ * @param signal - abort signal bounding the call.
100
+ * @returns the outcome and how many windows were reset.
101
+ */
102
+ export async function consumeResetCredit(
103
+ accessToken: string,
104
+ redeemRequestId: string,
105
+ options: {
106
+ creditId?: string
107
+ dispatcher?: Dispatcher
108
+ fetchImpl?: typeof undiciFetch
109
+ signal?: AbortSignal
110
+ } = {},
111
+ ): Promise<ConsumeReply> {
112
+ const { creditId, dispatcher, fetchImpl = undiciFetch, signal } = options
113
+ const response = await fetchImpl(CONSUME_URL, {
114
+ method: 'POST',
115
+ headers: {
116
+ authorization: `Bearer ${accessToken}`,
117
+ 'content-type': 'application/json',
118
+ accept: 'application/json',
119
+ },
120
+ body: JSON.stringify({
121
+ redeem_request_id: redeemRequestId,
122
+ ...(creditId === undefined ? {} : { credit_id: creditId }),
123
+ }),
124
+ ...(dispatcher === undefined ? {} : { dispatcher }),
125
+ ...(signal === undefined ? {} : { signal }),
126
+ })
127
+ return decode<ConsumeReply>(response, 'reset credit consume')
128
+ }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Codex OAuth credential access. Reads the Codex CLI's own auth.json so both
3
+ * tools share one login, and refreshes the access token before it expires.
4
+ *
5
+ * Expiry is judged solely from the access_token's exp claim. The id_token
6
+ * carries identity claims, lives one hour, and is routinely expired while the
7
+ * access token remains valid for days -- treating it as an expiry signal would
8
+ * trigger a refresh on nearly every request.
9
+ *
10
+ * @module dsh-gpt-sub/token-store
11
+ */
12
+
13
+ import { readFile, rename, rm, writeFile } from 'node:fs/promises'
14
+ import { fetch as undiciFetch } from 'undici'
15
+ import { jwtExpiryMs } from './jwt.ts'
16
+
17
+ /** Codex CLI's public OAuth client, read from the aud claim of a real id_token. */
18
+ const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
19
+
20
+ /** Issuer-derived token endpoint. */
21
+ const TOKEN_URL = 'https://auth.openai.com/oauth/token'
22
+
23
+ /** The subset of a token-endpoint response the refresh path reads. */
24
+ export interface RefreshResponse {
25
+ ok: boolean
26
+ status: number
27
+ text: () => Promise<string>
28
+ json: () => Promise<unknown>
29
+ }
30
+
31
+ /**
32
+ * The injectable token-endpoint call; undici's `fetch` satisfies this shape.
33
+ *
34
+ * The global `fetch` is deliberately not used: it has no `dispatcher` option,
35
+ * so a refresh through it ignores the proxy and cannot connect on a machine
36
+ * that reaches OpenAI only through one.
37
+ */
38
+ export type RefreshFetch = (
39
+ url: string,
40
+ init: { method: string; headers: Record<string, string>; body: string; dispatcher?: unknown },
41
+ ) => Promise<RefreshResponse>
42
+
43
+ /** What {@link TokenStore.inspect} reports: file-only facts, no network. */
44
+ export interface TokenInspection {
45
+ /** Access-token expiry in epoch ms, when the JWT carries a decodable exp. */
46
+ accessTokenExpiresAt?: number
47
+ }
48
+
49
+ /** The credential fields the shim needs for one upstream request. */
50
+ export interface CodexTokens {
51
+ accessToken: string
52
+ refreshToken: string
53
+ accountId: string
54
+ }
55
+
56
+ /** Construction options; the injectable seams exist for tests. */
57
+ export interface TokenStoreOptions {
58
+ /** Path to the Codex CLI credential file. */
59
+ authFile: string
60
+ /** Refresh once the access token has less than this many ms of life left. */
61
+ refreshMarginMs: number
62
+ /** undici Dispatcher (the same ProxyAgent the plugin's routing uses, in production). */
63
+ dispatcher?: unknown
64
+ /** Injectable token-endpoint call, defaulting to undici's fetch. */
65
+ fetchImpl?: RefreshFetch
66
+ /** Injectable clock, defaulting to Date.now. */
67
+ now?: () => number
68
+ }
69
+
70
+ /** The subset of auth.json this module reads and rewrites. */
71
+ interface AuthFileShape {
72
+ tokens?: {
73
+ id_token?: string
74
+ access_token?: string
75
+ refresh_token?: string
76
+ account_id?: string
77
+ }
78
+ last_refresh?: string
79
+ [key: string]: unknown
80
+ }
81
+
82
+ export class TokenStore {
83
+ #authFile: string
84
+ readonly #refreshMarginMs: number
85
+ readonly #now: () => number
86
+ #dispatcher: unknown
87
+ readonly #fetch: RefreshFetch
88
+ /** The in-flight exchange, shared by every caller that arrives during it. */
89
+ #inFlight: Promise<CodexTokens> | undefined
90
+
91
+ constructor(options: TokenStoreOptions) {
92
+ this.#authFile = options.authFile
93
+ this.#refreshMarginMs = options.refreshMarginMs
94
+ this.#now = options.now ?? (() => Date.now())
95
+ this.#dispatcher = options.dispatcher
96
+ // The same cast index.ts uses for undici's `request`: the real signature is
97
+ // wider than the seam, and the seam is what the tests stub.
98
+ this.#fetch = options.fetchImpl ?? (undiciFetch as unknown as RefreshFetch)
99
+ }
100
+
101
+ /**
102
+ * Return usable credentials, refreshing first when the access token is
103
+ * inside the configured margin.
104
+ *
105
+ * @returns credentials valid at the moment of the call.
106
+ */
107
+ async getTokens(): Promise<CodexTokens> {
108
+ const file = await this.#read()
109
+ const tokens = this.#extract(file)
110
+ const expiry = jwtExpiryMs(tokens.accessToken)
111
+ if (expiry !== undefined && expiry - this.#now() < this.#refreshMarginMs) {
112
+ return await this.#refresh(file, tokens)
113
+ }
114
+ return tokens
115
+ }
116
+
117
+ /**
118
+ * Report the current access token's expiry without touching the network.
119
+ *
120
+ * Reads and parses the file only -- no refresh, no matter how close to the
121
+ * margin the token is -- so the status endpoint can show when the next
122
+ * automatic refresh will happen without triggering it.
123
+ *
124
+ * @returns file-derived facts about the stored credentials.
125
+ */
126
+ async inspect(): Promise<TokenInspection> {
127
+ const tokens = this.#extract(await this.#read())
128
+ const expiry = jwtExpiryMs(tokens.accessToken)
129
+ return expiry === undefined ? {} : { accessTokenExpiresAt: expiry }
130
+ }
131
+
132
+ /**
133
+ * Point later reads at a different credential file, as a page-side switch
134
+ * does. The caller validates the new path before switching.
135
+ *
136
+ * @param authFile - the replacement credential file's path.
137
+ */
138
+ setAuthFile(authFile: string): void {
139
+ this.#authFile = authFile
140
+ }
141
+
142
+ /**
143
+ * Point later refreshes at a different dispatcher, as a proxy switch does.
144
+ *
145
+ * @param dispatcher - the dispatcher future refresh calls use; undefined for the global.
146
+ */
147
+ setDispatcher(dispatcher: unknown): void {
148
+ this.#dispatcher = dispatcher
149
+ }
150
+
151
+ /**
152
+ * Check that the credential file is readable and carries the fields the shim
153
+ * needs, without spending a refresh on it.
154
+ *
155
+ * Called at plugin start so a missing or unparseable auth.json fails there
156
+ * naming the path, as the spec's error table requires, instead of surfacing
157
+ * on the first request. Deliberately does not call getTokens(): that would
158
+ * put a network refresh on the startup path.
159
+ */
160
+ async verify(): Promise<void> {
161
+ this.#extract(await this.#read())
162
+ }
163
+
164
+ /** Read and parse auth.json, naming the path in every failure. */
165
+ async #read(): Promise<AuthFileShape> {
166
+ let raw: string
167
+ try {
168
+ raw = await readFile(this.#authFile, 'utf8')
169
+ } catch (error) {
170
+ throw new Error(`dsh-gpt-sub: cannot read Codex credentials at ${this.#authFile}`, { cause: error })
171
+ }
172
+ try {
173
+ return JSON.parse(raw) as AuthFileShape
174
+ } catch (error) {
175
+ throw new Error(`dsh-gpt-sub: cannot parse Codex credentials at ${this.#authFile}`, { cause: error })
176
+ }
177
+ }
178
+
179
+ /** Pull the required fields, naming whichever one is absent. */
180
+ #extract(file: AuthFileShape): CodexTokens {
181
+ const accessToken = file.tokens?.access_token
182
+ const refreshToken = file.tokens?.refresh_token
183
+ const accountId = file.tokens?.account_id
184
+ if (accessToken === undefined || refreshToken === undefined || accountId === undefined) {
185
+ throw new Error(
186
+ `dsh-gpt-sub: ${this.#authFile} is missing tokens.access_token, tokens.refresh_token, or tokens.account_id; run 'codex' to sign in again`,
187
+ )
188
+ }
189
+ return { accessToken, refreshToken, accountId }
190
+ }
191
+
192
+ /**
193
+ * Refresh regardless of remaining lifetime. Used when the upstream rejects a
194
+ * token that local expiry math believed was still good.
195
+ *
196
+ * @returns freshly issued credentials.
197
+ */
198
+ async forceRefresh(): Promise<CodexTokens> {
199
+ const file = await this.#read()
200
+ return await this.#refresh(file, this.#extract(file))
201
+ }
202
+
203
+ /**
204
+ * Exchange the refresh token, coalescing callers that arrive together.
205
+ *
206
+ * The refresh token is single-use and rotates, so two concurrent exchanges
207
+ * would spend the same token: the loser gets `invalid_grant` and tells the
208
+ * user to sign in again in the middle of entirely normal operation. DSH
209
+ * issues parallel LLM calls, so that is reachable, not theoretical.
210
+ *
211
+ * Only concurrent callers share a result. Once the exchange settles the slot
212
+ * is cleared, so a later refresh performs a new one.
213
+ *
214
+ * @param file - the parsed auth.json, whose unrelated fields are preserved.
215
+ * @param tokens - the credentials whose refresh token is being spent.
216
+ * @returns the newly issued credentials.
217
+ */
218
+ async #refresh(file: AuthFileShape, tokens: CodexTokens): Promise<CodexTokens> {
219
+ this.#inFlight ??= this.#exchange(file, tokens).finally(() => {
220
+ this.#inFlight = undefined
221
+ })
222
+ return await this.#inFlight
223
+ }
224
+
225
+ /**
226
+ * Perform one token exchange and persist the result.
227
+ *
228
+ * @param file - the parsed auth.json, whose unrelated fields are preserved.
229
+ * @param tokens - the credentials whose refresh token is being spent.
230
+ * @returns the newly issued credentials.
231
+ */
232
+ async #exchange(file: AuthFileShape, tokens: CodexTokens): Promise<CodexTokens> {
233
+ let response: RefreshResponse
234
+ try {
235
+ response = await this.#fetch(TOKEN_URL, {
236
+ method: 'POST',
237
+ headers: { 'content-type': 'application/json' },
238
+ body: JSON.stringify({
239
+ client_id: CLIENT_ID,
240
+ grant_type: 'refresh_token',
241
+ refresh_token: tokens.refreshToken,
242
+ }),
243
+ ...(this.#dispatcher === undefined ? {} : { dispatcher: this.#dispatcher }),
244
+ })
245
+ } catch (error) {
246
+ // A transport failure here is almost always the proxy, not the account.
247
+ // Say so, and still name the recovery the spec asks for.
248
+ throw new Error(
249
+ `dsh-gpt-sub: cannot reach the Codex token endpoint at ${TOKEN_URL}; check the plugin's proxyUrl, then run 'codex' to sign in again`,
250
+ { cause: error },
251
+ )
252
+ }
253
+ if (!response.ok) {
254
+ const detail = await response.text()
255
+ throw new Error(
256
+ `dsh-gpt-sub: refreshing the Codex token failed (${response.status}: ${detail}); run 'codex' to sign in again`,
257
+ )
258
+ }
259
+ const payload = (await response.json()) as {
260
+ access_token?: string
261
+ refresh_token?: string
262
+ id_token?: string
263
+ }
264
+ if (payload.access_token === undefined) {
265
+ throw new Error(`dsh-gpt-sub: the token endpoint returned no access_token; run 'codex' to sign in again`)
266
+ }
267
+ const refreshed: CodexTokens = {
268
+ accessToken: payload.access_token,
269
+ // A rotated refresh token must be kept; reusing a spent one fails next time.
270
+ refreshToken: payload.refresh_token ?? tokens.refreshToken,
271
+ accountId: tokens.accountId,
272
+ }
273
+ await this.#persist(file, refreshed, payload.id_token)
274
+ return refreshed
275
+ }
276
+
277
+ /**
278
+ * Write the credential file atomically so a concurrent Codex CLI read never
279
+ * observes a partial file. Fields this plugin does not own are preserved.
280
+ *
281
+ * @param file - the previously parsed file contents.
282
+ * @param tokens - the credentials to store.
283
+ * @param idToken - a refreshed id_token when the endpoint returned one.
284
+ */
285
+ async #persist(file: AuthFileShape, tokens: CodexTokens, idToken: string | undefined): Promise<void> {
286
+ const next: AuthFileShape = {
287
+ ...file,
288
+ tokens: {
289
+ ...file.tokens,
290
+ ...(idToken === undefined ? {} : { id_token: idToken }),
291
+ access_token: tokens.accessToken,
292
+ refresh_token: tokens.refreshToken,
293
+ account_id: tokens.accountId,
294
+ },
295
+ last_refresh: new Date(this.#now()).toISOString(),
296
+ }
297
+ const temporary = `${this.#authFile}.tmp-${String(process.pid)}`
298
+ // For as long as it exists this file holds live credentials, so create it
299
+ // private to the user, and never leave one on disk if the rename that
300
+ // should have consumed it fails.
301
+ await writeFile(temporary, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 })
302
+ try {
303
+ await rename(temporary, this.#authFile)
304
+ } catch (error) {
305
+ await rm(temporary, { force: true })
306
+ throw error
307
+ }
308
+ }
309
+ }
package/src/types.ts ADDED
@@ -0,0 +1,17 @@
1
+ /** Plugin configuration shape, validated by the schemastery schema in index.ts. */
2
+ export interface Config {
3
+ /** Proxy URL for reaching the token endpoint, e.g. http://127.0.0.1:7890; empty means direct. */
4
+ proxyUrl: string
5
+ /** Path to the Codex CLI credential file. */
6
+ authFile: string
7
+ /** Refresh the access token when less than this many minutes remain. */
8
+ refreshMarginMinutes: number
9
+ /** Credential the provider route names, and this plugin keeps populated. */
10
+ tokenRef: string
11
+ /** How often to re-read the credential file and republish the token. */
12
+ syncIntervalMinutes: number
13
+ /** Extra attempts per request after a dropped connection; 0 disables retry. */
14
+ bootstrapRetries: number
15
+ /** Where the panel's runtime overrides (proxy, credential file) are persisted. */
16
+ stateFile: string
17
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Subscription usage lookup for the Codex account.
3
+ *
4
+ * One GET against the same host the model calls use, authorized with the same
5
+ * access token. Used at startup as a reachability probe -- an unproxied egress
6
+ * answers 403 with a Cloudflare block page rather than an API error, and that
7
+ * failure is otherwise invisible until the first model call.
8
+ *
9
+ * @module dsh-gpt-sub/usage
10
+ */
11
+
12
+ import { fetch as undiciFetch, type Dispatcher } from 'undici'
13
+
14
+ /** One rate-limit window as the usage endpoint reports it. */
15
+ export interface UsageWindow {
16
+ used_percent: number
17
+ limit_window_seconds: number
18
+ reset_after_seconds?: number
19
+ reset_at?: number
20
+ }
21
+
22
+ /** The subset of the usage reply this plugin reads. */
23
+ export interface Usage {
24
+ plan_type?: string
25
+ rate_limit?: {
26
+ primary_window?: UsageWindow | null
27
+ secondary_window?: UsageWindow | null
28
+ }
29
+ /** On-demand rate-limit resets the plan grants, when it reports them. */
30
+ rate_limit_reset_credits?: {
31
+ /** Resets the account owns and can still spend. */
32
+ available_count?: number
33
+ /** Resets applicable to the current windows -- zero while none is capped. */
34
+ applicable_available_count?: number
35
+ } | null
36
+ }
37
+
38
+ /** Thrown when the usage endpoint answers with something other than JSON usage. */
39
+ export class UsageError extends Error {}
40
+
41
+ /** The usage endpoint, on the same host as the model calls. */
42
+ const USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage'
43
+
44
+ /**
45
+ * Read the account's current usage.
46
+ *
47
+ * @param accessToken - a live Codex access token.
48
+ * @param dispatcher - undici dispatcher; omit to use the global one.
49
+ * @returns the parsed usage reply.
50
+ * @throws UsageError when the endpoint refuses, or answers with a non-JSON
51
+ * body -- which is what an unproxied egress produces, and the message says so.
52
+ */
53
+ export async function fetchUsage(
54
+ accessToken: string,
55
+ dispatcher?: Dispatcher,
56
+ fetchImpl: typeof undiciFetch = undiciFetch,
57
+ signal?: AbortSignal,
58
+ ): Promise<Usage> {
59
+ const response = await fetchImpl(USAGE_URL, {
60
+ headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json' },
61
+ ...(dispatcher === undefined ? {} : { dispatcher }),
62
+ ...(signal === undefined ? {} : { signal }),
63
+ })
64
+
65
+ const body = await response.text()
66
+ if (!response.ok) {
67
+ const blocked = body.includes('Unable to load site') || body.includes('blocked-icon')
68
+ throw new UsageError(
69
+ blocked
70
+ ? `usage endpoint answered ${String(response.status)} with a Cloudflare block page; this address cannot reach chatgpt.com, so the request did not egress through the proxy`
71
+ : `usage endpoint answered ${String(response.status)}`,
72
+ )
73
+ }
74
+
75
+ try {
76
+ return JSON.parse(body) as Usage
77
+ } catch {
78
+ throw new UsageError('usage endpoint did not answer with JSON')
79
+ }
80
+ }
81
+
82
+ /**
83
+ * List every window the reply carries, primary slot first.
84
+ *
85
+ * Accounts differ here -- a plus account reports `secondary_window: null` and
86
+ * carries its weekly allowance on the primary, and a pro account currently
87
+ * reports only a weekly window with no 5-hour rolling limit at all -- so
88
+ * reading one chosen slot hides the 5-hour window on the accounts that have
89
+ * one. The panel renders whichever windows exist.
90
+ *
91
+ * @param usage - a usage reply.
92
+ * @returns the windows worth showing, primary slot first; empty when the reply
93
+ * carries none.
94
+ */
95
+ export function reportedWindows(usage: Usage): UsageWindow[] {
96
+ const primary = usage.rate_limit?.primary_window
97
+ const secondary = usage.rate_limit?.secondary_window
98
+ return [primary, secondary].filter((window): window is UsageWindow => window != null)
99
+ }
100
+
101
+ /**
102
+ * Count the on-demand usage resets the account can still spend -- what the
103
+ * usage endpoint reports as `rate_limit_reset_credits`, each one clearing a
104
+ * capped window without waiting out its timer.
105
+ *
106
+ * @param usage - a usage reply.
107
+ * @returns the resets remaining, or undefined when the reply carries none --
108
+ * which plans without the feature answer.
109
+ */
110
+ export function resetsRemaining(usage: Usage): number | undefined {
111
+ const count = usage.rate_limit_reset_credits?.available_count
112
+ return typeof count === 'number' ? count : undefined
113
+ }
114
+
115
+ /** A rate-limit window normalized for per-window rendering. */
116
+ export interface ReportedWindow {
117
+ /** Percent of this window consumed. */
118
+ usedPercent: number
119
+ /** Length of the window, in hours. */
120
+ windowHours: number
121
+ /** Epoch seconds at which the window resets. */
122
+ resetAt?: number
123
+ }
124
+
125
+ /**
126
+ * Normalize one raw window for the panel.
127
+ *
128
+ * @param window - the window as the usage endpoint reports it.
129
+ * @returns the normalized window.
130
+ */
131
+ const toReported = (window: UsageWindow): ReportedWindow => ({
132
+ usedPercent: window.used_percent,
133
+ windowHours: Math.round(window.limit_window_seconds / 3600),
134
+ ...(window.reset_at === undefined ? {} : { resetAt: window.reset_at }),
135
+ })
136
+
137
+ /**
138
+ * Every window the reply reports, primary first.
139
+ *
140
+ * The panel renders one row per window -- the 5-hour and the weekly allowance
141
+ * on accounts that report both -- so it needs all of them, not the single one
142
+ * {@link activeWindow} picks for the startup log. Window identity comes from
143
+ * the reported length, because which slot an account uses differs.
144
+ *
145
+ * @param usage - a usage reply.
146
+ * @returns the reported windows, primary before secondary.
147
+ */
148
+ export function reportWindows(usage: Usage): ReportedWindow[] {
149
+ const primary = usage.rate_limit?.primary_window
150
+ const secondary = usage.rate_limit?.secondary_window
151
+ return [primary, secondary]
152
+ .filter((window): window is UsageWindow => window != null)
153
+ .map(toReported)
154
+ }