@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 +21 -1
- package/dist/index.d.mts +65 -2
- package/dist/index.mjs +352 -23
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
# @demicodes/provider-codex
|
|
2
2
|
|
|
3
|
-
A Demi provider backed by Codex (
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
141
|
-
throw new CodexAuthError("auth_invalid", `Failed to read Codex auth file ${this.authFile}: ${
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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:
|
|
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:
|
|
1582
|
+
message: redactCodexSecretText(error.message),
|
|
1254
1583
|
code: httpErrorCode(error.status, error.message)
|
|
1255
1584
|
};
|
|
1256
1585
|
return {
|
|
1257
1586
|
type: "error",
|
|
1258
|
-
message:
|
|
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
|
|
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": "
|
|
16
|
-
"@demicodes/provider": "
|
|
17
|
-
"@demicodes/utils": "
|
|
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": "
|
|
21
|
-
"@demicodes/shell": "
|
|
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",
|