@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
package/src/jwt.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Minimal JWT claim reader. Only the `exp` claim is needed, and signature
3
+ * verification is deliberately absent: these tokens are read from the user's
4
+ * own credential file, never accepted from a remote party.
5
+ *
6
+ * @module dsh-gpt-sub/jwt
7
+ */
8
+
9
+ /**
10
+ * Read a JWT's expiry.
11
+ *
12
+ * @param token - a candidate JWT; opaque strings are tolerated.
13
+ * @returns expiry in epoch milliseconds, or undefined when absent or undecodable.
14
+ */
15
+ export function jwtExpiryMs(token: string): number | undefined {
16
+ const payload = token.split('.')[1]
17
+ if (payload === undefined) return undefined
18
+ try {
19
+ const claims: unknown = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))
20
+ if (typeof claims !== 'object' || claims === null) return undefined
21
+ const exp = (claims as { exp?: unknown }).exp
22
+ return typeof exp === 'number' ? exp * 1000 : undefined
23
+ } catch {
24
+ return undefined
25
+ }
26
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Runtime proxy configuration: request-body reading, URL validation, and the
3
+ * persisted override the settings panel writes.
4
+ *
5
+ * The override file exists because cordis config layers are the loader's
6
+ * property -- a panel edit cannot rewrite them. The file holds the panel's
7
+ * last saved choice; when present, it wins over the config's proxyUrl so a
8
+ * page edit survives restarts.
9
+ *
10
+ * @module dsh-gpt-sub/proxy-config
11
+ */
12
+
13
+ import type { IncomingMessage } from 'node:http'
14
+ import { readFile, rename, rm, writeFile } from 'node:fs/promises'
15
+
16
+ /** Bodies larger than this are refused rather than buffered. */
17
+ const BODY_LIMIT_BYTES = 65_536
18
+
19
+ /** Read one header, tolerating the array form node uses for repeated headers. */
20
+ function header(request: IncomingMessage, name: string): string | undefined {
21
+ const value = request.headers?.[name]
22
+ if (Array.isArray(value)) return value[0]
23
+ return value
24
+ }
25
+
26
+ /**
27
+ * Validate a caller-supplied proxy URL.
28
+ *
29
+ * @param value - the candidate value from a request body.
30
+ * @returns the trimmed URL, or the empty string for a direct connection.
31
+ * @throws Error when the value is not a string or not an http(s) URL.
32
+ */
33
+ export function validateProxyUrl(value: unknown): string {
34
+ if (typeof value !== 'string') throw new Error('proxyUrl must be a string')
35
+ const trimmed = value.trim()
36
+ if (trimmed === '') return ''
37
+ let parsed: URL
38
+ try {
39
+ parsed = new URL(trimmed)
40
+ } catch {
41
+ throw new Error(`proxyUrl is not a valid URL: ${trimmed}`)
42
+ }
43
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
44
+ throw new Error(`proxyUrl must be http or https, got ${parsed.protocol}`)
45
+ }
46
+ return trimmed
47
+ }
48
+
49
+ /**
50
+ * Mask any userinfo credentials in a proxy URL, for logging.
51
+ *
52
+ * The URL itself is worth logging; the password inside it is not. A URL
53
+ * without credentials is returned exactly as given.
54
+ *
55
+ * @param url - the proxy URL as configured.
56
+ * @returns the URL with its userinfo, if any, replaced by `***`.
57
+ */
58
+ export function redactProxyUrl(url: string): string {
59
+ if (url === '') return ''
60
+ const scheme = /^https?:\/\//.exec(url)
61
+ if (scheme === null) return url
62
+ const rest = url.slice(scheme[0].length)
63
+ const credentialsEnd = rest.indexOf('@')
64
+ if (credentialsEnd === -1) return url
65
+ // Replace the userinfo by string surgery rather than URL re-serialisation,
66
+ // so everything after the `@` stays byte-identical to what was configured.
67
+ return `${scheme[0]}***@${rest.slice(credentialsEnd + 1)}`
68
+ }
69
+
70
+ /**
71
+ * Whether a request's Origin header names an origin other than the host the
72
+ * request was addressed to.
73
+ *
74
+ * The mutating routes must refuse such requests: a page on another origin can
75
+ * otherwise make the harness reroute its credentials through a proxy of the
76
+ * page's choosing. An absent Origin passes -- browsers always set one on a
77
+ * POST, so absence means a non-browser client such as curl.
78
+ *
79
+ * @param request - the incoming request.
80
+ * @returns true when Origin is present and names another host.
81
+ */
82
+ export function crossOrigin(request: IncomingMessage): boolean {
83
+ const origin = header(request, 'origin')
84
+ if (origin === undefined || origin === '') return false
85
+ const host = header(request, 'host')
86
+ if (host === undefined || host === '') return true
87
+ try {
88
+ // Comparing through URL normalises default ports away on both sides.
89
+ return new URL(origin).host !== new URL(`http://${host}`).host
90
+ } catch {
91
+ // An unparsable Origin (including the literal "null") vouches for nothing.
92
+ return true
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Read one JSON object request body.
98
+ *
99
+ * A body that declares a content-type must declare JSON: browsers refuse to
100
+ * send `application/json` cross-origin without a preflight, so the check
101
+ * turns every form a forged cross-site POST can take into a refusal. An
102
+ * absent content-type passes for non-browser clients that send none.
103
+ *
104
+ * @param request - the incoming request whose body to consume.
105
+ * @param limitBytes - the maximum body size accepted.
106
+ * @returns the parsed object.
107
+ * @throws Error when the body is oversized, unparseable, not an object, or
108
+ * not JSON-content-typed.
109
+ */
110
+ export async function readJsonObject(
111
+ request: IncomingMessage,
112
+ limitBytes = BODY_LIMIT_BYTES,
113
+ ): Promise<Record<string, unknown>> {
114
+ const contentType = header(request, 'content-type')
115
+ if (contentType !== undefined) {
116
+ const mediaType = contentType.split(';')[0]?.trim().toLowerCase() ?? ''
117
+ if (mediaType !== 'application/json') {
118
+ throw new Error(`request body content-type must be application/json, got "${mediaType}"`)
119
+ }
120
+ }
121
+ const chunks: Buffer[] = []
122
+ let size = 0
123
+ for await (const chunk of request) {
124
+ const piece = typeof chunk === 'string' ? Buffer.from(chunk) : (chunk as Buffer)
125
+ size += piece.length
126
+ if (size > limitBytes) throw new Error('request body too large')
127
+ chunks.push(piece)
128
+ }
129
+ let parsed: unknown
130
+ try {
131
+ parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
132
+ } catch {
133
+ throw new Error('request body is not valid JSON')
134
+ }
135
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
136
+ throw new Error('request body must be a JSON object')
137
+ }
138
+ return parsed as Record<string, unknown>
139
+ }
140
+
141
+ /** The state file's on-disk shape; each field stands alone. */
142
+ export interface StateOverride {
143
+ /** The saved proxy URL; empty string means direct. */
144
+ proxyUrl?: string
145
+ /** The saved credential-file path, exactly as typed on the page. */
146
+ authFile?: string
147
+ }
148
+
149
+ /**
150
+ * Load the persisted panel overrides.
151
+ *
152
+ * An absent file simply means "no override". A corrupt file is ignored too:
153
+ * the panel that wrote it may have died mid-write, and failing the whole
154
+ * plugin over a status file would trade working settings for a stale byte.
155
+ *
156
+ * Each known field validates on its own, so one invalid value drops without
157
+ * discarding the other choice the panel made.
158
+ *
159
+ * @param path - the state file's path.
160
+ * @returns the saved choices, or undefined when there is no readable file.
161
+ */
162
+ export async function loadStateOverride(path: string): Promise<StateOverride | undefined> {
163
+ let raw: string
164
+ try {
165
+ raw = await readFile(path, 'utf8')
166
+ } catch {
167
+ return undefined
168
+ }
169
+ let parsed: unknown
170
+ try {
171
+ parsed = JSON.parse(raw)
172
+ } catch {
173
+ return undefined
174
+ }
175
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
176
+ const document = parsed as Record<string, unknown>
177
+
178
+ const state: StateOverride = {}
179
+ const proxyUrl = document['proxyUrl']
180
+ if (typeof proxyUrl === 'string') {
181
+ try {
182
+ state.proxyUrl = validateProxyUrl(proxyUrl)
183
+ } catch {
184
+ // A stale invalid proxy falls back to the config rather than
185
+ // invalidating the whole file.
186
+ }
187
+ }
188
+ const authFile = document['authFile']
189
+ if (typeof authFile === 'string' && authFile.trim() !== '') {
190
+ state.authFile = authFile.trim()
191
+ }
192
+ return state
193
+ }
194
+
195
+ /**
196
+ * Persist the panel's choices atomically, so a crash never leaves a half write.
197
+ *
198
+ * The object is written exactly as given; callers read-modify-write so saving
199
+ * one choice never erases the other.
200
+ *
201
+ * @param path - the state file's path.
202
+ * @param state - the choices to persist; absent fields mean "use the config".
203
+ * @returns nothing; failures propagate to the caller.
204
+ */
205
+ export async function saveStateOverride(path: string, state: StateOverride): Promise<void> {
206
+ const temporary = `${path}.tmp-${String(process.pid)}`
207
+ // The file can hold a proxy URL with embedded credentials and a private
208
+ // filesystem layout, so create it private to the user, the same way
209
+ // token-store writes auth.json.
210
+ await writeFile(temporary, JSON.stringify(state, null, 2), { encoding: 'utf8', mode: 0o600 })
211
+ try {
212
+ await rename(temporary, path)
213
+ } catch (error) {
214
+ await rm(temporary, { force: true })
215
+ throw error
216
+ }
217
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Connectivity probe for a candidate proxy: one real usage read through a
3
+ * throwaway ProxyAgent, timed, answering ok/latency or the failure text.
4
+ *
5
+ * This is the host half of the panel's "test connection" button. It exercises
6
+ * the exact path production traffic takes -- same host, same endpoint, same
7
+ * dispatcher kind -- so a green result means model calls will connect, not
8
+ * merely that the proxy's TCP port answers.
9
+ *
10
+ * @module dsh-gpt-sub/proxy-probe
11
+ */
12
+
13
+ import { ProxyAgent, fetch as undiciFetch } from 'undici'
14
+ import { fetchUsage } from './usage.ts'
15
+
16
+ /** Default ceiling on one probe; a proxy that cannot answer this is broken. */
17
+ const DEFAULT_TIMEOUT_MS = 15_000
18
+
19
+ /** What the probe answers. */
20
+ export interface ProxyProbeResult {
21
+ /** True when the usage endpoint answered through the candidate. */
22
+ ok: boolean
23
+ /** Round-trip milliseconds; present on success. */
24
+ latencyMs?: number
25
+ /** Subscription plan reported upstream; present on success when known. */
26
+ plan?: string
27
+ /** Failure text; present when ok is false. */
28
+ message?: string
29
+ }
30
+
31
+ /** Construction options; the injectable seams exist for tests. */
32
+ export interface ProxyProbeOptions {
33
+ /** Candidate proxy URL; empty string probes a direct connection. */
34
+ readonly proxyUrl: string
35
+ /** A live access token for the usage call. */
36
+ readonly accessToken: string
37
+ /** Per-probe timeout; defaults to {@link DEFAULT_TIMEOUT_MS}. */
38
+ readonly timeoutMs?: number
39
+ /** Injectable fetch, defaulting to undici's. */
40
+ readonly fetchImpl?: typeof undiciFetch
41
+ /** Injectable clock, defaulting to Date.now. */
42
+ readonly now?: () => number
43
+ }
44
+
45
+ /**
46
+ * Probe one candidate proxy with a single usage read.
47
+ *
48
+ * The probe's ProxyAgent is private: it is created here and closed here, so a
49
+ * probe can never leak dispatcher state into the live routing.
50
+ *
51
+ * @param options - the candidate and the credentials to read with.
52
+ * @returns the probe outcome, never a rejection.
53
+ */
54
+ export async function probeProxy(options: ProxyProbeOptions): Promise<ProxyProbeResult> {
55
+ const { proxyUrl, accessToken } = options
56
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
57
+ const now = options.now ?? (() => Date.now())
58
+ const agent = proxyUrl === '' ? undefined : new ProxyAgent(proxyUrl)
59
+ const started = now()
60
+ try {
61
+ const usage = await fetchUsage(
62
+ accessToken,
63
+ agent,
64
+ options.fetchImpl,
65
+ AbortSignal.timeout(timeoutMs),
66
+ )
67
+ const latencyMs = now() - started
68
+ return {
69
+ ok: true,
70
+ latencyMs,
71
+ ...(usage.plan_type === undefined ? {} : { plan: usage.plan_type }),
72
+ }
73
+ } catch (error) {
74
+ return {
75
+ ok: false,
76
+ message: error instanceof Error ? error.message : String(error),
77
+ }
78
+ } finally {
79
+ if (agent !== undefined) await agent.close().catch(() => undefined)
80
+ }
81
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Host-scoped proxy routing for the process-wide undici dispatcher.
3
+ *
4
+ * pi-ai issues its own requests through the global `fetch`, so this plugin
5
+ * cannot hand it a dispatcher the way it hands one to `TokenStore`. The only
6
+ * seam is undici's global dispatcher -- but replacing that wholesale would
7
+ * push every provider in the harness through the proxy, including endpoints
8
+ * that must not go near it.
9
+ *
10
+ * So route by hostname: the Codex hosts go through the proxy, everything else
11
+ * continues to whatever dispatcher was installed before. Reaching
12
+ * `chatgpt.com` from an unproxied address here answers 403 with a Cloudflare
13
+ * block page, which surfaces as an unreadable HTML body rather than an API
14
+ * error, so this is the difference between working and not.
15
+ *
16
+ * @module dsh-gpt-sub/proxy-routing
17
+ */
18
+
19
+ import { Dispatcher, ProxyAgent, getGlobalDispatcher, interceptors, setGlobalDispatcher } from 'undici'
20
+
21
+ /**
22
+ * Transport failures worth retrying: the link to this proxy drops connections
23
+ * at random, and each drop surfaces to DSH as a bare `fetch failed`.
24
+ *
25
+ * Measured direct to chatgpt.com through the same proxy, the failure is
26
+ * non-monotonic in payload size, so it is a random connection failure rather
27
+ * than a threshold -- which is exactly the shape a retry fixes.
28
+ */
29
+ const RETRYABLE_ERROR_CODES = [
30
+ 'ECONNRESET',
31
+ 'ECONNREFUSED',
32
+ 'ENOTFOUND',
33
+ 'ENETDOWN',
34
+ 'ENETUNREACH',
35
+ 'EHOSTDOWN',
36
+ 'EHOSTUNREACH',
37
+ 'EPIPE',
38
+ 'ETIMEDOUT',
39
+ 'UND_ERR_SOCKET',
40
+ 'UND_ERR_CONNECT_TIMEOUT',
41
+ ]
42
+
43
+ /**
44
+ * Hosts that must egress through the proxy: the Codex API and the OAuth token
45
+ * endpoint the refresh calls. Subdomains match too.
46
+ */
47
+ export const PROXIED_HOSTS: readonly string[] = ['chatgpt.com', 'auth.openai.com', 'api.openai.com']
48
+
49
+ /**
50
+ * Whether a hostname belongs to one of `hosts`, matching the host itself and
51
+ * any subdomain of it.
52
+ *
53
+ * @param hostname - the hostname to test.
54
+ * @param hosts - the suffixes to match against.
55
+ * @returns true when the hostname should be proxied.
56
+ */
57
+ export function shouldProxy(hostname: string, hosts: readonly string[] = PROXIED_HOSTS): boolean {
58
+ const lower = hostname.toLowerCase()
59
+ return hosts.some((host) => lower === host || lower.endsWith(`.${host}`))
60
+ }
61
+
62
+ /**
63
+ * A dispatcher that sends matching hosts to one delegate and everything else
64
+ * to another.
65
+ *
66
+ * Only the proxy delegate is owned: `close`/`destroy` never touch the
67
+ * fallback, because that dispatcher belongs to the host and outlives this
68
+ * plugin.
69
+ */
70
+ export class HostRoutingDispatcher extends Dispatcher {
71
+ readonly #proxy: Dispatcher
72
+ readonly #fallback: Dispatcher
73
+ readonly #hosts: readonly string[]
74
+
75
+ constructor(proxy: Dispatcher, fallback: Dispatcher, hosts: readonly string[] = PROXIED_HOSTS) {
76
+ super()
77
+ this.#proxy = proxy
78
+ this.#fallback = fallback
79
+ this.#hosts = hosts
80
+ }
81
+
82
+ /**
83
+ * Route one request by its origin's hostname.
84
+ *
85
+ * An origin that cannot be parsed is sent to the fallback: defaulting to the
86
+ * proxy would silently divert unrelated traffic.
87
+ *
88
+ * @param options - undici dispatch options.
89
+ * @param handler - undici dispatch handler.
90
+ * @returns whatever the chosen delegate returns.
91
+ */
92
+ override dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
93
+ let hostname = ''
94
+ try {
95
+ const origin = options.origin
96
+ hostname = new URL(typeof origin === 'string' ? origin : String(origin?.href ?? '')).hostname
97
+ } catch {
98
+ hostname = ''
99
+ }
100
+ const delegate = hostname !== '' && shouldProxy(hostname, this.#hosts) ? this.#proxy : this.#fallback
101
+ return delegate.dispatch(options, handler)
102
+ }
103
+
104
+ /** Close only the proxy delegate; the fallback belongs to the host. */
105
+ override async close(): Promise<void> {
106
+ await this.#proxy.close()
107
+ }
108
+
109
+ /** Destroy only the proxy delegate; the fallback belongs to the host. */
110
+ override async destroy(): Promise<void> {
111
+ await this.#proxy.destroy()
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Retry options that absorb a dropped connection without taking on any
117
+ * HTTP-level retry policy.
118
+ *
119
+ * `statusCodes: []` is deliberate: an upstream 429 or 500 carries a body DSH
120
+ * knows how to read and surface, and retrying here would swallow it. This
121
+ * dispatcher's job is narrow -- make the flaky link look reliable -- and every
122
+ * HTTP semantic stays with the harness.
123
+ *
124
+ * `POST` is listed even though undici omits it by default, because undici
125
+ * omits it for non-idempotency: a POST that may have been received must not be
126
+ * replayed. That does not apply to what is retried here. A connection error
127
+ * arrives before any response, and undici refuses to replay a request whose
128
+ * body was already consumed; a partially streamed response is caught by
129
+ * undici's own range check rather than silently concatenated. Model calls are
130
+ * POSTs, so without this the retry would never fire at all.
131
+ *
132
+ * @param maxRetries - how many extra attempts a single request may make.
133
+ * @returns options for undici's retry interceptor.
134
+ */
135
+ export function connectionRetryOptions(maxRetries: number): {
136
+ maxRetries: number
137
+ methods: string[]
138
+ statusCodes: number[]
139
+ errorCodes: string[]
140
+ minTimeout: number
141
+ maxTimeout: number
142
+ timeoutFactor: number
143
+ } {
144
+ return {
145
+ maxRetries,
146
+ methods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE', 'POST'],
147
+ statusCodes: [],
148
+ errorCodes: [...RETRYABLE_ERROR_CODES],
149
+ // 0.5s, 1s, 2s -- fast enough that a recovered connection still feels like
150
+ // one request, slow enough to let a flapping tunnel settle.
151
+ minTimeout: 500,
152
+ maxTimeout: 8_000,
153
+ timeoutFactor: 2,
154
+ }
155
+ }
156
+
157
+ /** What {@link installProxyRouting} returns, so the caller can undo it. */
158
+ export interface ProxyRouting {
159
+ /** The proxy agent, for handing to components that take an explicit dispatcher. */
160
+ readonly dispatcher: Dispatcher
161
+ /** Restore the previous global dispatcher and close the proxy agent. */
162
+ readonly uninstall: () => Promise<void>
163
+ }
164
+
165
+ /** Options for {@link installProxyRouting}. */
166
+ export interface ProxyRoutingOptions {
167
+ /** Hostnames to proxy; defaults to {@link PROXIED_HOSTS}. */
168
+ readonly hosts?: readonly string[]
169
+ /** Extra attempts per request after a connection failure. 0 disables retry. */
170
+ readonly maxRetries?: number
171
+ }
172
+
173
+ /**
174
+ * Install host-scoped proxy routing as the global dispatcher.
175
+ *
176
+ * @param proxyUrl - the proxy to route the Codex hosts through.
177
+ * @param options - hosts to proxy and how hard to retry a dropped connection.
178
+ * @returns the proxy dispatcher and the function that restores the previous global.
179
+ */
180
+ export function installProxyRouting(proxyUrl: string, options: ProxyRoutingOptions = {}): ProxyRouting {
181
+ const hosts = options.hosts ?? PROXIED_HOSTS
182
+ const maxRetries = options.maxRetries ?? 3
183
+ const proxy = new ProxyAgent(proxyUrl)
184
+ // `compose` returns a proxy over the same agent with only `dispatch`
185
+ // replaced, so `close()` on the result still closes the agent underneath --
186
+ // and only requests routed here are retried. Nothing else in the harness
187
+ // changes behaviour.
188
+ const dispatcher =
189
+ maxRetries > 0 ? proxy.compose(interceptors.retry(connectionRetryOptions(maxRetries))) : proxy
190
+ const previous = getGlobalDispatcher()
191
+ setGlobalDispatcher(new HostRoutingDispatcher(dispatcher, previous, hosts))
192
+
193
+ return {
194
+ dispatcher,
195
+ uninstall: async () => {
196
+ // Restore first, so nothing dispatches into a closing agent.
197
+ setGlobalDispatcher(previous)
198
+ try {
199
+ await proxy.close()
200
+ } catch {
201
+ // Disposal must not hinge on the agent shutting down cleanly.
202
+ }
203
+ },
204
+ }
205
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The host half of the quota panel: one cached JSON endpoint the browser polls.
3
+ *
4
+ * Every quota semantic lives here rather than in the client bundle -- which
5
+ * windows count, how often upstream may be asked, what a failure looks like --
6
+ * so the browser half stays a renderer.
7
+ *
8
+ * @module dsh-gpt-sub/quota-route
9
+ */
10
+
11
+ import type { Dispatcher } from 'undici'
12
+ import { fetchUsage, reportWindows, reportedWindows, resetsRemaining, type ReportedWindow, type UsageWindow } from './usage.ts'
13
+
14
+ /** One rate-limit window as the endpoint serves it to the panel. */
15
+ export interface QuotaWindowState {
16
+ /** Percent of the window consumed. */
17
+ usedPercent: number
18
+ /** Length of the window, in hours. */
19
+ windowHours: number
20
+ /** Epoch seconds at which the window resets. */
21
+ resetAt?: number
22
+ }
23
+
24
+ /** What the endpoint serves. */
25
+ export interface QuotaState {
26
+ /** 'ready' once a reading has been taken, 'error' when none ever succeeded. */
27
+ phase: 'ready' | 'error'
28
+ /** Subscription plan, when upstream reported one. */
29
+ plan?: string
30
+ /**
31
+ * The short rolling window -- 5 hours on every plan that reports one. A
32
+ * plan without it (pro currently) simply omits the field, and the panel
33
+ * says so instead of drawing an empty bar.
34
+ */
35
+ fiveHour?: QuotaWindowState
36
+ /** The weekly window, alone or beside the 5-hour one. */
37
+ weekly?: QuotaWindowState
38
+ /**
39
+ * On-demand usage resets the account can still spend, when the plan
40
+ * reports them -- each clears a capped window without waiting out its
41
+ * timer.
42
+ */
43
+ resetsRemaining?: number
44
+ /** Every reported window, primary first; one panel row each. */
45
+ windows?: ReportedWindow[]
46
+ /** Epoch milliseconds this reading was taken. */
47
+ fetchedAt?: number
48
+ /** True when the reading is older than the refresh interval and a retry failed. */
49
+ stale?: boolean
50
+ /** Human-readable failure note; present on error, or beside a stale reading. */
51
+ message?: string
52
+ }
53
+
54
+ /** Windows shorter than a day are the 5-hour-style rolling limit. */
55
+ const DAY_SECONDS = 24 * 3600
56
+
57
+ /**
58
+ * Fold one upstream window into the shape the panel renders.
59
+ *
60
+ * @param window - a window as the usage endpoint reported it.
61
+ * @returns the same figures in the panel's vocabulary.
62
+ */
63
+ const toWindowState = (window: UsageWindow): QuotaWindowState => ({
64
+ usedPercent: window.used_percent,
65
+ windowHours: Math.round(window.limit_window_seconds / 3600),
66
+ ...(window.reset_at === undefined ? {} : { resetAt: window.reset_at }),
67
+ })
68
+
69
+ /** Construction options. */
70
+ export interface QuotaSourceOptions {
71
+ /** Returns a live access token. */
72
+ readonly accessToken: () => Promise<string>
73
+ /** Dispatcher for the upstream call. */
74
+ readonly dispatcher?: Dispatcher
75
+ /** Minimum gap between upstream reads; a poll inside it is served from cache. */
76
+ readonly minIntervalMs?: number
77
+ /** Injectable clock, defaulting to Date.now. */
78
+ readonly now?: () => number
79
+ }
80
+
81
+ /**
82
+ * A throttled quota reader.
83
+ *
84
+ * The browser polls far more often than the account's usage changes, so an
85
+ * unthrottled endpoint would turn one open settings page into a steady stream
86
+ * of upstream requests.
87
+ */
88
+ export class QuotaSource {
89
+ readonly #accessToken: () => Promise<string>
90
+ #dispatcher: Dispatcher | undefined
91
+ readonly #minIntervalMs: number
92
+ readonly #now: () => number
93
+ #state: QuotaState = { phase: 'error', message: 'not read yet' }
94
+ #lastAttempt = 0
95
+ /** The in-flight read, shared by every caller that arrives during it. */
96
+ #inFlight: Promise<QuotaState> | undefined
97
+
98
+ constructor(options: QuotaSourceOptions) {
99
+ this.#accessToken = options.accessToken
100
+ this.#dispatcher = options.dispatcher
101
+ this.#minIntervalMs = options.minIntervalMs ?? 60_000
102
+ this.#now = options.now ?? (() => Date.now())
103
+ }
104
+
105
+ /**
106
+ * Point later upstream reads at a different dispatcher, as a proxy switch does.
107
+ *
108
+ * @param dispatcher - the dispatcher future reads use; undefined for the global.
109
+ */
110
+ setDispatcher(dispatcher: Dispatcher | undefined): void {
111
+ this.#dispatcher = dispatcher
112
+ }
113
+
114
+ /**
115
+ * Return the current reading, refreshing when the throttle allows.
116
+ *
117
+ * @param force - ignore the throttle, as the panel's refresh button does.
118
+ * @returns the reading to serve.
119
+ */
120
+ async read(force = false): Promise<QuotaState> {
121
+ const elapsed = this.#now() - this.#lastAttempt
122
+ if (!force && this.#state.phase === 'ready' && elapsed < this.#minIntervalMs) return this.#state
123
+ this.#inFlight ??= this.#refresh().finally(() => {
124
+ this.#inFlight = undefined
125
+ })
126
+ return this.#inFlight
127
+ }
128
+
129
+ /**
130
+ * Take one upstream reading and fold it into the cached state.
131
+ *
132
+ * A failure never discards a previous good reading: the panel shows the last
133
+ * known figure marked stale, which is more useful than an empty panel.
134
+ *
135
+ * @returns the new state.
136
+ */
137
+ async #refresh(): Promise<QuotaState> {
138
+ this.#lastAttempt = this.#now()
139
+ try {
140
+ const usage = await fetchUsage(await this.#accessToken(), this.#dispatcher)
141
+ const reported = reportedWindows(usage)
142
+ const fiveHour = reported.find((window) => window.limit_window_seconds < DAY_SECONDS)
143
+ const weekly = reported.find((window) => window.limit_window_seconds >= DAY_SECONDS)
144
+ const resets = resetsRemaining(usage)
145
+ const windows = reportWindows(usage)
146
+ this.#state = {
147
+ phase: 'ready',
148
+ ...(usage.plan_type === undefined ? {} : { plan: usage.plan_type }),
149
+ ...(fiveHour === undefined ? {} : { fiveHour: toWindowState(fiveHour) }),
150
+ ...(weekly === undefined ? {} : { weekly: toWindowState(weekly) }),
151
+ ...(resets === undefined ? {} : { resetsRemaining: resets }),
152
+ ...(windows.length === 0 ? {} : { windows }),
153
+ }
154
+ } catch (error) {
155
+ const message = error instanceof Error ? error.message : String(error)
156
+ this.#state =
157
+ this.#state.phase === 'ready'
158
+ ? { ...this.#state, stale: true, message }
159
+ : { phase: 'error', message }
160
+ }
161
+ return this.#state
162
+ }
163
+ }