@axiom-lattice/local-stores 3.1.21 → 3.1.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/local-stores",
3
- "version": "3.1.21",
3
+ "version": "3.1.22",
4
4
  "description": "Local SQLite-based stores for Axiom Lattice framework",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -25,8 +25,8 @@
25
25
  "@types/sql.js": "^1.4.11",
26
26
  "sql.js": "^1.14.1",
27
27
  "uuid": "^14.0.1",
28
- "@axiom-lattice/core": "6.0.0",
29
- "@axiom-lattice/protocols": "4.5.0"
28
+ "@axiom-lattice/core": "6.1.0",
29
+ "@axiom-lattice/protocols": "4.6.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/jest": "^29.5.14",
@@ -32,6 +32,7 @@ import { LocalDatabaseConfigStore } from "./stores/LocalDatabaseConfigStore";
32
32
  import { LocalConnectionStore } from "./stores/LocalConnectionStore";
33
33
  import { LocalMetricsServerConfigStore } from "./stores/LocalMetricsServerConfigStore";
34
34
  import { LocalMcpServerConfigStore } from "./stores/LocalMcpServerConfigStore";
35
+ import { LocalModelProviderStore } from "./stores/LocalModelProviderStore";
35
36
  import { LocalWorkflowTrackingStore } from "./stores/LocalWorkflowTrackingStore";
36
37
  import { LocalEvalStore } from "./stores/LocalEvalStore";
37
38
  import { LocalChannelBindingStore } from "./stores/LocalChannelBindingStore";
@@ -84,6 +85,7 @@ export async function createLocalStoreConfig(options: LocalStoreConfigOptions =
84
85
  connection: new LocalConnectionStore(db),
85
86
  metrics: new LocalMetricsServerConfigStore(db),
86
87
  mcp: new LocalMcpServerConfigStore(db),
88
+ modelProvider: new LocalModelProviderStore(db),
87
89
  workflowTracking: new LocalWorkflowTrackingStore(db),
88
90
  eval: new LocalEvalStore(db),
89
91
  channelBinding: new LocalChannelBindingStore(db),
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export { LocalDatabaseConfigStore } from "./stores/LocalDatabaseConfigStore";
43
43
  export { LocalConnectionStore } from "./stores/LocalConnectionStore";
44
44
  export { LocalMetricsServerConfigStore } from "./stores/LocalMetricsServerConfigStore";
45
45
  export { LocalMcpServerConfigStore } from "./stores/LocalMcpServerConfigStore";
46
+ export { LocalModelProviderStore } from "./stores/LocalModelProviderStore";
46
47
  export { LocalWorkflowTrackingStore } from "./stores/LocalWorkflowTrackingStore";
47
48
  export { LocalEvalStore } from "./stores/LocalEvalStore";
48
49
  export { LocalChannelBindingStore } from "./stores/LocalChannelBindingStore";
@@ -0,0 +1,506 @@
1
+ /**
2
+ * Local SQLite implementation of ModelProviderStore.
3
+ */
4
+
5
+ import { DatabaseWrapper } from "../database";
6
+ import type {
7
+ ModelProviderStore,
8
+ ModelProviderEntry,
9
+ ModelProviderModelEntry,
10
+ ModelProviderStatus,
11
+ ModelProviderWireProtocol,
12
+ ModelProviderApiStyle,
13
+ ModelProviderLlmProvider,
14
+ CreateModelProviderRequest,
15
+ UpdateModelProviderRequest,
16
+ ProviderModelInput,
17
+ } from "@axiom-lattice/protocols";
18
+ import { ensureTable, nowISO, parseISO } from "../database";
19
+ import { encrypt, decrypt } from "@axiom-lattice/core";
20
+
21
+ const DDL = `
22
+ CREATE TABLE IF NOT EXISTS lt_model_providers (
23
+ id TEXT NOT NULL,
24
+ tenant_id TEXT NOT NULL,
25
+ name TEXT NOT NULL,
26
+ display_name TEXT,
27
+ protocol TEXT NOT NULL,
28
+ api_style TEXT NOT NULL DEFAULT 'chat-completions',
29
+ llm_provider TEXT NOT NULL,
30
+ base_url TEXT NOT NULL,
31
+ api_key_enc TEXT,
32
+ api_key_hint TEXT,
33
+ enabled INTEGER NOT NULL DEFAULT 1,
34
+ status TEXT NOT NULL DEFAULT 'unknown',
35
+ last_error TEXT,
36
+ last_discovered_at TEXT,
37
+ created_by TEXT,
38
+ created_at TEXT NOT NULL,
39
+ updated_at TEXT NOT NULL,
40
+ PRIMARY KEY (tenant_id, id)
41
+ );
42
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_lt_model_providers_name
43
+ ON lt_model_providers(tenant_id, name);
44
+
45
+ CREATE TABLE IF NOT EXISTS lt_model_provider_models (
46
+ id TEXT NOT NULL,
47
+ tenant_id TEXT NOT NULL,
48
+ provider_id TEXT NOT NULL,
49
+ model_id TEXT NOT NULL,
50
+ display_name TEXT,
51
+ owned_by TEXT,
52
+ upstream_created TEXT,
53
+ enabled INTEGER NOT NULL DEFAULT 1,
54
+ stale INTEGER NOT NULL DEFAULT 0,
55
+ raw TEXT,
56
+ discovered_at TEXT NOT NULL,
57
+ PRIMARY KEY (tenant_id, provider_id, model_id)
58
+ );
59
+ CREATE INDEX IF NOT EXISTS idx_lt_model_provider_models_tenant
60
+ ON lt_model_provider_models(tenant_id, model_id);
61
+ `;
62
+
63
+ interface ProviderRow {
64
+ id: string;
65
+ tenant_id: string;
66
+ name: string;
67
+ display_name: string | null;
68
+ protocol: string;
69
+ api_style: string;
70
+ llm_provider: string;
71
+ base_url: string;
72
+ api_key_enc: string | null;
73
+ api_key_hint: string | null;
74
+ enabled: number;
75
+ status: string;
76
+ last_error: string | null;
77
+ last_discovered_at: string | null;
78
+ created_by: string | null;
79
+ created_at: string;
80
+ updated_at: string;
81
+ }
82
+
83
+ interface ModelRow {
84
+ id: string;
85
+ tenant_id: string;
86
+ provider_id: string;
87
+ model_id: string;
88
+ display_name: string | null;
89
+ owned_by: string | null;
90
+ upstream_created: string | null;
91
+ enabled: number;
92
+ stale: number;
93
+ discovered_at: string;
94
+ }
95
+
96
+ /**
97
+ * SQLite implementation of ModelProviderStore.
98
+ *
99
+ * Mirrors the PostgreSQL store's semantics: tenant-scoped, API keys encrypted
100
+ * at rest, and `enabled` preserved across a re-discovery.
101
+ */
102
+ export class LocalModelProviderStore implements ModelProviderStore {
103
+ private db: DatabaseWrapper;
104
+
105
+ constructor(db: DatabaseWrapper) {
106
+ this.db = db;
107
+ ensureTable(db, DDL);
108
+ // `CREATE TABLE IF NOT EXISTS` cannot add a column to a table that already
109
+ // exists, and this one gained `api_style` after it shipped — the same gap
110
+ // the PostgreSQL path needed v183 for.
111
+ this.ensureColumn(
112
+ "lt_model_providers",
113
+ "api_style",
114
+ "api_style TEXT NOT NULL DEFAULT 'chat-completions'"
115
+ );
116
+ this.ensureColumn(
117
+ "lt_model_provider_models",
118
+ "stale",
119
+ "stale INTEGER NOT NULL DEFAULT 0"
120
+ );
121
+ }
122
+
123
+ /** Add a column if it does not exist (SQLite version compatible). */
124
+ private ensureColumn(table: string, column: string, ddl: string): void {
125
+ const cols = this.db
126
+ .prepare(`PRAGMA table_info(${table})`)
127
+ .all() as unknown as Array<{ name: string }>;
128
+
129
+ if (!cols.some((c) => c.name === column)) {
130
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
131
+ }
132
+ }
133
+
134
+ async listProviders(tenantId: string): Promise<ModelProviderEntry[]> {
135
+ const rows = this.db
136
+ .prepare(
137
+ `SELECT * FROM lt_model_providers WHERE tenant_id = ? ORDER BY created_at DESC`
138
+ )
139
+ .all(tenantId) as unknown as ProviderRow[];
140
+ return rows.map((row) => mapRowToEntry(row));
141
+ }
142
+
143
+ async getProviderById(
144
+ tenantId: string,
145
+ id: string
146
+ ): Promise<ModelProviderEntry | null> {
147
+ const row = this.db
148
+ .prepare(
149
+ `SELECT * FROM lt_model_providers WHERE tenant_id = ? AND id = ?`
150
+ )
151
+ .get(tenantId, id) as unknown as ProviderRow | undefined;
152
+ return row ? mapRowToEntry(row) : null;
153
+ }
154
+
155
+ async getProviderByName(
156
+ tenantId: string,
157
+ name: string
158
+ ): Promise<ModelProviderEntry | null> {
159
+ const row = this.db
160
+ .prepare(
161
+ `SELECT * FROM lt_model_providers WHERE tenant_id = ? AND name = ?`
162
+ )
163
+ .get(tenantId, name) as unknown as ProviderRow | undefined;
164
+ return row ? mapRowToEntry(row) : null;
165
+ }
166
+
167
+ async createProvider(
168
+ tenantId: string,
169
+ id: string,
170
+ data: CreateModelProviderRequest
171
+ ): Promise<ModelProviderEntry> {
172
+ const now = nowISO();
173
+
174
+ this.db
175
+ .prepare(
176
+ `INSERT INTO lt_model_providers (
177
+ id, tenant_id, name, display_name, protocol, api_style, llm_provider, base_url,
178
+ api_key_enc, api_key_hint, enabled, status, created_at, updated_at
179
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unknown', ?, ?)
180
+ ON CONFLICT (tenant_id, id) DO UPDATE SET
181
+ name = excluded.name,
182
+ display_name = excluded.display_name,
183
+ protocol = excluded.protocol,
184
+ api_style = excluded.api_style,
185
+ llm_provider = excluded.llm_provider,
186
+ base_url = excluded.base_url,
187
+ api_key_enc = excluded.api_key_enc,
188
+ api_key_hint = excluded.api_key_hint,
189
+ enabled = excluded.enabled,
190
+ updated_at = excluded.updated_at`
191
+ )
192
+ .run(
193
+ id,
194
+ tenantId,
195
+ data.name,
196
+ data.displayName || null,
197
+ data.protocol,
198
+ data.apiStyle ?? "chat-completions",
199
+ // Phase 1 pins the LLM provider (ADR-138 decision 7b)
200
+ "openai",
201
+ data.baseURL,
202
+ data.apiKey ? encrypt(data.apiKey) : null,
203
+ data.apiKey ? data.apiKey.slice(-4) : null,
204
+ data.enabled === false ? 0 : 1,
205
+ now,
206
+ now
207
+ );
208
+
209
+ const created = await this.getProviderById(tenantId, id);
210
+ if (!created) {
211
+ throw new Error("Failed to create model provider");
212
+ }
213
+
214
+ return created;
215
+ }
216
+
217
+ async updateProvider(
218
+ tenantId: string,
219
+ id: string,
220
+ updates: Partial<UpdateModelProviderRequest>
221
+ ): Promise<ModelProviderEntry | null> {
222
+ const existing = await this.getProviderById(tenantId, id);
223
+ if (!existing) {
224
+ return null;
225
+ }
226
+
227
+ const fields: string[] = [];
228
+ const values: unknown[] = [];
229
+
230
+ const set = (column: string, value: unknown): void => {
231
+ fields.push(`${column} = ?`);
232
+ values.push(value);
233
+ };
234
+
235
+ if (updates.name !== undefined) set("name", updates.name);
236
+ if (updates.displayName !== undefined) {
237
+ set("display_name", updates.displayName || null);
238
+ }
239
+ if (updates.protocol !== undefined) set("protocol", updates.protocol);
240
+ if (updates.apiStyle !== undefined) set("api_style", updates.apiStyle);
241
+ if (updates.baseURL !== undefined) set("base_url", updates.baseURL);
242
+ if (updates.enabled !== undefined) set("enabled", updates.enabled ? 1 : 0);
243
+
244
+ // An empty or omitted apiKey means "keep the existing key"; `clearApiKey`
245
+ // is the only way to remove one outright.
246
+ if (updates.clearApiKey === true) {
247
+ set("api_key_enc", null);
248
+ set("api_key_hint", null);
249
+ } else if (updates.apiKey) {
250
+ set("api_key_enc", encrypt(updates.apiKey));
251
+ set("api_key_hint", updates.apiKey.slice(-4));
252
+ }
253
+
254
+ if (fields.length === 0) {
255
+ return existing;
256
+ }
257
+
258
+ fields.push("updated_at = ?");
259
+ values.push(nowISO(), tenantId, id);
260
+
261
+ this.db
262
+ .prepare(
263
+ `UPDATE lt_model_providers SET ${fields.join(", ")}
264
+ WHERE tenant_id = ? AND id = ?`
265
+ )
266
+ .run(...values);
267
+
268
+ return this.getProviderById(tenantId, id);
269
+ }
270
+
271
+ async deleteProvider(tenantId: string, id: string): Promise<boolean> {
272
+ await this.deleteModelsByProvider(tenantId, id);
273
+
274
+ const result = this.db
275
+ .prepare(`DELETE FROM lt_model_providers WHERE tenant_id = ? AND id = ?`)
276
+ .run(tenantId, id);
277
+
278
+ return result.changes > 0;
279
+ }
280
+
281
+ async markProviderStatus(
282
+ tenantId: string,
283
+ id: string,
284
+ status: ModelProviderStatus,
285
+ lastError?: string,
286
+ discoveredAt?: Date
287
+ ): Promise<void> {
288
+ this.db
289
+ .prepare(
290
+ `UPDATE lt_model_providers
291
+ SET status = ?,
292
+ last_error = ?,
293
+ last_discovered_at = COALESCE(?, last_discovered_at),
294
+ updated_at = ?
295
+ WHERE tenant_id = ? AND id = ?`
296
+ )
297
+ .run(
298
+ status,
299
+ status === "ok" ? null : lastError || null,
300
+ discoveredAt ? discoveredAt.toISOString() : null,
301
+ nowISO(),
302
+ tenantId,
303
+ id
304
+ );
305
+ }
306
+
307
+ async listModels(
308
+ tenantId: string,
309
+ providerId?: string
310
+ ): Promise<ModelProviderModelEntry[]> {
311
+ const rows = providerId
312
+ ? (this.db
313
+ .prepare(
314
+ `SELECT * FROM lt_model_provider_models
315
+ WHERE tenant_id = ? AND provider_id = ? ORDER BY model_id ASC`
316
+ )
317
+ .all(tenantId, providerId) as unknown as ModelRow[])
318
+ : (this.db
319
+ .prepare(
320
+ `SELECT * FROM lt_model_provider_models
321
+ WHERE tenant_id = ? ORDER BY model_id ASC`
322
+ )
323
+ .all(tenantId) as unknown as ModelRow[]);
324
+
325
+ return rows.map((row) => mapRowToModel(row));
326
+ }
327
+
328
+ async getModel(
329
+ tenantId: string,
330
+ providerId: string,
331
+ modelId: string
332
+ ): Promise<ModelProviderModelEntry | null> {
333
+ const row = this.db
334
+ .prepare(
335
+ `SELECT * FROM lt_model_provider_models
336
+ WHERE tenant_id = ? AND provider_id = ? AND model_id = ?`
337
+ )
338
+ .get(tenantId, providerId, modelId) as unknown as ModelRow | undefined;
339
+
340
+ return row ? mapRowToModel(row) : null;
341
+ }
342
+
343
+ /**
344
+ * Replace a provider's model snapshot.
345
+ *
346
+ * Models the upstream no longer offers are deleted; survivors are upserted
347
+ * without touching `enabled`, so the tenant's enable/disable choice survives.
348
+ */
349
+ async replaceProviderModels(
350
+ tenantId: string,
351
+ providerId: string,
352
+ models: ProviderModelInput[]
353
+ ): Promise<string[]> {
354
+ const now = nowISO();
355
+
356
+ // De-duplicate by model id: the upsert below targets
357
+ // (tenant_id, provider_id, model_id), and upstreams do repeat ids.
358
+ const deduped = new Map<string, ProviderModelInput>();
359
+ for (const model of models) {
360
+ deduped.set(model.id, model);
361
+ }
362
+ const rows = [...deduped.values()];
363
+
364
+ if (rows.length === 0) {
365
+ await this.deleteModelsByProvider(tenantId, providerId);
366
+ return [];
367
+ }
368
+
369
+ // Read the current enabled state first so the write can state it
370
+ // explicitly: discovery leaves `enabled` out (keeping the tenant's choice)
371
+ // while an explicit selection supplies it.
372
+ const current = new Map(
373
+ (await this.listModels(tenantId, providerId)).map((model) => [
374
+ model.modelId,
375
+ model.enabled,
376
+ ])
377
+ );
378
+
379
+ const placeholders = rows.map(() => "?").join(", ");
380
+ this.db
381
+ .prepare(
382
+ `DELETE FROM lt_model_provider_models
383
+ WHERE tenant_id = ? AND provider_id = ? AND model_id NOT IN (${placeholders})`
384
+ )
385
+ .run(tenantId, providerId, ...rows.map((model) => model.id));
386
+
387
+ for (const model of rows) {
388
+ this.db
389
+ .prepare(
390
+ `INSERT INTO lt_model_provider_models (
391
+ id, tenant_id, provider_id, model_id, display_name, owned_by,
392
+ upstream_created, raw, enabled, stale, discovered_at
393
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
394
+ ON CONFLICT (tenant_id, provider_id, model_id) DO UPDATE SET
395
+ display_name = excluded.display_name,
396
+ owned_by = excluded.owned_by,
397
+ upstream_created = excluded.upstream_created,
398
+ raw = excluded.raw,
399
+ enabled = excluded.enabled,
400
+ stale = excluded.stale,
401
+ discovered_at = excluded.discovered_at`
402
+ )
403
+ .run(
404
+ `${providerId}:${model.id}`,
405
+ tenantId,
406
+ providerId,
407
+ model.id,
408
+ model.displayName || null,
409
+ model.ownedBy || null,
410
+ model.createdAt || null,
411
+ model.raw === undefined ? null : JSON.stringify(model.raw),
412
+ model.enabled ?? current.get(model.id) ?? true ? 1 : 0,
413
+ model.stale ? 1 : 0,
414
+ now
415
+ );
416
+ }
417
+
418
+ return rows.map((model) => model.id);
419
+ }
420
+
421
+ async setModelEnabled(
422
+ tenantId: string,
423
+ providerId: string,
424
+ modelId: string,
425
+ enabled: boolean
426
+ ): Promise<ModelProviderModelEntry | null> {
427
+ const result = this.db
428
+ .prepare(
429
+ `UPDATE lt_model_provider_models SET enabled = ?
430
+ WHERE tenant_id = ? AND provider_id = ? AND model_id = ?`
431
+ )
432
+ .run(enabled ? 1 : 0, tenantId, providerId, modelId);
433
+
434
+ if (result.changes === 0) {
435
+ return null;
436
+ }
437
+
438
+ return this.getModel(tenantId, providerId, modelId);
439
+ }
440
+
441
+ async deleteModelsByProvider(
442
+ tenantId: string,
443
+ providerId: string
444
+ ): Promise<number> {
445
+ const result = this.db
446
+ .prepare(
447
+ `DELETE FROM lt_model_provider_models
448
+ WHERE tenant_id = ? AND provider_id = ?`
449
+ )
450
+ .run(tenantId, providerId);
451
+
452
+ return result.changes;
453
+ }
454
+ }
455
+
456
+ function mapRowToEntry(row: ProviderRow): ModelProviderEntry {
457
+ let apiKey: string | undefined;
458
+
459
+ if (row.api_key_enc) {
460
+ try {
461
+ apiKey = decrypt(row.api_key_enc);
462
+ } catch (error) {
463
+ console.error("Failed to decrypt model provider API key:", error);
464
+ throw new Error("Failed to decrypt model provider API key");
465
+ }
466
+ }
467
+
468
+ return {
469
+ id: row.id,
470
+ tenantId: row.tenant_id,
471
+ name: row.name,
472
+ displayName: row.display_name || undefined,
473
+ protocol: row.protocol as ModelProviderWireProtocol,
474
+ apiStyle: row.api_style as ModelProviderApiStyle,
475
+ llmProvider: row.llm_provider as ModelProviderLlmProvider,
476
+ baseURL: row.base_url,
477
+ apiKey,
478
+ apiKeyHint: row.api_key_hint || undefined,
479
+ enabled: row.enabled === 1,
480
+ status: row.status as ModelProviderStatus,
481
+ lastError: row.last_error || undefined,
482
+ lastDiscoveredAt: row.last_discovered_at
483
+ ? parseISO(row.last_discovered_at)
484
+ : undefined,
485
+ createdBy: row.created_by || undefined,
486
+ createdAt: parseISO(row.created_at),
487
+ updatedAt: parseISO(row.updated_at),
488
+ };
489
+ }
490
+
491
+ function mapRowToModel(row: ModelRow): ModelProviderModelEntry {
492
+ return {
493
+ id: row.id,
494
+ tenantId: row.tenant_id,
495
+ providerId: row.provider_id,
496
+ modelId: row.model_id,
497
+ displayName: row.display_name || undefined,
498
+ ownedBy: row.owned_by || undefined,
499
+ upstreamCreatedAt: row.upstream_created
500
+ ? parseISO(row.upstream_created)
501
+ : undefined,
502
+ enabled: row.enabled === 1,
503
+ stale: row.stale === 1,
504
+ discoveredAt: parseISO(row.discovered_at),
505
+ };
506
+ }