@open-mercato/ai-assistant 0.6.1-develop.3246.1.dbef9d7392 → 0.6.1-develop.3256.1.fe3dec2464

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 (133) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +82 -18
  3. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +370 -0
  4. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +7 -0
  5. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +194 -0
  6. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +7 -0
  7. package/dist/modules/ai_assistant/api/ai/agents/route.js +4 -0
  8. package/dist/modules/ai_assistant/api/ai/agents/route.js.map +2 -2
  9. package/dist/modules/ai_assistant/api/ai/chat/route.js +169 -5
  10. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
  11. package/dist/modules/ai_assistant/api/route/route.js +38 -19
  12. package/dist/modules/ai_assistant/api/route/route.js.map +3 -3
  13. package/dist/modules/ai_assistant/api/settings/allowlist/route.js +195 -0
  14. package/dist/modules/ai_assistant/api/settings/allowlist/route.js.map +7 -0
  15. package/dist/modules/ai_assistant/api/settings/route.js +537 -22
  16. package/dist/modules/ai_assistant/api/settings/route.js.map +3 -3
  17. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +701 -147
  18. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
  19. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +338 -0
  20. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +7 -0
  21. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.js +10 -0
  22. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.js.map +7 -0
  23. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.js +25 -0
  24. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.js.map +7 -0
  25. package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js +1 -1
  26. package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js.map +2 -2
  27. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +75 -26
  28. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
  29. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.js +10 -0
  30. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.js.map +7 -0
  31. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.js +25 -0
  32. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.js.map +7 -0
  33. package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js +503 -168
  34. package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js.map +2 -2
  35. package/dist/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.js +5 -0
  36. package/dist/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.js.map +7 -0
  37. package/dist/modules/ai_assistant/data/entities/AiTenantModelAllowlist.js +5 -0
  38. package/dist/modules/ai_assistant/data/entities/AiTenantModelAllowlist.js.map +7 -0
  39. package/dist/modules/ai_assistant/data/entities.js +123 -1
  40. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  41. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +157 -0
  42. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +7 -0
  43. package/dist/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.js +77 -0
  44. package/dist/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.js.map +7 -0
  45. package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js +1 -1
  46. package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js.map +2 -2
  47. package/dist/modules/ai_assistant/i18n/de.json +90 -1
  48. package/dist/modules/ai_assistant/i18n/en.json +90 -1
  49. package/dist/modules/ai_assistant/i18n/es.json +90 -1
  50. package/dist/modules/ai_assistant/i18n/pl.json +90 -1
  51. package/dist/modules/ai_assistant/lib/agent-registry.js +17 -1
  52. package/dist/modules/ai_assistant/lib/agent-registry.js.map +2 -2
  53. package/dist/modules/ai_assistant/lib/agent-runtime.js +133 -36
  54. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +2 -2
  55. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
  56. package/dist/modules/ai_assistant/lib/baseurl-allowlist.js +29 -0
  57. package/dist/modules/ai_assistant/lib/baseurl-allowlist.js.map +7 -0
  58. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +4 -1
  59. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
  60. package/dist/modules/ai_assistant/lib/llm-adapters/google.js +4 -1
  61. package/dist/modules/ai_assistant/lib/llm-adapters/google.js.map +2 -2
  62. package/dist/modules/ai_assistant/lib/model-allowlist.js +211 -0
  63. package/dist/modules/ai_assistant/lib/model-allowlist.js.map +7 -0
  64. package/dist/modules/ai_assistant/lib/model-factory.js +203 -31
  65. package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
  66. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +32 -1
  67. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
  68. package/dist/modules/ai_assistant/migrations/Migration20260508140000.js +18 -0
  69. package/dist/modules/ai_assistant/migrations/Migration20260508140000.js.map +7 -0
  70. package/dist/modules/ai_assistant/migrations/Migration20260512090000.js +16 -0
  71. package/dist/modules/ai_assistant/migrations/Migration20260512090000.js.map +7 -0
  72. package/dist/modules/ai_assistant/migrations/Migration20260512130000.js +15 -0
  73. package/dist/modules/ai_assistant/migrations/Migration20260512130000.js.map +7 -0
  74. package/generated/entities/ai_agent_runtime_override/index.ts +13 -0
  75. package/generated/entities/ai_tenant_model_allowlist/index.ts +9 -0
  76. package/generated/entities.ids.generated.ts +2 -0
  77. package/generated/entity-fields-registry.ts +26 -0
  78. package/jest.config.cjs +2 -0
  79. package/package.json +4 -4
  80. package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +477 -0
  81. package/src/modules/ai_assistant/__tests__/settings-page-logic.test.ts +116 -0
  82. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +240 -0
  83. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +251 -0
  84. package/src/modules/ai_assistant/api/ai/agents/route.ts +4 -0
  85. package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +273 -0
  86. package/src/modules/ai_assistant/api/ai/chat/route.ts +211 -2
  87. package/src/modules/ai_assistant/api/route/route.ts +49 -25
  88. package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +408 -0
  89. package/src/modules/ai_assistant/api/settings/allowlist/route.ts +221 -0
  90. package/src/modules/ai_assistant/api/settings/route.ts +721 -27
  91. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +858 -177
  92. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +458 -0
  93. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.ts +23 -0
  94. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.tsx +12 -0
  95. package/src/modules/ai_assistant/backend/config/ai-assistant/legacy/page.tsx +1 -1
  96. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +89 -12
  97. package/src/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.ts +23 -0
  98. package/src/modules/ai_assistant/backend/config/ai-assistant/settings/page.tsx +18 -0
  99. package/src/modules/ai_assistant/components/AiAssistantSettingsPageClient.tsx +617 -209
  100. package/src/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.ts +7 -0
  101. package/src/modules/ai_assistant/data/entities/AiTenantModelAllowlist.ts +2 -0
  102. package/src/modules/ai_assistant/data/entities.ts +164 -0
  103. package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +227 -0
  104. package/src/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.ts +132 -0
  105. package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentRuntimeOverrideRepository.test.ts +337 -0
  106. package/src/modules/ai_assistant/data/repositories/__tests__/AiTenantModelAllowlistRepository.test.ts +181 -0
  107. package/src/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.tsx +1 -1
  108. package/src/modules/ai_assistant/i18n/de.json +90 -1
  109. package/src/modules/ai_assistant/i18n/en.json +90 -1
  110. package/src/modules/ai_assistant/i18n/es.json +90 -1
  111. package/src/modules/ai_assistant/i18n/pl.json +90 -1
  112. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-phase4a.test.ts +396 -0
  113. package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +60 -6
  114. package/src/modules/ai_assistant/lib/__tests__/ai-api-operation-runner.test.ts +4 -2
  115. package/src/modules/ai_assistant/lib/__tests__/baseurl-allowlist.test.ts +75 -0
  116. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +18 -0
  117. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-google.test.ts +18 -0
  118. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +150 -4
  119. package/src/modules/ai_assistant/lib/__tests__/model-allowlist.test.ts +290 -0
  120. package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +634 -0
  121. package/src/modules/ai_assistant/lib/agent-registry.ts +20 -1
  122. package/src/modules/ai_assistant/lib/agent-runtime.ts +220 -44
  123. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +48 -0
  124. package/src/modules/ai_assistant/lib/baseurl-allowlist.ts +64 -0
  125. package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +11 -1
  126. package/src/modules/ai_assistant/lib/llm-adapters/google.ts +4 -1
  127. package/src/modules/ai_assistant/lib/model-allowlist.ts +407 -0
  128. package/src/modules/ai_assistant/lib/model-factory.ts +486 -58
  129. package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +44 -0
  130. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +704 -235
  131. package/src/modules/ai_assistant/migrations/Migration20260508140000.ts +18 -0
  132. package/src/modules/ai_assistant/migrations/Migration20260512090000.ts +16 -0
  133. package/src/modules/ai_assistant/migrations/Migration20260512130000.ts +13 -0
@@ -0,0 +1,7 @@
1
+ // Canonical entity class lives in `../entities.ts` (single-file aggregate, same
2
+ // pattern as `packages/core/src/modules/customers/data/entities.ts`). This
3
+ // thin re-export exists so call sites that prefer the by-name import path
4
+ // (`.../entities/AiAgentRuntimeOverride`) keep working.
5
+
6
+ export { AiAgentRuntimeOverride } from '../entities'
7
+ export type { AiAgentRuntimeOverride as default } from '../entities'
@@ -0,0 +1,2 @@
1
+ export { AiTenantModelAllowlist } from '../entities'
2
+ export type { AiTenantModelAllowlist as default } from '../entities'
@@ -210,6 +210,101 @@ export class AiPendingAction {
210
210
  resolvedByUserId?: string | null
211
211
  }
212
212
 
213
+ /**
214
+ * Per-tenant runtime override row that controls which provider, model, and
215
+ * base URL the AI runtime uses for a given agent (or all agents when
216
+ * `agent_id` is null).
217
+ *
218
+ * Resolution at query time: a non-null `agent_id` row takes precedence over
219
+ * a null `agent_id` (tenant-wide) row for the same `(tenant_id,
220
+ * organization_id)` scope. All value columns are nullable — an admin can
221
+ * override just the provider, just the model, or any subset. A null value
222
+ * means "inherit from the next source in the factory resolution chain."
223
+ *
224
+ * Soft-delete via `deleted_at` so the unique partial index and audit trail
225
+ * remain intact across upsert operations.
226
+ *
227
+ * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
228
+ */
229
+ @Entity({ tableName: 'ai_agent_runtime_overrides' })
230
+ @Index({
231
+ name: 'ai_agent_runtime_overrides_tenant_org_agent_uq',
232
+ expression:
233
+ 'create unique index "ai_agent_runtime_overrides_tenant_org_agent_uq" on "ai_agent_runtime_overrides" ("tenant_id", "organization_id", "agent_id") where "deleted_at" is null and "organization_id" is not null and "agent_id" is not null',
234
+ })
235
+ @Index({
236
+ name: 'ai_agent_runtime_overrides_tenant_agent_null_org_uq',
237
+ expression:
238
+ 'create unique index "ai_agent_runtime_overrides_tenant_agent_null_org_uq" on "ai_agent_runtime_overrides" ("tenant_id", "agent_id") where "deleted_at" is null and "organization_id" is null and "agent_id" is not null',
239
+ })
240
+ @Index({
241
+ name: 'ai_agent_runtime_overrides_tenant_null_agent_null_org_uq',
242
+ expression:
243
+ 'create unique index "ai_agent_runtime_overrides_tenant_null_agent_null_org_uq" on "ai_agent_runtime_overrides" ("tenant_id") where "deleted_at" is null and "organization_id" is null and "agent_id" is null',
244
+ })
245
+ @Index({
246
+ name: 'ai_agent_runtime_overrides_tenant_org_null_agent_uq',
247
+ expression:
248
+ 'create unique index "ai_agent_runtime_overrides_tenant_org_null_agent_uq" on "ai_agent_runtime_overrides" ("tenant_id", "organization_id") where "deleted_at" is null and "organization_id" is not null and "agent_id" is null',
249
+ })
250
+ @Index({
251
+ name: 'ai_agent_runtime_overrides_tenant_idx',
252
+ properties: ['tenantId'],
253
+ })
254
+ export class AiAgentRuntimeOverride {
255
+ [OptionalProps]?:
256
+ | 'createdAt'
257
+ | 'updatedAt'
258
+ | 'organizationId'
259
+ | 'agentId'
260
+ | 'providerId'
261
+ | 'modelId'
262
+ | 'baseUrl'
263
+ | 'allowedOverrideProviders'
264
+ | 'allowedOverrideModelsByProvider'
265
+ | 'updatedByUserId'
266
+ | 'deletedAt'
267
+
268
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
269
+ id!: string
270
+
271
+ @Property({ name: 'tenant_id', type: 'uuid' })
272
+ tenantId!: string
273
+
274
+ @Property({ name: 'organization_id', type: 'uuid', nullable: true })
275
+ organizationId?: string | null
276
+
277
+ @Property({ name: 'agent_id', type: 'string', columnType: 'varchar(128)', nullable: true })
278
+ agentId?: string | null
279
+
280
+ @Property({ name: 'provider_id', type: 'string', columnType: 'varchar(64)', nullable: true })
281
+ providerId?: string | null
282
+
283
+ @Property({ name: 'model_id', type: 'string', columnType: 'varchar(256)', nullable: true })
284
+ modelId?: string | null
285
+
286
+ @Property({ name: 'base_url', type: 'string', columnType: 'varchar(2048)', nullable: true })
287
+ baseUrl?: string | null
288
+
289
+ @Property({ name: 'allowed_override_providers', type: 'jsonb', nullable: true })
290
+ allowedOverrideProviders?: string[] | null
291
+
292
+ @Property({ name: 'allowed_override_models_by_provider', type: 'jsonb', default: '{}' })
293
+ allowedOverrideModelsByProvider: Record<string, string[]> = {}
294
+
295
+ @Property({ name: 'updated_by_user_id', type: 'uuid', nullable: true })
296
+ updatedByUserId?: string | null
297
+
298
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
299
+ createdAt: Date = new Date()
300
+
301
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
302
+ updatedAt: Date = new Date()
303
+
304
+ @Property({ name: 'deleted_at', type: Date, nullable: true })
305
+ deletedAt?: Date | null
306
+ }
307
+
213
308
  /**
214
309
  * Tenant-scoped override of an agent's declared `mutationPolicy` (Step 5.4).
215
310
  *
@@ -223,6 +318,75 @@ export class AiPendingAction {
223
318
  * < `confirm-required`. The route never allows an override to widen the
224
319
  * code-declared policy.
225
320
  */
321
+ /**
322
+ * Tenant-scoped allowlist clipping which providers and models the runtime is
323
+ * permitted to use within the env-driven allowlist (Phase 1780-6 of spec
324
+ * `2026-04-27-ai-agents-provider-model-baseurl-overrides`).
325
+ *
326
+ * Effective constraint chain (outer → inner): `OM_AI_AVAILABLE_*` env vars →
327
+ * this tenant allowlist → per-tenant runtime overrides → per-request overrides.
328
+ * The tenant allowlist may NEVER widen the env allowlist; the runtime
329
+ * intersects the two and surfaces the intersection through the settings GET
330
+ * response so the UI never offers a value the runtime would refuse.
331
+ *
332
+ * `allowedProviders === null` means "inherit env" (no tenant-level restriction
333
+ * beyond what the env imposes). `allowedModelsByProvider` keys are provider
334
+ * ids; a missing key means "inherit env" for that provider; an empty array
335
+ * means "no models permitted for this provider" (effectively disabling it).
336
+ */
337
+ @Entity({ tableName: 'ai_tenant_model_allowlists' })
338
+ @Index({
339
+ name: 'ai_tenant_model_allowlists_tenant_org_uq',
340
+ expression:
341
+ 'create unique index "ai_tenant_model_allowlists_tenant_org_uq" on "ai_tenant_model_allowlists" ("tenant_id", "organization_id") where "deleted_at" is null and "organization_id" is not null',
342
+ })
343
+ @Index({
344
+ name: 'ai_tenant_model_allowlists_tenant_null_org_uq',
345
+ expression:
346
+ 'create unique index "ai_tenant_model_allowlists_tenant_null_org_uq" on "ai_tenant_model_allowlists" ("tenant_id") where "deleted_at" is null and "organization_id" is null',
347
+ })
348
+ @Index({
349
+ name: 'ai_tenant_model_allowlists_tenant_idx',
350
+ properties: ['tenantId'],
351
+ })
352
+ export class AiTenantModelAllowlist {
353
+ [OptionalProps]?:
354
+ | 'createdAt'
355
+ | 'updatedAt'
356
+ | 'organizationId'
357
+ | 'allowedProviders'
358
+ | 'allowedModelsByProvider'
359
+ | 'updatedByUserId'
360
+ | 'deletedAt'
361
+
362
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
363
+ id!: string
364
+
365
+ @Property({ name: 'tenant_id', type: 'uuid' })
366
+ tenantId!: string
367
+
368
+ @Property({ name: 'organization_id', type: 'uuid', nullable: true })
369
+ organizationId?: string | null
370
+
371
+ @Property({ name: 'allowed_providers', type: 'jsonb', nullable: true })
372
+ allowedProviders?: string[] | null
373
+
374
+ @Property({ name: 'allowed_models_by_provider', type: 'jsonb', default: '{}' })
375
+ allowedModelsByProvider: Record<string, string[]> = {}
376
+
377
+ @Property({ name: 'updated_by_user_id', type: 'uuid', nullable: true })
378
+ updatedByUserId?: string | null
379
+
380
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
381
+ createdAt: Date = new Date()
382
+
383
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
384
+ updatedAt: Date = new Date()
385
+
386
+ @Property({ name: 'deleted_at', type: Date, nullable: true })
387
+ deletedAt?: Date | null
388
+ }
389
+
226
390
  @Entity({ tableName: 'ai_agent_mutation_policy_overrides' })
227
391
  @Index({
228
392
  name: 'ai_agent_mutation_policy_overrides_tenant_org_agent_uq',
@@ -0,0 +1,227 @@
1
+ import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
2
+ import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
3
+ import { canonicalProviderId } from '../../lib/model-allowlist'
4
+ import { AiAgentRuntimeOverride } from '../entities'
5
+
6
+ export interface AiAgentRuntimeOverrideContext {
7
+ tenantId: string
8
+ organizationId?: string | null
9
+ userId?: string | null
10
+ }
11
+
12
+ export interface AiAgentRuntimeOverrideInput {
13
+ /** null means tenant-wide default (no agent pinning). */
14
+ agentId?: string | null
15
+ providerId?: string | null
16
+ modelId?: string | null
17
+ baseURL?: string | null
18
+ allowedOverrideProviders?: string[] | null
19
+ allowedOverrideModelsByProvider?: Record<string, string[]>
20
+ }
21
+
22
+ /**
23
+ * Repository for per-tenant AI runtime overrides (Phase 4a of spec
24
+ * `2026-04-27-ai-agents-provider-model-baseurl-overrides`).
25
+ *
26
+ * All reads MUST filter by `tenant_id` first. The three public methods follow
27
+ * the same fail-safe pattern as sibling repositories: `getDefault` returns
28
+ * null on missing tenant context; `upsertDefault` validates provider id at
29
+ * write time; `clearDefault` soft-deletes via `deleted_at`.
30
+ *
31
+ * Resolution precedence returned by `getDefault`:
32
+ * 1. Agent-specific row (non-null `agent_id`) when `agentId` is provided.
33
+ * 2. Tenant-wide row (`agent_id IS NULL`) for the same `(tenant_id, org_id)`.
34
+ * Both rows are always scoped to the caller's `tenant_id` — cross-tenant
35
+ * reads are impossible because every query filters `WHERE tenant_id = ?`.
36
+ */
37
+ export class AiAgentRuntimeOverrideRepository {
38
+ constructor(private readonly em: EntityManager) {}
39
+
40
+ /**
41
+ * Returns the most-specific active runtime override for the given context.
42
+ *
43
+ * When `agentId` is provided, an agent-specific row takes precedence over a
44
+ * tenant-wide null-agent row. Returns null when neither exists.
45
+ *
46
+ * Never returns a row with `deleted_at IS NOT NULL`.
47
+ */
48
+ async getDefault(ctx: {
49
+ tenantId: string
50
+ organizationId?: string | null
51
+ agentId?: string | null
52
+ }): Promise<AiAgentRuntimeOverride | null> {
53
+ if (!ctx?.tenantId) return null
54
+
55
+ const orgFilter = ctx.organizationId ?? null
56
+
57
+ // Try agent-specific first when caller provided an agentId.
58
+ if (ctx.agentId) {
59
+ const agentRow = await this.em.findOne(AiAgentRuntimeOverride, {
60
+ tenantId: ctx.tenantId,
61
+ organizationId: orgFilter,
62
+ agentId: ctx.agentId,
63
+ deletedAt: null,
64
+ } satisfies FilterQuery<AiAgentRuntimeOverride>)
65
+ if (agentRow) return agentRow
66
+ }
67
+
68
+ // Fall back to tenant-wide row (agentId = null).
69
+ const tenantRow = await this.em.findOne(AiAgentRuntimeOverride, {
70
+ tenantId: ctx.tenantId,
71
+ organizationId: orgFilter,
72
+ agentId: null,
73
+ deletedAt: null,
74
+ } satisfies FilterQuery<AiAgentRuntimeOverride>)
75
+ return tenantRow ?? null
76
+ }
77
+
78
+ async getExact(ctx: {
79
+ tenantId: string
80
+ organizationId?: string | null
81
+ agentId?: string | null
82
+ }): Promise<AiAgentRuntimeOverride | null> {
83
+ if (!ctx?.tenantId) return null
84
+ const row = await this.em.findOne(AiAgentRuntimeOverride, {
85
+ tenantId: ctx.tenantId,
86
+ organizationId: ctx.organizationId ?? null,
87
+ agentId: ctx.agentId ?? null,
88
+ deletedAt: null,
89
+ } satisfies FilterQuery<AiAgentRuntimeOverride>)
90
+ return row ?? null
91
+ }
92
+
93
+ /**
94
+ * Inserts or updates the runtime override for the given context.
95
+ *
96
+ * Validates `providerId` against the registry at write time so an admin
97
+ * cannot save a typo (Phase 1.4 contract re-applied per spec §Data Models).
98
+ * An unknown provider id throws a typed error.
99
+ *
100
+ * The R6 base-URL allowlist check is intentionally NOT performed here —
101
+ * that enforcement lives at the HTTP layer (PUT settings route). The
102
+ * repository trusts that callers have already validated the value.
103
+ */
104
+ async upsertDefault(
105
+ input: AiAgentRuntimeOverrideInput,
106
+ ctx: AiAgentRuntimeOverrideContext,
107
+ ): Promise<AiAgentRuntimeOverride> {
108
+ if (!ctx?.tenantId) {
109
+ throw new Error('AiAgentRuntimeOverrideRepository.upsertDefault requires tenantId')
110
+ }
111
+
112
+ const normalizedProviderId = input.providerId
113
+ ? canonicalProviderId(input.providerId, llmProviderRegistry.list().map((p) => p.id))
114
+ : null
115
+ if (input.providerId) {
116
+ const knownProvider = normalizedProviderId ? llmProviderRegistry.get(normalizedProviderId) : null
117
+ if (!knownProvider) {
118
+ throw new AiAgentRuntimeOverrideValidationError(
119
+ `Unknown provider id "${input.providerId}". Registered provider ids: ${llmProviderRegistry.list().map((p) => p.id).join(', ')}.`,
120
+ )
121
+ }
122
+ }
123
+
124
+ const orgFilter = ctx.organizationId ?? null
125
+ const agentIdFilter = input.agentId ?? null
126
+ const hasProviderId = Object.prototype.hasOwnProperty.call(input, 'providerId')
127
+ const hasModelId = Object.prototype.hasOwnProperty.call(input, 'modelId')
128
+ const hasBaseURL = Object.prototype.hasOwnProperty.call(input, 'baseURL')
129
+ const hasAllowedOverrideProviders = Object.prototype.hasOwnProperty.call(input, 'allowedOverrideProviders')
130
+ const hasAllowedOverrideModels = Object.prototype.hasOwnProperty.call(input, 'allowedOverrideModelsByProvider')
131
+
132
+ return this.em.transactional(async (tx) => {
133
+ const existing = await tx.findOne(AiAgentRuntimeOverride, {
134
+ tenantId: ctx.tenantId,
135
+ organizationId: orgFilter,
136
+ agentId: agentIdFilter,
137
+ deletedAt: null,
138
+ } satisfies FilterQuery<AiAgentRuntimeOverride>)
139
+
140
+ if (existing) {
141
+ if (hasProviderId) existing.providerId = normalizedProviderId
142
+ if (hasModelId) existing.modelId = input.modelId ?? null
143
+ if (hasBaseURL) existing.baseUrl = input.baseURL ?? null
144
+ if (hasAllowedOverrideProviders) {
145
+ existing.allowedOverrideProviders = input.allowedOverrideProviders ?? null
146
+ }
147
+ if (hasAllowedOverrideModels) {
148
+ existing.allowedOverrideModelsByProvider = input.allowedOverrideModelsByProvider ?? {}
149
+ }
150
+ existing.updatedByUserId = ctx.userId ?? null
151
+ existing.updatedAt = new Date()
152
+ await tx.persist(existing).flush()
153
+ return existing
154
+ }
155
+
156
+ const row = tx.create(AiAgentRuntimeOverride, {
157
+ tenantId: ctx.tenantId,
158
+ organizationId: orgFilter,
159
+ agentId: agentIdFilter,
160
+ providerId: hasProviderId ? normalizedProviderId : null,
161
+ modelId: hasModelId ? (input.modelId ?? null) : null,
162
+ baseUrl: hasBaseURL ? (input.baseURL ?? null) : null,
163
+ allowedOverrideProviders: hasAllowedOverrideProviders
164
+ ? (input.allowedOverrideProviders ?? null)
165
+ : null,
166
+ allowedOverrideModelsByProvider: hasAllowedOverrideModels
167
+ ? (input.allowedOverrideModelsByProvider ?? {})
168
+ : {},
169
+ updatedByUserId: ctx.userId ?? null,
170
+ } as unknown as AiAgentRuntimeOverride)
171
+ await tx.persist(row).flush()
172
+ return row
173
+ })
174
+ }
175
+
176
+ /**
177
+ * Soft-deletes the active override matching the given context by setting
178
+ * `deleted_at = now()`. Returns true when a row was found and cleared,
179
+ * false when no active row existed.
180
+ */
181
+ async clearDefault(ctx: {
182
+ tenantId: string
183
+ organizationId?: string | null
184
+ agentId?: string | null
185
+ }): Promise<boolean> {
186
+ if (!ctx?.tenantId) return false
187
+
188
+ const orgFilter = ctx.organizationId ?? null
189
+ const agentIdFilter = ctx.agentId ?? null
190
+
191
+ return this.em.transactional(async (tx) => {
192
+ const existing = await tx.findOne(AiAgentRuntimeOverride, {
193
+ tenantId: ctx.tenantId,
194
+ organizationId: orgFilter,
195
+ agentId: agentIdFilter,
196
+ deletedAt: null,
197
+ } satisfies FilterQuery<AiAgentRuntimeOverride>)
198
+ if (!existing) return false
199
+ if (
200
+ existing.allowedOverrideProviders != null ||
201
+ Object.keys(existing.allowedOverrideModelsByProvider ?? {}).length > 0
202
+ ) {
203
+ existing.providerId = null
204
+ existing.modelId = null
205
+ existing.baseUrl = null
206
+ existing.updatedAt = new Date()
207
+ await tx.persist(existing).flush()
208
+ return true
209
+ }
210
+ existing.deletedAt = new Date()
211
+ await tx.persist(existing).flush()
212
+ return true
213
+ })
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Thrown by `upsertDefault` when an unknown provider id is submitted.
219
+ */
220
+ export class AiAgentRuntimeOverrideValidationError extends Error {
221
+ constructor(message: string) {
222
+ super(message)
223
+ this.name = 'AiAgentRuntimeOverrideValidationError'
224
+ }
225
+ }
226
+
227
+ export default AiAgentRuntimeOverrideRepository
@@ -0,0 +1,132 @@
1
+ import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
2
+ import { AiTenantModelAllowlist } from '../entities'
3
+
4
+ export interface AiTenantModelAllowlistContext {
5
+ tenantId: string
6
+ organizationId?: string | null
7
+ userId?: string | null
8
+ }
9
+
10
+ export interface AiTenantModelAllowlistInput {
11
+ /**
12
+ * Tenant-permitted provider ids. `null` means "inherit env" (no tenant
13
+ * restriction beyond what `OM_AI_AVAILABLE_PROVIDERS` imposes). An empty
14
+ * array means "no providers permitted" — the runtime will fall through to
15
+ * env-default behaviour but the settings UI will surface zero options.
16
+ */
17
+ allowedProviders?: string[] | null
18
+ /**
19
+ * Per-provider allowed model lists. A missing key means "inherit env" for
20
+ * that provider; an empty array means "no models permitted for this
21
+ * provider" (effectively disabling it for tenant-side picks).
22
+ */
23
+ allowedModelsByProvider?: Record<string, string[]>
24
+ }
25
+
26
+ export interface AiTenantModelAllowlistSnapshot {
27
+ allowedProviders: string[] | null
28
+ allowedModelsByProvider: Record<string, string[]>
29
+ }
30
+
31
+ /**
32
+ * Repository for the per-tenant provider/model allowlist (Phase 1780-6 of spec
33
+ * `2026-04-27-ai-agents-provider-model-baseurl-overrides`).
34
+ *
35
+ * Reads always filter by `tenant_id`. The tenant allowlist clips runtime
36
+ * choices within the env-driven outer constraint — the route layer is
37
+ * responsible for rejecting writes that escape `OM_AI_AVAILABLE_*`. This
38
+ * repository does not consult the env directly; it stores whatever the route
39
+ * has already validated.
40
+ */
41
+ export class AiTenantModelAllowlistRepository {
42
+ constructor(private readonly em: EntityManager) {}
43
+
44
+ async getForTenant(ctx: {
45
+ tenantId: string
46
+ organizationId?: string | null
47
+ }): Promise<AiTenantModelAllowlist | null> {
48
+ if (!ctx?.tenantId) return null
49
+ const orgFilter = ctx.organizationId ?? null
50
+ const row = await this.em.findOne(AiTenantModelAllowlist, {
51
+ tenantId: ctx.tenantId,
52
+ organizationId: orgFilter,
53
+ deletedAt: null,
54
+ } satisfies FilterQuery<AiTenantModelAllowlist>)
55
+ return row ?? null
56
+ }
57
+
58
+ async getSnapshot(ctx: {
59
+ tenantId: string
60
+ organizationId?: string | null
61
+ }): Promise<AiTenantModelAllowlistSnapshot | null> {
62
+ const row = await this.getForTenant(ctx)
63
+ if (!row) return null
64
+ return {
65
+ allowedProviders: row.allowedProviders ?? null,
66
+ allowedModelsByProvider: row.allowedModelsByProvider ?? {},
67
+ }
68
+ }
69
+
70
+ async upsert(
71
+ input: AiTenantModelAllowlistInput,
72
+ ctx: AiTenantModelAllowlistContext,
73
+ ): Promise<AiTenantModelAllowlist> {
74
+ if (!ctx?.tenantId) {
75
+ throw new Error('AiTenantModelAllowlistRepository.upsert requires tenantId')
76
+ }
77
+
78
+ const orgFilter = ctx.organizationId ?? null
79
+ const providers = input.allowedProviders === undefined
80
+ ? null
81
+ : input.allowedProviders
82
+ const models = input.allowedModelsByProvider ?? {}
83
+
84
+ return this.em.transactional(async (tx) => {
85
+ const existing = await tx.findOne(AiTenantModelAllowlist, {
86
+ tenantId: ctx.tenantId,
87
+ organizationId: orgFilter,
88
+ deletedAt: null,
89
+ } satisfies FilterQuery<AiTenantModelAllowlist>)
90
+
91
+ if (existing) {
92
+ existing.allowedProviders = providers
93
+ existing.allowedModelsByProvider = models
94
+ existing.updatedByUserId = ctx.userId ?? null
95
+ existing.updatedAt = new Date()
96
+ await tx.persist(existing).flush()
97
+ return existing
98
+ }
99
+
100
+ const row = tx.create(AiTenantModelAllowlist, {
101
+ tenantId: ctx.tenantId,
102
+ organizationId: orgFilter,
103
+ allowedProviders: providers,
104
+ allowedModelsByProvider: models,
105
+ updatedByUserId: ctx.userId ?? null,
106
+ } as unknown as AiTenantModelAllowlist)
107
+ await tx.persist(row).flush()
108
+ return row
109
+ })
110
+ }
111
+
112
+ async clear(ctx: {
113
+ tenantId: string
114
+ organizationId?: string | null
115
+ }): Promise<boolean> {
116
+ if (!ctx?.tenantId) return false
117
+ const orgFilter = ctx.organizationId ?? null
118
+ return this.em.transactional(async (tx) => {
119
+ const existing = await tx.findOne(AiTenantModelAllowlist, {
120
+ tenantId: ctx.tenantId,
121
+ organizationId: orgFilter,
122
+ deletedAt: null,
123
+ } satisfies FilterQuery<AiTenantModelAllowlist>)
124
+ if (!existing) return false
125
+ existing.deletedAt = new Date()
126
+ await tx.persist(existing).flush()
127
+ return true
128
+ })
129
+ }
130
+ }
131
+
132
+ export default AiTenantModelAllowlistRepository