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.
- package/CHANGELOG.md +9 -38
- package/README.md +1 -23
- package/dist/server/app.js +1 -10
- package/dist/server/auth/middleware.js +1 -37
- package/dist/server/db/index.js +0 -5
- package/dist/server/db/migrate.js +5 -38
- package/dist/server/db/schema.js +3 -44
- package/dist/server/errors.js +11 -0
- package/dist/server/gateway/runner.js +64 -126
- package/dist/server/protocols/anthropic.js +5 -3
- package/dist/server/protocols/canonical.js +29 -8
- package/dist/server/providers/index.js +25 -8
- package/dist/server/routes/admin/auth.js +1 -4
- package/dist/server/routes/admin/combos.js +98 -48
- package/dist/server/routes/admin/models.js +34 -24
- package/dist/server/routes/admin/providers.js +20 -63
- package/dist/server/routes/admin/requests.js +0 -1
- package/dist/server/routes/admin.js +0 -12
- package/dist/server/routes/gateway/anthropic.js +3 -3
- package/dist/server/routes/gateway/openai.js +5 -5
- package/dist/server/routing/capabilities.js +73 -14
- package/dist/server/routing/combo.js +20 -39
- package/dist/server/routing/resolver.js +16 -11
- package/dist/server/upstream/client.js +55 -61
- package/dist/web/assets/index-C5h2WXK5.css +1 -0
- package/dist/web/assets/index-CRnoua24.js +335 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -5
- package/dist/server/db/repositories/codex-accounts.js +0 -187
- package/dist/server/providers/codex-autostart.js +0 -98
- package/dist/server/providers/codex-import.js +0 -156
- package/dist/server/providers/codex-oauth.js +0 -77
- package/dist/server/providers/codex-refresh.js +0 -165
- package/dist/server/providers/codex-usage.js +0 -192
- package/dist/server/providers/codex.js +0 -186
- package/dist/server/routes/admin/codex.js +0 -331
- package/dist/web/assets/index-Coy-u6h8.css +0 -1
- package/dist/web/assets/index-qDG5c6aL.js +0 -386
- package/migrations/0005_codex_accounts.sql +0 -105
- 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.
|
|
7
|
+
## [1.16.3] - 2026-09-14
|
|
8
8
|
|
|
9
9
|
### Fixed
|
|
10
10
|
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
- **
|
|
18
|
-
-
|
|
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
|
|
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 |
|
package/dist/server/app.js
CHANGED
|
@@ -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
|
|
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
|
|
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];
|
package/dist/server/db/index.js
CHANGED
|
@@ -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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 (
|
package/dist/server/db/schema.js
CHANGED
|
@@ -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'
|
|
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)
|
package/dist/server/errors.js
CHANGED
|
@@ -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
|
+
}
|