@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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +68 -0
- package/dist/index.d.mts +125 -2
- package/dist/index.d.ts +125 -2
- package/dist/index.js +630 -9
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +629 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/scripts/check-migration-versions.mjs +6 -1
- package/src/createPgStoreConfig.ts +12 -0
- package/src/index.ts +2 -0
- package/src/migrations/model_provider_migrations.ts +157 -0
- package/src/stores/PostgreSQLModelProviderStore.ts +731 -0
|
@@ -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
|
+
}
|