ldrouter 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +101 -0
  4. package/dist/cli.js +13 -0
  5. package/dist/server/app.js +138 -0
  6. package/dist/server/auth/api-key.js +75 -0
  7. package/dist/server/auth/crypto.js +94 -0
  8. package/dist/server/auth/ids.js +40 -0
  9. package/dist/server/auth/middleware.js +36 -0
  10. package/dist/server/auth/recovery.js +11 -0
  11. package/dist/server/caching/store.js +119 -0
  12. package/dist/server/config/index.js +96 -0
  13. package/dist/server/db/index.js +64 -0
  14. package/dist/server/db/migrate.js +408 -0
  15. package/dist/server/db/repositories/audit.js +75 -0
  16. package/dist/server/db/repositories/settings.js +63 -0
  17. package/dist/server/db/schema.js +396 -0
  18. package/dist/server/errors.js +65 -0
  19. package/dist/server/gateway/runner.js +745 -0
  20. package/dist/server/logging/logger.js +35 -0
  21. package/dist/server/maintenance/retention.js +48 -0
  22. package/dist/server/metrics/registry.js +169 -0
  23. package/dist/server/protocols/anthropic.js +154 -0
  24. package/dist/server/protocols/canonical.js +201 -0
  25. package/dist/server/providers/index.js +89 -0
  26. package/dist/server/routes/admin/aliases.js +98 -0
  27. package/dist/server/routes/admin/api-keys.js +194 -0
  28. package/dist/server/routes/admin/audit.js +19 -0
  29. package/dist/server/routes/admin/auth.js +124 -0
  30. package/dist/server/routes/admin/backup.js +113 -0
  31. package/dist/server/routes/admin/combos.js +198 -0
  32. package/dist/server/routes/admin/dashboard.js +55 -0
  33. package/dist/server/routes/admin/models.js +178 -0
  34. package/dist/server/routes/admin/providers.js +212 -0
  35. package/dist/server/routes/admin/requests.js +156 -0
  36. package/dist/server/routes/admin/settings.js +197 -0
  37. package/dist/server/routes/admin/setup.js +80 -0
  38. package/dist/server/routes/admin/stats.js +180 -0
  39. package/dist/server/routes/admin.js +39 -0
  40. package/dist/server/routes/gateway/anthropic.js +112 -0
  41. package/dist/server/routes/gateway/openai.js +257 -0
  42. package/dist/server/routes/gateway.js +7 -0
  43. package/dist/server/routes/health.js +27 -0
  44. package/dist/server/routing/capabilities.js +52 -0
  45. package/dist/server/routing/circuit.js +37 -0
  46. package/dist/server/routing/combo.js +100 -0
  47. package/dist/server/routing/quota.js +51 -0
  48. package/dist/server/routing/ratelimit.js +58 -0
  49. package/dist/server/routing/resolver.js +43 -0
  50. package/dist/server/security/redact.js +111 -0
  51. package/dist/server/selfupdate/index.js +154 -0
  52. package/dist/server/upstream/client.js +179 -0
  53. package/dist/server/util/cidr.js +91 -0
  54. package/dist/server/util/client-ip.js +15 -0
  55. package/dist/server/util/stable-json.js +19 -0
  56. package/dist/shared/types.js +2 -0
  57. package/dist/web/assets/index-COSbvF8Z.css +1 -0
  58. package/dist/web/assets/index-DbnEzuxq.js +251 -0
  59. package/dist/web/favicon.png +0 -0
  60. package/dist/web/index.html +15 -0
  61. package/dist/web/logo.png +0 -0
  62. package/migrations/0001_initial_schema.sql +323 -0
  63. package/migrations/0002_source_api_key_secrets.sql +7 -0
  64. package/package.json +117 -0
@@ -0,0 +1,75 @@
1
+ // Repository: audit logs.
2
+ import { getDb, schema } from '../index.js';
3
+ import { and, desc, eq, gte, like, lte, sql } from 'drizzle-orm';
4
+ import { uuid } from '../../auth/ids.js';
5
+ import { redactValue } from '../../security/redact.js';
6
+ export function recordAudit(input) {
7
+ const db = getDb();
8
+ const safeMetadata = input.metadata ? redactValue(input.metadata) : null;
9
+ db.insert(schema.auditLogs)
10
+ .values({
11
+ id: uuid(),
12
+ action: input.action,
13
+ actor: input.actor ?? 'admin',
14
+ ip: input.ip ?? null,
15
+ success: input.success,
16
+ targetType: input.targetType ?? null,
17
+ targetId: input.targetId ?? null,
18
+ targetName: input.targetName ?? null,
19
+ metadataJson: safeMetadata ? JSON.stringify(safeMetadata) : null,
20
+ })
21
+ .run();
22
+ }
23
+ export function queryAudit(q = {}) {
24
+ const db = getDb();
25
+ const limit = Math.min(Math.max(q.limit ?? 50, 1), 200);
26
+ const offset = Math.max(q.offset ?? 0, 0);
27
+ const conds = [];
28
+ if (q.from)
29
+ conds.push(gte(schema.auditLogs.createdAt, q.from));
30
+ if (q.to)
31
+ conds.push(lte(schema.auditLogs.createdAt, q.to));
32
+ if (q.action)
33
+ conds.push(eq(schema.auditLogs.action, q.action));
34
+ if (q.success !== undefined)
35
+ conds.push(eq(schema.auditLogs.success, q.success));
36
+ if (q.search)
37
+ conds.push(like(schema.auditLogs.targetName, `%${q.search}%`));
38
+ const whereExpr = conds.length ? and(...conds) : undefined;
39
+ const rows = db
40
+ .select()
41
+ .from(schema.auditLogs)
42
+ .where(whereExpr)
43
+ .orderBy(desc(schema.auditLogs.createdAt))
44
+ .limit(limit)
45
+ .offset(offset)
46
+ .all();
47
+ const totalRow = db
48
+ .select({ c: sql `COUNT(*)` })
49
+ .from(schema.auditLogs)
50
+ .where(whereExpr)
51
+ .get();
52
+ return {
53
+ rows: rows.map((r) => ({
54
+ id: r.id,
55
+ createdAt: r.createdAt,
56
+ action: r.action,
57
+ actor: r.actor,
58
+ ip: r.ip ?? '',
59
+ success: Boolean(r.success),
60
+ targetType: r.targetType,
61
+ targetId: r.targetId,
62
+ targetName: r.targetName,
63
+ metadata: r.metadataJson ? safeJsonParse(r.metadataJson) : {},
64
+ })),
65
+ total: totalRow?.c ?? 0,
66
+ };
67
+ }
68
+ function safeJsonParse(s) {
69
+ try {
70
+ return JSON.parse(s);
71
+ }
72
+ catch {
73
+ return {};
74
+ }
75
+ }
@@ -0,0 +1,63 @@
1
+ // Repository: application settings (singleton row in app_settings table).
2
+ import { getDb, schema } from '../index.js';
3
+ import { eq } from 'drizzle-orm';
4
+ function rowToSettings(row) {
5
+ return {
6
+ setupComplete: row.setupComplete,
7
+ retentionDays: row.retentionDays,
8
+ retentionMode: 'custom',
9
+ customRetentionDays: row.retentionDays,
10
+ contentLogMode: row.contentLogMode,
11
+ dbSizeLimitMb: row.dbSizeLimitMb,
12
+ trustProxyHops: row.trustProxyHops,
13
+ schemaVersion: row.schemaVersion,
14
+ appVersion: row.appVersion,
15
+ gatewayCacheEnabled: row.gatewayCacheEnabled,
16
+ gatewayCacheDefaultTtlSeconds: row.gatewayCacheDefaultTtlSeconds,
17
+ gatewayCacheMaxSizeMb: row.gatewayCacheMaxSizeMb,
18
+ masterKeyConfigured: row.masterKeyConfigured,
19
+ masterKeyVersion: row.masterKeyVersion,
20
+ };
21
+ }
22
+ export function getSettings() {
23
+ const db = getDb();
24
+ const row = db.select().from(schema.appSettings).where(eq(schema.appSettings.id, 1)).get();
25
+ if (!row) {
26
+ // Bootstrap if somehow missing.
27
+ db.insert(schema.appSettings).values({ id: 1 }).run();
28
+ const again = db.select().from(schema.appSettings).where(eq(schema.appSettings.id, 1)).get();
29
+ if (!again)
30
+ throw new Error('app_settings bootstrap failed');
31
+ return rowToSettings(again);
32
+ }
33
+ return rowToSettings(row);
34
+ }
35
+ export function updateSettings(patch) {
36
+ const db = getDb();
37
+ const update = {
38
+ updatedAt: new Date().toISOString(),
39
+ };
40
+ if (patch.setupComplete !== undefined)
41
+ update.setupComplete = patch.setupComplete;
42
+ if (patch.retentionDays !== undefined)
43
+ update.retentionDays = patch.retentionDays;
44
+ if (patch.contentLogMode !== undefined)
45
+ update.contentLogMode = patch.contentLogMode;
46
+ if (patch.dbSizeLimitMb !== undefined)
47
+ update.dbSizeLimitMb = patch.dbSizeLimitMb;
48
+ if (patch.trustProxyHops !== undefined)
49
+ update.trustProxyHops = patch.trustProxyHops;
50
+ if (patch.gatewayCacheEnabled !== undefined)
51
+ update.gatewayCacheEnabled = patch.gatewayCacheEnabled;
52
+ if (patch.gatewayCacheDefaultTtlSeconds !== undefined)
53
+ update.gatewayCacheDefaultTtlSeconds = patch.gatewayCacheDefaultTtlSeconds;
54
+ if (patch.gatewayCacheMaxSizeMb !== undefined)
55
+ update.gatewayCacheMaxSizeMb = patch.gatewayCacheMaxSizeMb;
56
+ if (patch.masterKeyConfigured !== undefined)
57
+ update.masterKeyConfigured = patch.masterKeyConfigured;
58
+ db.update(schema.appSettings).set(update).where(eq(schema.appSettings.id, 1)).run();
59
+ return getSettings();
60
+ }
61
+ export function markSetupComplete() {
62
+ updateSettings({ setupComplete: true });
63
+ }
@@ -0,0 +1,396 @@
1
+ // Drizzle ORM schema for LateDev Router SQLite database.
2
+ // Source of truth: docs/02-DATA-MODEL.md
3
+ import { sql } from 'drizzle-orm';
4
+ import { sqliteTable, text, integer, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
5
+ // ============================================================================
6
+ // Application settings (singleton)
7
+ // ============================================================================
8
+ export const appSettings = sqliteTable('app_settings', {
9
+ id: integer('id').primaryKey(),
10
+ setupComplete: integer('setup_complete', { mode: 'boolean' }).notNull().notNull().default(false),
11
+ retentionDays: integer('retention_days').notNull().default(30),
12
+ contentLogMode: text('content_log_mode').notNull().default('metadata'),
13
+ dbSizeLimitMb: integer('db_size_limit_mb').notNull().default(2048),
14
+ trustProxyHops: integer('trust_proxy_hops').notNull().default(0),
15
+ schemaVersion: integer('schema_version').notNull().default(0),
16
+ appVersion: text('app_version').notNull().default('0.0.0'),
17
+ gatewayCacheEnabled: integer('gateway_cache_enabled', { mode: 'boolean' }).notNull().notNull().default(false),
18
+ gatewayCacheDefaultTtlSeconds: integer('gateway_cache_default_ttl_seconds').notNull().default(300),
19
+ gatewayCacheMaxSizeMb: integer('gateway_cache_max_size_mb').notNull().default(256),
20
+ masterKeyVersion: integer('master_key_version').notNull().default(1),
21
+ masterKeyConfigured: integer('master_key_configured', { mode: 'boolean' }).notNull().notNull().default(false),
22
+ updatedAt: text('updated_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
23
+ });
24
+ // ============================================================================
25
+ // Admin account + sessions
26
+ // ============================================================================
27
+ export const adminAccount = sqliteTable('admin_account', {
28
+ id: text('id').primaryKey(),
29
+ username: text('username').notNull().unique(),
30
+ passwordHash: text('password_hash').notNull(),
31
+ totpEnabled: integer('totp_enabled', { mode: 'boolean' }).notNull().notNull().default(false),
32
+ totpSecretEncrypted: text('totp_secret_encrypted'),
33
+ totpSecretNonce: text('totp_secret_nonce'),
34
+ createdAt: text('created_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
35
+ updatedAt: text('updated_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
36
+ lastLoginAt: text('last_login_at'),
37
+ });
38
+ export const adminRecoveryCodes = sqliteTable('admin_recovery_codes', {
39
+ id: text('id').primaryKey(),
40
+ adminId: text('admin_id')
41
+ .notNull()
42
+ .references(() => adminAccount.id, { onDelete: 'cascade' }),
43
+ codeHash: text('code_hash').notNull(),
44
+ usedAt: text('used_at'),
45
+ createdAt: text('created_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
46
+ }, (t) => ({
47
+ adminIdx: index('idx_recovery_admin').on(t.adminId),
48
+ }));
49
+ export const adminSessions = sqliteTable('admin_sessions', {
50
+ id: text('id').primaryKey(),
51
+ tokenDigest: text('token_digest').notNull().unique(),
52
+ createdAt: text('created_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
53
+ expiresAt: text('expires_at').notNull(),
54
+ lastSeenAt: text('last_seen_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
55
+ ip: text('ip'),
56
+ userAgent: text('user_agent'),
57
+ }, (t) => ({
58
+ expiresIdx: index('idx_session_expires').on(t.expiresAt),
59
+ }));
60
+ export const loginAttempts = sqliteTable('login_attempts', {
61
+ id: text('id').primaryKey(),
62
+ username: text('username').notNull(),
63
+ ip: text('ip').notNull(),
64
+ success: integer('success', { mode: 'boolean' }).notNull(),
65
+ createdAt: text('created_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
66
+ }, (t) => ({
67
+ userIdx: index('idx_login_user_time').on(t.username, t.createdAt),
68
+ ipIdx: index('idx_login_ip_time').on(t.ip, t.createdAt),
69
+ }));
70
+ // ============================================================================
71
+ // Providers
72
+ // ============================================================================
73
+ export const providers = sqliteTable('providers', {
74
+ id: text('id').primaryKey(),
75
+ name: text('name').notNull(),
76
+ slug: text('slug').notNull().unique(),
77
+ type: text('type', { enum: ['openai', 'anthropic'] }).notNull(),
78
+ baseUrl: text('base_url').notNull(),
79
+ encryptedApiKey: text('encrypted_api_key').notNull(),
80
+ apiKeyNonce: text('api_key_nonce').notNull(),
81
+ apiKeyVersion: integer('api_key_version').notNull().default(1),
82
+ customHeadersEncrypted: text('custom_headers_encrypted'),
83
+ customHeadersNonce: text('custom_headers_nonce'),
84
+ enabled: integer('enabled', { mode: 'boolean' }).notNull().notNull().default(true),
85
+ connectTimeoutMs: integer('connect_timeout_ms').notNull().default(10000),
86
+ firstTokenTimeoutMs: integer('first_token_timeout_ms').notNull().default(30000),
87
+ streamIdleTimeoutMs: integer('stream_idle_timeout_ms').notNull().default(60000),
88
+ totalTimeoutMs: integer('total_timeout_ms').notNull().default(180000),
89
+ maxRetries: integer('max_retries').notNull().default(2),
90
+ retryBaseMs: integer('retry_base_ms').notNull().default(500),
91
+ retryMaxMs: integer('retry_max_ms').notNull().default(8000),
92
+ cbFailureThreshold: integer('cb_failure_threshold').notNull().default(5),
93
+ cbCooldownSeconds: integer('cb_cooldown_seconds').notNull().default(60),
94
+ healthState: text('health_state', { enum: ['healthy', 'degraded', 'down', 'circuit_open', 'unknown'] })
95
+ .notNull()
96
+ .default('unknown'),
97
+ createdAt: text('created_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
98
+ updatedAt: text('updated_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
99
+ }, (t) => ({
100
+ slugIdx: uniqueIndex('uniq_provider_slug').on(t.slug),
101
+ }));
102
+ // ============================================================================
103
+ // Models
104
+ // ============================================================================
105
+ export const models = sqliteTable('models', {
106
+ id: text('id').primaryKey(),
107
+ providerId: text('provider_id')
108
+ .notNull()
109
+ .references(() => providers.id, { onDelete: 'restrict' }),
110
+ upstreamModelId: text('upstream_model_id').notNull(),
111
+ publicModelId: text('public_model_id').notNull().unique(),
112
+ displayName: text('display_name').notNull(),
113
+ enabled: integer('enabled', { mode: 'boolean' }).notNull().notNull().default(true),
114
+ upstreamAvailable: integer('upstream_available', { mode: 'boolean' }).notNull().notNull().default(true),
115
+ capabilitiesJson: text('capabilities_json').notNull().default('{}'),
116
+ maxContextTokens: integer('max_context_tokens'),
117
+ maxOutputTokens: integer('max_output_tokens'),
118
+ discoveredMetadataJson: text('discovered_metadata_json'),
119
+ cacheOverrideEnabled: integer('cache_override_enabled', { mode: 'boolean' }),
120
+ createdAt: text('created_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
121
+ updatedAt: text('updated_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
122
+ lastSeenUpstreamAt: text('last_seen_upstream_at'),
123
+ }, (t) => ({
124
+ publicIdx: uniqueIndex('uniq_model_public').on(t.publicModelId),
125
+ providerUpstreamIdx: uniqueIndex('uniq_provider_upstream').on(t.providerId, t.upstreamModelId),
126
+ enabledIdx: index('idx_model_enabled').on(t.enabled),
127
+ }));
128
+ // ============================================================================
129
+ // Combos
130
+ // ============================================================================
131
+ export const combos = sqliteTable('combos', {
132
+ id: text('id').primaryKey(),
133
+ name: text('name').notNull(),
134
+ slug: text('slug').notNull().unique(),
135
+ publicModelId: text('public_model_id').notNull().unique(),
136
+ mode: text('mode', { enum: ['fallback', 'weighted_round_robin'] }).notNull(),
137
+ enabled: integer('enabled', { mode: 'boolean' }).notNull().notNull().default(true),
138
+ maxTotalAttempts: integer('max_total_attempts').notNull().default(3),
139
+ fallbackOnConnection: integer('fallback_on_connection', { mode: 'boolean' }).notNull().notNull().default(true),
140
+ fallbackOnConnectTimeout: integer('fallback_on_connect_timeout', { mode: 'boolean' }).notNull().notNull().default(true),
141
+ fallbackOnFirstTokenTimeout: integer('fallback_on_first_token_timeout', { mode: 'boolean' }).notNull().notNull().default(true),
142
+ fallbackOn408: integer('fallback_on_408', { mode: 'boolean' }).notNull().notNull().default(true),
143
+ fallbackOn429: integer('fallback_on_429', { mode: 'boolean' }).notNull().notNull().default(true),
144
+ fallbackOn5xx: integer('fallback_on_5xx', { mode: 'boolean' }).notNull().notNull().default(true),
145
+ cacheOverrideEnabled: integer('cache_override_enabled', { mode: 'boolean' }),
146
+ configVersion: integer('config_version').notNull().default(1),
147
+ createdAt: text('created_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
148
+ updatedAt: text('updated_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
149
+ }, (t) => ({
150
+ slugIdx: uniqueIndex('uniq_combo_slug').on(t.slug),
151
+ publicIdx: uniqueIndex('uniq_combo_public').on(t.publicModelId),
152
+ }));
153
+ export const comboMembers = sqliteTable('combo_members', {
154
+ id: text('id').primaryKey(),
155
+ comboId: text('combo_id')
156
+ .notNull()
157
+ .references(() => combos.id, { onDelete: 'cascade' }),
158
+ modelId: text('model_id')
159
+ .notNull()
160
+ .references(() => models.id, { onDelete: 'restrict' }),
161
+ position: integer('position').notNull(),
162
+ weight: integer('weight').notNull().default(1),
163
+ enabled: integer('enabled', { mode: 'boolean' }).notNull().notNull().default(true),
164
+ }, (t) => ({
165
+ uniqMember: uniqueIndex('uniq_combo_model').on(t.comboId, t.modelId),
166
+ posIdx: index('idx_combo_pos').on(t.comboId, t.position),
167
+ }));
168
+ // ============================================================================
169
+ // Aliases
170
+ // ============================================================================
171
+ export const modelAliases = sqliteTable('model_aliases', {
172
+ id: text('id').primaryKey(),
173
+ alias: text('alias').notNull().unique(),
174
+ targetKind: text('target_kind', { enum: ['model', 'combo'] }).notNull(),
175
+ targetId: text('target_id').notNull(),
176
+ enabled: integer('enabled', { mode: 'boolean' }).notNull().notNull().default(true),
177
+ configVersion: integer('config_version').notNull().default(1),
178
+ createdAt: text('created_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
179
+ updatedAt: text('updated_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
180
+ }, (t) => ({
181
+ aliasIdx: uniqueIndex('uniq_alias').on(t.alias),
182
+ }));
183
+ // ============================================================================
184
+ // API keys (gateway keys with ld- prefix)
185
+ // ============================================================================
186
+ export const apiKeys = sqliteTable('api_keys', {
187
+ id: text('id').primaryKey(),
188
+ name: text('name').notNull(),
189
+ keyPrefix: text('key_prefix').notNull(),
190
+ keyDigest: text('key_digest').notNull().unique(),
191
+ keySecretEncrypted: text('key_secret_encrypted'),
192
+ keySecretNonce: text('key_secret_nonce'),
193
+ keySecretVersion: integer('key_secret_version').notNull().default(1),
194
+ enabled: integer('enabled', { mode: 'boolean' }).notNull().notNull().default(true),
195
+ expiresAt: text('expires_at'),
196
+ rpmLimit: integer('rpm_limit'),
197
+ tpmLimit: integer('tpm_limit'),
198
+ dailyTokenLimit: integer('daily_token_limit'),
199
+ monthlyTokenLimit: integer('monthly_token_limit'),
200
+ maxConcurrent: integer('max_concurrent'),
201
+ maxOutputTokensPerRequest: integer('max_output_tokens_per_request'),
202
+ allowAllModels: integer('allow_all_models', { mode: 'boolean' }).notNull().notNull().default(false),
203
+ cacheOverrideEnabled: integer('cache_override_enabled', { mode: 'boolean' }),
204
+ createdAt: text('created_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
205
+ updatedAt: text('updated_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
206
+ lastUsedAt: text('last_used_at'),
207
+ }, (t) => ({
208
+ digestIdx: uniqueIndex('uniq_key_digest').on(t.keyDigest),
209
+ prefixIdx: index('idx_key_prefix').on(t.keyPrefix),
210
+ }));
211
+ export const apiKeyModelPermissions = sqliteTable('api_key_model_permissions', {
212
+ id: text('id').primaryKey(),
213
+ apiKeyId: text('api_key_id')
214
+ .notNull()
215
+ .references(() => apiKeys.id, { onDelete: 'cascade' }),
216
+ targetKind: text('target_kind', { enum: ['model', 'combo', 'alias'] }).notNull(),
217
+ targetId: text('target_id').notNull(),
218
+ }, (t) => ({
219
+ uniqPerm: uniqueIndex('uniq_key_target').on(t.apiKeyId, t.targetKind, t.targetId),
220
+ }));
221
+ export const apiKeyIpRules = sqliteTable('api_key_ip_rules', {
222
+ id: text('id').primaryKey(),
223
+ apiKeyId: text('api_key_id')
224
+ .notNull()
225
+ .references(() => apiKeys.id, { onDelete: 'cascade' }),
226
+ mode: text('mode', { enum: ['allow', 'deny'] }).notNull(),
227
+ cidr: text('cidr').notNull(),
228
+ createdAt: text('created_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
229
+ }, (t) => ({
230
+ keyIdx: index('idx_ip_rule_key').on(t.apiKeyId),
231
+ }));
232
+ // ============================================================================
233
+ // Request logs + attempts
234
+ // ============================================================================
235
+ export const requests = sqliteTable('requests', {
236
+ id: text('id').primaryKey(),
237
+ createdAt: text('created_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
238
+ completedAt: text('completed_at'),
239
+ apiKeyId: text('api_key_id'),
240
+ keyPrefixSnapshot: text('key_prefix_snapshot'),
241
+ clientIp: text('client_ip').notNull(),
242
+ protocol: text('protocol', { enum: ['openai', 'anthropic'] }).notNull(),
243
+ endpoint: text('endpoint').notNull(),
244
+ requestedModel: text('requested_model').notNull(),
245
+ resolvedTargetKind: text('resolved_target_kind', { enum: ['model', 'combo', 'alias', 'unknown'] }).notNull(),
246
+ resolvedTargetId: text('resolved_target_id'),
247
+ finalModelId: text('final_model_id'),
248
+ streaming: integer('streaming', { mode: 'boolean' }).notNull(),
249
+ httpStatus: integer('http_status').notNull(),
250
+ success: integer('success', { mode: 'boolean' }).notNull(),
251
+ totalLatencyMs: integer('total_latency_ms').notNull().default(0),
252
+ ttftMs: integer('ttft_ms'),
253
+ inputTokens: integer('input_tokens').notNull().default(0),
254
+ outputTokens: integer('output_tokens').notNull().default(0),
255
+ cacheReadTokens: integer('cache_read_tokens').notNull().default(0),
256
+ cacheWriteTokens: integer('cache_write_tokens').notNull().default(0),
257
+ reasoningTokens: integer('reasoning_tokens').notNull().default(0),
258
+ totalTokens: integer('total_tokens').notNull().default(0),
259
+ attemptsCount: integer('attempts_count').notNull().default(0),
260
+ errorType: text('error_type'),
261
+ errorMessage: text('error_message'),
262
+ requestPayloadJson: text('request_payload_json'),
263
+ responsePayloadJson: text('response_payload_json'),
264
+ gatewayCacheHit: integer('gateway_cache_hit', { mode: 'boolean' }).notNull().notNull().default(false),
265
+ partialStream: integer('partial_stream', { mode: 'boolean' }).notNull().notNull().default(false),
266
+ }, (t) => ({
267
+ createdIdx: index('idx_request_created').on(t.createdAt),
268
+ successIdx: index('idx_request_success').on(t.success, t.createdAt),
269
+ apiKeyIdx: index('idx_request_apikey').on(t.apiKeyId, t.createdAt),
270
+ finalModelIdx: index('idx_request_final_model').on(t.finalModelId, t.createdAt),
271
+ requestedIdx: index('idx_request_requested').on(t.requestedModel, t.createdAt),
272
+ protocolIdx: index('idx_request_protocol').on(t.protocol, t.createdAt),
273
+ }));
274
+ export const requestAttempts = sqliteTable('request_attempts', {
275
+ id: text('id').primaryKey(),
276
+ requestId: text('request_id')
277
+ .notNull()
278
+ .references(() => requests.id, { onDelete: 'cascade' }),
279
+ attemptNumber: integer('attempt_number').notNull(),
280
+ providerId: text('provider_id').notNull(),
281
+ modelId: text('model_id').notNull(),
282
+ startedAt: text('started_at').notNull(),
283
+ completedAt: text('completed_at'),
284
+ statusCode: integer('status_code'),
285
+ success: integer('success', { mode: 'boolean' }).notNull(),
286
+ latencyMs: integer('latency_ms').notNull().default(0),
287
+ ttftMs: integer('ttft_ms'),
288
+ inputTokens: integer('input_tokens').notNull().default(0),
289
+ outputTokens: integer('output_tokens').notNull().default(0),
290
+ cacheReadTokens: integer('cache_read_tokens').notNull().default(0),
291
+ cacheWriteTokens: integer('cache_write_tokens').notNull().default(0),
292
+ reasoningTokens: integer('reasoning_tokens').notNull().default(0),
293
+ streamStarted: integer('stream_started', { mode: 'boolean' }).notNull().notNull().default(false),
294
+ partialResponse: integer('partial_response', { mode: 'boolean' }).notNull().notNull().default(false),
295
+ selectionReason: text('selection_reason').notNull(),
296
+ failureReason: text('failure_reason'),
297
+ errorMessage: text('error_message'),
298
+ upstreamRequestId: text('upstream_request_id'),
299
+ }, (t) => ({
300
+ requestIdx: index('idx_attempt_request').on(t.requestId, t.attemptNumber),
301
+ providerModelIdx: index('idx_attempt_provider_model').on(t.providerId, t.modelId, t.startedAt),
302
+ }));
303
+ // ============================================================================
304
+ // Audit logs (immutable, excluded from request retention)
305
+ // ============================================================================
306
+ export const auditLogs = sqliteTable('audit_logs', {
307
+ id: text('id').primaryKey(),
308
+ createdAt: text('created_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
309
+ action: text('action').notNull(),
310
+ actor: text('actor').notNull().default('admin'),
311
+ ip: text('ip'),
312
+ success: integer('success', { mode: 'boolean' }).notNull(),
313
+ targetType: text('target_type'),
314
+ targetId: text('target_id'),
315
+ targetName: text('target_name'),
316
+ metadataJson: text('metadata_json'),
317
+ }, (t) => ({
318
+ createdIdx: index('idx_audit_created').on(t.createdAt),
319
+ actionIdx: index('idx_audit_action').on(t.action, t.createdAt),
320
+ }));
321
+ // ============================================================================
322
+ // Gateway response cache (canonical, exact-key)
323
+ // ============================================================================
324
+ export const responseCache = sqliteTable('response_cache', {
325
+ id: text('id').primaryKey(),
326
+ cacheKey: text('cache_key').notNull().unique(),
327
+ targetKind: text('target_kind', { enum: ['model', 'combo', 'alias'] }).notNull(),
328
+ targetId: text('target_id').notNull(),
329
+ targetConfigVersion: integer('target_config_version').notNull().default(1),
330
+ protocol: text('protocol', { enum: ['openai', 'anthropic'] }).notNull(),
331
+ responseJson: text('response_json').notNull(),
332
+ usageJson: text('usage_json'),
333
+ createdAt: text('created_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
334
+ expiresAt: text('expires_at').notNull(),
335
+ lastHitAt: text('last_hit_at'),
336
+ hitCount: integer('hit_count').notNull().default(0),
337
+ bytes: integer('bytes').notNull().default(0),
338
+ }, (t) => ({
339
+ expiresIdx: index('idx_cache_expires').on(t.expiresAt),
340
+ targetIdx: index('idx_cache_target').on(t.targetKind, t.targetId),
341
+ }));
342
+ // ============================================================================
343
+ // Daily usage aggregates (for daily/monthly quota + statistics fast path)
344
+ // ============================================================================
345
+ export const usageDaily = sqliteTable('usage_daily', {
346
+ day: text('day').notNull(), // YYYY-MM-DD
347
+ apiKeyId: text('api_key_id').notNull(),
348
+ inputTokens: integer('input_tokens').notNull().default(0),
349
+ outputTokens: integer('output_tokens').notNull().default(0),
350
+ totalTokens: integer('total_tokens').notNull().default(0),
351
+ }, (t) => ({
352
+ pk: uniqueIndex('uniq_usage_day_key').on(t.day, t.apiKeyId),
353
+ }));
354
+ export const usageMonthly = sqliteTable('usage_monthly', {
355
+ month: text('month').notNull(), // YYYY-MM
356
+ apiKeyId: text('api_key_id').notNull(),
357
+ inputTokens: integer('input_tokens').notNull().default(0),
358
+ outputTokens: integer('output_tokens').notNull().default(0),
359
+ totalTokens: integer('total_tokens').notNull().default(0),
360
+ }, (t) => ({
361
+ pk: uniqueIndex('uniq_usage_month_key').on(t.month, t.apiKeyId),
362
+ }));
363
+ // ============================================================================
364
+ // Backups
365
+ // ============================================================================
366
+ export const backups = sqliteTable('backups', {
367
+ id: text('id').primaryKey(),
368
+ createdAt: text('created_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
369
+ schemaVersion: integer('schema_version').notNull(),
370
+ appVersion: text('app_version').notNull(),
371
+ sizeBytes: integer('size_bytes').notNull(),
372
+ checksum: text('checksum').notNull(),
373
+ path: text('path').notNull(),
374
+ notes: text('notes'),
375
+ });
376
+ // ============================================================================
377
+ // CSRF tokens (stateful per-session CSRF reference)
378
+ // ============================================================================
379
+ export const csrfTokens = sqliteTable('csrf_tokens', {
380
+ id: text('id').primaryKey(),
381
+ sessionId: text('session_id').notNull(),
382
+ token: text('token').notNull(),
383
+ createdAt: text('created_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
384
+ expiresAt: text('expires_at').notNull(),
385
+ }, (t) => ({
386
+ sessionIdx: index('idx_csrf_session').on(t.sessionId),
387
+ expiresIdx: index('idx_csrf_expires').on(t.expiresAt),
388
+ }));
389
+ // ============================================================================
390
+ // Schema version tracking (for backup compatibility checks)
391
+ // ============================================================================
392
+ export const schemaMigrations = sqliteTable('schema_migrations', {
393
+ version: integer('version').primaryKey(),
394
+ name: text('name').notNull(),
395
+ appliedAt: text('applied_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
396
+ });
@@ -0,0 +1,65 @@
1
+ // Canonical gateway error categories. Maps to OpenAI / Anthropic error envelopes.
2
+ export class GatewayError extends Error {
3
+ type;
4
+ status;
5
+ code;
6
+ safe;
7
+ cause;
8
+ constructor(type, message, opts = {}) {
9
+ super(message);
10
+ this.type = type;
11
+ this.status = opts.status ?? defaultStatusFor(type);
12
+ this.code = opts.code ?? type;
13
+ this.safe = opts.safe ?? true;
14
+ this.cause = opts.cause;
15
+ this.name = 'GatewayError';
16
+ }
17
+ }
18
+ export function defaultStatusFor(t) {
19
+ switch (t) {
20
+ case 'authentication_error':
21
+ return 401;
22
+ case 'permission_error':
23
+ return 403;
24
+ case 'model_not_found':
25
+ return 404;
26
+ case 'invalid_request_error':
27
+ return 400;
28
+ case 'capability_not_supported':
29
+ return 400;
30
+ case 'rate_limit_error':
31
+ return 429;
32
+ case 'timeout_error':
33
+ return 504;
34
+ case 'upstream_auth_error':
35
+ return 502;
36
+ case 'upstream_rate_limit':
37
+ return 529;
38
+ case 'upstream_unavailable':
39
+ return 502;
40
+ case 'upstream_error':
41
+ return 502;
42
+ case 'gateway_error':
43
+ default:
44
+ return 500;
45
+ }
46
+ }
47
+ /** OpenAI-compatible error envelope. */
48
+ export function toOpenAIError(g, requestId) {
49
+ return {
50
+ error: {
51
+ message: g.safe ? g.message : 'Gateway error',
52
+ type: g.type,
53
+ ...(g.code ? { code: g.code } : {}),
54
+ ...(requestId ? { request_id: requestId } : {}),
55
+ },
56
+ };
57
+ }
58
+ /** Anthropic-compatible error envelope. */
59
+ export function toAnthropicError(g, requestId) {
60
+ return {
61
+ type: 'error',
62
+ error: { type: g.type, message: g.safe ? g.message : 'Gateway error' },
63
+ ...(requestId ? { request_id: requestId } : {}),
64
+ };
65
+ }