@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
|
@@ -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
|
+
}
|