@demicodes/provider-codex 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -2,6 +2,23 @@ import { ModelPolicy, Provider, ProviderAuthState, ProviderCredentials, Provider
2
2
  import { FileCredentialPool } from "@demicodes/provider/credentials-pool";
3
3
  import "@demicodes/core";
4
4
  //#region src/auth.d.ts
5
+ type CodexAuthMode = 'apiKey' | 'chatgpt' | 'chatgptAuthTokens' | 'agentIdentity' | 'personalAccessToken' | 'bedrockApiKey';
6
+ interface CodexTokenData {
7
+ id_token?: string | Record<string, unknown>;
8
+ access_token?: string;
9
+ refresh_token?: string;
10
+ account_id?: string | null;
11
+ }
12
+ interface CodexAuthDotJson {
13
+ auth_mode?: CodexAuthMode;
14
+ OPENAI_API_KEY?: string | null;
15
+ tokens?: CodexTokenData | null;
16
+ last_refresh?: string | null;
17
+ agent_identity?: unknown;
18
+ personal_access_token?: string | null;
19
+ bedrock_api_key?: unknown;
20
+ [key: string]: unknown;
21
+ }
5
22
  type CodexResolvedAuth = {
6
23
  kind: 'chatgpt';
7
24
  mode: 'chatgpt' | 'chatgptAuthTokens';
@@ -142,13 +159,29 @@ declare class PoolAwareCodexAuthStore implements CodexAuthStore {
142
159
  }
143
160
  declare function createCodexCredentials(pool: FileCredentialPool, authStore: CodexAuthStore, options?: {
144
161
  codexHome?: string;
145
- loginCommand?: string;
146
- loginArgs?: string[];
147
162
  quota?: ProviderQuota | null;
148
163
  onActiveChange?: () => void;
164
+ /** Injectable fetch for the device-code login flow (tests). */
165
+ loginFetch?: typeof fetch;
149
166
  }): ProviderCredentials;
150
167
  declare function openCodexCredentialPool(options?: {
151
168
  stateDir?: string;
152
169
  }): FileCredentialPool;
153
170
  //#endregion
154
- export { type CodexModelCatalogOptions, type CodexProviderOptions, type CodexQuotaOptions, type CodexResolvedAuth, type CodexTransportMode, type FileCodexAuthStoreOptions, PoolAwareCodexAuthStore, codexAuthStatus, createCodexCredentials, createCodexProvider, createCodexQuota, listCodexModels, mapCodexRateLimitHeaders, openCodexCredentialPool };
171
+ //#region src/device-login.d.ts
172
+ interface CodexDeviceLoginPending {
173
+ verificationUrl: string;
174
+ userCode: string;
175
+ expiresAt: string;
176
+ }
177
+ interface CodexDeviceLoginOptions {
178
+ signal?: AbortSignal;
179
+ /** Fires once with the URL + one-time code the user needs. */
180
+ onPending?: (pending: CodexDeviceLoginPending) => void;
181
+ fetch?: typeof fetch;
182
+ issuer?: string;
183
+ }
184
+ /** Runs the full device-code flow and returns vendor-shaped auth material. */
185
+ declare function runCodexDeviceLogin(options?: CodexDeviceLoginOptions): Promise<CodexAuthDotJson>;
186
+ //#endregion
187
+ export { type CodexDeviceLoginOptions, type CodexDeviceLoginPending, type CodexModelCatalogOptions, type CodexProviderOptions, type CodexQuotaOptions, type CodexResolvedAuth, type CodexTransportMode, type FileCodexAuthStoreOptions, PoolAwareCodexAuthStore, codexAuthStatus, createCodexCredentials, createCodexProvider, createCodexQuota, listCodexModels, mapCodexRateLimitHeaders, openCodexCredentialPool, runCodexDeviceLogin };
package/dist/index.mjs CHANGED
@@ -4,7 +4,7 @@ import { chmod, mkdir, open, readFile, rename, rm, writeFile } from "node:fs/pro
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
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";
7
+ import { FileCredentialPool, credentialIdFromIdentity } from "@demicodes/provider/credentials-pool";
8
8
  import { randomUUID } from "node:crypto";
9
9
  //#region src/auth.ts
10
10
  async function codexAuthStatus(options = {}) {
@@ -15,8 +15,12 @@ const SECRET_FIELD_PATTERNS = ["OPENAI_API_KEY"];
15
15
  function redactCodexSecretText(text) {
16
16
  return redactCredentialText(text, SECRET_FIELD_PATTERNS);
17
17
  }
18
- const TOKEN_REFRESH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
18
+ const CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
19
19
  const TOKEN_REFRESH_URL = "https://auth.openai.com/oauth/token";
20
+ /** OAuth client id shared by token refresh and device-code login. */
21
+ function codexOauthClientId() {
22
+ return process.env.CODEX_APP_SERVER_LOGIN_CLIENT_ID || CODEX_OAUTH_CLIENT_ID;
23
+ }
20
24
  const REFRESH_EXPIRY_SKEW_MS = 300 * 1e3;
21
25
  const REFRESH_STALENESS_MS = 11520 * 60 * 1e3;
22
26
  var FileCodexAuthStore = class {
@@ -218,7 +222,7 @@ async function refreshCodexToken(refreshToken, signal) {
218
222
  method: "POST",
219
223
  headers: { "content-type": "application/json" },
220
224
  body: JSON.stringify({
221
- client_id: process.env.CODEX_APP_SERVER_LOGIN_CLIENT_ID || TOKEN_REFRESH_CLIENT_ID,
225
+ client_id: codexOauthClientId(),
222
226
  grant_type: "refresh_token",
223
227
  refresh_token: refreshToken
224
228
  }),
@@ -501,6 +505,113 @@ function defaultModelCatalogUserAgent() {
501
505
  return `demi-provider-codex/0.0.0`;
502
506
  }
503
507
  //#endregion
508
+ //#region src/device-login.ts
509
+ const DEVICE_LOGIN_ISSUER = "https://auth.openai.com";
510
+ const DEVICE_LOGIN_MAX_WAIT_MS = 900 * 1e3;
511
+ const DEVICE_LOGIN_FALLBACK_INTERVAL_S = 5;
512
+ async function postJson(fetchImpl, url, body, signal) {
513
+ return fetchImpl(url, {
514
+ method: "POST",
515
+ headers: { "content-type": "application/json" },
516
+ body: JSON.stringify(body),
517
+ signal
518
+ });
519
+ }
520
+ async function jsonBody(response, what) {
521
+ const body = await response.json().catch(() => null);
522
+ if (!isRecord(body)) throw new CodexAuthError("auth_login_failed", `${what} response is not a JSON object`);
523
+ return body;
524
+ }
525
+ async function requestUserCode(fetchImpl, issuer, clientId, signal) {
526
+ const response = await postJson(fetchImpl, `${issuer}/api/accounts/deviceauth/usercode`, { client_id: clientId }, signal);
527
+ if (response.status === 404) throw new CodexAuthError("auth_unsupported", "Device-code login is not enabled for this Codex account");
528
+ if (!response.ok) throw new CodexAuthError("auth_login_failed", `Device code request failed with HTTP ${response.status}`);
529
+ const body = await jsonBody(response, "Device code");
530
+ const deviceAuthId = nonEmptyString(body.device_auth_id);
531
+ const userCode = nonEmptyString(body.user_code) ?? nonEmptyString(body.usercode);
532
+ if (!deviceAuthId || !userCode) throw new CodexAuthError("auth_login_failed", "Device code response is missing device_auth_id or user_code");
533
+ const interval = Number(typeof body.interval === "string" ? body.interval.trim() : body.interval);
534
+ return {
535
+ deviceAuthId,
536
+ userCode,
537
+ intervalSeconds: Number.isFinite(interval) && interval >= 0 ? interval : DEVICE_LOGIN_FALLBACK_INTERVAL_S
538
+ };
539
+ }
540
+ async function pollForAuthorization(fetchImpl, issuer, userCode, startedAt, signal) {
541
+ for (;;) {
542
+ signal?.throwIfAborted();
543
+ const response = await postJson(fetchImpl, `${issuer}/api/accounts/deviceauth/token`, {
544
+ device_auth_id: userCode.deviceAuthId,
545
+ user_code: userCode.userCode
546
+ }, signal);
547
+ if (response.ok) {
548
+ const body = await jsonBody(response, "Device authorization");
549
+ const authorizationCode = nonEmptyString(body.authorization_code);
550
+ const codeVerifier = nonEmptyString(body.code_verifier);
551
+ if (!authorizationCode || !codeVerifier) throw new CodexAuthError("auth_login_failed", "Device authorization response is missing authorization_code or code_verifier");
552
+ return {
553
+ authorizationCode,
554
+ codeVerifier
555
+ };
556
+ }
557
+ if (response.status !== 403 && response.status !== 404) throw new CodexAuthError("auth_login_failed", `Device authorization failed with HTTP ${response.status}`);
558
+ if (Date.now() - startedAt >= DEVICE_LOGIN_MAX_WAIT_MS) throw new CodexAuthError("auth_login_failed", "Device-code login timed out after 15 minutes");
559
+ await delay(userCode.intervalSeconds * 1e3);
560
+ }
561
+ }
562
+ async function exchangeAuthorizationCode(fetchImpl, issuer, clientId, authorization, signal) {
563
+ const params = new URLSearchParams({
564
+ grant_type: "authorization_code",
565
+ code: authorization.authorizationCode,
566
+ redirect_uri: `${issuer}/deviceauth/callback`,
567
+ client_id: clientId,
568
+ code_verifier: authorization.codeVerifier
569
+ });
570
+ const response = await fetchImpl(`${issuer}/oauth/token`, {
571
+ method: "POST",
572
+ headers: { "content-type": "application/x-www-form-urlencoded" },
573
+ body: params.toString(),
574
+ signal
575
+ });
576
+ if (!response.ok) throw new CodexAuthError("auth_login_failed", `Device-code token exchange failed with HTTP ${response.status}`);
577
+ const body = await jsonBody(response, "Token exchange");
578
+ const idToken = nonEmptyString(body.id_token);
579
+ const accessToken = nonEmptyString(body.access_token);
580
+ const refreshToken = nonEmptyString(body.refresh_token);
581
+ if (!idToken || !accessToken || !refreshToken) throw new CodexAuthError("auth_login_failed", "Token exchange response is missing id_token, access_token, or refresh_token");
582
+ return {
583
+ idToken,
584
+ accessToken,
585
+ refreshToken
586
+ };
587
+ }
588
+ /** Runs the full device-code flow and returns vendor-shaped auth material. */
589
+ async function runCodexDeviceLogin(options = {}) {
590
+ const fetchImpl = options.fetch ?? fetch;
591
+ const issuer = (options.issuer ?? DEVICE_LOGIN_ISSUER).replace(/\/+$/, "");
592
+ const clientId = codexOauthClientId();
593
+ const startedAt = Date.now();
594
+ const userCode = await requestUserCode(fetchImpl, issuer, clientId, options.signal);
595
+ options.onPending?.({
596
+ verificationUrl: `${issuer}/codex/device`,
597
+ userCode: userCode.userCode,
598
+ expiresAt: new Date(startedAt + DEVICE_LOGIN_MAX_WAIT_MS).toISOString()
599
+ });
600
+ const tokens = await exchangeAuthorizationCode(fetchImpl, issuer, clientId, await pollForAuthorization(fetchImpl, issuer, userCode, startedAt, options.signal), options.signal);
601
+ const accountId = parseChatGptClaims(tokens.accessToken).accountId ?? parseIdTokenClaims(tokens.idToken).accountId;
602
+ return {
603
+ auth_mode: "chatgpt",
604
+ OPENAI_API_KEY: null,
605
+ tokens: {
606
+ id_token: tokens.idToken,
607
+ access_token: tokens.accessToken,
608
+ refresh_token: tokens.refreshToken,
609
+ account_id: accountId
610
+ },
611
+ last_refresh: (/* @__PURE__ */ new Date()).toISOString()
612
+ };
613
+ }
614
+ //#endregion
504
615
  //#region src/credentials.ts
505
616
  /**
506
617
  * Auth store that prefers the demi pool active entry; falls back to vendor ~/.codex.
@@ -536,8 +647,6 @@ var PoolAwareCodexAuthStore = class {
536
647
  };
537
648
  function createCodexCredentials(pool, authStore, options = {}) {
538
649
  const vendorHome = options.codexHome ?? defaultCodexHome();
539
- const loginCommand = options.loginCommand ?? "codex";
540
- const loginArgs = options.loginArgs ?? ["login"];
541
650
  const capability = () => ({
542
651
  mode: "supported",
543
652
  canBeginLogin: true,
@@ -593,17 +702,27 @@ function createCodexCredentials(pool, authStore, options = {}) {
593
702
  getActive,
594
703
  setActive,
595
704
  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
- };
705
+ try {
706
+ const auth = await runCodexDeviceLogin({
707
+ signal: loginOptions?.signal,
708
+ onPending: loginOptions?.onPending,
709
+ fetch: options.loginFetch
710
+ });
711
+ return {
712
+ status: "completed",
713
+ credentialId: (await importFromAuthJson(`${JSON.stringify(auth, null, 2)}\n`, "login:device")).id
714
+ };
715
+ } catch (error) {
716
+ if (loginOptions?.signal?.aborted) return { status: "cancelled" };
717
+ if (error instanceof CodexAuthError && error.code === "auth_unsupported") return {
718
+ status: "unavailable",
719
+ message: error.message
720
+ };
721
+ return {
722
+ status: "failed",
723
+ message: errorMessage(error)
724
+ };
725
+ }
607
726
  },
608
727
  importDefault: async () => {
609
728
  const authFile = join(vendorHome, "auth.json");
@@ -1605,4 +1724,4 @@ function sleep(ms, signal) {
1605
1724
  });
1606
1725
  }
1607
1726
  //#endregion
1608
- export { PoolAwareCodexAuthStore, codexAuthStatus, createCodexCredentials, createCodexProvider, createCodexQuota, listCodexModels, mapCodexRateLimitHeaders, openCodexCredentialPool };
1727
+ export { PoolAwareCodexAuthStore, codexAuthStatus, createCodexCredentials, createCodexProvider, createCodexQuota, listCodexModels, mapCodexRateLimitHeaders, openCodexCredentialPool, runCodexDeviceLogin };
package/package.json CHANGED
@@ -1,24 +1,23 @@
1
1
  {
2
2
  "name": "@demicodes/provider-codex",
3
3
  "description": "Codex provider adapter for Demi.",
4
- "version": "0.3.1",
4
+ "version": "0.4.0",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "exports": {
8
8
  ".": {
9
- "development": "./src/index.ts",
10
9
  "types": "./dist/index.d.mts",
11
10
  "import": "./dist/index.mjs"
12
11
  }
13
12
  },
14
13
  "dependencies": {
15
- "@demicodes/core": "^0.3.1",
16
- "@demicodes/provider": "^0.3.1",
17
- "@demicodes/utils": "^0.3.1"
14
+ "@demicodes/core": "^0.3.2",
15
+ "@demicodes/provider": "^0.4.0",
16
+ "@demicodes/utils": "^0.3.2"
18
17
  },
19
18
  "devDependencies": {
20
- "@demicodes/agent": "^0.3.1",
21
- "@demicodes/shell": "^0.3.1"
19
+ "@demicodes/agent": "^0.3.3",
20
+ "@demicodes/shell": "^0.3.2"
22
21
  },
23
22
  "license": "Apache-2.0",
24
23
  "main": "./dist/index.mjs",