@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.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +268 -0
- package/client.js +908 -0
- package/cordis.patch.yml +44 -0
- package/lib/index.d.ts +52 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +581 -0
- package/lib/index.js.map +1 -0
- package/lib/jwt.d.ts +15 -0
- package/lib/jwt.d.ts.map +1 -0
- package/lib/jwt.js +29 -0
- package/lib/jwt.js.map +1 -0
- package/lib/proxy-config.d.ts +91 -0
- package/lib/proxy-config.d.ts.map +1 -0
- package/lib/proxy-config.js +213 -0
- package/lib/proxy-config.js.map +1 -0
- package/lib/proxy-probe.d.ts +47 -0
- package/lib/proxy-probe.d.ts.map +1 -0
- package/lib/proxy-probe.js +51 -0
- package/lib/proxy-probe.js.map +1 -0
- package/lib/proxy-routing.d.ts +111 -0
- package/lib/proxy-routing.d.ts.map +1 -0
- package/lib/proxy-routing.js +171 -0
- package/lib/proxy-routing.js.map +1 -0
- package/lib/quota-route.d.ts +85 -0
- package/lib/quota-route.d.ts.map +1 -0
- package/lib/quota-route.js +105 -0
- package/lib/quota-route.js.map +1 -0
- package/lib/reset-credits.d.ts +64 -0
- package/lib/reset-credits.d.ts.map +1 -0
- package/lib/reset-credits.js +84 -0
- package/lib/reset-credits.js.map +1 -0
- package/lib/token-store.d.ts +107 -0
- package/lib/token-store.d.ts.map +1 -0
- package/lib/token-store.js +228 -0
- package/lib/token-store.js.map +1 -0
- package/lib/types.d.ts +18 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/usage.d.ts +92 -0
- package/lib/usage.d.ts.map +1 -0
- package/lib/usage.js +106 -0
- package/lib/usage.js.map +1 -0
- package/package.json +82 -0
- package/src/index.ts +685 -0
- package/src/jwt.ts +26 -0
- package/src/proxy-config.ts +217 -0
- package/src/proxy-probe.ts +81 -0
- package/src/proxy-routing.ts +205 -0
- package/src/quota-route.ts +163 -0
- package/src/reset-credits.ts +128 -0
- package/src/token-store.ts +309 -0
- package/src/types.ts +17 -0
- 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
|
+
}
|