ldrouter 1.16.2 → 1.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +9 -38
  2. package/README.md +1 -23
  3. package/dist/server/app.js +1 -10
  4. package/dist/server/auth/middleware.js +1 -37
  5. package/dist/server/db/index.js +0 -5
  6. package/dist/server/db/migrate.js +5 -38
  7. package/dist/server/db/schema.js +3 -44
  8. package/dist/server/errors.js +11 -0
  9. package/dist/server/gateway/runner.js +64 -126
  10. package/dist/server/protocols/anthropic.js +5 -3
  11. package/dist/server/protocols/canonical.js +29 -8
  12. package/dist/server/providers/index.js +25 -8
  13. package/dist/server/routes/admin/auth.js +1 -4
  14. package/dist/server/routes/admin/combos.js +98 -48
  15. package/dist/server/routes/admin/models.js +34 -24
  16. package/dist/server/routes/admin/providers.js +20 -63
  17. package/dist/server/routes/admin/requests.js +0 -1
  18. package/dist/server/routes/admin.js +0 -12
  19. package/dist/server/routes/gateway/anthropic.js +3 -3
  20. package/dist/server/routes/gateway/openai.js +5 -5
  21. package/dist/server/routing/capabilities.js +73 -14
  22. package/dist/server/routing/combo.js +20 -39
  23. package/dist/server/routing/resolver.js +16 -11
  24. package/dist/server/upstream/client.js +55 -61
  25. package/dist/web/assets/index-C5h2WXK5.css +1 -0
  26. package/dist/web/assets/index-CRnoua24.js +335 -0
  27. package/dist/web/index.html +2 -2
  28. package/package.json +1 -5
  29. package/dist/server/db/repositories/codex-accounts.js +0 -187
  30. package/dist/server/providers/codex-autostart.js +0 -98
  31. package/dist/server/providers/codex-import.js +0 -156
  32. package/dist/server/providers/codex-oauth.js +0 -77
  33. package/dist/server/providers/codex-refresh.js +0 -165
  34. package/dist/server/providers/codex-usage.js +0 -192
  35. package/dist/server/providers/codex.js +0 -186
  36. package/dist/server/routes/admin/codex.js +0 -331
  37. package/dist/web/assets/index-Coy-u6h8.css +0 -1
  38. package/dist/web/assets/index-qDG5c6aL.js +0 -386
  39. package/migrations/0005_codex_accounts.sql +0 -105
  40. package/migrations/0006_codex_usage.sql +0 -9
package/CHANGELOG.md CHANGED
@@ -4,47 +4,18 @@ 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
7
+ ## [1.16.3] - 2026-09-14
8
8
 
9
9
  ### Fixed
10
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.
11
+ - **Routing rejections now name the model and the reason.** A request a model could not serve answered `No available model candidates` (direct model) or `No combo member satisfies the request capabilities or availability` (combo) the same failure described differently, naming neither the model nor the cause. Both paths now share one taxonomy of capability gaps and availability reasons, grouped per model and reported with the model name without its provider prefix (`vl/gpt-5.5` is reported as `gpt-5.5`).
12
+ - **Error codes reached clients again.** `GatewayError.code` was dropped when each gateway route rebuilt the error from the runner outcome, so `rpm_limit`, `tpm_limit`, `quota_limit`, `concurrency_limit`, and `upstream_http_*` never left the process.
13
+ - **Admin-disabled models answered `404 Unknown model`.** The resolver skipped disabled models, so callers looked for a typo in a model that had just been switched off; it now reports `model disabled`. The candidate loader also pre-filtered disabled models and providers, making the recorded reasons unreachable and collapsing every case into `model not found`.
14
+ - **Upstream HTTP failures agreed across paths.** A 429 surfaced as 529 without a code on the non-streaming path and with one on another; all paths now share one mapping (429 → `upstream_http_429`, 401/403 → `upstream_http_401`/`upstream_http_403`, ≥500 → `upstream_http_503`).
15
+ - **No internal JS errors in responses.** A 200 with an unexpected body shape surfaced `Cannot read properties of undefined (reading '0')`; it is now `Upstream response for "<model>" has no "choices" array` with code `upstream_bad_response`.
16
+ - **Combo create/update no longer 500, leave orphans, or rename the public ID.** Uniqueness checked only `publicModelId` while `combos.slug` is its own UNIQUE column, duplicate members hit `UNIQUE(combo_id, model_id)`, and both operations ran outside a transaction — update deleting members before inserting the new ones. Saving the edit form unchanged also rewrote a public ID such as `smart` into `combo/smart`, breaking aliases and API keys. All uniqueness checks share one path, both operations are atomic, and the ID is never derived from the name.
17
+ - **Image requests to image-capable models failed.** `/v1/responses` dropped `input_image` blocks, Anthropic inbound read URLs from `source.data`, and outbound Anthropic conversion emitted `{"type":"url","media_type","data"}` instead of `source.url`.
18
+ - **Model capabilities are no longer guessed.** `inferOpenAICapabilities` wrote `image_input`, `structured_output`, and `reasoning` as `false` whenever a model name missed a substring heuristic, hard-rejecting `gpt-4o-2024-11-20`, `gemini-*`, `qwen-vl-*`, and `grok-*`. Unknown stays unknown; manual admin edits survive re-import.
48
19
 
49
20
  ## [1.14.1] - 2026-09-12
50
21
 
package/README.md CHANGED
@@ -19,7 +19,6 @@ 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
23
22
 
24
23
  ## Quick start
25
24
 
@@ -37,33 +36,12 @@ Then visit `http://localhost:8787/` and complete the first-run admin setup.
37
36
  ### Using npm
38
37
 
39
38
  ```bash
40
- npx ldrouter
39
+ npx latedev-router
41
40
  latedev-router --host 0.0.0.0 --port 8787
42
41
  ```
43
42
 
44
43
  The data directory defaults to `~/.latedev-router/` and can be overridden via `LATEDEV_DATA_DIR` or `--data-dir`.
45
44
 
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
-
67
45
  ## Environment variables
68
46
 
69
47
  | Variable | Description | Default |
@@ -4,7 +4,6 @@ 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';
8
7
  import fs from 'node:fs';
9
8
  import path from 'node:path';
10
9
  import { fileURLToPath } from 'node:url';
@@ -44,7 +43,6 @@ export async function buildApp(opts = {}) {
44
43
  crossOriginEmbedderPolicy: false,
45
44
  });
46
45
  await app.register(cors, { origin: false, credentials: true });
47
- await app.register(multipart, { limits: { fileSize: 2_000_000, files: 20, parts: 25 } });
48
46
  // Every request is logged as structured JSON for Docker. Bodies and headers
49
47
  // are intentionally excluded; sensitive values must never reach logs.
50
48
  app.addHook('onRequest', async (req) => {
@@ -65,12 +63,7 @@ export async function buildApp(opts = {}) {
65
63
  const normalized = err instanceof ZodError
66
64
  ? new GatewayError('invalid_request_error', err.issues.map((i) => `${i.path.join('.') || 'body'}: ${i.message}`).join('; '), { status: 400 })
67
65
  : err;
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;
66
+ const g = normalized instanceof GatewayError ? normalized : null;
74
67
  const status = g?.status ?? 500;
75
68
  const requestId = req.id;
76
69
  // Fastify runs with `logger: false`, so req.log is a silent no-op — use the app
@@ -198,8 +191,6 @@ export async function startApp() {
198
191
  process.once('SIGTERM', () => void close('SIGTERM'));
199
192
  process.once('SIGINT', () => void close('SIGINT'));
200
193
  await app.listen({ host: cfg.host, port: cfg.port });
201
- const { startCodexAutostart } = await import('./providers/codex-autostart.js');
202
- startCodexAutostart();
203
194
  return app;
204
195
  }
205
196
  // re-export for convenience
@@ -1,45 +1,9 @@
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, 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
- }
4
+ import { sha256Hex } from './ids.js';
10
5
  import { GatewayError } from '../errors.js';
11
6
  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 };
43
7
  const SessionCookie = 'ld_session';
44
8
  export async function requireAdminAuth(req, _reply) {
45
9
  const token = req.cookies[SessionCookie];
@@ -61,9 +61,4 @@ 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
- }
69
64
  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','codex')),
144
- base_url TEXT NOT NULL,
145
- encrypted_api_key TEXT,
146
- api_key_nonce TEXT,
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,
147
147
  api_key_version INTEGER NOT NULL DEFAULT 1,
148
148
  custom_headers_encrypted TEXT,
149
149
  custom_headers_nonce TEXT,
@@ -302,37 +302,6 @@ 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
-
336
305
  CREATE TABLE IF NOT EXISTS request_attempts (
337
306
  id TEXT PRIMARY KEY,
338
307
  request_id TEXT NOT NULL REFERENCES requests(id) ON DELETE CASCADE,
@@ -355,11 +324,9 @@ function buildInitialSchemaSql() {
355
324
  selection_reason TEXT NOT NULL,
356
325
  failure_reason TEXT,
357
326
  error_message TEXT,
358
- upstream_request_id TEXT,
359
- codex_account_id TEXT REFERENCES codex_accounts(id) ON DELETE SET NULL
327
+ upstream_request_id TEXT
360
328
  );
361
329
  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);
363
330
  CREATE INDEX IF NOT EXISTS idx_attempt_provider_model ON request_attempts(provider_id, model_id, started_at);
364
331
 
365
332
  CREATE TABLE IF NOT EXISTS audit_logs (
@@ -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', 'codex'] }).notNull(),
84
+ type: text('type', { enum: ['openai', 'anthropic'] }).notNull(),
85
85
  baseUrl: text('base_url').notNull(),
86
- encryptedApiKey: text('encrypted_api_key'),
87
- apiKeyNonce: text('api_key_nonce'),
86
+ encryptedApiKey: text('encrypted_api_key').notNull(),
87
+ apiKeyNonce: text('api_key_nonce').notNull(),
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,45 +106,6 @@ 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
- }));
148
109
  // ============================================================================
149
110
  // Models
150
111
  // ============================================================================
@@ -326,7 +287,6 @@ export const requestAttempts = sqliteTable('request_attempts', {
326
287
  attemptNumber: integer('attempt_number').notNull(),
327
288
  providerId: text('provider_id').notNull(),
328
289
  modelId: text('model_id').notNull(),
329
- codexAccountId: text('codex_account_id').references(() => codexAccounts.id, { onDelete: 'set null' }),
330
290
  startedAt: text('started_at').notNull(),
331
291
  completedAt: text('completed_at'),
332
292
  statusCode: integer('status_code'),
@@ -347,7 +307,6 @@ export const requestAttempts = sqliteTable('request_attempts', {
347
307
  }, (t) => ({
348
308
  requestIdx: index('idx_attempt_request').on(t.requestId, t.attemptNumber),
349
309
  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),
351
310
  }));
352
311
  // ============================================================================
353
312
  // Audit logs (immutable, excluded from request retention)
@@ -63,3 +63,14 @@ export function toAnthropicError(g, requestId) {
63
63
  ...(requestId ? { request_id: requestId } : {}),
64
64
  };
65
65
  }
66
+ /**
67
+ * Rehydrate the error for the client from a gateway outcome. `errorCode` matters:
68
+ * without it every propagated failure lost its specific code (rpm_limit,
69
+ * upstream_http_429, …) and arrived as a bare type.
70
+ */
71
+ export function outcomeError(outcome) {
72
+ return new GatewayError(outcome.errorType ?? 'gateway_error', outcome.errorMessage ?? 'Gateway error', {
73
+ status: outcome.httpStatus,
74
+ ...(outcome.errorCode ? { code: outcome.errorCode } : {}),
75
+ });
76
+ }