@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +82 -18
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +370 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +194 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/route.js +4 -0
- package/dist/modules/ai_assistant/api/ai/agents/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/chat/route.js +169 -5
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/route/route.js +38 -19
- package/dist/modules/ai_assistant/api/route/route.js.map +3 -3
- package/dist/modules/ai_assistant/api/settings/allowlist/route.js +195 -0
- package/dist/modules/ai_assistant/api/settings/allowlist/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/settings/route.js +537 -22
- package/dist/modules/ai_assistant/api/settings/route.js.map +3 -3
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +701 -147
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +338 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.js +25 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js +1 -1
- package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +75 -26
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.js +25 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js +503 -168
- package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.js +5 -0
- package/dist/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities/AiTenantModelAllowlist.js +5 -0
- package/dist/modules/ai_assistant/data/entities/AiTenantModelAllowlist.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities.js +123 -1
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +157 -0
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +7 -0
- package/dist/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.js +77 -0
- package/dist/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.js.map +7 -0
- package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js +1 -1
- package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/i18n/de.json +90 -1
- package/dist/modules/ai_assistant/i18n/en.json +90 -1
- package/dist/modules/ai_assistant/i18n/es.json +90 -1
- package/dist/modules/ai_assistant/i18n/pl.json +90 -1
- package/dist/modules/ai_assistant/lib/agent-registry.js +17 -1
- package/dist/modules/ai_assistant/lib/agent-registry.js.map +2 -2
- package/dist/modules/ai_assistant/lib/agent-runtime.js +133 -36
- package/dist/modules/ai_assistant/lib/agent-runtime.js.map +2 -2
- package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
- package/dist/modules/ai_assistant/lib/baseurl-allowlist.js +29 -0
- package/dist/modules/ai_assistant/lib/baseurl-allowlist.js.map +7 -0
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +4 -1
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
- package/dist/modules/ai_assistant/lib/llm-adapters/google.js +4 -1
- package/dist/modules/ai_assistant/lib/llm-adapters/google.js.map +2 -2
- package/dist/modules/ai_assistant/lib/model-allowlist.js +211 -0
- package/dist/modules/ai_assistant/lib/model-allowlist.js.map +7 -0
- package/dist/modules/ai_assistant/lib/model-factory.js +203 -31
- package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +32 -1
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
- package/dist/modules/ai_assistant/migrations/Migration20260508140000.js +18 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508140000.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260512090000.js +16 -0
- package/dist/modules/ai_assistant/migrations/Migration20260512090000.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260512130000.js +15 -0
- package/dist/modules/ai_assistant/migrations/Migration20260512130000.js.map +7 -0
- package/generated/entities/ai_agent_runtime_override/index.ts +13 -0
- package/generated/entities/ai_tenant_model_allowlist/index.ts +9 -0
- package/generated/entities.ids.generated.ts +2 -0
- package/generated/entity-fields-registry.ts +26 -0
- package/jest.config.cjs +2 -0
- package/package.json +4 -4
- package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +477 -0
- package/src/modules/ai_assistant/__tests__/settings-page-logic.test.ts +116 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +240 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +251 -0
- package/src/modules/ai_assistant/api/ai/agents/route.ts +4 -0
- package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +273 -0
- package/src/modules/ai_assistant/api/ai/chat/route.ts +211 -2
- package/src/modules/ai_assistant/api/route/route.ts +49 -25
- package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +408 -0
- package/src/modules/ai_assistant/api/settings/allowlist/route.ts +221 -0
- package/src/modules/ai_assistant/api/settings/route.ts +721 -27
- package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +858 -177
- package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +458 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.ts +23 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.tsx +12 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/legacy/page.tsx +1 -1
- package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +89 -12
- package/src/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.ts +23 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/settings/page.tsx +18 -0
- package/src/modules/ai_assistant/components/AiAssistantSettingsPageClient.tsx +617 -209
- package/src/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.ts +7 -0
- package/src/modules/ai_assistant/data/entities/AiTenantModelAllowlist.ts +2 -0
- package/src/modules/ai_assistant/data/entities.ts +164 -0
- package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +227 -0
- package/src/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.ts +132 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentRuntimeOverrideRepository.test.ts +337 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiTenantModelAllowlistRepository.test.ts +181 -0
- package/src/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.tsx +1 -1
- package/src/modules/ai_assistant/i18n/de.json +90 -1
- package/src/modules/ai_assistant/i18n/en.json +90 -1
- package/src/modules/ai_assistant/i18n/es.json +90 -1
- package/src/modules/ai_assistant/i18n/pl.json +90 -1
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-phase4a.test.ts +396 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +60 -6
- package/src/modules/ai_assistant/lib/__tests__/ai-api-operation-runner.test.ts +4 -2
- package/src/modules/ai_assistant/lib/__tests__/baseurl-allowlist.test.ts +75 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +18 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-google.test.ts +18 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +150 -4
- package/src/modules/ai_assistant/lib/__tests__/model-allowlist.test.ts +290 -0
- package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +634 -0
- package/src/modules/ai_assistant/lib/agent-registry.ts +20 -1
- package/src/modules/ai_assistant/lib/agent-runtime.ts +220 -44
- package/src/modules/ai_assistant/lib/ai-agent-definition.ts +48 -0
- package/src/modules/ai_assistant/lib/baseurl-allowlist.ts +64 -0
- package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +11 -1
- package/src/modules/ai_assistant/lib/llm-adapters/google.ts +4 -1
- package/src/modules/ai_assistant/lib/model-allowlist.ts +407 -0
- package/src/modules/ai_assistant/lib/model-factory.ts +486 -58
- package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +44 -0
- package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +704 -235
- package/src/modules/ai_assistant/migrations/Migration20260508140000.ts +18 -0
- package/src/modules/ai_assistant/migrations/Migration20260512090000.ts +16 -0
- 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'
|
|
@@ -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
|