@demicodes/provider-codex 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -1,11 +1,31 @@
1
1
  # @demicodes/provider-codex
2
2
 
3
- A Demi provider backed by Codex (CLI/OAuth transport). Exposes
3
+ A Demi provider backed by Codex (ChatGPT / Responses transport). Exposes
4
4
  `createCodexProvider()`.
5
5
 
6
6
  ```ts
7
7
  import { createCodexProvider } from '@demicodes/provider-codex'
8
+
9
+ const provider = createCodexProvider()
10
+ // provider.auth, provider.quota, provider.credentials (multi-account pool by default)
8
11
  ```
9
12
 
13
+ ## Auth and credentials
14
+
15
+ - Default material: `~/.codex/auth.json` (or `$CODEX_HOME`).
16
+ - Multi-credential pool under `$DEMI_HOME/credentials/codex/` with global
17
+ `provider.credentials.setActive(id)`.
18
+ - Lifecycle: `beginLogin` → vendor `codex login` → `importDefault` → `setActive`.
19
+
20
+ See [docs/provider-global-credentials.md](../../docs/provider-global-credentials.md).
21
+
22
+ ## Quota
23
+
24
+ - **probe** (cost: `minimal_request`): tiny streamed Responses call; reads
25
+ `x-codex-primary-*` / `x-codex-secondary-*` headers.
26
+ - **observe**: same headers on live inference responses (preferred steady-state).
27
+
28
+ See [docs/provider-quota.md](../../docs/provider-quota.md).
29
+
10
30
  Implements the [`@demicodes/provider`](../provider/README.md) contract. Part of
11
31
  [Demi](../../README.md). Apache-2.0.
package/dist/index.d.mts CHANGED
@@ -1,4 +1,6 @@
1
- import { ModelPolicy, Provider, ProviderAuthState, ProviderModelList } from "@demicodes/provider";
1
+ import { ModelPolicy, Provider, ProviderAuthState, ProviderCredentials, ProviderModelList, ProviderQuota, ProviderQuotaProbeResult } from "@demicodes/provider";
2
+ import { FileCredentialPool } from "@demicodes/provider/credentials-pool";
3
+ import "@demicodes/core";
2
4
  //#region src/auth.d.ts
3
5
  type CodexResolvedAuth = {
4
6
  kind: 'chatgpt';
@@ -38,6 +40,8 @@ interface CodexAuthStore {
38
40
  declare function codexAuthStatus(options?: FileCodexAuthStoreOptions): Promise<ProviderAuthState>;
39
41
  interface FileCodexAuthStoreOptions {
40
42
  codexHome?: string;
43
+ /** Override auth.json path (e.g. demi credential pool entry). */
44
+ authFile?: string;
41
45
  refresh?: CodexTokenRefresh;
42
46
  now?: () => Date;
43
47
  lockRetryDelayMs?: number;
@@ -85,7 +89,66 @@ interface CodexProviderOptions extends CodexProviderConfig {
85
89
  id?: string;
86
90
  displayName?: string;
87
91
  models?: ModelPolicy;
92
+ authStore?: CodexAuthStore;
93
+ /** Demi state root for credential pool (`$DEMI_HOME` / `~/.demi`). */
94
+ stateDir?: string;
95
+ /**
96
+ * When true (default if `authStore` is not provided), attach multi-credential
97
+ * pool + global switch under `provider.credentials`.
98
+ */
99
+ credentials?: boolean;
88
100
  }
89
101
  declare function createCodexProvider(options?: CodexProviderOptions): Provider;
90
102
  //#endregion
91
- export { type CodexModelCatalogOptions, type CodexProviderOptions, type CodexTransportMode, type FileCodexAuthStoreOptions, codexAuthStatus, createCodexProvider, listCodexModels };
103
+ //#region src/quota.d.ts
104
+ interface CodexQuotaOptions {
105
+ providerId?: string;
106
+ codexHome?: string;
107
+ baseUrl?: string;
108
+ authStore?: CodexAuthStore;
109
+ /** Model id for the minimal probe request (must be accepted by the backend). */
110
+ probeModelId?: string;
111
+ fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
112
+ userAgent?: string;
113
+ }
114
+ /**
115
+ * Codex consumer rate windows come from Responses headers:
116
+ * x-codex-{primary,secondary}-used-percent / -window-minutes / -reset-at
117
+ *
118
+ * probe() issues a minimal streamed Responses request and cancels the body
119
+ * (probeCost: minimal_request). observeResponse can parse the same headers
120
+ * from any live Responses call without an extra request.
121
+ */
122
+ declare function createCodexQuota(options?: CodexQuotaOptions): ProviderQuota;
123
+ declare function mapCodexRateLimitHeaders(headers: Headers | undefined): ProviderQuotaProbeResult | null;
124
+ //#endregion
125
+ //#region src/credentials.d.ts
126
+ /**
127
+ * Auth store that prefers the demi pool active entry; falls back to vendor ~/.codex.
128
+ */
129
+ declare class PoolAwareCodexAuthStore implements CodexAuthStore {
130
+ private readonly pool;
131
+ private readonly vendorHome;
132
+ private readonly fileAuthOptions;
133
+ constructor(pool: FileCredentialPool, options?: {
134
+ codexHome?: string;
135
+ fileAuthOptions?: Omit<FileCodexAuthStoreOptions, 'codexHome' | 'authFile'>;
136
+ });
137
+ status(): Promise<import("@demicodes/provider").ProviderAuthState>;
138
+ resolveAuth(options?: {
139
+ forceRefresh?: boolean;
140
+ }): Promise<CodexResolvedAuth>;
141
+ private currentStore;
142
+ }
143
+ declare function createCodexCredentials(pool: FileCredentialPool, authStore: CodexAuthStore, options?: {
144
+ codexHome?: string;
145
+ loginCommand?: string;
146
+ loginArgs?: string[];
147
+ quota?: ProviderQuota | null;
148
+ onActiveChange?: () => void;
149
+ }): ProviderCredentials;
150
+ declare function openCodexCredentialPool(options?: {
151
+ stateDir?: string;
152
+ }): FileCredentialPool;
153
+ //#endregion
154
+ export { type CodexModelCatalogOptions, type CodexProviderOptions, type CodexQuotaOptions, type CodexResolvedAuth, type CodexTransportMode, type FileCodexAuthStoreOptions, PoolAwareCodexAuthStore, codexAuthStatus, createCodexCredentials, createCodexProvider, createCodexQuota, listCodexModels, mapCodexRateLimitHeaders, openCodexCredentialPool };
package/dist/index.mjs CHANGED
@@ -1,13 +1,20 @@
1
- import { delay, errorMessage, isRecord, nonEmptyString, numberOrNull, numberOrZero, parseJsonObject, parseJsonOrString, shortHash, stringOrNull } from "@demicodes/utils";
1
+ import { delay, errorCode, errorMessage, isRecord, nonEmptyString, numberOrNull, numberOrZero, parseJsonObject, parseJsonOrString, shortHash, stringOrNull } from "@demicodes/utils";
2
2
  import { Buffer } from "node:buffer";
3
3
  import { chmod, mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
- import { applyModelPolicy, clampPromptCacheKey, defineProvider, httpErrorCode } from "@demicodes/provider";
6
+ import { applyModelPolicy, clampPromptCacheKey, clampUsedPercent, createProviderQuota, defineProvider, httpErrorCode, numberHeader, redactCredentialText, severityFromUsedPercent, unixSecondsToIso } from "@demicodes/provider";
7
+ import { FileCredentialPool, credentialIdFromIdentity, runVendorLoginCommand } from "@demicodes/provider/credentials-pool";
8
+ import { randomUUID } from "node:crypto";
7
9
  //#region src/auth.ts
8
10
  async function codexAuthStatus(options = {}) {
9
11
  return new FileCodexAuthStore(options).status();
10
12
  }
13
+ /** Codex error text may embed the env-provided API key by name; redact it alongside the standard fields. */
14
+ const SECRET_FIELD_PATTERNS = ["OPENAI_API_KEY"];
15
+ function redactCodexSecretText(text) {
16
+ return redactCredentialText(text, SECRET_FIELD_PATTERNS);
17
+ }
11
18
  const TOKEN_REFRESH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
12
19
  const TOKEN_REFRESH_URL = "https://auth.openai.com/oauth/token";
13
20
  const REFRESH_EXPIRY_SKEW_MS = 300 * 1e3;
@@ -21,7 +28,7 @@ var FileCodexAuthStore = class {
21
28
  lockTimeoutMs;
22
29
  constructor(options = {}) {
23
30
  this.codexHome = options.codexHome ?? defaultCodexHome();
24
- this.authFile = join(this.codexHome, "auth.json");
31
+ this.authFile = options.authFile ?? join(this.codexHome, "auth.json");
25
32
  this.refreshImpl = options.refresh ?? refreshCodexToken;
26
33
  this.now = options.now ?? (() => /* @__PURE__ */ new Date());
27
34
  this.lockRetryDelayMs = options.lockRetryDelayMs ?? 25;
@@ -57,7 +64,7 @@ var FileCodexAuthStore = class {
57
64
  };
58
65
  return {
59
66
  status: "error",
60
- message: redactSecretText(error instanceof Error ? error.message : String(error))
67
+ message: redactCodexSecretText(error instanceof Error ? error.message : String(error))
61
68
  };
62
69
  }
63
70
  }
@@ -137,8 +144,8 @@ var FileCodexAuthStore = class {
137
144
  try {
138
145
  return JSON.parse(await readFile(this.authFile, "utf8"));
139
146
  } catch (error) {
140
- if (isNodeError(error) && error.code === "ENOENT") throw new CodexAuthError("auth_missing", `Codex auth file not found: ${this.authFile}`);
141
- throw new CodexAuthError("auth_invalid", `Failed to read Codex auth file ${this.authFile}: ${redactSecretText(errorMessage(error))}`);
147
+ if (errorCode(error) === "ENOENT") throw new CodexAuthError("auth_missing", `Codex auth file not found: ${this.authFile}`);
148
+ throw new CodexAuthError("auth_invalid", `Failed to read Codex auth file ${this.authFile}: ${redactCodexSecretText(errorMessage(error))}`);
142
149
  }
143
150
  }
144
151
  async withAuthFileLock(fn) {
@@ -149,7 +156,7 @@ var FileCodexAuthStore = class {
149
156
  while (!handle) try {
150
157
  handle = await open(lockFile, "wx", 384);
151
158
  } catch (error) {
152
- if (!isNodeError(error) || error.code !== "EEXIST" || Date.now() - started > this.lockTimeoutMs) throw new CodexAuthError("auth_lock_failed", `Failed to lock Codex auth file: ${redactSecretText(errorMessage(error))}`);
159
+ if (errorCode(error) !== "EEXIST" || Date.now() - started > this.lockTimeoutMs) throw new CodexAuthError("auth_lock_failed", `Failed to lock Codex auth file: ${redactCodexSecretText(errorMessage(error))}`);
153
160
  await delay(this.lockRetryDelayMs);
154
161
  }
155
162
  try {
@@ -220,9 +227,6 @@ async function refreshCodexToken(refreshToken, signal) {
220
227
  if (!response.ok) throw new CodexAuthError("auth_refresh_failed", `Codex token refresh failed with HTTP ${response.status}`);
221
228
  return await response.json();
222
229
  }
223
- function redactSecretText(text) {
224
- return text.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [REDACTED]").replace(/(access_token|refresh_token|id_token|OPENAI_API_KEY)["'=:\s]+[A-Za-z0-9._~+/=-]+/gi, "$1=[REDACTED]");
225
- }
226
230
  function resolveChatGptAuthFromFile(auth, authFile) {
227
231
  const tokens = auth.tokens;
228
232
  if (!tokens || typeof tokens !== "object") throw new CodexAuthError("auth_missing", `No ChatGPT tokens found in ${authFile}`);
@@ -288,9 +292,6 @@ function parseDate(value) {
288
292
  const ms = Date.parse(value);
289
293
  return Number.isFinite(ms) ? new Date(ms) : null;
290
294
  }
291
- function isNodeError(error) {
292
- return error instanceof Error && "code" in error;
293
- }
294
295
  //#endregion
295
296
  //#region src/models.ts
296
297
  const DEFAULT_CHATGPT_CODEX_BASE_URL$1 = "https://chatgpt.com/backend-api";
@@ -491,7 +492,7 @@ function isAuthCatalogError(error) {
491
492
  var CodexModelCatalogHttpError = class extends Error {
492
493
  status;
493
494
  constructor(status, message) {
494
- super(redactSecretText(message));
495
+ super(redactCodexSecretText(message));
495
496
  this.status = status;
496
497
  this.name = "CodexModelCatalogHttpError";
497
498
  }
@@ -500,6 +501,291 @@ function defaultModelCatalogUserAgent() {
500
501
  return `demi-provider-codex/0.0.0`;
501
502
  }
502
503
  //#endregion
504
+ //#region src/credentials.ts
505
+ /**
506
+ * Auth store that prefers the demi pool active entry; falls back to vendor ~/.codex.
507
+ */
508
+ var PoolAwareCodexAuthStore = class {
509
+ pool;
510
+ vendorHome;
511
+ fileAuthOptions;
512
+ constructor(pool, options = {}) {
513
+ this.pool = pool;
514
+ this.vendorHome = options.codexHome ?? defaultCodexHome();
515
+ this.fileAuthOptions = options.fileAuthOptions ?? {};
516
+ }
517
+ async status() {
518
+ return this.currentStore().then((s) => s.status());
519
+ }
520
+ async resolveAuth(options) {
521
+ return this.currentStore().then((s) => s.resolveAuth(options));
522
+ }
523
+ async currentStore() {
524
+ await this.pool.ensureActivePointer();
525
+ const activeId = await this.pool.getActiveId();
526
+ if (activeId) return new FileCodexAuthStore({
527
+ ...this.fileAuthOptions,
528
+ authFile: this.pool.secretPath(activeId),
529
+ codexHome: this.pool.entryDir(activeId)
530
+ });
531
+ return new FileCodexAuthStore({
532
+ ...this.fileAuthOptions,
533
+ codexHome: this.vendorHome
534
+ });
535
+ }
536
+ };
537
+ function createCodexCredentials(pool, authStore, options = {}) {
538
+ const vendorHome = options.codexHome ?? defaultCodexHome();
539
+ const loginCommand = options.loginCommand ?? "codex";
540
+ const loginArgs = options.loginArgs ?? ["login"];
541
+ const capability = () => ({
542
+ mode: "supported",
543
+ canBeginLogin: true,
544
+ canImportDefault: true,
545
+ canAdd: true,
546
+ multi: true
547
+ });
548
+ const getActive = async () => {
549
+ await pool.ensureActivePointer();
550
+ return {
551
+ credentialId: await pool.getActiveId(),
552
+ status: await authStore.status()
553
+ };
554
+ };
555
+ const setActive = async (credentialId) => {
556
+ await pool.setActiveId(credentialId);
557
+ options.quota?.clearLatest?.();
558
+ options.onActiveChange?.();
559
+ return getActive();
560
+ };
561
+ const importFromAuthJson = async (authText, source) => {
562
+ let auth;
563
+ try {
564
+ auth = JSON.parse(authText);
565
+ } catch {
566
+ throw new CodexAuthError("auth_invalid", "Codex auth material is not valid JSON");
567
+ }
568
+ if (!isRecord(auth)) throw new CodexAuthError("auth_invalid", "Codex auth material is not an object");
569
+ const { label, identityKey, detail } = labelFromCodexAuth(auth);
570
+ const id = (identityKey ? await pool.findByIdentityKey(identityKey) : null)?.id ?? credentialIdFromIdentity(identityKey, label);
571
+ const meta = {
572
+ id,
573
+ label,
574
+ detail,
575
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
576
+ source,
577
+ identityKey
578
+ };
579
+ await pool.writeEntry(meta, `${JSON.stringify(auth, null, 2)}\n`);
580
+ if (!await pool.getActiveId()) await pool.setActiveId(id);
581
+ options.quota?.clearLatest?.();
582
+ options.onActiveChange?.();
583
+ return {
584
+ id: meta.id,
585
+ label: meta.label,
586
+ detail: meta.detail,
587
+ updatedAt: meta.updatedAt
588
+ };
589
+ };
590
+ return {
591
+ capability,
592
+ list: () => pool.list(),
593
+ getActive,
594
+ setActive,
595
+ beginLogin: async (loginOptions) => {
596
+ const result = await runVendorLoginCommand(loginCommand, loginArgs, { signal: loginOptions?.signal });
597
+ if (result.status === "completed") return { status: "completed" };
598
+ if (result.status === "cancelled") return { status: "cancelled" };
599
+ if (result.status === "unavailable") return {
600
+ status: "unavailable",
601
+ message: result.message ?? "Login unavailable"
602
+ };
603
+ return {
604
+ status: "failed",
605
+ message: result.message ?? "Login failed"
606
+ };
607
+ },
608
+ importDefault: async () => {
609
+ const authFile = join(vendorHome, "auth.json");
610
+ let text;
611
+ try {
612
+ text = await readFile(authFile, "utf8");
613
+ } catch {
614
+ throw new CodexAuthError("auth_missing", `No Codex auth at ${authFile}. Run codex login or beginLogin first.`);
615
+ }
616
+ return importFromAuthJson(text, `vendor:${authFile}`);
617
+ },
618
+ add: async (input) => {
619
+ if (typeof input.authJsonText === "string") return importFromAuthJson(input.authJsonText, "add:authJsonText");
620
+ if (typeof input.authFile === "string") {
621
+ const text = await readFile(input.authFile, "utf8");
622
+ return importFromAuthJson(text, `add:authFile:${input.authFile}`);
623
+ }
624
+ if (isRecord(input.auth) || isRecord(input.authJson)) {
625
+ const obj = input.auth ?? input.authJson;
626
+ return importFromAuthJson(`${JSON.stringify(obj, null, 2)}\n`, "add:auth");
627
+ }
628
+ throw new Error("Codex credentials.add expects authJsonText, authFile, or auth/authJson object");
629
+ },
630
+ remove: async (credentialId) => {
631
+ await pool.remove(credentialId);
632
+ options.quota?.clearLatest?.();
633
+ options.onActiveChange?.();
634
+ }
635
+ };
636
+ }
637
+ function openCodexCredentialPool(options = {}) {
638
+ return new FileCredentialPool({
639
+ stateDir: options.stateDir,
640
+ providerKey: "codex",
641
+ secretFileName: "auth.json"
642
+ });
643
+ }
644
+ function labelFromCodexAuth(auth) {
645
+ if (nonEmptyString(auth.OPENAI_API_KEY)) return {
646
+ label: "OPENAI_API_KEY",
647
+ identityKey: "apiKey",
648
+ detail: "apiKey"
649
+ };
650
+ const pat = nonEmptyString(auth.personal_access_token);
651
+ if (pat) {
652
+ const claims = parseChatGptClaims(pat);
653
+ const label = claims.email ?? claims.accountId ?? "personal access token";
654
+ return {
655
+ label,
656
+ identityKey: claims.accountId ?? label,
657
+ detail: "personalAccessToken"
658
+ };
659
+ }
660
+ const tokens = auth.tokens;
661
+ if (tokens && typeof tokens === "object") {
662
+ const access = nonEmptyString(tokens.access_token);
663
+ const idClaims = parseIdTokenClaims(tokens.id_token);
664
+ const accessClaims = access ? parseChatGptClaims(access) : {
665
+ accountId: null,
666
+ email: null,
667
+ isFedrampAccount: false
668
+ };
669
+ const accountId = nonEmptyString(tokens.account_id) ?? idClaims.accountId ?? accessClaims.accountId;
670
+ const email = idClaims.email ?? accessClaims.email;
671
+ return {
672
+ label: email ?? accountId ?? "chatgpt",
673
+ identityKey: accountId ?? email,
674
+ detail: "chatgpt"
675
+ };
676
+ }
677
+ return {
678
+ label: "codex",
679
+ identityKey: null,
680
+ detail: null
681
+ };
682
+ }
683
+ //#endregion
684
+ //#region src/quota.ts
685
+ const DEFAULT_PROBE_MODEL = "gpt-5.4";
686
+ /**
687
+ * Codex consumer rate windows come from Responses headers:
688
+ * x-codex-{primary,secondary}-used-percent / -window-minutes / -reset-at
689
+ *
690
+ * probe() issues a minimal streamed Responses request and cancels the body
691
+ * (probeCost: minimal_request). observeResponse can parse the same headers
692
+ * from any live Responses call without an extra request.
693
+ */
694
+ function createCodexQuota(options = {}) {
695
+ const providerId = options.providerId ?? "codex";
696
+ const authStore = options.authStore ?? new FileCodexAuthStore({ codexHome: options.codexHome });
697
+ const fetchImpl = options.fetch ?? fetch;
698
+ const probeModelId = options.probeModelId ?? DEFAULT_PROBE_MODEL;
699
+ return createProviderQuota({
700
+ providerId,
701
+ canProbe: true,
702
+ canObserve: true,
703
+ probeCost: "minimal_request",
704
+ staleAfterMs: 5 * 6e4,
705
+ probe: async ({ signal } = {}) => {
706
+ const auth = await authStore.resolveAuth();
707
+ const status = await authStore.status();
708
+ const accountLabel = status.status === "authenticated" ? status.accountLabel ?? null : null;
709
+ const headers = buildCodexHeaders(auth, {
710
+ sessionId: "demi-codex-quota-probe",
711
+ requestId: randomUUID()
712
+ }, { userAgent: options.userAgent });
713
+ const body = {
714
+ model: probeModelId,
715
+ instructions: "Reply with pong.",
716
+ input: [{
717
+ type: "message",
718
+ role: "user",
719
+ content: [{
720
+ type: "input_text",
721
+ text: "ping"
722
+ }]
723
+ }],
724
+ tools: [],
725
+ tool_choice: "auto",
726
+ parallel_tool_calls: true,
727
+ store: false,
728
+ stream: true,
729
+ include: [],
730
+ prompt_cache_key: "demi-codex-quota-probe",
731
+ text: { verbosity: "low" }
732
+ };
733
+ const response = await fetchImpl(responsesUrlForAuth(auth, options.baseUrl), {
734
+ method: "POST",
735
+ headers,
736
+ body: JSON.stringify(body),
737
+ signal
738
+ });
739
+ const partial = mapCodexRateLimitHeaders(response.headers);
740
+ await response.body?.cancel().catch(() => {});
741
+ if (!partial || partial.windows.length === 0) throw new Error(`Codex quota probe got no rate-limit headers (HTTP ${response.status})`);
742
+ return {
743
+ ...partial,
744
+ accountLabel,
745
+ raw: {
746
+ ...isRecord(partial.raw) ? partial.raw : {},
747
+ httpStatus: response.status,
748
+ authKind: auth.kind
749
+ }
750
+ };
751
+ },
752
+ observe: ({ headers }) => mapCodexRateLimitHeaders(headers)
753
+ });
754
+ }
755
+ function mapCodexRateLimitHeaders(headers) {
756
+ if (!headers) return null;
757
+ const windows = [parseCodexWindow(headers, "primary"), parseCodexWindow(headers, "secondary")].filter((w) => w !== null);
758
+ if (windows.length === 0) return null;
759
+ return {
760
+ windows,
761
+ raw: {
762
+ primary: headerBag(headers, "primary"),
763
+ secondary: headerBag(headers, "secondary")
764
+ }
765
+ };
766
+ }
767
+ function parseCodexWindow(headers, kind) {
768
+ const usedPercent = clampUsedPercent(numberHeader(headers, `x-codex-${kind}-used-percent`));
769
+ if (usedPercent == null && !headers.has(`x-codex-${kind}-used-percent`)) return null;
770
+ const windowMinutes = numberHeader(headers, `x-codex-${kind}-window-minutes`);
771
+ const resetAt = unixSecondsToIso(numberHeader(headers, `x-codex-${kind}-reset-at`));
772
+ return {
773
+ id: kind,
774
+ label: kind === "primary" ? windowMinutes != null ? `Primary (${windowMinutes}m)` : "Primary" : windowMinutes != null ? `Secondary (${windowMinutes}m)` : "Secondary",
775
+ usedPercent,
776
+ unit: "percent",
777
+ resetsAt: resetAt,
778
+ severity: severityFromUsedPercent(usedPercent)
779
+ };
780
+ }
781
+ function headerBag(headers, kind) {
782
+ return {
783
+ usedPercent: headers.get(`x-codex-${kind}-used-percent`),
784
+ windowMinutes: headers.get(`x-codex-${kind}-window-minutes`),
785
+ resetAt: headers.get(`x-codex-${kind}-reset-at`)
786
+ };
787
+ }
788
+ //#endregion
503
789
  //#region src/responses.ts
504
790
  function buildCodexResponsesRequestBody(request) {
505
791
  const body = {
@@ -877,7 +1163,13 @@ var FetchCodexResponsesTransport = class {
877
1163
  headerTimeout.clear();
878
1164
  combined.cleanup();
879
1165
  }
880
- if (!response.ok) throw new CodexHttpError(response.status, response.statusText, await response.text());
1166
+ try {
1167
+ request.onHttpResponse?.({
1168
+ headers: response.headers,
1169
+ status: response.status
1170
+ });
1171
+ } catch {}
1172
+ if (!response.ok) throw new CodexHttpError(response.status, response.statusText, await response.text(), response.headers);
881
1173
  if (!response.body) throw new Error("Codex response did not include a body");
882
1174
  yield* parseSseResponseStream(response.body);
883
1175
  }
@@ -989,10 +1281,12 @@ var AutoCodexResponsesTransport = class {
989
1281
  var CodexHttpError = class extends Error {
990
1282
  status;
991
1283
  responseText;
992
- constructor(status, statusText, responseText) {
1284
+ headers;
1285
+ constructor(status, statusText, responseText, headers = new Headers()) {
993
1286
  super(codexHttpErrorMessage(status, statusText, responseText));
994
1287
  this.status = status;
995
1288
  this.responseText = responseText;
1289
+ this.headers = headers;
996
1290
  this.name = "CodexHttpError";
997
1291
  }
998
1292
  };
@@ -1133,6 +1427,7 @@ var CodexProvider = class {
1133
1427
  config;
1134
1428
  authStore;
1135
1429
  transport;
1430
+ quota;
1136
1431
  constructor(options = {}) {
1137
1432
  this.config = {
1138
1433
  codexHome: options.codexHome,
@@ -1149,6 +1444,7 @@ var CodexProvider = class {
1149
1444
  };
1150
1445
  this.authStore = options.authStore ?? new FileCodexAuthStore({ codexHome: options.codexHome });
1151
1446
  this.transport = options.transportImpl ?? createCodexTransport(this.config.transport);
1447
+ this.quota = options.quota ?? null;
1152
1448
  }
1153
1449
  async *run(request) {
1154
1450
  const body = buildCodexResponsesRequestBody(request);
@@ -1166,10 +1462,20 @@ var CodexProvider = class {
1166
1462
  signal: request.cancel,
1167
1463
  headerTimeoutMs: this.config.headerTimeoutMs,
1168
1464
  websocketConnectTimeoutMs: this.config.websocketConnectTimeoutMs,
1169
- streamIdleTimeoutMs: this.config.streamIdleTimeoutMs || void 0
1465
+ streamIdleTimeoutMs: this.config.streamIdleTimeoutMs || void 0,
1466
+ onHttpResponse: (response) => {
1467
+ this.observeQuotaResponse({
1468
+ headers: response.headers,
1469
+ status: response.status
1470
+ });
1471
+ }
1170
1472
  }));
1171
1473
  return;
1172
1474
  } catch (error) {
1475
+ if (error instanceof CodexHttpError) this.observeQuotaResponse({
1476
+ headers: error.headers,
1477
+ status: error.status
1478
+ });
1173
1479
  if (request.cancel.aborted) {
1174
1480
  yield { type: "abort" };
1175
1481
  return;
@@ -1187,11 +1493,20 @@ var CodexProvider = class {
1187
1493
  return;
1188
1494
  }
1189
1495
  }
1496
+ observeQuotaResponse(input) {
1497
+ try {
1498
+ this.quota?.observeResponse?.(input);
1499
+ } catch {}
1500
+ }
1190
1501
  };
1191
1502
  function createCodexProvider(options = {}) {
1192
1503
  const id = options.id ?? "codex";
1193
1504
  const displayName = options.displayName ?? "Codex";
1505
+ const enableCredentials = options.credentials ?? options.authStore === void 0;
1506
+ const pool = !options.authStore && enableCredentials ? openCodexCredentialPool({ stateDir: options.stateDir }) : null;
1507
+ const authStore = options.authStore ?? (pool ? new PoolAwareCodexAuthStore(pool, { codexHome: options.codexHome }) : new FileCodexAuthStore({ codexHome: options.codexHome }));
1194
1508
  const runtimeOptions = {
1509
+ authStore,
1195
1510
  codexHome: options.codexHome,
1196
1511
  baseUrl: options.baseUrl,
1197
1512
  transport: options.transport,
@@ -1204,13 +1519,27 @@ function createCodexProvider(options = {}) {
1204
1519
  streamIdleTimeoutMs: options.streamIdleTimeoutMs,
1205
1520
  clientVersion: options.clientVersion
1206
1521
  };
1522
+ const quota = createCodexQuota({
1523
+ providerId: id,
1524
+ codexHome: options.codexHome,
1525
+ baseUrl: options.baseUrl,
1526
+ authStore,
1527
+ userAgent: options.userAgent
1528
+ });
1529
+ runtimeOptions.quota = quota;
1530
+ const credentialsApi = pool ? createCodexCredentials(pool, authStore, {
1531
+ codexHome: options.codexHome,
1532
+ quota
1533
+ }) : void 0;
1207
1534
  return defineProvider({
1208
1535
  id,
1209
1536
  displayName,
1210
- auth: { status: () => new FileCodexAuthStore({ codexHome: options.codexHome }).status() },
1537
+ auth: { status: () => authStore.status() },
1538
+ quota,
1539
+ ...credentialsApi ? { credentials: credentialsApi } : {},
1211
1540
  state: () => ({
1212
1541
  status: "ready",
1213
- message: "Uses official Codex auth storage"
1542
+ message: credentialsApi ? "Uses Codex auth + demi credential pool" : "Uses official Codex auth storage"
1214
1543
  }),
1215
1544
  listModels: async () => {
1216
1545
  return applyModelPolicy(await listCodexModels(runtimeOptions), id, options.models);
@@ -1245,17 +1574,17 @@ function openAiResponsesUrl(baseUrl) {
1245
1574
  function providerErrorFromUnknown(error) {
1246
1575
  if (error instanceof CodexAuthError) return {
1247
1576
  type: "error",
1248
- message: redactSecretText(error.message),
1577
+ message: redactCodexSecretText(error.message),
1249
1578
  code: error.code
1250
1579
  };
1251
1580
  if (error instanceof CodexHttpError) return {
1252
1581
  type: "error",
1253
- message: redactSecretText(error.message),
1582
+ message: redactCodexSecretText(error.message),
1254
1583
  code: httpErrorCode(error.status, error.message)
1255
1584
  };
1256
1585
  return {
1257
1586
  type: "error",
1258
- message: redactSecretText(error instanceof Error ? error.message : String(error)),
1587
+ message: redactCodexSecretText(error instanceof Error ? error.message : String(error)),
1259
1588
  code: null
1260
1589
  };
1261
1590
  }
@@ -1276,4 +1605,4 @@ function sleep(ms, signal) {
1276
1605
  });
1277
1606
  }
1278
1607
  //#endregion
1279
- export { codexAuthStatus, createCodexProvider, listCodexModels };
1608
+ export { PoolAwareCodexAuthStore, codexAuthStatus, createCodexCredentials, createCodexProvider, createCodexQuota, listCodexModels, mapCodexRateLimitHeaders, openCodexCredentialPool };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@demicodes/provider-codex",
3
3
  "description": "Codex provider adapter for Demi.",
4
- "version": "0.1.0",
4
+ "version": "0.2.1",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "exports": {
@@ -12,13 +12,13 @@
12
12
  }
13
13
  },
14
14
  "dependencies": {
15
- "@demicodes/core": "workspace:*",
16
- "@demicodes/provider": "workspace:*",
17
- "@demicodes/utils": "workspace:*"
15
+ "@demicodes/core": "^0.2.1",
16
+ "@demicodes/provider": "^0.2.1",
17
+ "@demicodes/utils": "^0.2.1"
18
18
  },
19
19
  "devDependencies": {
20
- "@demicodes/agent": "workspace:*",
21
- "@demicodes/shell": "workspace:*"
20
+ "@demicodes/agent": "^0.2.1",
21
+ "@demicodes/shell": "^0.2.1"
22
22
  },
23
23
  "license": "Apache-2.0",
24
24
  "main": "./dist/index.mjs",