@axiom-lattice/pg-stores 3.1.21 → 3.2.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.
@@ -0,0 +1,731 @@
1
+ /**
2
+ * PostgreSQL implementation of ModelProviderStore
3
+ */
4
+
5
+ import { Pool, PoolClient } from 'pg';
6
+ import type { PoolConfig } from 'pg';
7
+ import {
8
+ ModelProviderStore,
9
+ ModelProviderEntry,
10
+ ModelProviderModelEntry,
11
+ ModelProviderStatus,
12
+ ModelProviderWireProtocol,
13
+ ModelProviderApiStyle,
14
+ ModelProviderLlmProvider,
15
+ CreateModelProviderRequest,
16
+ UpdateModelProviderRequest,
17
+ ProviderModelInput,
18
+ } from '@axiom-lattice/protocols';
19
+ import { MigrationManager } from '../migrations/migration';
20
+ import {
21
+ createModelProvidersTable,
22
+ createModelProviderModelsTable,
23
+ } from '../migrations/model_provider_migrations';
24
+ import { encrypt, decrypt } from '@axiom-lattice/core';
25
+
26
+ /**
27
+ * PostgreSQL ModelProviderStore options
28
+ */
29
+ export interface PostgreSQLModelProviderStoreOptions {
30
+ /**
31
+ * External pool instance. When provided, poolConfig is not required and the
32
+ * caller is responsible for pool lifecycle and migrations.
33
+ */
34
+ pool?: Pool;
35
+
36
+ /**
37
+ * PostgreSQL connection pool configuration
38
+ * Can be a connection string or PoolConfig object
39
+ */
40
+ poolConfig?: string | PoolConfig;
41
+
42
+ /**
43
+ * Whether to run migrations automatically on initialization
44
+ * @default true
45
+ */
46
+ autoMigrate?: boolean;
47
+ }
48
+
49
+ interface ProviderRow {
50
+ id: string;
51
+ tenant_id: string;
52
+ name: string;
53
+ display_name: string | null;
54
+ protocol: string;
55
+ api_style: string;
56
+ llm_provider: string;
57
+ base_url: string;
58
+ api_key_enc: string | null;
59
+ api_key_hint: string | null;
60
+ enabled: boolean;
61
+ status: string;
62
+ last_error: string | null;
63
+ last_discovered_at: Date | null;
64
+ created_by: string | null;
65
+ created_at: Date;
66
+ updated_at: Date;
67
+ }
68
+
69
+ interface ModelRow {
70
+ id: string;
71
+ tenant_id: string;
72
+ provider_id: string;
73
+ model_id: string;
74
+ display_name: string | null;
75
+ owned_by: string | null;
76
+ upstream_created: Date | null;
77
+ enabled: boolean;
78
+ stale: boolean;
79
+ discovered_at: Date;
80
+ }
81
+
82
+ /**
83
+ * PostgreSQL implementation of ModelProviderStore
84
+ *
85
+ * Features:
86
+ * - Multi-tenant isolation via tenant_id
87
+ * - Automatic API key encryption/decryption
88
+ * - Unique constraint on (tenant_id, name) and (tenant_id, model_id)
89
+ */
90
+ export class PostgreSQLModelProviderStore implements ModelProviderStore {
91
+ private pool: Pool;
92
+ private migrationManager!: MigrationManager;
93
+ private initialized: boolean = false;
94
+ private ownsPool: boolean = true;
95
+ private initPromise: Promise<void> | null = null;
96
+
97
+ constructor(options: PostgreSQLModelProviderStoreOptions) {
98
+ // Use externally-managed pool if provided
99
+ if (options.pool) {
100
+ this.pool = options.pool;
101
+ this.ownsPool = false;
102
+ this.initialized = true;
103
+ return;
104
+ }
105
+
106
+ // Create Pool from config
107
+ if (typeof options.poolConfig === 'string') {
108
+ this.pool = new Pool({ connectionString: options.poolConfig });
109
+ } else if (options.poolConfig) {
110
+ this.pool = new Pool(options.poolConfig as PoolConfig);
111
+ } else {
112
+ throw new Error('Either pool or poolConfig must be provided');
113
+ }
114
+
115
+ this.migrationManager = new MigrationManager(this.pool);
116
+ this.migrationManager.register(createModelProvidersTable);
117
+ this.migrationManager.register(createModelProviderModelsTable);
118
+
119
+ // Auto-migrate by default
120
+ if (options.autoMigrate !== false) {
121
+ this.initialize().catch((error) => {
122
+ console.error('Failed to initialize PostgreSQLModelProviderStore:', error);
123
+ throw error;
124
+ });
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Initialize the store and run migrations
130
+ * Uses a promise-based lock to prevent concurrent initialization
131
+ */
132
+ async initialize(): Promise<void> {
133
+ if (this.initialized) {
134
+ return;
135
+ }
136
+
137
+ if (this.initPromise) {
138
+ return this.initPromise;
139
+ }
140
+
141
+ this.initPromise = (async () => {
142
+ try {
143
+ await this.migrationManager.migrate();
144
+ this.initialized = true;
145
+ } finally {
146
+ this.initPromise = null;
147
+ }
148
+ })();
149
+
150
+ return this.initPromise;
151
+ }
152
+
153
+ /**
154
+ * Get all provider configurations for a tenant
155
+ */
156
+ async listProviders(tenantId: string): Promise<ModelProviderEntry[]> {
157
+ await this.ensureInitialized();
158
+
159
+ const result = await this.pool.query<ProviderRow>(
160
+ `
161
+ SELECT id, tenant_id, name, display_name, protocol, api_style, llm_provider, base_url,
162
+ api_key_enc, api_key_hint, enabled, status, last_error,
163
+ last_discovered_at, created_by, created_at, updated_at
164
+ FROM lattice_model_providers
165
+ WHERE tenant_id = $1
166
+ ORDER BY created_at DESC
167
+ `,
168
+ [tenantId]
169
+ );
170
+
171
+ return result.rows.map((row) => this.mapRowToEntry(row));
172
+ }
173
+
174
+ /**
175
+ * Get a provider configuration by ID
176
+ */
177
+ async getProviderById(
178
+ tenantId: string,
179
+ id: string
180
+ ): Promise<ModelProviderEntry | null> {
181
+ await this.ensureInitialized();
182
+
183
+ const result = await this.pool.query<ProviderRow>(
184
+ `
185
+ SELECT id, tenant_id, name, display_name, protocol, api_style, llm_provider, base_url,
186
+ api_key_enc, api_key_hint, enabled, status, last_error,
187
+ last_discovered_at, created_by, created_at, updated_at
188
+ FROM lattice_model_providers
189
+ WHERE tenant_id = $1 AND id = $2
190
+ `,
191
+ [tenantId, id]
192
+ );
193
+
194
+ if (result.rows.length === 0) {
195
+ return null;
196
+ }
197
+
198
+ return this.mapRowToEntry(result.rows[0]);
199
+ }
200
+
201
+ /**
202
+ * Get a provider configuration by business name
203
+ */
204
+ async getProviderByName(
205
+ tenantId: string,
206
+ name: string
207
+ ): Promise<ModelProviderEntry | null> {
208
+ await this.ensureInitialized();
209
+
210
+ const result = await this.pool.query<ProviderRow>(
211
+ `
212
+ SELECT id, tenant_id, name, display_name, protocol, api_style, llm_provider, base_url,
213
+ api_key_enc, api_key_hint, enabled, status, last_error,
214
+ last_discovered_at, created_by, created_at, updated_at
215
+ FROM lattice_model_providers
216
+ WHERE tenant_id = $1 AND name = $2
217
+ `,
218
+ [tenantId, name]
219
+ );
220
+
221
+ if (result.rows.length === 0) {
222
+ return null;
223
+ }
224
+
225
+ return this.mapRowToEntry(result.rows[0]);
226
+ }
227
+
228
+ /**
229
+ * Create a new provider configuration
230
+ */
231
+ async createProvider(
232
+ tenantId: string,
233
+ id: string,
234
+ data: CreateModelProviderRequest
235
+ ): Promise<ModelProviderEntry> {
236
+ await this.ensureInitialized();
237
+
238
+ const now = new Date();
239
+ const nowString = now.toISOString();
240
+ const apiKeyEnc = data.apiKey ? encrypt(data.apiKey) : null;
241
+ const apiKeyHint = data.apiKey ? data.apiKey.slice(-4) : null;
242
+
243
+ await this.pool.query(
244
+ `
245
+ INSERT INTO lattice_model_providers (
246
+ id, tenant_id, name, display_name, protocol, api_style, llm_provider, base_url,
247
+ api_key_enc, api_key_hint, enabled, status, created_at, updated_at
248
+ )
249
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'unknown', $12::timestamp, $13::timestamp)
250
+ ON CONFLICT (tenant_id, id) DO UPDATE SET
251
+ name = EXCLUDED.name,
252
+ display_name = EXCLUDED.display_name,
253
+ protocol = EXCLUDED.protocol,
254
+ api_style = EXCLUDED.api_style,
255
+ llm_provider = EXCLUDED.llm_provider,
256
+ base_url = EXCLUDED.base_url,
257
+ api_key_enc = EXCLUDED.api_key_enc,
258
+ api_key_hint = EXCLUDED.api_key_hint,
259
+ enabled = EXCLUDED.enabled,
260
+ updated_at = EXCLUDED.updated_at
261
+ `,
262
+ [
263
+ id,
264
+ tenantId,
265
+ data.name,
266
+ data.displayName || null,
267
+ data.protocol,
268
+ data.apiStyle || 'chat-completions',
269
+ // Phase 1 pins the LLM provider: the UI does not offer a choice, and
270
+ // some initChatModel branches hardcode their own baseURL and would
271
+ // silently ignore the tenant's (see ADR-138 decision 7b).
272
+ 'openai',
273
+ data.baseURL,
274
+ apiKeyEnc,
275
+ apiKeyHint,
276
+ data.enabled !== false,
277
+ nowString,
278
+ nowString,
279
+ ]
280
+ );
281
+
282
+ const created = await this.getProviderById(tenantId, id);
283
+ if (!created) {
284
+ throw new Error('Failed to create model provider');
285
+ }
286
+
287
+ return created;
288
+ }
289
+
290
+ /**
291
+ * Update an existing provider configuration.
292
+ * An omitted or empty `apiKey` leaves the stored key untouched.
293
+ */
294
+ async updateProvider(
295
+ tenantId: string,
296
+ id: string,
297
+ updates: Partial<UpdateModelProviderRequest>
298
+ ): Promise<ModelProviderEntry | null> {
299
+ await this.ensureInitialized();
300
+
301
+ const existing = await this.getProviderById(tenantId, id);
302
+ if (!existing) {
303
+ return null;
304
+ }
305
+
306
+ const updateData: Record<string, unknown> = {};
307
+
308
+ if (updates.name !== undefined) {
309
+ updateData.name = updates.name;
310
+ }
311
+
312
+ if (updates.displayName !== undefined) {
313
+ updateData.display_name = updates.displayName || null;
314
+ }
315
+
316
+ if (updates.protocol !== undefined) {
317
+ updateData.protocol = updates.protocol;
318
+ }
319
+
320
+ if (updates.apiStyle !== undefined) {
321
+ updateData.api_style = updates.apiStyle;
322
+ }
323
+
324
+ if (updates.baseURL !== undefined) {
325
+ updateData.base_url = updates.baseURL;
326
+ }
327
+
328
+ // An empty or omitted apiKey means "keep the existing key"; `clearApiKey`
329
+ // is the only way to remove one outright.
330
+ if (updates.clearApiKey === true) {
331
+ updateData.api_key_enc = null;
332
+ updateData.api_key_hint = null;
333
+ } else if (updates.apiKey) {
334
+ updateData.api_key_enc = encrypt(updates.apiKey);
335
+ updateData.api_key_hint = updates.apiKey.slice(-4);
336
+ }
337
+
338
+ if (updates.enabled !== undefined) {
339
+ updateData.enabled = updates.enabled;
340
+ }
341
+
342
+ if (Object.keys(updateData).length === 0) {
343
+ return existing;
344
+ }
345
+
346
+ // Always update the updated_at timestamp
347
+ updateData.updated_at = new Date().toISOString();
348
+
349
+ const fields = Object.keys(updateData);
350
+ const values: unknown[] = Object.values(updateData);
351
+
352
+ // Add WHERE clause values at the end
353
+ values.push(tenantId);
354
+ values.push(id);
355
+
356
+ const setClauses = fields.map((field, index) =>
357
+ field === 'updated_at'
358
+ ? `${field} = $${index + 1}::timestamp`
359
+ : `${field} = $${index + 1}`
360
+ );
361
+
362
+ const whereTenantIndex = fields.length + 1;
363
+ const whereIdIndex = fields.length + 2;
364
+
365
+ const sql = `
366
+ UPDATE lattice_model_providers
367
+ SET ${setClauses.join(', ')}
368
+ WHERE tenant_id = $${whereTenantIndex} AND id = $${whereIdIndex}
369
+ `;
370
+
371
+ await this.pool.query(sql, values);
372
+
373
+ return await this.getProviderById(tenantId, id);
374
+ }
375
+
376
+ /**
377
+ * Delete a provider configuration and its model snapshot
378
+ */
379
+ async deleteProvider(tenantId: string, id: string): Promise<boolean> {
380
+ await this.ensureInitialized();
381
+
382
+ await this.deleteModelsByProvider(tenantId, id);
383
+
384
+ const result = await this.pool.query(
385
+ `
386
+ DELETE FROM lattice_model_providers
387
+ WHERE tenant_id = $1 AND id = $2
388
+ `,
389
+ [tenantId, id]
390
+ );
391
+
392
+ return result.rowCount !== null && result.rowCount > 0;
393
+ }
394
+
395
+ /**
396
+ * Record the outcome of a discovery attempt
397
+ */
398
+ async markProviderStatus(
399
+ tenantId: string,
400
+ id: string,
401
+ status: ModelProviderStatus,
402
+ lastError?: string,
403
+ discoveredAt?: Date
404
+ ): Promise<void> {
405
+ await this.ensureInitialized();
406
+
407
+ await this.pool.query(
408
+ `
409
+ UPDATE lattice_model_providers
410
+ SET status = $3,
411
+ last_error = $4,
412
+ last_discovered_at = CASE
413
+ WHEN $5::timestamp IS NULL THEN last_discovered_at
414
+ ELSE $5::timestamp
415
+ END,
416
+ updated_at = $6::timestamp
417
+ WHERE tenant_id = $1 AND id = $2
418
+ `,
419
+ [
420
+ tenantId,
421
+ id,
422
+ status,
423
+ status === 'ok' ? null : lastError || null,
424
+ discoveredAt ? discoveredAt.toISOString() : null,
425
+ new Date().toISOString(),
426
+ ]
427
+ );
428
+ }
429
+
430
+ /**
431
+ * List snapshot models for a tenant
432
+ */
433
+ async listModels(
434
+ tenantId: string,
435
+ providerId?: string
436
+ ): Promise<ModelProviderModelEntry[]> {
437
+ await this.ensureInitialized();
438
+
439
+ const result = providerId
440
+ ? await this.pool.query<ModelRow>(
441
+ `
442
+ SELECT id, tenant_id, provider_id, model_id, display_name, owned_by,
443
+ upstream_created, enabled, stale, discovered_at
444
+ FROM lattice_model_provider_models
445
+ WHERE tenant_id = $1 AND provider_id = $2
446
+ ORDER BY model_id ASC
447
+ `,
448
+ [tenantId, providerId]
449
+ )
450
+ : await this.pool.query<ModelRow>(
451
+ `
452
+ SELECT id, tenant_id, provider_id, model_id, display_name, owned_by,
453
+ upstream_created, enabled, discovered_at
454
+ FROM lattice_model_provider_models
455
+ WHERE tenant_id = $1
456
+ ORDER BY model_id ASC
457
+ `,
458
+ [tenantId]
459
+ );
460
+
461
+ return result.rows.map((row) => this.mapRowToModel(row));
462
+ }
463
+
464
+ /**
465
+ * Get a single snapshot model.
466
+ * Keyed by provider as well as model id, since two providers in the same
467
+ * tenant may expose the same upstream model id.
468
+ */
469
+ async getModel(
470
+ tenantId: string,
471
+ providerId: string,
472
+ modelId: string
473
+ ): Promise<ModelProviderModelEntry | null> {
474
+ await this.ensureInitialized();
475
+
476
+ const result = await this.pool.query<ModelRow>(
477
+ `
478
+ SELECT id, tenant_id, provider_id, model_id, display_name, owned_by,
479
+ upstream_created, enabled, discovered_at
480
+ FROM lattice_model_provider_models
481
+ WHERE tenant_id = $1 AND provider_id = $2 AND model_id = $3
482
+ `,
483
+ [tenantId, providerId, modelId]
484
+ );
485
+
486
+ if (result.rows.length === 0) {
487
+ return null;
488
+ }
489
+
490
+ return this.mapRowToModel(result.rows[0]);
491
+ }
492
+
493
+ /**
494
+ * Replace a provider's model snapshot with the result of one discovery.
495
+ *
496
+ * Runs in a single transaction. Model ids are scoped per provider, so the
497
+ * same upstream id may appear under several providers in one tenant — each
498
+ * is an independent model.
499
+ *
500
+ * Models the upstream no longer offers are deleted; models that survive are
501
+ * upserted *without* touching `enabled`, so the tenant's enable/disable
502
+ * choice survives a re-discovery. New rows take the column default (enabled).
503
+ */
504
+ async replaceProviderModels(
505
+ tenantId: string,
506
+ providerId: string,
507
+ models: ProviderModelInput[]
508
+ ): Promise<string[]> {
509
+ await this.ensureInitialized();
510
+
511
+ const client = await this.pool.connect();
512
+
513
+ try {
514
+ await client.query('BEGIN');
515
+
516
+ // Read the current enabled state first, so the write below can state it
517
+ // explicitly: discovery leaves `enabled` out (the tenant's choice is kept)
518
+ // while an explicit selection supplies it.
519
+ const current = await client.query<{ model_id: string; enabled: boolean }>(
520
+ `
521
+ SELECT model_id, enabled FROM lattice_model_provider_models
522
+ WHERE tenant_id = $1 AND provider_id = $2
523
+ `,
524
+ [tenantId, providerId]
525
+ );
526
+ const currentEnabled = new Map(
527
+ current.rows.map((row) => [row.model_id, row.enabled])
528
+ );
529
+
530
+ // De-duplicate by model id: a single `ON CONFLICT DO UPDATE` statement
531
+ // cannot touch the same target row twice, and upstreams (and callers)
532
+ // do repeat ids. Last occurrence wins.
533
+ const deduped = new Map<string, ProviderModelInput>();
534
+ for (const model of models) {
535
+ deduped.set(model.id, model);
536
+ }
537
+ const rows = [...deduped.values()];
538
+
539
+ const effectiveEnabled = rows.map(
540
+ (model) => model.enabled ?? currentEnabled.get(model.id) ?? true
541
+ );
542
+
543
+ // Drop only what the upstream no longer offers
544
+
545
+ await client.query(
546
+ `
547
+ DELETE FROM lattice_model_provider_models
548
+ WHERE tenant_id = $1
549
+ AND provider_id = $2
550
+ AND model_id <> ALL($3::text[])
551
+ `,
552
+ [tenantId, providerId, rows.map((model) => model.id)]
553
+ );
554
+
555
+ if (models.length > 0) {
556
+ // One statement for the whole snapshot rather than one per model.
557
+ // `enabled` is deliberately absent from both the column list and the
558
+ // update clause, so existing rows keep their value.
559
+ await client.query(
560
+ `
561
+ INSERT INTO lattice_model_provider_models (
562
+ id, tenant_id, provider_id, model_id, display_name, owned_by,
563
+ upstream_created, raw, enabled, stale, discovered_at
564
+ )
565
+ SELECT * FROM unnest(
566
+ $1::text[], $2::text[], $3::text[], $4::text[], $5::text[],
567
+ $6::text[], $7::timestamp[], $8::jsonb[], $9::boolean[], $10::boolean[], $11::timestamp[]
568
+ ) AS t(
569
+ id, tenant_id, provider_id, model_id, display_name, owned_by,
570
+ upstream_created, raw, enabled, stale, discovered_at
571
+ )
572
+ ON CONFLICT (tenant_id, provider_id, model_id) DO UPDATE SET
573
+ display_name = EXCLUDED.display_name,
574
+ owned_by = EXCLUDED.owned_by,
575
+ upstream_created = EXCLUDED.upstream_created,
576
+ raw = EXCLUDED.raw,
577
+ enabled = EXCLUDED.enabled,
578
+ stale = EXCLUDED.stale,
579
+ discovered_at = EXCLUDED.discovered_at
580
+ `,
581
+ [
582
+ rows.map((model) => `${providerId}:${model.id}`),
583
+ rows.map(() => tenantId),
584
+ rows.map(() => providerId),
585
+ rows.map((model) => model.id),
586
+ rows.map((model) => model.displayName ?? null),
587
+ rows.map((model) => model.ownedBy ?? null),
588
+ rows.map((model) =>
589
+ model.createdAt ? new Date(model.createdAt).toISOString() : null
590
+ ),
591
+ rows.map((model) =>
592
+ model.raw === undefined ? null : JSON.stringify(model.raw)
593
+ ),
594
+ effectiveEnabled,
595
+ rows.map((model) => model.stale ?? false),
596
+ rows.map(() => new Date().toISOString()),
597
+ ]
598
+ );
599
+ }
600
+
601
+ await client.query('COMMIT');
602
+ } catch (error) {
603
+ await client.query('ROLLBACK');
604
+ throw error;
605
+ } finally {
606
+ client.release();
607
+ }
608
+
609
+ return models.map((model) => model.id);
610
+ }
611
+
612
+ /**
613
+ * Enable or disable a single snapshot model
614
+ */
615
+ async setModelEnabled(
616
+ tenantId: string,
617
+ providerId: string,
618
+ modelId: string,
619
+ enabled: boolean
620
+ ): Promise<ModelProviderModelEntry | null> {
621
+ await this.ensureInitialized();
622
+
623
+ const result = await this.pool.query(
624
+ `
625
+ UPDATE lattice_model_provider_models
626
+ SET enabled = $4
627
+ WHERE tenant_id = $1 AND provider_id = $2 AND model_id = $3
628
+ `,
629
+ [tenantId, providerId, modelId, enabled]
630
+ );
631
+
632
+ if (result.rowCount === null || result.rowCount === 0) {
633
+ return null;
634
+ }
635
+
636
+ return await this.getModel(tenantId, providerId, modelId);
637
+ }
638
+
639
+ /**
640
+ * Delete every snapshot model belonging to a provider
641
+ */
642
+ async deleteModelsByProvider(
643
+ tenantId: string,
644
+ providerId: string
645
+ ): Promise<number> {
646
+ await this.ensureInitialized();
647
+
648
+ const result = await this.pool.query(
649
+ `
650
+ DELETE FROM lattice_model_provider_models
651
+ WHERE tenant_id = $1 AND provider_id = $2
652
+ `,
653
+ [tenantId, providerId]
654
+ );
655
+
656
+ return result.rowCount ?? 0;
657
+ }
658
+
659
+ /**
660
+ * Dispose resources and close the connection pool
661
+ */
662
+ async dispose(): Promise<void> {
663
+ if (this.ownsPool && this.pool) {
664
+ await this.pool.end();
665
+ }
666
+ }
667
+
668
+ /**
669
+ * Ensure store is initialized
670
+ */
671
+ private async ensureInitialized(): Promise<void> {
672
+ if (!this.initialized) {
673
+ await this.initialize();
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Map provider row to ModelProviderEntry
679
+ * Automatically decrypts the API key if present
680
+ */
681
+ private mapRowToEntry(row: ProviderRow): ModelProviderEntry {
682
+ let apiKey: string | undefined;
683
+
684
+ if (row.api_key_enc) {
685
+ try {
686
+ apiKey = decrypt(row.api_key_enc);
687
+ } catch (error) {
688
+ console.error('Failed to decrypt model provider API key:', error);
689
+ throw new Error('Failed to decrypt model provider API key');
690
+ }
691
+ }
692
+
693
+ return {
694
+ id: row.id,
695
+ tenantId: row.tenant_id,
696
+ name: row.name,
697
+ displayName: row.display_name || undefined,
698
+ protocol: row.protocol as ModelProviderWireProtocol,
699
+ apiStyle: row.api_style as ModelProviderApiStyle,
700
+ llmProvider: row.llm_provider as ModelProviderLlmProvider,
701
+ baseURL: row.base_url,
702
+ apiKey,
703
+ apiKeyHint: row.api_key_hint || undefined,
704
+ enabled: row.enabled,
705
+ status: row.status as ModelProviderStatus,
706
+ lastError: row.last_error || undefined,
707
+ lastDiscoveredAt: row.last_discovered_at || undefined,
708
+ createdBy: row.created_by || undefined,
709
+ createdAt: row.created_at,
710
+ updatedAt: row.updated_at,
711
+ };
712
+ }
713
+
714
+ /**
715
+ * Map model row to ModelProviderModelEntry
716
+ */
717
+ private mapRowToModel(row: ModelRow): ModelProviderModelEntry {
718
+ return {
719
+ id: row.id,
720
+ tenantId: row.tenant_id,
721
+ providerId: row.provider_id,
722
+ modelId: row.model_id,
723
+ displayName: row.display_name || undefined,
724
+ ownedBy: row.owned_by || undefined,
725
+ upstreamCreatedAt: row.upstream_created || undefined,
726
+ enabled: row.enabled,
727
+ stale: row.stale,
728
+ discoveredAt: row.discovered_at,
729
+ };
730
+ }
731
+ }