dsh-codex-community 0.0.1 → 0.0.2
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 +48 -0
- package/README.en.md +12 -10
- package/README.md +12 -10
- package/THIRD_PARTY_NOTICES.md +2 -0
- package/dist/client/index.js +487 -49
- package/dist/host/index.mjs +3 -0
- package/dist/internal/authorization-bridge.mjs +12 -0
- package/dist/internal/codex-account-usage.mjs +509 -0
- package/dist/internal/codex-model-capabilities.mjs +90 -0
- package/dist/internal/codex-pi-provider.mjs +30 -3
- package/dist/internal/codex-provider-runtime.mjs +27 -1
- package/dist/internal/codex-route-adapter.mjs +36 -3
- package/dist/internal/session-preference-bridge.mjs +150 -0
- package/docs/architecture.en.md +20 -11
- package/docs/architecture.md +20 -11
- package/docs/compatibility.en.md +13 -12
- package/docs/compatibility.md +13 -12
- package/docs/configuration.en.md +23 -1
- package/docs/configuration.md +23 -1
- package/docs/releases/v0.0.1.md +0 -2
- package/docs/releases/v0.0.2.acceptance.json +160 -0
- package/docs/releases/v0.0.2.md +56 -0
- package/docs/releasing.en.md +1 -1
- package/docs/releasing.md +1 -1
- package/docs/troubleshooting.en.md +19 -3
- package/docs/troubleshooting.md +19 -3
- package/package.json +11 -1
- package/types/client.d.ts +59 -0
package/dist/host/index.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { createQuotaObserver } from "../internal/quota-observer.mjs"
|
|
|
12
12
|
import { createReadImageUrlMiddleware } from "../internal/remote-image-input.mjs"
|
|
13
13
|
import { createCodexSessionResourceManager } from "../internal/codex-session-resources.mjs"
|
|
14
14
|
import { registerCodexSessionCommand } from "../internal/session-preference-command.mjs"
|
|
15
|
+
import { registerSessionPreferenceRpc } from "../internal/session-preference-bridge.mjs"
|
|
15
16
|
import { createSessionPreferences } from "../internal/session-preferences.mjs"
|
|
16
17
|
import { stabilizeCodexStream } from "../internal/stream-resilience.mjs"
|
|
17
18
|
|
|
@@ -72,9 +73,11 @@ export function apply(ctx, config = {}) {
|
|
|
72
73
|
|
|
73
74
|
ctx.inject(["connection"], (connectionCtx) => {
|
|
74
75
|
installAuthorizationRpc(connectionCtx, {
|
|
76
|
+
accountUsageReader: runtime.accountUsageReader,
|
|
75
77
|
commitTracker: authorizationCommitTracker,
|
|
76
78
|
quotaObserver,
|
|
77
79
|
})
|
|
80
|
+
registerSessionPreferenceRpc(connectionCtx, sessionPreferences)
|
|
78
81
|
})
|
|
79
82
|
ctx.inject(["commands"], (commandCtx) => {
|
|
80
83
|
registerCodexSessionCommand(commandCtx, sessionPreferences, {
|
|
@@ -45,6 +45,7 @@ export class CodexAuthorizationBridge {
|
|
|
45
45
|
#credentials
|
|
46
46
|
#commitTracker
|
|
47
47
|
#quotaObserver
|
|
48
|
+
#accountUsageReader
|
|
48
49
|
#attempts = new Map()
|
|
49
50
|
#closed = false
|
|
50
51
|
#retentionMs
|
|
@@ -55,6 +56,7 @@ export class CodexAuthorizationBridge {
|
|
|
55
56
|
this.#credentials = credentials
|
|
56
57
|
this.#commitTracker = options.commitTracker
|
|
57
58
|
this.#quotaObserver = options.quotaObserver
|
|
59
|
+
this.#accountUsageReader = options.accountUsageReader
|
|
58
60
|
this.#retentionMs = options.retentionMs ?? ATTEMPT_RETENTION_MS
|
|
59
61
|
this.#waitMs = options.waitMs ?? STATUS_WAIT_MS
|
|
60
62
|
}
|
|
@@ -203,6 +205,15 @@ export class CodexAuthorizationBridge {
|
|
|
203
205
|
return { signedOut: true }
|
|
204
206
|
}
|
|
205
207
|
|
|
208
|
+
async usage(payload = {}, signal) {
|
|
209
|
+
const input = objectInput(payload)
|
|
210
|
+
assertOnlyKeys(input, [])
|
|
211
|
+
if (this.#accountUsageReader?.read === undefined) {
|
|
212
|
+
throw new RpcInputError("Codex account usage is unavailable")
|
|
213
|
+
}
|
|
214
|
+
return this.#accountUsageReader.read({ signal })
|
|
215
|
+
}
|
|
216
|
+
|
|
206
217
|
dispatch(endpoint, payload, signal) {
|
|
207
218
|
switch (endpoint) {
|
|
208
219
|
case "status": return this.status(payload, signal)
|
|
@@ -210,6 +221,7 @@ export class CodexAuthorizationBridge {
|
|
|
210
221
|
case "respond": return this.respond(payload)
|
|
211
222
|
case "cancel": return this.cancel(payload)
|
|
212
223
|
case "logout": return this.logout(payload)
|
|
224
|
+
case "usage": return this.usage(payload, signal)
|
|
213
225
|
default: throw new RpcInputError("Unknown dsh-codex RPC endpoint")
|
|
214
226
|
}
|
|
215
227
|
}
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clearTimeout as cancelTimeout,
|
|
3
|
+
setTimeout as scheduleTimeout,
|
|
4
|
+
} from "node:timers"
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 10_000
|
|
7
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 128 * 1024
|
|
8
|
+
const MAX_TIMEOUT_MS = 60_000
|
|
9
|
+
const MAX_CONFIGURED_RESPONSE_BYTES = 1024 * 1024
|
|
10
|
+
const MAX_RESET_HORIZON_MS = 366 * 24 * 60 * 60_000
|
|
11
|
+
const MAX_ADDITIONAL_LIMITS = 64
|
|
12
|
+
const MAX_BODY_CHUNKS = 4_096
|
|
13
|
+
const MAX_BASE_URL_LENGTH = 2_048
|
|
14
|
+
const MAX_ACCESS_LENGTH = 64 * 1024
|
|
15
|
+
const MAX_ACCOUNT_ID_LENGTH = 1_024
|
|
16
|
+
const MAX_LABEL_LENGTH = 128
|
|
17
|
+
|
|
18
|
+
const OPTION_FIELDS = new Set([
|
|
19
|
+
"baseUrl",
|
|
20
|
+
"clock",
|
|
21
|
+
"fetch",
|
|
22
|
+
"maxResponseBytes",
|
|
23
|
+
"resolveAuth",
|
|
24
|
+
"timeoutMs",
|
|
25
|
+
])
|
|
26
|
+
const READ_OPTION_FIELDS = new Set(["signal"])
|
|
27
|
+
|
|
28
|
+
export class CodexAccountUsageError extends Error {
|
|
29
|
+
constructor(message, code, details = {}) {
|
|
30
|
+
super(message)
|
|
31
|
+
this.name = "CodexAccountUsageError"
|
|
32
|
+
this.code = code
|
|
33
|
+
if (details.status !== undefined) this.status = details.status
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isPlainObject(value) {
|
|
38
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false
|
|
39
|
+
const prototype = Object.getPrototypeOf(value)
|
|
40
|
+
return prototype === Object.prototype || prototype === null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function plainObject(value, name) {
|
|
44
|
+
if (!isPlainObject(value)) throw new TypeError(`${name} must be a plain object`)
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function rejectUnknownFields(value, allowed, prefix) {
|
|
49
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
50
|
+
if (typeof key === "string" && allowed.has(key)) continue
|
|
51
|
+
throw new TypeError(`${prefix}: ${String(key)}`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function boundedPositiveInteger(value, name, maximum) {
|
|
56
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) {
|
|
57
|
+
throw new TypeError(`${name} must be a positive safe integer no greater than ${String(maximum)}`)
|
|
58
|
+
}
|
|
59
|
+
return value
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readClock(clock) {
|
|
63
|
+
let now
|
|
64
|
+
try {
|
|
65
|
+
now = clock()
|
|
66
|
+
} catch {
|
|
67
|
+
throw new CodexAccountUsageError("account usage clock returned an invalid timestamp", "INVALID_CLOCK")
|
|
68
|
+
}
|
|
69
|
+
if (!Number.isSafeInteger(now) || now < 0) {
|
|
70
|
+
throw new CodexAccountUsageError("account usage clock returned an invalid timestamp", "INVALID_CLOCK")
|
|
71
|
+
}
|
|
72
|
+
return now
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function usageUrl(rawBaseUrl) {
|
|
76
|
+
if (typeof rawBaseUrl !== "string" || rawBaseUrl.length === 0 || rawBaseUrl.length > MAX_BASE_URL_LENGTH) {
|
|
77
|
+
throw new TypeError("baseUrl must be a non-empty bounded string")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let baseUrl
|
|
81
|
+
try {
|
|
82
|
+
baseUrl = new URL(rawBaseUrl)
|
|
83
|
+
} catch {
|
|
84
|
+
throw new TypeError("baseUrl must be a valid HTTPS URL")
|
|
85
|
+
}
|
|
86
|
+
if (
|
|
87
|
+
baseUrl.protocol !== "https:"
|
|
88
|
+
|| baseUrl.username !== ""
|
|
89
|
+
|| baseUrl.password !== ""
|
|
90
|
+
|| baseUrl.search !== ""
|
|
91
|
+
|| baseUrl.hash !== ""
|
|
92
|
+
) {
|
|
93
|
+
throw new TypeError("baseUrl must be a credential-free HTTPS URL without query or fragment")
|
|
94
|
+
}
|
|
95
|
+
if (!baseUrl.pathname.endsWith("/")) baseUrl.pathname += "/"
|
|
96
|
+
return new URL("wham/usage", baseUrl).href
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function assertAbortSignal(value) {
|
|
100
|
+
if (value === undefined) return
|
|
101
|
+
if (!(value instanceof AbortSignal)) throw new TypeError("signal must be an AbortSignal")
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function deadline(parentSignal, timeoutMs) {
|
|
105
|
+
const controller = new AbortController()
|
|
106
|
+
const abortFromParent = () => {
|
|
107
|
+
controller.abort(new CodexAccountUsageError("account usage request was aborted", "ABORTED"))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (parentSignal?.aborted === true) abortFromParent()
|
|
111
|
+
else parentSignal?.addEventListener("abort", abortFromParent, { once: true })
|
|
112
|
+
|
|
113
|
+
let timer
|
|
114
|
+
if (!controller.signal.aborted) {
|
|
115
|
+
timer = scheduleTimeout(() => {
|
|
116
|
+
controller.abort(new CodexAccountUsageError("account usage request timed out", "TIMEOUT"))
|
|
117
|
+
}, timeoutMs)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
signal: controller.signal,
|
|
122
|
+
dispose() {
|
|
123
|
+
if (timer !== undefined) cancelTimeout(timer)
|
|
124
|
+
parentSignal?.removeEventListener("abort", abortFromParent)
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function abortReason(signal) {
|
|
130
|
+
if (signal.reason instanceof CodexAccountUsageError) return signal.reason
|
|
131
|
+
return new CodexAccountUsageError("account usage request was aborted", "ABORTED")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function awaitWithSignal(value, signal) {
|
|
135
|
+
if (signal.aborted) return Promise.reject(abortReason(signal))
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
const onAbort = () => reject(abortReason(signal))
|
|
138
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
139
|
+
Promise.resolve(value).then(
|
|
140
|
+
(result) => {
|
|
141
|
+
signal.removeEventListener("abort", onAbort)
|
|
142
|
+
resolve(result)
|
|
143
|
+
},
|
|
144
|
+
(error) => {
|
|
145
|
+
signal.removeEventListener("abort", onAbort)
|
|
146
|
+
reject(error)
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function safeHeaderValue(value, name, maximum, allowWhitespace) {
|
|
153
|
+
if (
|
|
154
|
+
typeof value !== "string"
|
|
155
|
+
|| value.length === 0
|
|
156
|
+
|| value.length > maximum
|
|
157
|
+
|| value.trim() !== value
|
|
158
|
+
|| /[\u0000-\u001f\u007f]/u.test(value)
|
|
159
|
+
|| (!allowWhitespace && /\s/u.test(value))
|
|
160
|
+
) {
|
|
161
|
+
throw new CodexAccountUsageError(`account usage ${name} is unavailable`, "AUTH_UNAVAILABLE")
|
|
162
|
+
}
|
|
163
|
+
return value
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function resolveRequestAuth(resolveAuth, signal) {
|
|
167
|
+
let resolved
|
|
168
|
+
try {
|
|
169
|
+
resolved = await awaitWithSignal(
|
|
170
|
+
Promise.resolve().then(() => resolveAuth({ signal })),
|
|
171
|
+
signal,
|
|
172
|
+
)
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (signal.aborted) throw abortReason(signal)
|
|
175
|
+
if (error instanceof CodexAccountUsageError) throw error
|
|
176
|
+
throw new CodexAccountUsageError("account usage authentication is unavailable", "AUTH_UNAVAILABLE")
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!isPlainObject(resolved)) {
|
|
180
|
+
throw new CodexAccountUsageError("account usage authentication is unavailable", "AUTH_UNAVAILABLE")
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
return {
|
|
184
|
+
access: safeHeaderValue(resolved.access, "access token", MAX_ACCESS_LENGTH, false),
|
|
185
|
+
accountId: safeHeaderValue(resolved.accountId, "account identifier", MAX_ACCOUNT_ID_LENGTH, true),
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error instanceof CodexAccountUsageError) throw error
|
|
189
|
+
throw new CodexAccountUsageError("account usage authentication is unavailable", "AUTH_UNAVAILABLE")
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function discardResponse(response) {
|
|
194
|
+
try {
|
|
195
|
+
const cancellation = response?.body?.cancel?.()
|
|
196
|
+
cancellation?.catch?.(() => {})
|
|
197
|
+
} catch {
|
|
198
|
+
// Discard failures never replace the safe protocol error.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function responseHeader(response, name) {
|
|
203
|
+
try {
|
|
204
|
+
return response?.headers?.get?.(name) ?? null
|
|
205
|
+
} catch {
|
|
206
|
+
throw new CodexAccountUsageError("account usage response headers are invalid", "INVALID_RESPONSE")
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function assertJsonContentType(response) {
|
|
211
|
+
const raw = responseHeader(response, "content-type")
|
|
212
|
+
const mediaType = raw?.split(";", 1)[0].trim().toLowerCase()
|
|
213
|
+
if (
|
|
214
|
+
mediaType !== "application/json"
|
|
215
|
+
&& !/^application\/[a-z0-9!#$&^_.+-]+\+json$/u.test(mediaType ?? "")
|
|
216
|
+
) {
|
|
217
|
+
throw new CodexAccountUsageError("account usage response is not JSON", "INVALID_CONTENT_TYPE")
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function assertContentLength(response, maxResponseBytes) {
|
|
222
|
+
const raw = responseHeader(response, "content-length")
|
|
223
|
+
if (raw === null) return
|
|
224
|
+
if (!/^(?:0|[1-9]\d*)$/u.test(raw)) {
|
|
225
|
+
throw new CodexAccountUsageError("account usage response length is invalid", "INVALID_RESPONSE")
|
|
226
|
+
}
|
|
227
|
+
const length = Number(raw)
|
|
228
|
+
if (!Number.isSafeInteger(length)) {
|
|
229
|
+
throw new CodexAccountUsageError("account usage response length is invalid", "INVALID_RESPONSE")
|
|
230
|
+
}
|
|
231
|
+
if (length > maxResponseBytes) {
|
|
232
|
+
throw new CodexAccountUsageError("account usage response exceeds the byte limit", "BODY_TOO_LARGE")
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function readBoundedBody(response, maxResponseBytes, signal) {
|
|
237
|
+
let reader
|
|
238
|
+
try {
|
|
239
|
+
reader = response?.body?.getReader?.()
|
|
240
|
+
} catch {
|
|
241
|
+
throw new CodexAccountUsageError("account usage response body is unavailable", "INVALID_RESPONSE")
|
|
242
|
+
}
|
|
243
|
+
if (reader === undefined) {
|
|
244
|
+
throw new CodexAccountUsageError("account usage response body is unavailable", "INVALID_RESPONSE")
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const chunks = []
|
|
248
|
+
let total = 0
|
|
249
|
+
let chunkCount = 0
|
|
250
|
+
try {
|
|
251
|
+
while (true) {
|
|
252
|
+
const part = await awaitWithSignal(reader.read(), signal)
|
|
253
|
+
if (part.done) break
|
|
254
|
+
if (!(part.value instanceof Uint8Array)) {
|
|
255
|
+
throw new CodexAccountUsageError("account usage response body is invalid", "INVALID_RESPONSE")
|
|
256
|
+
}
|
|
257
|
+
total += part.value.byteLength
|
|
258
|
+
if (total > maxResponseBytes) {
|
|
259
|
+
throw new CodexAccountUsageError("account usage response exceeds the byte limit", "BODY_TOO_LARGE")
|
|
260
|
+
}
|
|
261
|
+
chunkCount += 1
|
|
262
|
+
if (chunkCount > MAX_BODY_CHUNKS) {
|
|
263
|
+
throw new CodexAccountUsageError("account usage response body is invalid", "INVALID_RESPONSE")
|
|
264
|
+
}
|
|
265
|
+
chunks.push(part.value)
|
|
266
|
+
}
|
|
267
|
+
} catch (error) {
|
|
268
|
+
try {
|
|
269
|
+
const cancellation = reader.cancel()
|
|
270
|
+
cancellation?.catch?.(() => {})
|
|
271
|
+
} catch {
|
|
272
|
+
// Cancellation failures never replace the safe body error.
|
|
273
|
+
}
|
|
274
|
+
if (signal.aborted) throw abortReason(signal)
|
|
275
|
+
if (error instanceof CodexAccountUsageError) throw error
|
|
276
|
+
throw new CodexAccountUsageError("account usage response body is invalid", "INVALID_RESPONSE")
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const body = new Uint8Array(total)
|
|
280
|
+
let offset = 0
|
|
281
|
+
for (const chunk of chunks) {
|
|
282
|
+
body.set(chunk, offset)
|
|
283
|
+
offset += chunk.byteLength
|
|
284
|
+
}
|
|
285
|
+
return body
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function readJson(response, maxResponseBytes, signal) {
|
|
289
|
+
assertJsonContentType(response)
|
|
290
|
+
assertContentLength(response, maxResponseBytes)
|
|
291
|
+
const body = await readBoundedBody(response, maxResponseBytes, signal)
|
|
292
|
+
if (body.byteLength === 0) {
|
|
293
|
+
throw new CodexAccountUsageError("account usage response body is invalid", "INVALID_RESPONSE")
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let text
|
|
297
|
+
try {
|
|
298
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(body)
|
|
299
|
+
} catch {
|
|
300
|
+
throw new CodexAccountUsageError("account usage response body is invalid", "INVALID_RESPONSE")
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
return JSON.parse(text)
|
|
305
|
+
} catch {
|
|
306
|
+
throw new CodexAccountUsageError("account usage response body is invalid", "INVALID_RESPONSE")
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function safeShortString(value, name, { nullable = false } = {}) {
|
|
311
|
+
if (nullable && (value === undefined || value === null)) return null
|
|
312
|
+
if (
|
|
313
|
+
typeof value !== "string"
|
|
314
|
+
|| value.length === 0
|
|
315
|
+
|| value.length > MAX_LABEL_LENGTH
|
|
316
|
+
|| value.trim() !== value
|
|
317
|
+
|| /[\u0000-\u001f\u007f]/u.test(value)
|
|
318
|
+
) {
|
|
319
|
+
throw new CodexAccountUsageError(`account usage ${name} is invalid`, "INVALID_RESPONSE")
|
|
320
|
+
}
|
|
321
|
+
return value
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function optionalConsistentField(envelope, bucket, field) {
|
|
325
|
+
const outer = envelope[field]
|
|
326
|
+
const inner = bucket[field]
|
|
327
|
+
if (outer !== undefined && inner !== undefined && outer !== inner) {
|
|
328
|
+
throw new CodexAccountUsageError("account usage limit identity is inconsistent", "INVALID_RESPONSE")
|
|
329
|
+
}
|
|
330
|
+
return outer ?? inner
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function normalizeWindow(raw, observedAt) {
|
|
334
|
+
if (raw === undefined || raw === null) return undefined
|
|
335
|
+
if (!isPlainObject(raw)) {
|
|
336
|
+
throw new CodexAccountUsageError("account usage quota window is invalid", "INVALID_RESPONSE")
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const usedPercent = raw.used_percent
|
|
340
|
+
if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent) || usedPercent < 0 || usedPercent > 100) {
|
|
341
|
+
throw new CodexAccountUsageError("account usage percentage is invalid", "INVALID_RESPONSE")
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const seconds = raw.limit_window_seconds
|
|
345
|
+
if (!Number.isSafeInteger(seconds) || seconds <= 0 || seconds % 60 !== 0) {
|
|
346
|
+
throw new CodexAccountUsageError("account usage window duration is invalid", "INVALID_RESPONSE")
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const resetSeconds = raw.reset_at
|
|
350
|
+
if (
|
|
351
|
+
!Number.isSafeInteger(resetSeconds)
|
|
352
|
+
|| resetSeconds < 0
|
|
353
|
+
|| resetSeconds > Math.floor(Number.MAX_SAFE_INTEGER / 1_000)
|
|
354
|
+
) {
|
|
355
|
+
throw new CodexAccountUsageError("account usage reset timestamp is invalid", "INVALID_RESPONSE")
|
|
356
|
+
}
|
|
357
|
+
const resetsAt = resetSeconds * 1_000
|
|
358
|
+
if (resetsAt <= observedAt || resetsAt - observedAt > MAX_RESET_HORIZON_MS) {
|
|
359
|
+
throw new CodexAccountUsageError("account usage reset timestamp is implausible", "INVALID_RESPONSE")
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return Object.freeze({
|
|
363
|
+
usedPercent,
|
|
364
|
+
windowDurationMins: seconds / 60,
|
|
365
|
+
resetsAt,
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function normalizeLimit(envelope, bucket, observedAt, fallbackId) {
|
|
370
|
+
if (!isPlainObject(envelope) || !isPlainObject(bucket)) {
|
|
371
|
+
throw new CodexAccountUsageError("account usage rate limit is invalid", "INVALID_RESPONSE")
|
|
372
|
+
}
|
|
373
|
+
const explicitId = optionalConsistentField(envelope, bucket, "limit_id")
|
|
374
|
+
const explicitName = optionalConsistentField(envelope, bucket, "limit_name")
|
|
375
|
+
const limitName = safeShortString(explicitName, "limit name", { nullable: true })
|
|
376
|
+
const limitId = safeShortString(explicitId ?? fallbackId ?? limitName, "limit identifier")
|
|
377
|
+
const primary = normalizeWindow(bucket.primary_window, observedAt)
|
|
378
|
+
const secondary = normalizeWindow(bucket.secondary_window, observedAt)
|
|
379
|
+
|
|
380
|
+
const result = {
|
|
381
|
+
limitId,
|
|
382
|
+
limitName,
|
|
383
|
+
}
|
|
384
|
+
if (primary !== undefined) result.primary = primary
|
|
385
|
+
if (secondary !== undefined) result.secondary = secondary
|
|
386
|
+
return Object.freeze(result)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function normalizePayload(payload, observedAt) {
|
|
390
|
+
if (!isPlainObject(payload) || !isPlainObject(payload.rate_limit)) {
|
|
391
|
+
throw new CodexAccountUsageError("account usage response shape is invalid", "INVALID_RESPONSE")
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const main = normalizeLimit(payload, payload.rate_limit, observedAt, "codex")
|
|
395
|
+
const limits = [main]
|
|
396
|
+
const seen = new Set([main.limitId])
|
|
397
|
+
const additional = payload.additional_rate_limits ?? []
|
|
398
|
+
if (!Array.isArray(additional) || additional.length > MAX_ADDITIONAL_LIMITS) {
|
|
399
|
+
throw new CodexAccountUsageError("account usage additional limits are invalid", "INVALID_RESPONSE")
|
|
400
|
+
}
|
|
401
|
+
for (const entry of additional) {
|
|
402
|
+
if (!isPlainObject(entry) || !isPlainObject(entry.rate_limit)) {
|
|
403
|
+
throw new CodexAccountUsageError("account usage additional limit is invalid", "INVALID_RESPONSE")
|
|
404
|
+
}
|
|
405
|
+
const limit = normalizeLimit(entry, entry.rate_limit, observedAt)
|
|
406
|
+
if (seen.has(limit.limitId)) {
|
|
407
|
+
throw new CodexAccountUsageError("account usage limit identifier is duplicated", "INVALID_RESPONSE")
|
|
408
|
+
}
|
|
409
|
+
seen.add(limit.limitId)
|
|
410
|
+
limits.push(limit)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const result = {
|
|
414
|
+
observedAt,
|
|
415
|
+
rateLimits: Object.freeze(limits),
|
|
416
|
+
}
|
|
417
|
+
if (payload.plan_type !== undefined && payload.plan_type !== null) {
|
|
418
|
+
result.planType = safeShortString(payload.plan_type, "plan type")
|
|
419
|
+
}
|
|
420
|
+
return Object.freeze(result)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function responseMetadata(response) {
|
|
424
|
+
try {
|
|
425
|
+
return {
|
|
426
|
+
redirected: response?.redirected === true,
|
|
427
|
+
status: response?.status,
|
|
428
|
+
}
|
|
429
|
+
} catch {
|
|
430
|
+
throw new CodexAccountUsageError("account usage response metadata is invalid", "INVALID_RESPONSE")
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Build a host-side reader for the authenticated Codex account usage endpoint.
|
|
436
|
+
* `resolveAuth` owns pi-ai-compatible refresh/serialization and must return the
|
|
437
|
+
* resulting OAuth credential as `{ access, accountId }`.
|
|
438
|
+
*/
|
|
439
|
+
export function createCodexAccountUsageReader(options) {
|
|
440
|
+
const input = plainObject(options, "account usage reader options")
|
|
441
|
+
rejectUnknownFields(input, OPTION_FIELDS, "unknown account usage reader option")
|
|
442
|
+
|
|
443
|
+
const endpoint = usageUrl(input.baseUrl)
|
|
444
|
+
const resolveAuth = input.resolveAuth
|
|
445
|
+
const fetchImpl = input.fetch ?? globalThis.fetch
|
|
446
|
+
const clock = input.clock ?? Date.now
|
|
447
|
+
if (typeof resolveAuth !== "function") throw new TypeError("resolveAuth must be a function")
|
|
448
|
+
if (typeof fetchImpl !== "function") throw new TypeError("fetch must be a function")
|
|
449
|
+
if (typeof clock !== "function") throw new TypeError("clock must be a function")
|
|
450
|
+
const timeoutMs = boundedPositiveInteger(input.timeoutMs ?? DEFAULT_TIMEOUT_MS, "timeoutMs", MAX_TIMEOUT_MS)
|
|
451
|
+
const maxResponseBytes = boundedPositiveInteger(
|
|
452
|
+
input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
|
|
453
|
+
"maxResponseBytes",
|
|
454
|
+
MAX_CONFIGURED_RESPONSE_BYTES,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
async function read(readOptions = {}) {
|
|
458
|
+
const request = plainObject(readOptions, "account usage read options")
|
|
459
|
+
rejectUnknownFields(request, READ_OPTION_FIELDS, "unknown account usage read option")
|
|
460
|
+
assertAbortSignal(request.signal)
|
|
461
|
+
const active = deadline(request.signal, timeoutMs)
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
if (active.signal.aborted) throw abortReason(active.signal)
|
|
465
|
+
const auth = await resolveRequestAuth(resolveAuth, active.signal)
|
|
466
|
+
|
|
467
|
+
let response
|
|
468
|
+
try {
|
|
469
|
+
response = await awaitWithSignal(fetchImpl(endpoint, {
|
|
470
|
+
method: "GET",
|
|
471
|
+
redirect: "error",
|
|
472
|
+
headers: {
|
|
473
|
+
Accept: "application/json",
|
|
474
|
+
Authorization: `Bearer ${auth.access}`,
|
|
475
|
+
"ChatGPT-Account-Id": auth.accountId,
|
|
476
|
+
},
|
|
477
|
+
signal: active.signal,
|
|
478
|
+
}), active.signal)
|
|
479
|
+
} catch (error) {
|
|
480
|
+
if (active.signal.aborted) throw abortReason(active.signal)
|
|
481
|
+
if (error instanceof CodexAccountUsageError) throw error
|
|
482
|
+
throw new CodexAccountUsageError("account usage network request failed", "NETWORK")
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const metadata = responseMetadata(response)
|
|
486
|
+
const status = metadata.status
|
|
487
|
+
if (!Number.isInteger(status) || status < 100 || status > 599) {
|
|
488
|
+
discardResponse(response)
|
|
489
|
+
throw new CodexAccountUsageError("account usage response status is invalid", "INVALID_RESPONSE")
|
|
490
|
+
}
|
|
491
|
+
if (metadata.redirected || (status >= 300 && status < 400)) {
|
|
492
|
+
discardResponse(response)
|
|
493
|
+
throw new CodexAccountUsageError("account usage response redirected", "REDIRECT")
|
|
494
|
+
}
|
|
495
|
+
if (status < 200 || status >= 300) {
|
|
496
|
+
discardResponse(response)
|
|
497
|
+
throw new CodexAccountUsageError("account usage request returned an HTTP error", "HTTP_STATUS", { status })
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const payload = await readJson(response, maxResponseBytes, active.signal)
|
|
501
|
+
const observedAt = readClock(clock)
|
|
502
|
+
return normalizePayload(payload, observedAt)
|
|
503
|
+
} finally {
|
|
504
|
+
active.dispose()
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return Object.freeze({ read })
|
|
509
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const EFFORT_NAMES = Object.freeze({
|
|
2
|
+
low: "Low",
|
|
3
|
+
medium: "Medium",
|
|
4
|
+
high: "High",
|
|
5
|
+
xhigh: "Xhigh",
|
|
6
|
+
max: "Max",
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
const MODEL_CAPABILITIES = new Map([
|
|
10
|
+
["gpt-5.3-codex-spark", modelCapability({
|
|
11
|
+
defaultReasoningEffort: "high",
|
|
12
|
+
reasoningEfforts: ["low", "medium", "high", "xhigh"],
|
|
13
|
+
})],
|
|
14
|
+
["gpt-5.4", modelCapability({
|
|
15
|
+
defaultReasoningEffort: "medium",
|
|
16
|
+
reasoningEfforts: ["low", "medium", "high", "xhigh"],
|
|
17
|
+
fast: true,
|
|
18
|
+
})],
|
|
19
|
+
["gpt-5.4-mini", modelCapability({
|
|
20
|
+
defaultReasoningEffort: "medium",
|
|
21
|
+
reasoningEfforts: ["low", "medium", "high", "xhigh"],
|
|
22
|
+
})],
|
|
23
|
+
["gpt-5.5", modelCapability({
|
|
24
|
+
defaultReasoningEffort: "medium",
|
|
25
|
+
reasoningEfforts: ["low", "medium", "high", "xhigh"],
|
|
26
|
+
fast: true,
|
|
27
|
+
})],
|
|
28
|
+
["gpt-5.6-luna", modelCapability({
|
|
29
|
+
defaultReasoningEffort: "medium",
|
|
30
|
+
reasoningEfforts: ["low", "medium", "high", "xhigh", "max"],
|
|
31
|
+
fast: true,
|
|
32
|
+
})],
|
|
33
|
+
["gpt-5.6-sol", modelCapability({
|
|
34
|
+
defaultReasoningEffort: "low",
|
|
35
|
+
reasoningEfforts: ["low", "medium", "high", "xhigh", "max"],
|
|
36
|
+
fast: true,
|
|
37
|
+
})],
|
|
38
|
+
["gpt-5.6-terra", modelCapability({
|
|
39
|
+
defaultReasoningEffort: "medium",
|
|
40
|
+
reasoningEfforts: ["low", "medium", "high", "xhigh", "max"],
|
|
41
|
+
fast: true,
|
|
42
|
+
})],
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
function modelCapability({ defaultReasoningEffort, reasoningEfforts, fast = false }) {
|
|
46
|
+
if (!reasoningEfforts.includes(defaultReasoningEffort)) {
|
|
47
|
+
throw new TypeError("defaultReasoningEffort must be included in reasoningEfforts")
|
|
48
|
+
}
|
|
49
|
+
return Object.freeze({
|
|
50
|
+
defaultReasoningEffort,
|
|
51
|
+
reasoningEfforts: Object.freeze(reasoningEfforts.map((id) => Object.freeze({
|
|
52
|
+
id,
|
|
53
|
+
name: EFFORT_NAMES[id],
|
|
54
|
+
}))),
|
|
55
|
+
fast,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Return the Codex model controls verified from the first-party model catalog.
|
|
61
|
+
* Unknown future models deliberately expose no inferred controls.
|
|
62
|
+
*/
|
|
63
|
+
export function codexModelCapability(modelId) {
|
|
64
|
+
return MODEL_CAPABILITIES.get(modelId)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function supportsCodexFast(modelId) {
|
|
68
|
+
return MODEL_CAPABILITIES.get(modelId)?.fast === true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function supportsCodexReasoningEffort(modelId, reasoningEffort) {
|
|
72
|
+
return MODEL_CAPABILITIES.get(modelId)?.reasoningEfforts.some(
|
|
73
|
+
({ id }) => id === reasoningEffort,
|
|
74
|
+
) === true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function piThinkingLevelMap(modelId) {
|
|
78
|
+
const capability = MODEL_CAPABILITIES.get(modelId)
|
|
79
|
+
if (capability === undefined) return undefined
|
|
80
|
+
const supported = new Set(capability.reasoningEfforts.map(({ id }) => id))
|
|
81
|
+
return Object.freeze({
|
|
82
|
+
off: null,
|
|
83
|
+
minimal: null,
|
|
84
|
+
low: supported.has("low") ? "low" : null,
|
|
85
|
+
medium: supported.has("medium") ? "medium" : null,
|
|
86
|
+
high: supported.has("high") ? "high" : null,
|
|
87
|
+
xhigh: supported.has("xhigh") ? "xhigh" : null,
|
|
88
|
+
max: supported.has("max") ? "max" : null,
|
|
89
|
+
})
|
|
90
|
+
}
|