ldrouter 1.14.1 → 1.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,48 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/) and the project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.16.2] - 2026-09-14
8
+
9
+ ### Fixed
10
+
11
+ - npm release failed with `E409 Conflict - Failed to save packument`: the package had been renamed to `latedev-router`, which the registry still holds as an empty package after all of its versions were unpublished, so no new version can be written under that name. The package name is back to `ldrouter` (the name that last published successfully); the CLI still exposes both `ldrouter` and `latedev-router` binaries.
12
+
13
+ ## [1.16.1] - 2026-09-13
14
+
15
+ ### Fixed
16
+
17
+ - **Test connection** and **Import models** on the Codex accounts group always failed with `Gateway error`: the models request omitted the `client_version` query parameter the Codex endpoint requires, and the response was read from `models[].id` while the endpoint returns `models[].slug`, so discovery came back empty or errored.
18
+ - Deleting a Codex provider failed with `Gateway error`: `codex_accounts.provider_id` is `ON DELETE RESTRICT`, so removing the provider last raised a raw SQLite constraint error. The provider delete now removes the provider and its Codex account pool in one transaction and reports how many accounts were deleted.
19
+
20
+ ## [1.16.0] - 2026-09-13
21
+
22
+ ### Added
23
+
24
+ - Codex account pool UI: a dedicated Codex accounts group on the Providers page with expand/collapse, account filtering, and pagination — separate from the generic Add provider dialog, which no longer collects Codex credentials.
25
+ - Codex quota panel showing the 5-hour and weekly windows with live reset countdowns, per-account and refresh-all usage refresh, weekly reset-credit count, and a **Reset quota** action that spends one credit.
26
+ - Codex 5-hour window auto-start (opt-in per account): when a window is exhausted and its reset time has passed, the gateway sends one tiny ping so the next window opens immediately. One ping per reset minute, persisted so it survives restarts.
27
+ - Codex model import dialog with search, Select All, and existing-model detection.
28
+ - Connect OpenAI Codex: a browser-based PKCE flow that shows the authorize URL, waits for the loopback callback, and also accepts a pasted callback URL or bare authorization code. The verifier stays server-side and the authorization code is exchanged server-side, so neither ever appears in the UI.
29
+ - `GET /oauth/codex/callback` captures the Codex CLI loopback redirect (`http://localhost:1455/auth/callback`) and holds the code in memory against its `state`; the dialog polls `GET /api/admin/codex/oauth/:state` and enables **Connect** as soon as a code is captured. The page never echoes the code.
30
+ - Drag-and-drop routing order for Codex accounts (`@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/modifiers`), replacing the numeric priority prompt. Rows can also be reordered with the keyboard; the saved order is the router's fallback order.
31
+ - `migrations/0006_codex_usage.sql`: usage snapshot, usage error, auto-start flag, and ping bookkeeping columns on `codex_accounts`.
32
+
33
+ ### Fixed
34
+
35
+ - Codex account API returned `enabled` as SQLite `0`/`1` instead of a boolean, so the enable/disable control could show the wrong state.
36
+ - Codex credential refresh/decrypt failures returned an opaque HTTP 500 with `Gateway error`; they now return a typed 401 naming the fix (re-import the account).
37
+ - Codex model discovery threw a bare error without an HTTP status, so a 401 never triggered the refresh-and-retry path and surfaced as `Gateway error` instead of an auth failure.
38
+ - **Delete** on a Codex account was a soft delete that left the encrypted credentials on disk; it is now a hard delete. Past request attempts are unaffected because their account reference is `ON DELETE SET NULL`.
39
+ - The Codex account row rendered two identical Enable/Disable controls.
40
+
41
+ ## [1.15.0] - 2026-09-12
42
+
43
+ ### Added
44
+
45
+ - Native Codex OAuth account import from redacted JSON, JSON arrays, wrapper objects, and JSONL, with provider-scoped deduplication and encrypted token storage.
46
+ - Codex token refresh/rotation, bounded unauthorized retry, account health tracking, account-aware routing, and attempt attribution.
47
+ - Codex provider setup, account import, refresh status, enable/disable, and safe test controls in the admin UI.
48
+
7
49
  ## [1.14.1] - 2026-09-12
8
50
 
9
51
  ### Fixed
package/README.md CHANGED
@@ -19,6 +19,7 @@ Lightweight self-hosted LLM gateway with a polished admin UI. Presents stable Op
19
19
  - Consistent backup / restore (SQLite snapshot + checksum + schema validation)
20
20
  - Prometheus `/metrics`, structured logs, graceful shutdown
21
21
  - One distributable npm package, multi-stage Dockerfile, Docker Compose
22
+ - Native Codex OAuth account pools with encrypted JSON/JSONL import, refresh/rotation, account-aware routing, and admin management
22
23
 
23
24
  ## Quick start
24
25
 
@@ -36,12 +37,33 @@ Then visit `http://localhost:8787/` and complete the first-run admin setup.
36
37
  ### Using npm
37
38
 
38
39
  ```bash
39
- npx latedev-router
40
+ npx ldrouter
40
41
  latedev-router --host 0.0.0.0 --port 8787
41
42
  ```
42
43
 
43
44
  The data directory defaults to `~/.latedev-router/` and can be overridden via `LATEDEV_DATA_DIR` or `--data-dir`.
44
45
 
46
+ ### Codex OAuth setup
47
+
48
+ 1. Set `LATEDEV_MASTER_KEY` before creating or importing credentials. Use a strong random base64 key; it encrypts Codex access, refresh, and ID tokens at rest.
49
+ 2. In **Providers**, create a provider with type **Codex**. Codex providers do not use the generic API-key field.
50
+ 3. Open the Codex account panel and import a redacted copy of a Codex OAuth `auth.json`, a JSON array, an `{ "accounts": [...] }` wrapper, or JSONL with one record per line. Records may contain a `tokens` object with access/refresh/ID tokens. For example:
51
+
52
+ `{ "accountId": "acct-…masked", "email": "admin@example.invalid", "tokens": { "accessToken": "[REDACTED]", "refreshToken": "[REDACTED]" } }`
53
+
54
+ JSONL stores one similarly redacted object per line; an array uses the same records: `[ { "accountId": "acct-…masked", "tokens": { "accessToken": "[REDACTED]" } } ]`.
55
+
56
+ Alternatively, use **Connect Codex** to authorize a ChatGPT account in the browser. The dialog shows the Codex CLI PKCE authorize URL, waits for the loopback callback, and also accepts a pasted callback URL or bare authorization code. The PKCE verifier and the authorization code stay server-side and never appear in the UI. The Codex CLI callback (`http://localhost:1455/auth/callback`) is captured by `GET /oauth/codex/callback`; because browsers block that cross-origin redirect, paste the address bar URL into step 2 when the auto-capture page fails to load.
57
+ 4. Review the preview and import only the records you want. Account/workspace identity is preferred for deduplication; email alone never merges unrelated accounts. Re-importing the same identity updates its encrypted tokens while preserving its enabled state.
58
+
59
+ Raw tokens are accepted only by the authenticated import pipeline and are never returned in previews, API responses, UI state, audit logs, request logs, errors, or database backups. JWT claims are decoded for metadata only; token signatures are not verified locally. Tokens are refreshed proactively near expiry and once after an upstream 401/403, with rotated values persisted atomically.
60
+
61
+ The import endpoint requires the normal admin session and CSRF token (`x-csrf-token`) for mutations, including multipart uploads. Use HTTPS for remote administration and protect the master key like any encryption key. Back up the SQLite data directory consistently; restoring encrypted credentials requires the matching master key, otherwise re-save/re-import credentials after restore.
62
+
63
+ ZIP upload and automatic Codex CLI config-file generation/mutation are not included in this release. LateDev Router does not modify Codex CLI files.
64
+
65
+ The account panel shows the 5-hour and weekly quota windows with reset countdowns, per-account and bulk usage refresh, weekly reset credits, an opt-in 5-hour window auto-start, and a **Test** control that probes the upstream account without exposing tokens. Routing order is set by dragging rows; the saved order is the fallback order the router uses. **Delete** is permanent and erases the stored encrypted credentials — past request logs are kept but lose the account reference.
66
+
45
67
  ## Environment variables
46
68
 
47
69
  | Variable | Description | Default |
@@ -4,6 +4,7 @@ import cookie from '@fastify/cookie';
4
4
  import helmet from '@fastify/helmet';
5
5
  import cors from '@fastify/cors';
6
6
  import staticPlugin from '@fastify/static';
7
+ import multipart from '@fastify/multipart';
7
8
  import fs from 'node:fs';
8
9
  import path from 'node:path';
9
10
  import { fileURLToPath } from 'node:url';
@@ -43,6 +44,7 @@ export async function buildApp(opts = {}) {
43
44
  crossOriginEmbedderPolicy: false,
44
45
  });
45
46
  await app.register(cors, { origin: false, credentials: true });
47
+ await app.register(multipart, { limits: { fileSize: 2_000_000, files: 20, parts: 25 } });
46
48
  // Every request is logged as structured JSON for Docker. Bodies and headers
47
49
  // are intentionally excluded; sensitive values must never reach logs.
48
50
  app.addHook('onRequest', async (req) => {
@@ -63,7 +65,12 @@ export async function buildApp(opts = {}) {
63
65
  const normalized = err instanceof ZodError
64
66
  ? new GatewayError('invalid_request_error', err.issues.map((i) => `${i.path.join('.') || 'body'}: ${i.message}`).join('; '), { status: 400 })
65
67
  : err;
66
- const g = normalized instanceof GatewayError ? normalized : null;
68
+ const multipartTooLarge = normalized.code === 'FST_REQ_FILE_TOO_LARGE';
69
+ const g = normalized instanceof GatewayError
70
+ ? normalized
71
+ : multipartTooLarge
72
+ ? new GatewayError('invalid_request_error', 'Import exceeds maximum size', { status: 413 })
73
+ : null;
67
74
  const status = g?.status ?? 500;
68
75
  const requestId = req.id;
69
76
  // Fastify runs with `logger: false`, so req.log is a silent no-op — use the app
@@ -191,6 +198,8 @@ export async function startApp() {
191
198
  process.once('SIGTERM', () => void close('SIGTERM'));
192
199
  process.once('SIGINT', () => void close('SIGINT'));
193
200
  await app.listen({ host: cfg.host, port: cfg.port });
201
+ const { startCodexAutostart } = await import('./providers/codex-autostart.js');
202
+ startCodexAutostart();
194
203
  return app;
195
204
  }
196
205
  // re-export for convenience
@@ -1,9 +1,45 @@
1
1
  // Auth middleware: admin session validation.
2
2
  import { getDb, schema } from '../db/index.js';
3
3
  import { sql } from 'drizzle-orm';
4
- import { sha256Hex } from './ids.js';
4
+ import { sha256Hex, uuid } from './ids.js';
5
+ import crypto from 'node:crypto';
6
+ const CSRF_TOKEN_MAX_AGE = 60 * 60 * 12; // 12h, aligned with admin sessions
7
+ function csrfExpiry() {
8
+ return new Date(Date.now() + CSRF_TOKEN_MAX_AGE * 1000).toISOString();
9
+ }
5
10
  import { GatewayError } from '../errors.js';
6
11
  import { recordAudit } from '../db/repositories/audit.js';
12
+ import { timingSafeEqual } from 'node:crypto';
13
+ const CsrfHeader = 'x-csrf-token';
14
+ export async function requireAdminCsrf(req) {
15
+ const token = req.headers[CsrfHeader];
16
+ if (typeof token !== 'string' || !req.adminSessionId)
17
+ throw new GatewayError('authentication_error', 'CSRF token required', { status: 403 });
18
+ const row = getDb().select().from(schema.csrfTokens).where(sql `session_id = ${req.adminSessionId}`).get();
19
+ const expected = row?.token;
20
+ if (!expected || new Date(row.expiresAt).getTime() < Date.now())
21
+ throw new GatewayError('authentication_error', 'Invalid CSRF token', { status: 403 });
22
+ const actualBytes = Buffer.from(token);
23
+ const expectedBytes = Buffer.from(expected);
24
+ if (actualBytes.length !== expectedBytes.length || !timingSafeEqual(actualBytes, expectedBytes))
25
+ throw new GatewayError('authentication_error', 'Invalid CSRF token', { status: 403 });
26
+ }
27
+ export function csrfTokenForSession(sessionId) {
28
+ const db = getDb();
29
+ const row = db.select().from(schema.csrfTokens).where(sql `session_id = ${sessionId}`).get();
30
+ if (row && new Date(row.expiresAt).getTime() >= Date.now())
31
+ return row.token;
32
+ const token = crypto.randomBytes(32).toString('base64url');
33
+ const expiresAt = csrfExpiry();
34
+ if (row) {
35
+ db.update(schema.csrfTokens).set({ token, expiresAt }).where(sql `id = ${row.id}`).run();
36
+ }
37
+ else {
38
+ db.insert(schema.csrfTokens).values({ id: uuid(), sessionId, token, expiresAt }).run();
39
+ }
40
+ return token;
41
+ }
42
+ export { CsrfHeader };
7
43
  const SessionCookie = 'ld_session';
8
44
  export async function requireAdminAuth(req, _reply) {
9
45
  const token = req.cookies[SessionCookie];
@@ -61,4 +61,9 @@ export function getDb() {
61
61
  throw new Error('Database not opened');
62
62
  return _db;
63
63
  }
64
+ export function getRawDb() {
65
+ if (!_raw)
66
+ throw new Error('Database not opened');
67
+ return _raw;
68
+ }
64
69
  export { schema };
@@ -140,10 +140,10 @@ function buildInitialSchemaSql() {
140
140
  id TEXT PRIMARY KEY,
141
141
  name TEXT NOT NULL,
142
142
  slug TEXT NOT NULL UNIQUE,
143
- type TEXT NOT NULL CHECK (type IN ('openai','anthropic')),
144
- base_url TEXT NOT NULL,
145
- encrypted_api_key TEXT NOT NULL,
146
- api_key_nonce TEXT NOT NULL,
143
+ type TEXT NOT NULL CHECK (type IN ('openai','anthropic','codex')),
144
+ base_url TEXT NOT NULL,
145
+ encrypted_api_key TEXT,
146
+ api_key_nonce TEXT,
147
147
  api_key_version INTEGER NOT NULL DEFAULT 1,
148
148
  custom_headers_encrypted TEXT,
149
149
  custom_headers_nonce TEXT,
@@ -302,6 +302,37 @@ function buildInitialSchemaSql() {
302
302
  CREATE INDEX IF NOT EXISTS idx_request_requested ON requests(requested_model, created_at);
303
303
  CREATE INDEX IF NOT EXISTS idx_request_protocol ON requests(protocol, created_at);
304
304
 
305
+ CREATE TABLE IF NOT EXISTS codex_accounts (
306
+ id TEXT PRIMARY KEY,
307
+ provider_id TEXT NOT NULL REFERENCES providers(id) ON DELETE RESTRICT,
308
+ email TEXT,
309
+ workspace_id TEXT,
310
+ chatgpt_account_id TEXT,
311
+ plan_type TEXT,
312
+ encrypted_access_token TEXT NOT NULL,
313
+ access_token_nonce TEXT NOT NULL,
314
+ access_token_version INTEGER NOT NULL DEFAULT 1,
315
+ encrypted_refresh_token TEXT NOT NULL,
316
+ refresh_token_nonce TEXT NOT NULL,
317
+ refresh_token_version INTEGER NOT NULL DEFAULT 1,
318
+ encrypted_id_token TEXT,
319
+ id_token_nonce TEXT,
320
+ id_token_version INTEGER NOT NULL DEFAULT 1,
321
+ token_expires_at TEXT NOT NULL,
322
+ last_refresh_at TEXT,
323
+ auth_method TEXT NOT NULL DEFAULT 'oauth' CHECK (auth_method IN ('oauth','access_token')),
324
+ enabled INTEGER NOT NULL DEFAULT 1,
325
+ health_state TEXT NOT NULL DEFAULT 'unknown' CHECK (health_state IN ('healthy','degraded','down','unknown')),
326
+ last_error TEXT,
327
+ consecutive_failures INTEGER NOT NULL DEFAULT 0,
328
+ priority INTEGER NOT NULL DEFAULT 0,
329
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
330
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
331
+ );
332
+ CREATE INDEX IF NOT EXISTS idx_codex_account_provider_enabled_priority ON codex_accounts(provider_id, enabled, priority);
333
+ CREATE INDEX IF NOT EXISTS idx_codex_account_provider_email ON codex_accounts(provider_id, email);
334
+ CREATE INDEX IF NOT EXISTS idx_codex_account_provider_chatgpt ON codex_accounts(provider_id, chatgpt_account_id);
335
+
305
336
  CREATE TABLE IF NOT EXISTS request_attempts (
306
337
  id TEXT PRIMARY KEY,
307
338
  request_id TEXT NOT NULL REFERENCES requests(id) ON DELETE CASCADE,
@@ -324,9 +355,11 @@ function buildInitialSchemaSql() {
324
355
  selection_reason TEXT NOT NULL,
325
356
  failure_reason TEXT,
326
357
  error_message TEXT,
327
- upstream_request_id TEXT
358
+ upstream_request_id TEXT,
359
+ codex_account_id TEXT REFERENCES codex_accounts(id) ON DELETE SET NULL
328
360
  );
329
361
  CREATE INDEX IF NOT EXISTS idx_attempt_request ON request_attempts(request_id, attempt_number);
362
+ CREATE INDEX IF NOT EXISTS idx_attempt_codex_account ON request_attempts(codex_account_id, started_at);
330
363
  CREATE INDEX IF NOT EXISTS idx_attempt_provider_model ON request_attempts(provider_id, model_id, started_at);
331
364
 
332
365
  CREATE TABLE IF NOT EXISTS audit_logs (
@@ -0,0 +1,187 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { decryptSecret, encryptSecret } from '../../auth/crypto.js';
3
+ import { uuid } from '../../auth/ids.js';
4
+ import { getRawDb } from '../index.js';
5
+ const digest = (value) => createHash('sha256').update(value, 'utf8').digest('hex');
6
+ export function maskCodexValue(value) {
7
+ if (!value)
8
+ return null;
9
+ return value.length <= 8 ? `${value.slice(0, 2)}…${value.slice(-2)}` : `${value.slice(0, 4)}…${value.slice(-4)}`;
10
+ }
11
+ export function identityFromCodexRecord(record) {
12
+ return {
13
+ chatgptAccountId: record.chatgptAccountId,
14
+ workspaceId: record.workspaceId,
15
+ email: record.email,
16
+ tokenDigest: digest(record.accessToken),
17
+ };
18
+ }
19
+ export function toCodexAccountSummary(row) {
20
+ return {
21
+ id: row.id,
22
+ email: row.email,
23
+ accountIdMasked: maskCodexValue(row.chatgptAccountId),
24
+ workspaceIdMasked: maskCodexValue(row.workspaceId),
25
+ planType: row.planType,
26
+ tokenExpiresAt: row.tokenExpiresAt,
27
+ // SQLite hands raw rows back as 0/1; the API contract (and the web UI) expects a boolean.
28
+ enabled: Boolean(row.enabled),
29
+ healthState: row.healthState,
30
+ lastRefreshAt: row.lastRefreshAt,
31
+ priority: row.priority,
32
+ createdAt: row.createdAt,
33
+ updatedAt: row.updatedAt,
34
+ };
35
+ }
36
+ const client = getRawDb;
37
+ function encrypted(value) {
38
+ return encryptSecret(value);
39
+ }
40
+ export function listCodexAccountsForProvider(providerId) {
41
+ return client().prepare(`SELECT id, chatgpt_account_id AS chatgptAccountId, enabled, health_state AS healthState, token_expires_at AS tokenExpiresAt, priority FROM codex_accounts WHERE provider_id=? ORDER BY priority,id`).all(providerId);
42
+ }
43
+ export function getCodexAccountForProvider(providerId) {
44
+ const row = listCodexAccountsForProvider(providerId).find((a) => a.enabled && (a.healthState === 'healthy' || a.healthState === 'unknown') && Date.parse(a.tokenExpiresAt) > Date.now());
45
+ return row?.chatgptAccountId ? { id: row.id, chatgptAccountId: row.chatgptAccountId } : null;
46
+ }
47
+ export function getCodexAccountById(id) {
48
+ const row = client().prepare(`SELECT id, chatgpt_account_id AS chatgptAccountId, enabled, health_state AS healthState, token_expires_at AS tokenExpiresAt FROM codex_accounts WHERE id=?`).get(id);
49
+ if (!row || !row.enabled || !['healthy', 'unknown'].includes(row.healthState) || Date.parse(row.tokenExpiresAt) <= Date.now())
50
+ return null;
51
+ return row.chatgptAccountId ? { id: row.id, chatgptAccountId: row.chatgptAccountId } : null;
52
+ }
53
+ export function listCodexAccountSummaries(providerId) {
54
+ const rows = client().prepare(`SELECT id,email,workspace_id AS workspaceId,chatgpt_account_id AS chatgptAccountId,plan_type AS planType,token_expires_at AS tokenExpiresAt,enabled,health_state AS healthState,last_refresh_at AS lastRefreshAt,priority,codex_autostart_enabled AS autostart,codex_usage_json AS usageJson,codex_usage_error AS usageError,codex_usage_updated_at AS usageUpdatedAt,last_pinged_reset_at AS lastPingedResetAt,last_ping_at AS lastPingAt,created_at AS createdAt,updated_at AS updatedAt FROM codex_accounts WHERE provider_id=? ORDER BY priority,id`).all(providerId);
55
+ return rows.map(toCodexAccountSummaryRow);
56
+ }
57
+ export function findCodexAccountForImport(providerId, identity) {
58
+ const rows = client().prepare(`SELECT id,provider_id AS providerId,email,workspace_id AS workspaceId,chatgpt_account_id AS chatgptAccountId,plan_type AS planType,encrypted_access_token AS encryptedAccessToken,access_token_nonce AS accessTokenNonce,access_token_version AS accessTokenVersion,encrypted_refresh_token AS encryptedRefreshToken,refresh_token_nonce AS refreshTokenNonce,refresh_token_version AS refreshTokenVersion,encrypted_id_token AS encryptedIdToken,id_token_nonce AS idTokenNonce,id_token_version AS idTokenVersion,token_expires_at AS tokenExpiresAt,last_refresh_at AS lastRefreshAt,auth_method AS authMethod,enabled,health_state AS healthState,last_error AS lastError,consecutive_failures AS consecutiveFailures,priority,created_at AS createdAt,updated_at AS updatedAt FROM codex_accounts WHERE provider_id=?`).all(providerId);
59
+ const providerRows = rows;
60
+ if (identity.chatgptAccountId) {
61
+ const account = providerRows.find((row) => row.chatgptAccountId === identity.chatgptAccountId);
62
+ if (account)
63
+ return account;
64
+ }
65
+ if (identity.workspaceId) {
66
+ const workspace = providerRows.find((row) => row.workspaceId === identity.workspaceId);
67
+ if (workspace)
68
+ return workspace;
69
+ }
70
+ for (const row of providerRows) {
71
+ const credentials = getCodexCredentials(row.id);
72
+ if (digest(credentials.accessToken) === identity.tokenDigest)
73
+ return row;
74
+ }
75
+ return null;
76
+ }
77
+ function persistImport(providerId, record, existingId) {
78
+ const raw = client();
79
+ const access = encrypted(record.accessToken);
80
+ const refresh = encrypted(record.refreshToken);
81
+ const idToken = record.idToken ? encrypted(record.idToken) : null;
82
+ const now = existingId ? nextUpdatedAt(existingId) : new Date().toISOString();
83
+ const transaction = raw.transaction(() => {
84
+ if (existingId) {
85
+ raw.prepare(`UPDATE codex_accounts SET email=?, workspace_id=?, chatgpt_account_id=?, plan_type=?, encrypted_access_token=?, access_token_nonce=?, access_token_version=?, encrypted_refresh_token=?, refresh_token_nonce=?, refresh_token_version=?, encrypted_id_token=?, id_token_nonce=?, id_token_version=?, token_expires_at=?, last_error=NULL, consecutive_failures=0, health_state='unknown', updated_at=? WHERE id=?`).run(record.email, record.workspaceId, record.chatgptAccountId, record.planType, access.ciphertext, access.nonce, access.version, refresh.ciphertext, refresh.nonce, refresh.version, idToken?.ciphertext ?? null, idToken?.nonce ?? null, idToken?.version ?? 1, record.expiresAt, now, existingId);
86
+ return existingId;
87
+ }
88
+ const priority = raw.prepare('SELECT COALESCE(MAX(priority), -1) AS value FROM codex_accounts WHERE provider_id=?').get(providerId).value + 1;
89
+ const id = uuid();
90
+ raw.prepare(`INSERT INTO codex_accounts (id, provider_id, email, workspace_id, chatgpt_account_id, plan_type, encrypted_access_token, access_token_nonce, access_token_version, encrypted_refresh_token, refresh_token_nonce, refresh_token_version, encrypted_id_token, id_token_nonce, id_token_version, token_expires_at, priority) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(id, providerId, record.email, record.workspaceId, record.chatgptAccountId, record.planType, access.ciphertext, access.nonce, access.version, refresh.ciphertext, refresh.nonce, refresh.version, idToken?.ciphertext ?? null, idToken?.nonce ?? null, idToken?.version ?? 1, record.expiresAt, priority);
91
+ return id;
92
+ });
93
+ return transaction();
94
+ }
95
+ export function insertCodexAccount(providerId, record) {
96
+ return persistImport(providerId, record);
97
+ }
98
+ export function updateCodexAccountFromImport(id, record) {
99
+ const row = client().prepare('SELECT provider_id FROM codex_accounts WHERE id=?').get(id);
100
+ if (!row)
101
+ throw new Error('Codex account not found');
102
+ return persistImport(row.provider_id, record, id);
103
+ }
104
+ export function getCodexCredentials(id) {
105
+ const row = client().prepare('SELECT encrypted_access_token, access_token_nonce, access_token_version, encrypted_refresh_token, refresh_token_nonce, refresh_token_version, encrypted_id_token, id_token_nonce, id_token_version FROM codex_accounts WHERE id=?').get(id);
106
+ if (!row)
107
+ throw new Error('Codex account not found');
108
+ return {
109
+ accessToken: decryptSecret({ ciphertext: row.encrypted_access_token, nonce: row.access_token_nonce, version: row.access_token_version }),
110
+ refreshToken: decryptSecret({ ciphertext: row.encrypted_refresh_token, nonce: row.refresh_token_nonce, version: row.refresh_token_version }),
111
+ idToken: row.encrypted_id_token ? decryptSecret({ ciphertext: row.encrypted_id_token, nonce: row.id_token_nonce, version: row.id_token_version }) : null,
112
+ };
113
+ }
114
+ export function getCodexAccountRefreshState(id) {
115
+ const row = client().prepare('SELECT token_expires_at AS tokenExpiresAt FROM codex_accounts WHERE id=?').get(id);
116
+ if (!row)
117
+ return null;
118
+ const credentials = getCodexCredentials(id);
119
+ return { tokenExpiresAt: row.tokenExpiresAt, refreshToken: credentials.refreshToken, idToken: credentials.idToken };
120
+ }
121
+ export function persistCodexRefresh(id, update) {
122
+ const access = encrypted(update.accessToken);
123
+ const refresh = encrypted(update.refreshToken);
124
+ const idToken = update.idToken ? encrypted(update.idToken) : null;
125
+ const updatedAt = nextUpdatedAt(id);
126
+ client().transaction(() => {
127
+ client().prepare(`UPDATE codex_accounts SET encrypted_access_token=?, access_token_nonce=?, access_token_version=?, encrypted_refresh_token=?, refresh_token_nonce=?, refresh_token_version=?, encrypted_id_token=?, id_token_nonce=?, id_token_version=?, token_expires_at=?, last_refresh_at=?, last_error=NULL, consecutive_failures=0, health_state='healthy', updated_at=? WHERE id=?`).run(access.ciphertext, access.nonce, access.version, refresh.ciphertext, refresh.nonce, refresh.version, idToken?.ciphertext ?? null, idToken?.nonce ?? null, idToken?.version ?? 1, update.expiresAt, updatedAt, updatedAt, id);
128
+ })();
129
+ }
130
+ function nextUpdatedAt(id) {
131
+ const current = client().prepare('SELECT updated_at AS updatedAt FROM codex_accounts WHERE id=?').get(id);
132
+ const now = Date.now();
133
+ const previous = current ? Date.parse(current.updatedAt) : 0;
134
+ return new Date(Math.max(now, previous + 1)).toISOString();
135
+ }
136
+ export function setCodexAccountHealth(id, healthState, lastError = null, enabled) {
137
+ const fields = enabled === undefined ? 'health_state=?, last_error=?, updated_at=?' : 'health_state=?, last_error=?, enabled=?, updated_at=?';
138
+ const updatedAt = nextUpdatedAt(id);
139
+ const values = enabled === undefined ? [healthState, lastError, updatedAt, id] : [healthState, lastError, enabled ? 1 : 0, updatedAt, id];
140
+ client().prepare(`UPDATE codex_accounts SET ${fields} WHERE id=?`).run(...values);
141
+ }
142
+ export function upsertCodexAccount(providerId, record) {
143
+ const existing = findCodexAccountForImport(providerId, identityFromCodexRecord(record));
144
+ return existing ? { id: updateCodexAccountFromImport(existing.id, record), status: 'updated' } : { id: insertCodexAccount(providerId, record), status: 'added' };
145
+ }
146
+ export function saveCodexUsage(id, usage) {
147
+ client().prepare('UPDATE codex_accounts SET codex_usage_json=?, codex_usage_updated_at=?, codex_usage_error=NULL, updated_at=? WHERE id=?')
148
+ .run(JSON.stringify(usage), usage.fetchedAt, nextUpdatedAt(id), id);
149
+ }
150
+ export function saveCodexUsageError(id, message) {
151
+ client().prepare('UPDATE codex_accounts SET codex_usage_error=?, updated_at=? WHERE id=?').run(message.slice(0, 500), nextUpdatedAt(id), id);
152
+ }
153
+ function parseUsage(raw) {
154
+ if (!raw)
155
+ return null;
156
+ try {
157
+ return JSON.parse(raw);
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ export function listCodexAccountUsage(providerId) {
164
+ const rows = client().prepare('SELECT id,provider_id AS providerId,chatgpt_account_id AS accountId,enabled,codex_autostart_enabled AS autostart,last_pinged_reset_at AS lastPingedResetAt,last_pinged_reset_key AS lastPingedResetKey,last_ping_at AS lastPingAt,codex_usage_json AS usageJson,codex_usage_error AS usageError FROM codex_accounts WHERE provider_id=? ORDER BY priority,id').all(providerId);
165
+ return rows.map(({ usageJson, ...row }) => ({ ...row, autostart: Boolean(row.autostart), enabled: Boolean(row.enabled), usage: parseUsage(usageJson) }));
166
+ }
167
+ /** Auto-start targets: enabled accounts that opted in, for the 10-minute scheduler tick. */
168
+ export function listCodexAutostartTargets() {
169
+ const rows = client().prepare('SELECT id,provider_id AS providerId,last_ping_at AS lastPingAt FROM codex_accounts WHERE enabled=1 AND codex_autostart_enabled=1').all();
170
+ return rows;
171
+ }
172
+ export function markCodexAccountPinged(id, resetAt, resetKey) {
173
+ const now = new Date().toISOString();
174
+ client().prepare('UPDATE codex_accounts SET last_pinged_reset_at=?, last_pinged_reset_key=?, last_ping_at=?, updated_at=? WHERE id=?')
175
+ .run(resetAt, resetKey, now, nextUpdatedAt(id), id);
176
+ }
177
+ export function toCodexAccountSummaryRow(row) {
178
+ return {
179
+ ...toCodexAccountSummary(row),
180
+ autostart: Boolean(row.autostart),
181
+ usage: parseUsage(row.usageJson ?? null),
182
+ usageError: row.usageError ?? null,
183
+ usageUpdatedAt: row.usageUpdatedAt ?? null,
184
+ lastPingedResetAt: row.lastPingedResetAt ?? null,
185
+ lastPingAt: row.lastPingAt ?? null,
186
+ };
187
+ }
@@ -81,10 +81,10 @@ export const providers = sqliteTable('providers', {
81
81
  id: text('id').primaryKey(),
82
82
  name: text('name').notNull(),
83
83
  slug: text('slug').notNull().unique(),
84
- type: text('type', { enum: ['openai', 'anthropic'] }).notNull(),
84
+ type: text('type', { enum: ['openai', 'anthropic', 'codex'] }).notNull(),
85
85
  baseUrl: text('base_url').notNull(),
86
- encryptedApiKey: text('encrypted_api_key').notNull(),
87
- apiKeyNonce: text('api_key_nonce').notNull(),
86
+ encryptedApiKey: text('encrypted_api_key'),
87
+ apiKeyNonce: text('api_key_nonce'),
88
88
  apiKeyVersion: integer('api_key_version').notNull().default(1),
89
89
  customHeadersEncrypted: text('custom_headers_encrypted'),
90
90
  customHeadersNonce: text('custom_headers_nonce'),
@@ -106,6 +106,45 @@ export const providers = sqliteTable('providers', {
106
106
  }, (t) => ({
107
107
  slugIdx: uniqueIndex('uniq_provider_slug').on(t.slug),
108
108
  }));
109
+ export const codexAccounts = sqliteTable('codex_accounts', {
110
+ id: text('id').primaryKey(),
111
+ providerId: text('provider_id').notNull().references(() => providers.id, { onDelete: 'restrict' }),
112
+ email: text('email'),
113
+ workspaceId: text('workspace_id'),
114
+ chatgptAccountId: text('chatgpt_account_id'),
115
+ planType: text('plan_type'),
116
+ encryptedAccessToken: text('encrypted_access_token').notNull(),
117
+ accessTokenNonce: text('access_token_nonce').notNull(),
118
+ accessTokenVersion: integer('access_token_version').notNull().default(1),
119
+ encryptedRefreshToken: text('encrypted_refresh_token').notNull(),
120
+ refreshTokenNonce: text('refresh_token_nonce').notNull(),
121
+ refreshTokenVersion: integer('refresh_token_version').notNull().default(1),
122
+ encryptedIdToken: text('encrypted_id_token'),
123
+ idTokenNonce: text('id_token_nonce'),
124
+ idTokenVersion: integer('id_token_version').notNull().default(1),
125
+ tokenExpiresAt: text('token_expires_at').notNull(),
126
+ lastRefreshAt: text('last_refresh_at'),
127
+ authMethod: text('auth_method', { enum: ['oauth', 'access_token'] }).notNull().default('oauth'),
128
+ enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
129
+ healthState: text('health_state', { enum: ['healthy', 'degraded', 'down', 'unknown'] }).notNull().default('unknown'),
130
+ lastError: text('last_error'),
131
+ consecutiveFailures: integer('consecutive_failures').notNull().default(0),
132
+ priority: integer('priority').notNull().default(0),
133
+ // Codex quota snapshot (wham/usage) and 5-hour window auto-start state (v1.15.0).
134
+ codexUsageJson: text('codex_usage_json'),
135
+ codexUsageUpdatedAt: text('codex_usage_updated_at'),
136
+ codexUsageError: text('codex_usage_error'),
137
+ codexAutostartEnabled: integer('codex_autostart_enabled', { mode: 'boolean' }).notNull().default(false),
138
+ lastPingedResetAt: text('last_pinged_reset_at'),
139
+ lastPingedResetKey: text('last_pinged_reset_key'),
140
+ lastPingAt: text('last_ping_at'),
141
+ createdAt: text('created_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
142
+ updatedAt: text('updated_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
143
+ }, (t) => ({
144
+ providerEnabledPriorityIdx: index('idx_codex_account_provider_enabled_priority').on(t.providerId, t.enabled, t.priority),
145
+ providerEmailIdx: index('idx_codex_account_provider_email').on(t.providerId, t.email),
146
+ providerChatgptIdx: index('idx_codex_account_provider_chatgpt').on(t.providerId, t.chatgptAccountId),
147
+ }));
109
148
  // ============================================================================
110
149
  // Models
111
150
  // ============================================================================
@@ -287,6 +326,7 @@ export const requestAttempts = sqliteTable('request_attempts', {
287
326
  attemptNumber: integer('attempt_number').notNull(),
288
327
  providerId: text('provider_id').notNull(),
289
328
  modelId: text('model_id').notNull(),
329
+ codexAccountId: text('codex_account_id').references(() => codexAccounts.id, { onDelete: 'set null' }),
290
330
  startedAt: text('started_at').notNull(),
291
331
  completedAt: text('completed_at'),
292
332
  statusCode: integer('status_code'),
@@ -307,6 +347,7 @@ export const requestAttempts = sqliteTable('request_attempts', {
307
347
  }, (t) => ({
308
348
  requestIdx: index('idx_attempt_request').on(t.requestId, t.attemptNumber),
309
349
  providerModelIdx: index('idx_attempt_provider_model').on(t.providerId, t.modelId, t.startedAt),
350
+ codexAccountIdx: index('idx_attempt_codex_account').on(t.codexAccountId, t.startedAt),
310
351
  }));
311
352
  // ============================================================================
312
353
  // Audit logs (immutable, excluded from request retention)