dsh-codex-community 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/CONTRIBUTING.en.md +54 -0
  3. package/CONTRIBUTING.md +54 -0
  4. package/LICENSE +201 -0
  5. package/NOTICE +12 -0
  6. package/README.en.md +98 -0
  7. package/README.md +98 -0
  8. package/SECURITY.md +41 -0
  9. package/SUPPORT.md +17 -0
  10. package/THIRD_PARTY_NOTICES.md +25 -0
  11. package/codex-community.patch.yml +13 -0
  12. package/dist/client/index.js +1037 -0
  13. package/dist/host/index.mjs +98 -0
  14. package/dist/internal/authorization-bridge.mjs +662 -0
  15. package/dist/internal/authorization-commit-tracker.mjs +49 -0
  16. package/dist/internal/codex-authorization.mjs +202 -0
  17. package/dist/internal/codex-credential-store.mjs +164 -0
  18. package/dist/internal/codex-identifiers.mjs +4 -0
  19. package/dist/internal/codex-pi-provider.mjs +137 -0
  20. package/dist/internal/codex-provider-runtime.mjs +256 -0
  21. package/dist/internal/codex-route-adapter.mjs +133 -0
  22. package/dist/internal/codex-session-resources.mjs +64 -0
  23. package/dist/internal/failure-normalizer.mjs +456 -0
  24. package/dist/internal/image-policy.mjs +45 -0
  25. package/dist/internal/quota-observer.mjs +142 -0
  26. package/dist/internal/reliability.mjs +12 -0
  27. package/dist/internal/remote-image-input.mjs +801 -0
  28. package/dist/internal/session-preference-command.mjs +52 -0
  29. package/dist/internal/session-preferences.mjs +93 -0
  30. package/dist/internal/stream-resilience.mjs +273 -0
  31. package/docs/README.en.md +15 -0
  32. package/docs/README.md +15 -0
  33. package/docs/architecture.en.md +106 -0
  34. package/docs/architecture.md +106 -0
  35. package/docs/compatibility.en.md +53 -0
  36. package/docs/compatibility.md +53 -0
  37. package/docs/configuration.en.md +61 -0
  38. package/docs/configuration.md +61 -0
  39. package/docs/contribution-sources.en.md +35 -0
  40. package/docs/contribution-sources.md +35 -0
  41. package/docs/github-about.md +15 -0
  42. package/docs/releases/v0.0.1.acceptance.json +160 -0
  43. package/docs/releases/v0.0.1.md +174 -0
  44. package/docs/releasing.en.md +290 -0
  45. package/docs/releasing.md +290 -0
  46. package/docs/testing.en.md +85 -0
  47. package/docs/testing.md +85 -0
  48. package/docs/troubleshooting.en.md +47 -0
  49. package/docs/troubleshooting.md +47 -0
  50. package/package.json +144 -0
  51. package/types/client.d.ts +160 -0
  52. package/types/index.d.ts +22 -0
  53. package/types/reliability.d.ts +78 -0
@@ -0,0 +1,456 @@
1
+ const ACCOUNT_QUOTA_CODES = new Set([
2
+ "accountquotaexceeded",
3
+ "billinghardlimitreached",
4
+ "insufficientquota",
5
+ "insufficientcredits",
6
+ "quota",
7
+ "quotaexceeded",
8
+ "usagequotaexceeded",
9
+ ])
10
+
11
+ const ACCOUNT_QUOTA_PATTERNS = [
12
+ /\binsufficient[\s_-]+(?:quota|credits?|balance)\b/iu,
13
+ /\byou\s+have\s+exceeded\s+the\s+(?:(?:\d+|five)[\s-]*hour\s+)?usage\s+quota\b/iu,
14
+ /\b(?:chatgpt|account)\s+(?:quota|usage\s+limit)\s+will\s+reset\s+at\b/iu,
15
+ ]
16
+
17
+ const PI_AI_AMBIGUOUS_429_PATTERN = /^you\s+have\s+hit\s+your\s+chatgpt\s+usage\s+limit(?:\s+\([^\r\n)]{1,80}\))?\.(?:\s+try\s+again\s+in\s+~\d+\s+min\.)?$/iu
18
+
19
+ const PI_AI_TRANSPORT_PATTERNS = [
20
+ /^websocket\s+closed(?:\s|$)/iu,
21
+ /^websocket\s+stream\s+closed\s+before\s+response\.completed$/iu,
22
+ /^websocket\s+error(?:\s|$)/iu,
23
+ ]
24
+
25
+ const MAX_FAILURE_TEXT_CHARS = 65_536
26
+ const MAX_EMBEDDED_JSON_CHARS = 32_768
27
+ const MAX_EMBEDDED_OBJECTS = 16
28
+
29
+ const RESET_KEYS = new Set([
30
+ "resetat",
31
+ "resettime",
32
+ "resetsat",
33
+ "quotaresetat",
34
+ "usageresetat",
35
+ ])
36
+
37
+ const REQUEST_ID_KEYS = new Set([
38
+ "requestid",
39
+ "requestidentifier",
40
+ "xrequestid",
41
+ ])
42
+
43
+ const CLASSIFIER_KEYS = new Set(["code", "type"])
44
+
45
+ function normalizedKey(value) {
46
+ return String(value).replaceAll(/[^a-z0-9]/giu, "").toLowerCase()
47
+ }
48
+
49
+ function scalarText(value) {
50
+ if (typeof value === "string") return value
51
+ if (typeof value === "number" && Number.isFinite(value)) return String(value)
52
+ }
53
+
54
+ function safeRequestId(value) {
55
+ const candidate = scalarText(value)?.trim()
56
+ if (candidate === undefined || !/^[A-Za-z0-9._:-]{1,128}$/u.test(candidate)) return undefined
57
+ return candidate
58
+ }
59
+
60
+ /** Extract balanced JSON objects without evaluating the surrounding text. */
61
+ export function parseEmbeddedJsonObjects(text) {
62
+ if (typeof text !== "string" || text.length === 0) return []
63
+
64
+ const bounded = text.slice(0, MAX_FAILURE_TEXT_CHARS)
65
+ const values = []
66
+ let start = -1
67
+ let depth = 0
68
+ let quoted = false
69
+ let escaped = false
70
+
71
+ for (let index = 0; index < bounded.length; index += 1) {
72
+ const character = bounded[index]
73
+ if (quoted) {
74
+ if (escaped) escaped = false
75
+ else if (character === "\\") escaped = true
76
+ else if (character === '"') quoted = false
77
+ continue
78
+ }
79
+
80
+ if (character === '"') {
81
+ quoted = true
82
+ continue
83
+ }
84
+ if (character === "{") {
85
+ if (depth === 0) start = index
86
+ depth += 1
87
+ continue
88
+ }
89
+ if (character !== "}" || depth === 0) continue
90
+
91
+ depth -= 1
92
+ if (depth !== 0 || start < 0) continue
93
+ if (index - start + 1 > MAX_EMBEDDED_JSON_CHARS) {
94
+ start = -1
95
+ continue
96
+ }
97
+ try {
98
+ const value = JSON.parse(bounded.slice(start, index + 1))
99
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
100
+ values.push(value)
101
+ if (values.length >= MAX_EMBEDDED_OBJECTS) return values
102
+ }
103
+ } catch {
104
+ // The provider prefix may contain braces that are not JSON. Ignore them.
105
+ }
106
+ start = -1
107
+ }
108
+
109
+ return values
110
+ }
111
+
112
+ function knownErrorRecords(values) {
113
+ const records = []
114
+ for (const value of values) {
115
+ if (value === null || typeof value !== "object" || Array.isArray(value)) continue
116
+ records.push(value)
117
+ let error
118
+ try {
119
+ error = value.error
120
+ } catch {
121
+ continue
122
+ }
123
+ if (error !== null && typeof error === "object" && !Array.isArray(error)) records.push(error)
124
+ }
125
+ return records
126
+ }
127
+
128
+ function withoutEmbeddedObjects(text) {
129
+ let output = ""
130
+ let cursor = 0
131
+ let start = -1
132
+ let depth = 0
133
+ let quoted = false
134
+ let escaped = false
135
+
136
+ for (let index = 0; index < text.length; index += 1) {
137
+ const character = text[index]
138
+ if (quoted) {
139
+ if (escaped) escaped = false
140
+ else if (character === "\\") escaped = true
141
+ else if (character === '"') quoted = false
142
+ continue
143
+ }
144
+ if (character === '"') {
145
+ quoted = true
146
+ continue
147
+ }
148
+ if (character === "{") {
149
+ if (depth === 0) start = index
150
+ depth += 1
151
+ continue
152
+ }
153
+ if (character !== "}" || depth === 0) continue
154
+ depth -= 1
155
+ if (depth !== 0) continue
156
+ output += `${text.slice(cursor, start)} `
157
+ cursor = index + 1
158
+ start = -1
159
+ }
160
+
161
+ if (depth > 0 && start >= 0) return output + text.slice(cursor, start)
162
+ return output + text.slice(cursor)
163
+ }
164
+
165
+ function parseStatus(text, records, explicit) {
166
+ if (Number.isInteger(explicit) && explicit >= 100 && explicit <= 599) return explicit
167
+ for (const record of records) {
168
+ for (const [key, value] of Object.entries(record)) {
169
+ if (!new Set(["status", "statuscode", "httpstatus"]).has(normalizedKey(key))) continue
170
+ const parsed = Number(value)
171
+ if (Number.isInteger(parsed) && parsed >= 100 && parsed <= 599) return parsed
172
+ }
173
+ }
174
+ const hit = text.match(/\b(?:openai\s+api\s+error|api\s+error|http(?:\s+status)?)\s*\(?\s*(\d{3})\s*\)?/iu)
175
+ return hit === null ? undefined : Number(hit[1])
176
+ }
177
+
178
+ function explicitOffsetDate(raw) {
179
+ const hit = raw.match(/\b(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})\s*([+-])(\d{2}):?(\d{2})\b/u)
180
+ if (hit === null) return undefined
181
+ const [, year, month, day, hour, minute, second, sign, offsetHour, offsetMinute] = hit
182
+ const values = [year, month, day, hour, minute, second].map(Number)
183
+ const [yearValue, monthValue, dayValue, hourValue, minuteValue, secondValue] = values
184
+ const offsetHourValue = Number(offsetHour)
185
+ const offsetMinuteValue = Number(offsetMinute)
186
+ if (monthValue < 1 || monthValue > 12 || dayValue < 1 || dayValue > 31
187
+ || hourValue > 23 || minuteValue > 59 || secondValue > 59
188
+ || offsetHourValue > 23 || offsetMinuteValue > 59) return undefined
189
+ const wallTime = Date.UTC(
190
+ yearValue,
191
+ monthValue - 1,
192
+ dayValue,
193
+ hourValue,
194
+ minuteValue,
195
+ secondValue,
196
+ )
197
+ const wall = new Date(wallTime)
198
+ if (wall.getUTCFullYear() !== yearValue || wall.getUTCMonth() !== monthValue - 1
199
+ || wall.getUTCDate() !== dayValue || wall.getUTCHours() !== hourValue
200
+ || wall.getUTCMinutes() !== minuteValue || wall.getUTCSeconds() !== secondValue) return undefined
201
+ const offset = (offsetHourValue * 60 + offsetMinuteValue) * (sign === "+" ? 1 : -1)
202
+ const epochMs = wallTime - offset * 60_000
203
+ if (!Number.isFinite(epochMs)) return undefined
204
+ return {
205
+ raw: `${year}-${month}-${day} ${hour}:${minute}:${second} UTC${sign}${offsetHour}:${offsetMinute}`,
206
+ epochMs,
207
+ iso: new Date(epochMs).toISOString(),
208
+ }
209
+ }
210
+
211
+ function explicitZuluDate(raw) {
212
+ const hit = raw.match(/\b(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?Z\b/u)
213
+ if (hit === null) return undefined
214
+ const [, year, month, day, hour, minute, second] = hit
215
+ const [yearValue, monthValue, dayValue, hourValue, minuteValue, secondValue] = [
216
+ year,
217
+ month,
218
+ day,
219
+ hour,
220
+ minute,
221
+ second,
222
+ ].map(Number)
223
+ if (monthValue < 1 || monthValue > 12 || dayValue < 1 || dayValue > 31
224
+ || hourValue > 23 || minuteValue > 59 || secondValue > 59) return undefined
225
+ const epochMs = Date.UTC(
226
+ yearValue,
227
+ monthValue - 1,
228
+ dayValue,
229
+ hourValue,
230
+ minuteValue,
231
+ secondValue,
232
+ )
233
+ const date = new Date(epochMs)
234
+ if (date.getUTCFullYear() !== yearValue || date.getUTCMonth() !== monthValue - 1
235
+ || date.getUTCDate() !== dayValue || date.getUTCHours() !== hourValue
236
+ || date.getUTCMinutes() !== minuteValue || date.getUTCSeconds() !== secondValue) return undefined
237
+ const iso = date.toISOString()
238
+ return { raw: iso, epochMs, iso }
239
+ }
240
+
241
+ function resetCandidate(text, records) {
242
+ for (const record of records) {
243
+ for (const [key, value] of Object.entries(record)) {
244
+ if (!RESET_KEYS.has(normalizedKey(key))) continue
245
+ const candidate = scalarText(value)
246
+ if (candidate !== undefined && candidate.length > 0) return candidate
247
+ }
248
+ }
249
+ const hit = text.match(/\b(?:it\s+will\s+)?reset(?:s)?\s+at\s+(.+?)(?=\.\s+(?:we|please|request)\b|$)/iu)
250
+ return hit?.[1]?.trim()
251
+ }
252
+
253
+ function parseReset(text, records) {
254
+ const candidate = resetCandidate(text, records)
255
+ if (candidate === undefined || candidate.length > 128 || /[\r\n\0]/u.test(candidate)) return undefined
256
+ const offset = explicitOffsetDate(candidate)
257
+ if (offset !== undefined) return offset
258
+ const zulu = explicitZuluDate(candidate)
259
+ if (zulu !== undefined) return zulu
260
+ const numeric = /^\d{10}(?:\d{3})?$/u.test(candidate)
261
+ ? Number(candidate) * (candidate.length === 10 ? 1_000 : 1)
262
+ : undefined
263
+ const epochMs = numeric
264
+ if (!Number.isFinite(epochMs)) return undefined
265
+ const iso = new Date(epochMs).toISOString()
266
+ return { raw: iso, epochMs, iso }
267
+ }
268
+
269
+ function requestIdCandidate(text, records, explicit) {
270
+ const direct = safeRequestId(explicit)
271
+ if (direct !== undefined) return direct
272
+ for (const record of records) {
273
+ for (const [key, value] of Object.entries(record)) {
274
+ if (!REQUEST_ID_KEYS.has(normalizedKey(key))) continue
275
+ const candidate = safeRequestId(value)
276
+ if (candidate !== undefined) return candidate
277
+ }
278
+ }
279
+ return safeRequestId(
280
+ text.match(/\brequest\s+id\s*[::]\s*([A-Za-z0-9._:-]{1,128})(?=$|[\s,;}"'])/iu)?.[1],
281
+ )
282
+ }
283
+
284
+ function structuredClassifiers(records) {
285
+ const values = []
286
+ for (const record of records) {
287
+ for (const [key, value] of Object.entries(record)) {
288
+ if (!CLASSIFIER_KEYS.has(normalizedKey(key))) continue
289
+ const text = scalarText(value)
290
+ if (text !== undefined) values.push(text)
291
+ }
292
+ }
293
+ return values
294
+ }
295
+
296
+ function structuredMessages(records) {
297
+ const values = []
298
+ for (const record of records) {
299
+ for (const [key, value] of Object.entries(record)) {
300
+ if (normalizedKey(key) !== "message") continue
301
+ const text = scalarText(value)
302
+ if (text !== undefined) values.push(text)
303
+ }
304
+ }
305
+ return values
306
+ }
307
+
308
+ function hasAccountQuotaClassifier(records) {
309
+ return structuredClassifiers(records)
310
+ .some((code) => ACCOUNT_QUOTA_CODES.has(normalizedKey(code)))
311
+ }
312
+
313
+ function hasAccountQuotaMessage(text, records) {
314
+ const joined = [
315
+ text,
316
+ ...structuredMessages(records).map((value) => withoutEmbeddedObjects(value)),
317
+ ].join("\n")
318
+ return ACCOUNT_QUOTA_PATTERNS.some((pattern) => pattern.test(joined))
319
+ }
320
+
321
+ function accountQuotaEvidence(source, message, embeddedObjects) {
322
+ const sourceRecords = knownErrorRecords([source])
323
+ const sourceGroup = {
324
+ classifierRecords: [source],
325
+ messageRecords: [],
326
+ records: [source],
327
+ text: withoutEmbeddedObjects(message),
328
+ }
329
+ const detailGroups = [
330
+ ...sourceRecords.slice(1).map((error) => ({
331
+ classifierRecords: [error],
332
+ messageRecords: [error],
333
+ records: [error],
334
+ text: "",
335
+ })),
336
+ ...embeddedObjects.map((embedded) => {
337
+ const records = knownErrorRecords([embedded])
338
+ return {
339
+ classifierRecords: records,
340
+ messageRecords: records,
341
+ records,
342
+ text: "",
343
+ }
344
+ }),
345
+ ]
346
+ const groups = [sourceGroup, ...detailGroups]
347
+
348
+ const matchingGroup = groups.find((group) => hasAccountQuotaClassifier(group.classifierRecords))
349
+ ?? groups.find((group) => hasAccountQuotaMessage(group.text, group.messageRecords))
350
+ if (matchingGroup === undefined || matchingGroup !== sourceGroup) return matchingGroup
351
+
352
+ return detailGroups.find((group) => hasAccountQuotaClassifier(group.classifierRecords))
353
+ ?? detailGroups.find((group) => hasAccountQuotaMessage(group.text, group.messageRecords))
354
+ ?? sourceGroup
355
+ }
356
+
357
+ function isKnownTransportFailure(source, message) {
358
+ const code = normalizedKey(source.code)
359
+ if (code === "streamclosed") return true
360
+ return code === "piaierror"
361
+ && PI_AI_TRANSPORT_PATTERNS.some((pattern) => pattern.test(message.trim()))
362
+ }
363
+
364
+ function isAmbiguousPiAi429(source, message) {
365
+ return normalizedKey(source.code) === "piaierror"
366
+ && PI_AI_AMBIGUOUS_429_PATTERN.test(message.trim())
367
+ }
368
+
369
+ export function inspectCodexFailure(failure) {
370
+ const source = failure !== null && typeof failure === "object" ? failure : {}
371
+ const rawMessage = typeof source.message === "string" ? source.message : String(failure ?? "Unknown Codex failure")
372
+ const message = rawMessage.slice(0, MAX_FAILURE_TEXT_CHARS)
373
+ const embeddedObjects = parseEmbeddedJsonObjects(message)
374
+ const quotaEvidence = accountQuotaEvidence(source, message, embeddedObjects)
375
+ const accountQuota = quotaEvidence !== undefined
376
+ const factRecords = accountQuota
377
+ ? [source, ...quotaEvidence.records.filter((record) => record !== source)]
378
+ : [source]
379
+ const trustedText = [
380
+ withoutEmbeddedObjects(message),
381
+ ...(quotaEvidence === undefined
382
+ ? []
383
+ : structuredMessages(quotaEvidence.messageRecords)
384
+ .map((value) => withoutEmbeddedObjects(value))),
385
+ ].join("\n")
386
+ const transport = !accountQuota && isKnownTransportFailure(source, message)
387
+ const ambiguous429 = !accountQuota && !transport && isAmbiguousPiAi429(source, message)
388
+ return Object.freeze({
389
+ kind: accountQuota
390
+ ? "account-quota"
391
+ : transport
392
+ ? "transport"
393
+ : ambiguous429
394
+ ? "ambiguous-limit"
395
+ : "other",
396
+ status: parseStatus(trustedText, factRecords, source.status),
397
+ reset: parseReset(trustedText, factRecords),
398
+ requestId: requestIdCandidate(trustedText, factRecords, source.requestId),
399
+ })
400
+ }
401
+
402
+ function quotaMessage(facts) {
403
+ const reset = facts.reset?.raw === undefined
404
+ ? ""
405
+ : ` 重置时间 / Reset: ${facts.reset.raw}.`
406
+ const request = facts.requestId === undefined
407
+ ? ""
408
+ : ` 请求 ID / Request ID: ${facts.requestId}.`
409
+ return `ChatGPT Codex 账户用量配额已耗尽,已停止自动重试。 / ChatGPT Codex account quota exhausted; automatic retry stopped.${reset}${request}`
410
+ }
411
+
412
+ function ambiguousLimitMessage() {
413
+ return "pi-ai 返回了无法区分账户配额与临时限流的通用 ChatGPT usage-limit 文案;因原始结构化证据不可用,未标记为账户配额,并已停止自动重试。 / pi-ai returned generalized ChatGPT usage-limit text that cannot distinguish account quota from transient rate limiting; because the original structured evidence is unavailable, it was not marked as account quota, and automatic retry stopped."
414
+ }
415
+
416
+ function transportMessage() {
417
+ return "Codex 传输在响应完成前中断。 / Codex transport ended before the response completed."
418
+ }
419
+
420
+ /** Normalize only failures with a public, narrowly verifiable classification. */
421
+ export function normalizeCodexFailure(failure) {
422
+ const facts = inspectCodexFailure(failure)
423
+ if (facts.kind === "transport") {
424
+ return {
425
+ changed: true,
426
+ failure: Object.freeze({
427
+ message: transportMessage(),
428
+ code: "TRANSPORT",
429
+ ...(facts.status === undefined ? {} : { status: facts.status }),
430
+ ...(facts.requestId === undefined ? {} : { requestId: facts.requestId }),
431
+ }),
432
+ facts,
433
+ }
434
+ }
435
+ if (facts.kind === "ambiguous-limit") {
436
+ return {
437
+ changed: true,
438
+ failure: Object.freeze({
439
+ message: ambiguousLimitMessage(),
440
+ code: "QUOTA_OR_RATE_LIMIT",
441
+ ...(facts.status === undefined ? {} : { status: facts.status }),
442
+ ...(facts.requestId === undefined ? {} : { requestId: facts.requestId }),
443
+ }),
444
+ facts,
445
+ }
446
+ }
447
+ if (facts.kind !== "account-quota") return { changed: false, failure, facts }
448
+
449
+ const normalized = {
450
+ message: quotaMessage(facts),
451
+ code: "QUOTA",
452
+ status: facts.status ?? 429,
453
+ ...(facts.requestId === undefined ? {} : { requestId: facts.requestId }),
454
+ }
455
+ return { changed: true, failure: Object.freeze(normalized), facts }
456
+ }
@@ -0,0 +1,45 @@
1
+ export const DEFAULT_IMAGE_POLICY = Object.freeze({
2
+ maxRequestImageBytes: 20 * 1024 * 1024,
3
+ requestImagePixelBudget: 2048 * 2048,
4
+ requestImageMaxBytes: 1024 * 1024,
5
+ })
6
+
7
+ function positiveSafeInteger(value, name) {
8
+ if (!Number.isSafeInteger(value) || value <= 0) {
9
+ throw new TypeError(`${name} must be a positive safe integer`)
10
+ }
11
+ return value
12
+ }
13
+ /**
14
+ * Resolve every optional image limit at the configuration boundary.
15
+ * Downstream callers receive no optional numeric fields.
16
+ */
17
+ export function resolveImagePolicy(input = {}) {
18
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
19
+ throw new TypeError("image policy must be an object")
20
+ }
21
+
22
+ return Object.freeze({
23
+ maxRequestImageBytes: positiveSafeInteger(
24
+ input.maxRequestImageBytes ?? DEFAULT_IMAGE_POLICY.maxRequestImageBytes,
25
+ "maxRequestImageBytes",
26
+ ),
27
+ requestImagePixelBudget: positiveSafeInteger(
28
+ input.requestImagePixelBudget ?? DEFAULT_IMAGE_POLICY.requestImagePixelBudget,
29
+ "requestImagePixelBudget",
30
+ ),
31
+ requestImageMaxBytes: positiveSafeInteger(
32
+ input.requestImageMaxBytes ?? DEFAULT_IMAGE_POLICY.requestImageMaxBytes,
33
+ "requestImageMaxBytes",
34
+ ),
35
+ })
36
+ }
37
+
38
+ /** Convert a resolved provider profile into the attachment service contract. */
39
+ export function toAttachmentRequestPolicy(policy) {
40
+ const resolved = resolveImagePolicy(policy)
41
+ return Object.freeze({
42
+ maxPixels: resolved.requestImagePixelBudget,
43
+ maxBytes: resolved.requestImageMaxBytes,
44
+ })
45
+ }
@@ -0,0 +1,142 @@
1
+ const DEFAULT_STALE_MS = 5 * 60_000
2
+ const DEFAULT_MAX_RESET_HORIZON_MS = 8 * 24 * 60 * 60_000
3
+ const UNKNOWN_SNAPSHOT = Object.freeze({ status: "unknown" })
4
+
5
+ const OPTION_FIELDS = new Set(["clock", "staleMs", "maxResetHorizonMs"])
6
+ const QUOTA_FIELDS = new Set(["observedAt", "resetAt"])
7
+
8
+ function isPlainObject(value) {
9
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false
10
+ const prototype = Object.getPrototypeOf(value)
11
+ return prototype === Object.prototype || prototype === null
12
+ }
13
+
14
+ function plainObject(value, name) {
15
+ if (!isPlainObject(value)) throw new TypeError(`${name} must be a plain object`)
16
+ return value
17
+ }
18
+
19
+ function rejectUnknownFields(value, allowed, messagePrefix) {
20
+ for (const field of Reflect.ownKeys(value)) {
21
+ if (typeof field === "string" && allowed.has(field)) continue
22
+ throw new TypeError(`${messagePrefix}: ${String(field)}`)
23
+ }
24
+ }
25
+
26
+ function timestamp(value, name) {
27
+ if (!Number.isSafeInteger(value) || value < 0) {
28
+ throw new TypeError(`${name} must be a non-negative safe integer`)
29
+ }
30
+ return value
31
+ }
32
+
33
+ function positiveDuration(value, name) {
34
+ if (!Number.isSafeInteger(value) || value <= 0) {
35
+ throw new TypeError(`${name} must be a positive safe integer`)
36
+ }
37
+ return value
38
+ }
39
+
40
+ function staleAt(now, observedAt, staleMs) {
41
+ return now >= observedAt && now - observedAt >= staleMs
42
+ }
43
+
44
+ function frozenSnapshot(evidence) {
45
+ if (evidence === undefined) return UNKNOWN_SNAPSHOT
46
+ if (evidence.status === "recent-success") {
47
+ return Object.freeze({
48
+ status: evidence.status,
49
+ observedAt: evidence.observedAt,
50
+ })
51
+ }
52
+ if (evidence.resetAt === undefined) {
53
+ return Object.freeze({
54
+ status: evidence.status,
55
+ observedAt: evidence.observedAt,
56
+ })
57
+ }
58
+ return Object.freeze({
59
+ status: evidence.status,
60
+ observedAt: evidence.observedAt,
61
+ resetAt: evidence.resetAt,
62
+ })
63
+ }
64
+
65
+ /**
66
+ * Observe only quota availability facts. Provider payloads, account data,
67
+ * request identifiers, and error text never cross this module's interface.
68
+ */
69
+ export function createQuotaObserver(options = {}) {
70
+ const input = plainObject(options, "quota observer options")
71
+ rejectUnknownFields(input, OPTION_FIELDS, "unknown quota observer option")
72
+
73
+ const clock = input.clock === undefined ? Date.now : input.clock
74
+ if (typeof clock !== "function") throw new TypeError("clock must be a function")
75
+ const staleMs = positiveDuration(
76
+ input.staleMs === undefined ? DEFAULT_STALE_MS : input.staleMs,
77
+ "staleMs",
78
+ )
79
+ const maxResetHorizonMs = positiveDuration(
80
+ input.maxResetHorizonMs === undefined ? DEFAULT_MAX_RESET_HORIZON_MS : input.maxResetHorizonMs,
81
+ "maxResetHorizonMs",
82
+ )
83
+
84
+ let evidence
85
+ let lastObservedAt = -1
86
+
87
+ function readClock() {
88
+ return timestamp(clock(), "clock()")
89
+ }
90
+
91
+ function expire(now) {
92
+ if (evidence === undefined) return
93
+ if (evidence.status === "recent-success") {
94
+ if (staleAt(now, evidence.observedAt, staleMs)) evidence = undefined
95
+ return
96
+ }
97
+ if (evidence.resetAt !== undefined) {
98
+ if (now >= evidence.resetAt) evidence = undefined
99
+ return
100
+ }
101
+ if (staleAt(now, evidence.observedAt, staleMs)) evidence = undefined
102
+ }
103
+
104
+ function snapshot() {
105
+ expire(readClock())
106
+ return frozenSnapshot(evidence)
107
+ }
108
+
109
+ function observeSuccess(now) {
110
+ const observedAt = timestamp(now, "now")
111
+ if (observedAt < lastObservedAt) return snapshot()
112
+
113
+ lastObservedAt = observedAt
114
+ evidence = { status: "recent-success", observedAt }
115
+ return snapshot()
116
+ }
117
+
118
+ function observeQuota(observation) {
119
+ const value = plainObject(observation, "quota observation")
120
+ rejectUnknownFields(value, QUOTA_FIELDS, "unknown quota observation field")
121
+
122
+ const observedAt = timestamp(value.observedAt, "observedAt")
123
+ const resetAt = value.resetAt === undefined
124
+ ? undefined
125
+ : timestamp(value.resetAt, "resetAt")
126
+ if (observedAt < lastObservedAt) return snapshot()
127
+
128
+ lastObservedAt = observedAt
129
+ if (resetAt !== undefined && resetAt <= observedAt) {
130
+ evidence = undefined
131
+ return snapshot()
132
+ }
133
+
134
+ const acceptedResetAt = resetAt !== undefined && resetAt - observedAt <= maxResetHorizonMs
135
+ ? resetAt
136
+ : undefined
137
+ evidence = { status: "exhausted", observedAt, resetAt: acceptedResetAt }
138
+ return snapshot()
139
+ }
140
+
141
+ return Object.freeze({ observeSuccess, observeQuota, snapshot })
142
+ }
@@ -0,0 +1,12 @@
1
+ export {
2
+ inspectCodexFailure,
3
+ normalizeCodexFailure,
4
+ parseEmbeddedJsonObjects,
5
+ } from "./failure-normalizer.mjs"
6
+ export {
7
+ DEFAULT_IMAGE_POLICY,
8
+ resolveImagePolicy,
9
+ toAttachmentRequestPolicy,
10
+ } from "./image-policy.mjs"
11
+ export { createQuotaObserver } from "./quota-observer.mjs"
12
+ export { stabilizeCodexStream } from "./stream-resilience.mjs"