@open-mercato/ai-assistant 0.6.7 → 0.6.8-develop.6874.1.982d6097d8

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 (120) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +12 -29
  3. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js +80 -0
  4. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js.map +7 -0
  5. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.js +124 -0
  6. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.js.map +7 -0
  7. package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js +30 -0
  8. package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js.map +2 -2
  9. package/dist/modules/ai_assistant/api/ai/chat/route.js +7 -0
  10. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
  11. package/dist/modules/ai_assistant/api/moderation-flags/route.js +121 -0
  12. package/dist/modules/ai_assistant/api/moderation-flags/route.js.map +7 -0
  13. package/dist/modules/ai_assistant/api/settings/allowlist/route.js +10 -10
  14. package/dist/modules/ai_assistant/api/settings/allowlist/route.js.map +2 -2
  15. package/dist/modules/ai_assistant/api/settings/route.js +34 -13
  16. package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
  17. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +121 -15
  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 +2 -2
  20. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +2 -2
  21. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js +139 -0
  22. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js.map +7 -0
  23. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js +10 -0
  24. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js.map +7 -0
  25. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js +26 -0
  26. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js.map +7 -0
  27. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +4 -4
  28. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
  29. package/dist/modules/ai_assistant/data/entities.js +42 -0
  30. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  31. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +3 -1
  32. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
  33. package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js +44 -0
  34. package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js.map +7 -0
  35. package/dist/modules/ai_assistant/di.js +6 -2
  36. package/dist/modules/ai_assistant/di.js.map +2 -2
  37. package/dist/modules/ai_assistant/events.js +6 -0
  38. package/dist/modules/ai_assistant/events.js.map +2 -2
  39. package/dist/modules/ai_assistant/i18n/de.json +42 -0
  40. package/dist/modules/ai_assistant/i18n/en.json +42 -0
  41. package/dist/modules/ai_assistant/i18n/es.json +42 -0
  42. package/dist/modules/ai_assistant/i18n/ko.json +527 -0
  43. package/dist/modules/ai_assistant/i18n/pl.json +42 -0
  44. package/dist/modules/ai_assistant/lib/agent-runtime.js +158 -5
  45. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +2 -2
  46. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
  47. package/dist/modules/ai_assistant/lib/auth.js +6 -7
  48. package/dist/modules/ai_assistant/lib/auth.js.map +2 -2
  49. package/dist/modules/ai_assistant/lib/entity-graph.js +3 -3
  50. package/dist/modules/ai_assistant/lib/entity-graph.js.map +2 -2
  51. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +3 -0
  52. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
  53. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js +8 -1
  54. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +2 -2
  55. package/dist/modules/ai_assistant/lib/model-factory.js +6 -1
  56. package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
  57. package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js +43 -0
  58. package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js.map +7 -0
  59. package/dist/modules/ai_assistant/lib/moderation-policy.js +24 -0
  60. package/dist/modules/ai_assistant/lib/moderation-policy.js.map +7 -0
  61. package/dist/modules/ai_assistant/lib/moderation.js +109 -0
  62. package/dist/modules/ai_assistant/lib/moderation.js.map +7 -0
  63. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +2 -0
  64. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
  65. package/dist/modules/ai_assistant/lib/tool-search.js +4 -12
  66. package/dist/modules/ai_assistant/lib/tool-search.js.map +2 -2
  67. package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js +19 -0
  68. package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js.map +7 -0
  69. package/generated/entities/ai_agent_runtime_override/index.ts +1 -0
  70. package/generated/entities/ai_moderation_flag/index.ts +9 -0
  71. package/generated/entities.ids.generated.ts +2 -1
  72. package/generated/entity-fields-registry.ts +13 -1
  73. package/package.json +13 -12
  74. package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.ts +126 -0
  75. package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.ts +161 -0
  76. package/src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts +48 -0
  77. package/src/modules/ai_assistant/api/ai/chat/route.ts +10 -0
  78. package/src/modules/ai_assistant/api/moderation-flags/__tests__/route.test.ts +122 -0
  79. package/src/modules/ai_assistant/api/moderation-flags/route.ts +137 -0
  80. package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +58 -8
  81. package/src/modules/ai_assistant/api/settings/allowlist/route.ts +10 -12
  82. package/src/modules/ai_assistant/api/settings/route.ts +33 -10
  83. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +161 -15
  84. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +2 -2
  85. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.tsx +186 -0
  86. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.ts +24 -0
  87. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.tsx +12 -0
  88. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +4 -4
  89. package/src/modules/ai_assistant/data/entities.ts +56 -0
  90. package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +4 -0
  91. package/src/modules/ai_assistant/data/repositories/AiModerationFlagRepository.ts +79 -0
  92. package/src/modules/ai_assistant/di.ts +5 -1
  93. package/src/modules/ai_assistant/events.ts +21 -0
  94. package/src/modules/ai_assistant/i18n/de.json +42 -0
  95. package/src/modules/ai_assistant/i18n/en.json +42 -0
  96. package/src/modules/ai_assistant/i18n/es.json +42 -0
  97. package/src/modules/ai_assistant/i18n/ko.json +527 -0
  98. package/src/modules/ai_assistant/i18n/pl.json +42 -0
  99. package/src/modules/ai_assistant/lib/__tests__/auth.test.ts +2 -5
  100. package/src/modules/ai_assistant/lib/__tests__/input-moderation-gate.test.ts +121 -0
  101. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +13 -0
  102. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +41 -0
  103. package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +60 -0
  104. package/src/modules/ai_assistant/lib/__tests__/moderation-flag-recorder.test.ts +76 -0
  105. package/src/modules/ai_assistant/lib/__tests__/moderation-policy.test.ts +86 -0
  106. package/src/modules/ai_assistant/lib/__tests__/moderation.test.ts +114 -0
  107. package/src/modules/ai_assistant/lib/agent-runtime.ts +259 -0
  108. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +10 -0
  109. package/src/modules/ai_assistant/lib/auth.ts +6 -15
  110. package/src/modules/ai_assistant/lib/entity-graph.ts +3 -3
  111. package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +10 -0
  112. package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +32 -1
  113. package/src/modules/ai_assistant/lib/model-factory.ts +33 -0
  114. package/src/modules/ai_assistant/lib/moderation-flag-recorder.ts +75 -0
  115. package/src/modules/ai_assistant/lib/moderation-policy.ts +77 -0
  116. package/src/modules/ai_assistant/lib/moderation.ts +179 -0
  117. package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +2 -0
  118. package/src/modules/ai_assistant/lib/tool-search.ts +4 -17
  119. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +388 -16
  120. package/src/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.ts +21 -0
@@ -27,6 +27,20 @@ import {
27
27
  import type { StopCondition } from 'ai'
28
28
  import type { ZodTypeAny } from 'zod'
29
29
  import { createModelFactory, resolveAllowRuntimeOverride } from './model-factory'
30
+ import { computeEndUserIdentifier } from '@open-mercato/shared/lib/ai/safety-identifier'
31
+ import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
32
+ import type { EnvLookup } from '@open-mercato/shared/lib/ai/llm-provider'
33
+ import {
34
+ AiModerationBlockedError,
35
+ AiModerationUnavailableError,
36
+ type ModerationService,
37
+ } from './moderation'
38
+ import {
39
+ isModerationActive,
40
+ resolveModerationPolicy,
41
+ shouldFailClosed,
42
+ } from './moderation-policy'
43
+ import { recordModerationFlag } from './moderation-flag-recorder'
30
44
  import type {
31
45
  AiAgentDefinition,
32
46
  AiAgentLoopConfig,
@@ -342,6 +356,16 @@ export interface PreparedAiSdkOptions {
342
356
  * Phase 5 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
343
357
  */
344
358
  toolLoopAgent?: ToolLoopAgent<never, ToolSet>
359
+ /**
360
+ * Provider-specific `providerOptions` fragment carrying the mapped end-user
361
+ * safety identifier (e.g. OpenAI `safety_identifier`). Present only when an
362
+ * identifier was derived and the resolved provider supports the mapping.
363
+ * Callers using the `generateText` escape-hatch MUST forward it to the SDK
364
+ * call so the identifier reaches the provider.
365
+ *
366
+ * Spec `2026-06-04-ai-input-moderation-and-safety-identifiers`.
367
+ */
368
+ providerOptions?: Record<string, unknown>
345
369
  }
346
370
 
347
371
  /**
@@ -968,6 +992,15 @@ interface ResolvedAgentModel {
968
992
  model: LanguageModel
969
993
  modelId: string
970
994
  providerId: string
995
+ /**
996
+ * Provider-specific `providerOptions` fragment carrying the mapped end-user
997
+ * safety identifier (undefined when no identifier or no provider mapping).
998
+ */
999
+ providerOptions?: Record<string, unknown>
1000
+ /** Whether the resolved chat provider supports input moderation. */
1001
+ supportsInputModeration: boolean
1002
+ /** Resolved base URL (if any), reused by the moderation endpoint call. */
1003
+ baseURL?: string
971
1004
  }
972
1005
 
973
1006
  function resolveAgentModel(
@@ -979,6 +1012,7 @@ function resolveAgentModel(
979
1012
  tenantOverride?: { providerId?: string | null; modelId?: string | null; baseURL?: string | null } | null,
980
1013
  requestOverride?: { providerId?: string | null; modelId?: string | null; baseURL?: string | null } | null,
981
1014
  tenantAllowlist?: TenantAllowlistSnapshot | null,
1015
+ endUserIdentifier?: string,
982
1016
  ): ResolvedAgentModel {
983
1017
  const effectiveContainer = container ?? createContainer()
984
1018
  const resolution = createModelFactory(effectiveContainer).resolveModel({
@@ -993,11 +1027,145 @@ function resolveAgentModel(
993
1027
  tenantOverride: tenantOverride ?? undefined,
994
1028
  requestOverride: requestOverride ?? undefined,
995
1029
  tenantAllowlist: tenantAllowlist ?? null,
1030
+ endUserIdentifier,
996
1031
  })
997
1032
  return {
998
1033
  model: resolution.model as LanguageModel,
999
1034
  modelId: resolution.modelId,
1000
1035
  providerId: resolution.providerId,
1036
+ providerOptions: resolution.providerOptions,
1037
+ supportsInputModeration: resolution.supportsInputModeration === true,
1038
+ baseURL: resolution.baseURL,
1039
+ }
1040
+ }
1041
+
1042
+ /**
1043
+ * Concatenates the text parts of the most recent user message. Only the new
1044
+ * user turn is moderated (prior history was screened on its own turn). Returns
1045
+ * an empty string when there is no user message or no text content.
1046
+ */
1047
+ export function extractLatestUserText(messages: UIMessage[]): string {
1048
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
1049
+ const raw = messages[index] as unknown as {
1050
+ role?: string
1051
+ content?: string
1052
+ parts?: Array<{ type?: string; text?: string }>
1053
+ }
1054
+ if (raw.role !== 'user') continue
1055
+ if (Array.isArray(raw.parts)) {
1056
+ return raw.parts
1057
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
1058
+ .map((part) => part.text as string)
1059
+ .join('\n')
1060
+ }
1061
+ return typeof raw.content === 'string' ? raw.content : ''
1062
+ }
1063
+ return ''
1064
+ }
1065
+
1066
+ export interface InputModerationGateParams {
1067
+ untrustedInput?: boolean
1068
+ supportsInputModeration: boolean
1069
+ userText: string
1070
+ service: ModerationService | null
1071
+ /**
1072
+ * Lazy resolver for the moderation provider's API key. Invoked only when the
1073
+ * gate actually proceeds (policy active, provider supports moderation, text
1074
+ * non-empty, service present) — so callers never pay for (or fail on) key
1075
+ * resolution on the common skip paths.
1076
+ */
1077
+ resolveApiKey: () => string | null
1078
+ baseURL?: string
1079
+ moderationModel?: string
1080
+ perAgentOverride?: boolean | null
1081
+ tenantWideOverride?: boolean | null
1082
+ env?: EnvLookup
1083
+ /**
1084
+ * Best-effort side effect invoked with the flagged categories right before
1085
+ * the gate throws {@link AiModerationBlockedError}. Phase 3 wires the audit
1086
+ * insert + event emit here; it MUST NOT throw (failures are swallowed by the
1087
+ * caller) and MUST NOT block the rejection.
1088
+ */
1089
+ onFlagged?: (categories: Record<string, { flagged: boolean; score: number }>) => void | Promise<void>
1090
+ }
1091
+
1092
+ /**
1093
+ * Pre-loop input moderation gate. Runs the moderation check for the current
1094
+ * user turn before the model call. Decoupled from the registry/container so it
1095
+ * is unit-testable: the caller resolves the {@link ModerationService} and API
1096
+ * key. Throws {@link AiModerationBlockedError} when flagged; honors
1097
+ * fail-closed (enforced) vs fail-open (opt-in) on endpoint unavailability.
1098
+ */
1099
+ export async function runInputModerationGate(params: InputModerationGateParams): Promise<void> {
1100
+ const policy = resolveModerationPolicy({
1101
+ untrustedInput: params.untrustedInput,
1102
+ perAgentOverride: params.perAgentOverride,
1103
+ tenantWideOverride: params.tenantWideOverride,
1104
+ env: params.env,
1105
+ })
1106
+ if (!isModerationActive(policy)) return
1107
+ // The resolved chat provider has no moderation endpoint — rely on its own
1108
+ // server-side filtering and skip the gate.
1109
+ if (!params.supportsInputModeration) return
1110
+ const text = params.userText.trim()
1111
+ if (!text) return
1112
+
1113
+ const failClosed = shouldFailClosed(policy)
1114
+ const failOpenOrThrow = (reason: string, error?: unknown): void => {
1115
+ if (failClosed) {
1116
+ if (error instanceof AiModerationUnavailableError) throw error
1117
+ throw new AiModerationUnavailableError(reason, error)
1118
+ }
1119
+ logger.warn('Input moderation unavailable; failing open for opt-in surface', { reason })
1120
+ }
1121
+
1122
+ if (!params.service) {
1123
+ return failOpenOrThrow('moderation service is not available in the container')
1124
+ }
1125
+ const apiKey = params.resolveApiKey()
1126
+ if (!apiKey) {
1127
+ return failOpenOrThrow('no API key available for the moderation provider')
1128
+ }
1129
+
1130
+ let result
1131
+ try {
1132
+ result = await params.service.checkInput({
1133
+ text,
1134
+ apiKey,
1135
+ baseURL: params.baseURL,
1136
+ model: params.moderationModel,
1137
+ })
1138
+ } catch (error) {
1139
+ if (error instanceof AiModerationUnavailableError) {
1140
+ return failOpenOrThrow(error.message, error)
1141
+ }
1142
+ throw error
1143
+ }
1144
+
1145
+ if (result.flagged) {
1146
+ if (params.onFlagged) {
1147
+ try {
1148
+ await params.onFlagged(result.categories)
1149
+ } catch (error) {
1150
+ logger.error('Moderation flag side effect failed (rejection still applies)', { err: error })
1151
+ }
1152
+ }
1153
+ throw new AiModerationBlockedError(result.categories)
1154
+ }
1155
+ }
1156
+
1157
+ /**
1158
+ * Compute the tenant-salted end-user safety identifier for a chat turn,
1159
+ * best-effort. Returns undefined (never throws) so a derivation failure — e.g.
1160
+ * a missing base secret in a misconfigured environment — degrades to "no
1161
+ * identifier attached" instead of breaking the chat. Failures are logged.
1162
+ */
1163
+ function resolveEndUserIdentifier(authContext: AiChatRequestContext): string | undefined {
1164
+ try {
1165
+ return computeEndUserIdentifier(authContext.tenantId, authContext.userId)
1166
+ } catch (error) {
1167
+ logger.warn('Failed to derive end-user safety identifier', { err: error })
1168
+ return undefined
1001
1169
  }
1002
1170
  }
1003
1171
 
@@ -1198,6 +1366,44 @@ async function resolveRuntimeModelOverride(
1198
1366
  }
1199
1367
  }
1200
1368
 
1369
+ /**
1370
+ * Resolves the per-agent and tenant-wide `input_moderation` override values for
1371
+ * the moderation policy precedence. Best-effort and fail-open (returns nulls on
1372
+ * any error so the gate falls back to the env default). Returns the two values
1373
+ * separately because the precedence treats per-agent and tenant-wide as
1374
+ * distinct steps.
1375
+ */
1376
+ async function resolveModerationOverrideValues(
1377
+ container: AwilixContainer | undefined,
1378
+ tenantId: string | null,
1379
+ organizationId: string | null,
1380
+ agentId: string,
1381
+ ): Promise<{ perAgentOverride: boolean | null; tenantWideOverride: boolean | null }> {
1382
+ const empty = { perAgentOverride: null, tenantWideOverride: null }
1383
+ if (!tenantId || !container) return empty
1384
+ let em: EntityManager | null = null
1385
+ try {
1386
+ em = container.resolve<EntityManager>('em')
1387
+ } catch {
1388
+ em = null
1389
+ }
1390
+ if (!em) return empty
1391
+ try {
1392
+ const repo = new AiAgentRuntimeOverrideRepository(em)
1393
+ const [agentRow, tenantRow] = await Promise.all([
1394
+ repo.getExact({ tenantId, organizationId: organizationId ?? null, agentId }),
1395
+ repo.getDefault({ tenantId, organizationId: organizationId ?? null, agentId: null }),
1396
+ ])
1397
+ return {
1398
+ perAgentOverride: agentRow?.inputModeration ?? null,
1399
+ tenantWideOverride: tenantRow?.inputModeration ?? null,
1400
+ }
1401
+ } catch (error) {
1402
+ logger.warn('Moderation override lookup failed; falling back to env default', { agentId, err: error })
1403
+ return empty
1404
+ }
1405
+ }
1406
+
1201
1407
  /**
1202
1408
  * Normalizes simple `{ role, content }` chat messages into the AI SDK
1203
1409
  * `UIMessage` shape that `convertToModelMessages` requires. When the
@@ -1484,9 +1690,59 @@ export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Respon
1484
1690
  tenantRuntimeOverride,
1485
1691
  input.requestOverride,
1486
1692
  tenantAllowlistSnapshot,
1693
+ resolveEndUserIdentifier(input.authContext),
1487
1694
  )
1488
1695
  const { model } = resolvedModel
1696
+ const modelProviderOptions = resolvedModel.providerOptions
1489
1697
  const normalizedMessages = ensureUiMessageShape(input.messages)
1698
+
1699
+ // Pre-loop input moderation gate (spec 2026-06-04). Runs before the model
1700
+ // call on enabled/enforced surfaces where the provider supports moderation.
1701
+ // Throws AiModerationBlockedError (flagged) or AiModerationUnavailableError
1702
+ // (enforced + outage); both are surfaced by the SSE error path.
1703
+ let moderationService: ModerationService | null = null
1704
+ try {
1705
+ moderationService = input.container?.resolve<ModerationService>('moderationService') ?? null
1706
+ } catch {
1707
+ moderationService = null
1708
+ }
1709
+ // Read tenant overrides only when the provider can moderate and the agent is
1710
+ // not already enforced (untrustedInput short-circuits to enforced) — avoids a
1711
+ // DB round-trip on the common skip paths.
1712
+ const moderationOverrides =
1713
+ resolvedModel.supportsInputModeration && agent.untrustedInput !== true
1714
+ ? await resolveModerationOverrideValues(
1715
+ input.container,
1716
+ input.authContext.tenantId,
1717
+ input.authContext.organizationId,
1718
+ agent.id,
1719
+ )
1720
+ : { perAgentOverride: null, tenantWideOverride: null }
1721
+ await runInputModerationGate({
1722
+ untrustedInput: agent.untrustedInput,
1723
+ supportsInputModeration: resolvedModel.supportsInputModeration,
1724
+ perAgentOverride: moderationOverrides.perAgentOverride,
1725
+ tenantWideOverride: moderationOverrides.tenantWideOverride,
1726
+ userText: extractLatestUserText(normalizedMessages),
1727
+ service: moderationService,
1728
+ resolveApiKey: () => llmProviderRegistry.get(resolvedModel.providerId)?.resolveApiKey() ?? null,
1729
+ baseURL: resolvedModel.baseURL,
1730
+ moderationModel: process.env.OM_AI_MODERATION_MODEL,
1731
+ onFlagged: (categories) =>
1732
+ recordModerationFlag(
1733
+ {
1734
+ tenantId: input.authContext.tenantId,
1735
+ organizationId: input.authContext.organizationId,
1736
+ agentId: agent.id,
1737
+ userId: input.authContext.userId,
1738
+ providerId: resolvedModel.providerId,
1739
+ modelId: resolvedModel.modelId,
1740
+ categories,
1741
+ },
1742
+ input.container,
1743
+ ),
1744
+ })
1745
+
1490
1746
  const hydratedMessages = attachAttachmentsToMessages(normalizedMessages, resolvedAttachments)
1491
1747
  const modelMessages = await convertToModelMessages(hydratedMessages)
1492
1748
 
@@ -1579,6 +1835,7 @@ export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Respon
1579
1835
  : {}),
1580
1836
  ...(sdkActiveTools !== undefined ? { activeTools: sdkActiveTools } : {}),
1581
1837
  ...(sdkToolChoice !== undefined ? { toolChoice: sdkToolChoice } : {}),
1838
+ ...(modelProviderOptions !== undefined ? { providerOptions: modelProviderOptions as never } : {}),
1582
1839
  }
1583
1840
  builtToolLoopAgent = new ToolLoopAgent(agentSettings)
1584
1841
  }
@@ -1600,6 +1857,7 @@ export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Respon
1600
1857
  toolChoice: sdkToolChoice,
1601
1858
  abortSignal: abortController.signal,
1602
1859
  finalizeLoopTrace: () => loopTraceCollector.finalize(budgetEnforcer.abortReason),
1860
+ ...(modelProviderOptions !== undefined ? { providerOptions: modelProviderOptions } : {}),
1603
1861
  ...(builtToolLoopAgent !== undefined ? { toolLoopAgent: builtToolLoopAgent } : {}),
1604
1862
  }
1605
1863
 
@@ -1672,6 +1930,7 @@ export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Respon
1672
1930
  experimental_repairToolCall: effectiveLoop.repairToolCall as never,
1673
1931
  ...(sdkActiveTools !== undefined ? { activeTools: sdkActiveTools } : {}),
1674
1932
  ...(sdkToolChoice !== undefined ? { toolChoice: sdkToolChoice } : {}),
1933
+ ...(modelProviderOptions !== undefined ? { providerOptions: modelProviderOptions as never } : {}),
1675
1934
  abortSignal: abortController.signal,
1676
1935
  })
1677
1936
  if (wallClockTimer !== undefined) {
@@ -384,6 +384,16 @@ export interface AiAgentDefinition {
384
384
  uiParts?: string[]
385
385
  readOnly?: boolean
386
386
  mutationPolicy?: AiAgentMutationPolicy
387
+ /**
388
+ * Marks this agent as accepting untrusted end-user input (e.g. a customer
389
+ * portal or public-widget surface). When `true`, input pre-moderation is
390
+ * ENFORCED on every turn wherever the resolved chat provider supports it —
391
+ * tenants cannot disable it. Other agents default to off and are tenant
392
+ * opt-in. Additive + optional; omitting it preserves today's behavior.
393
+ *
394
+ * Spec `2026-06-04-ai-input-moderation-and-safety-identifiers`.
395
+ */
396
+ untrustedInput?: boolean
387
397
  /**
388
398
  * @deprecated Use `loop.maxSteps` instead. Honored as alias when `loop` is
389
399
  * omitted. When both `maxSteps` and `loop.maxSteps` are specified, `loop.maxSteps`
@@ -2,7 +2,7 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
2
2
  import type { AwilixContainer } from 'awilix'
3
3
  import type { EntityManager } from '@mikro-orm/postgresql'
4
4
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
5
- import { hasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'
5
+ import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
6
6
 
7
7
  const logger = createLogger('ai_assistant')
8
8
 
@@ -137,20 +137,11 @@ export function hasRequiredFeatures(
137
137
  isSuperAdmin: boolean,
138
138
  rbacService?: RbacService
139
139
  ): boolean {
140
- if (isSuperAdmin) return true
141
- if (!requiredFeatures?.length) return true
142
-
143
- // Delegate to RbacService if provided
144
- if (rbacService) {
145
- return rbacService.hasAllFeatures(requiredFeatures, userFeatures)
146
- }
147
-
148
- // Fallback for cases without rbacService: delegate to the canonical
149
- // wildcard-aware matcher so this path stays consistent with
150
- // RbacService.hasAllFeatures (which uses the same helper). The previous
151
- // bespoke loop rejected a bare-segment requirement (e.g. 'entities')
152
- // against an 'entities.*' grant, diverging from the canonical matcher.
153
- return hasAllFeatures(requiredFeatures, userFeatures)
140
+ void rbacService
141
+ return authorizeFeatures(requiredFeatures ?? [], {
142
+ grantedFeatures: userFeatures,
143
+ unrestricted: isSuperAdmin,
144
+ })
154
145
  }
155
146
 
156
147
  /**
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { ReferenceKind, type MikroORM } from '@mikro-orm/core'
9
9
  import type { PostgreSqlDriver } from '@mikro-orm/postgresql'
10
+ import { listEntityMetadata } from '@open-mercato/shared/lib/db/entityMetadata'
10
11
 
11
12
  /**
12
13
  * Relationship types mapped from MikroORM reference kinds.
@@ -86,13 +87,12 @@ function getSimpleTypeName(type: string | ((...args: unknown[]) => unknown) | un
86
87
  * Extract the entity graph from MikroORM metadata.
87
88
  */
88
89
  export async function extractEntityGraph(orm: MikroORM<PostgreSqlDriver>): Promise<EntityGraph> {
89
- const metadata = orm.getMetadata()
90
- const allMetadata = metadata.getAll()
90
+ const allMetadata = listEntityMetadata(orm)
91
91
 
92
92
  const nodes: EntityNode[] = []
93
93
  const edges: EntityTriple[] = []
94
94
 
95
- for (const [, entityMeta] of Object.entries(allMetadata)) {
95
+ for (const entityMeta of allMetadata) {
96
96
  // Skip abstract entities and embeddables
97
97
  if (entityMeta.abstract || entityMeta.embeddable) continue
98
98
 
@@ -90,6 +90,16 @@ export function createAnthropicAdapter(): LlmProvider {
90
90
  return envKeys[0]
91
91
  },
92
92
 
93
+ mapEndUserIdentifier(identifier: string): Record<string, unknown> {
94
+ // Anthropic Messages API accepts `metadata.user_id` so abuse enforcement
95
+ // can target a single hashed end user instead of the org key. The key MUST
96
+ // be the AI SDK provider-option name `userId` (camelCase) — the SDK
97
+ // translates it to the `metadata.user_id` request-body field and strips
98
+ // unknown providerOptions keys, so a snake_case key would be dropped and
99
+ // never reach Anthropic.
100
+ return { anthropic: { metadata: { userId: identifier } } }
101
+ },
102
+
93
103
  createModel(options: LlmCreateModelOptions): unknown {
94
104
  const anthropic = createAnthropic({
95
105
  apiKey: options.apiKey,
@@ -62,6 +62,23 @@ export interface OpenAICompatiblePreset {
62
62
  * The first non-empty value wins and overrides {@link baseURL}.
63
63
  */
64
64
  baseURLEnvKeys?: readonly string[]
65
+ /**
66
+ * When `true`, the provider exposes OpenAI's `/v1/moderations` endpoint and
67
+ * the runtime may run input pre-moderation through it. Only the native
68
+ * `openai` preset sets this — OpenAI-compatible backends (Groq, DeepInfra,
69
+ * Together, …) do not implement the moderations endpoint, so they leave it
70
+ * unset and rely on their own server-side filtering.
71
+ */
72
+ supportsInputModeration?: boolean
73
+ /**
74
+ * When `true`, the provider maps the runtime end-user safety identifier into
75
+ * OpenAI's per-request `safetyIdentifier` provider option. Only the native
76
+ * `openai` preset sets this — `safety_identifier` is an OpenAI-specific body
77
+ * field, and OpenAI-compatible/self-hosted backends (Groq, DeepInfra, Azure,
78
+ * Ollama, LM Studio, …) may reject the unknown parameter, so they leave it
79
+ * unset and send no identifier (matching pre-feature behavior).
80
+ */
81
+ supportsEndUserSafetyIdentifier?: boolean
65
82
  /**
66
83
  * True when this gateway's model ids are `vendor/model` strings (OpenRouter,
67
84
  * Requesty, LiteLLM). Surfaced on the resolved `LlmProvider` so the model
@@ -116,12 +133,13 @@ export function createOpenAICompatibleProvider(
116
133
  return preset.baseURL
117
134
  }
118
135
 
119
- return {
136
+ const provider: LlmProvider = {
120
137
  id: preset.id,
121
138
  name: preset.name,
122
139
  envKeys: preset.envKeys,
123
140
  defaultModel: preset.defaultModel,
124
141
  defaultModels: preset.defaultModels,
142
+ supportsInputModeration: preset.supportsInputModeration ?? false,
125
143
  usesVendorPrefixedModelIds: preset.usesVendorPrefixedModelIds ?? false,
126
144
 
127
145
  isConfigured(env?: EnvLookup): boolean {
@@ -151,4 +169,17 @@ export function createOpenAICompatibleProvider(
151
169
  return openai(options.modelId)
152
170
  },
153
171
  }
172
+
173
+ // Only the native OpenAI preset maps the end-user safety identifier. The key
174
+ // MUST be the AI SDK provider-option name `safetyIdentifier` (camelCase) — the
175
+ // SDK translates it to the `safety_identifier` request-body field and strips
176
+ // any unknown providerOptions keys, so a snake_case key would be silently
177
+ // dropped and never reach OpenAI.
178
+ if (preset.supportsEndUserSafetyIdentifier) {
179
+ provider.mapEndUserIdentifier = (identifier: string): Record<string, unknown> => ({
180
+ openai: { safetyIdentifier: identifier },
181
+ })
182
+ }
183
+
184
+ return provider
154
185
  }
@@ -217,6 +217,16 @@ export interface AiModelFactoryInput {
217
217
  * intersecting at resolution time.
218
218
  */
219
219
  tenantAllowlist?: TenantAllowlistSnapshot | null
220
+ /**
221
+ * Optional opaque end-user identifier (tenant-salted HMAC). When set and the
222
+ * resolved provider implements {@link LlmProvider.mapEndUserIdentifier}, the
223
+ * factory returns the provider-specific `providerOptions` fragment on the
224
+ * resolution so the runtime can attach it to the model call. No-op for
225
+ * providers without a mapping or when no identifier is supplied.
226
+ *
227
+ * @see .ai/specs/2026-06-04-ai-input-moderation-and-safety-identifiers.md
228
+ */
229
+ endUserIdentifier?: string
220
230
  }
221
231
 
222
232
  /**
@@ -268,6 +278,20 @@ export interface AiModelResolution {
268
278
  originalModelId: string
269
279
  reason: string
270
280
  }
281
+ /**
282
+ * Provider-specific `providerOptions` fragment produced by mapping the input
283
+ * {@link AiModelFactoryInput.endUserIdentifier} through the resolved
284
+ * provider's {@link LlmProvider.mapEndUserIdentifier}. Undefined when no
285
+ * identifier was supplied or the provider has no mapping. The runtime merges
286
+ * this into the AI SDK `providerOptions` for the model call.
287
+ */
288
+ providerOptions?: Record<string, unknown>
289
+ /**
290
+ * Mirror of the resolved provider's {@link LlmProvider.supportsInputModeration}
291
+ * capability flag. The runtime uses this to decide whether to run the input
292
+ * pre-moderation gate before the model call.
293
+ */
294
+ supportsInputModeration?: boolean
271
295
  }
272
296
 
273
297
  /**
@@ -776,17 +800,26 @@ export function createModelFactory(
776
800
  )
777
801
  }
778
802
 
803
+ const endUserIdentifier = normalizeOverride(input.endUserIdentifier)
804
+ const providerOptions =
805
+ endUserIdentifier && finalProvider.mapEndUserIdentifier
806
+ ? finalProvider.mapEndUserIdentifier(endUserIdentifier)
807
+ : undefined
808
+
779
809
  const model = finalProvider.createModel({
780
810
  modelId: finalModelId,
781
811
  apiKey: finalApiKey,
782
812
  baseURL: resolvedBaseURL,
813
+ ...(endUserIdentifier ? { endUserIdentifier } : {}),
783
814
  })
784
815
  return {
785
816
  model,
786
817
  modelId: finalModelId,
787
818
  providerId: finalProvider.id,
788
819
  source: finalSource,
820
+ supportsInputModeration: finalProvider.supportsInputModeration === true,
789
821
  ...(resolvedBaseURL !== undefined ? { baseURL: resolvedBaseURL } : {}),
822
+ ...(providerOptions !== undefined ? { providerOptions } : {}),
790
823
  ...(allowlistResult.fallback
791
824
  ? {
792
825
  allowlistFallback: {
@@ -0,0 +1,75 @@
1
+ import type { AwilixContainer } from 'awilix'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { createLogger } from '@open-mercato/shared/lib/logger'
4
+ import { AiModerationFlagRepository } from '../data/repositories/AiModerationFlagRepository'
5
+ import { emitAiAssistantEvent } from '../events'
6
+ import type { ModerationCategoryResult } from './moderation'
7
+
8
+ const logger = createLogger('ai_assistant')
9
+
10
+ export interface RecordModerationFlagInput {
11
+ tenantId: string | null
12
+ organizationId: string | null
13
+ agentId: string
14
+ userId: string
15
+ providerId: string
16
+ modelId: string
17
+ categories: Record<string, ModerationCategoryResult>
18
+ }
19
+
20
+ /**
21
+ * Persists one `ai_moderation_flags` audit row and emits
22
+ * `ai_assistant.moderation_flag.created`, best-effort.
23
+ *
24
+ * SAFETY CONTRACT: this MUST NEVER throw — the moderation rejection is the
25
+ * primary effect and is thrown by the gate regardless. A missing tenant scope
26
+ * (e.g. a system-scope caller) means the row cannot be tenant-scoped, so the
27
+ * audit write is skipped. Any persistence/emit failure is logged and
28
+ * swallowed so the user still receives the rejection.
29
+ *
30
+ * Spec `2026-06-04-ai-input-moderation-and-safety-identifiers`.
31
+ */
32
+ export async function recordModerationFlag(
33
+ input: RecordModerationFlagInput,
34
+ container: AwilixContainer | undefined,
35
+ ): Promise<void> {
36
+ if (!container) return
37
+ if (!input.tenantId) return
38
+
39
+ let createdId: string | null = null
40
+ try {
41
+ const em = container.resolve<EntityManager>('em')
42
+ const repo = new AiModerationFlagRepository(em.fork())
43
+ const flag = await repo.create({
44
+ tenantId: input.tenantId,
45
+ organizationId: input.organizationId,
46
+ agentId: input.agentId,
47
+ userId: input.userId,
48
+ providerId: input.providerId,
49
+ modelId: input.modelId,
50
+ categories: input.categories,
51
+ })
52
+ createdId = flag.id
53
+ } catch (error) {
54
+ logger.error('Failed to persist moderation flag (rejection still applies)', { err: error })
55
+ return
56
+ }
57
+
58
+ // Emit AFTER a successful write, detached so a failed emit never retroactively
59
+ // fails the audit insert.
60
+ try {
61
+ const flaggedCategories = Object.entries(input.categories)
62
+ .filter(([, value]) => value.flagged)
63
+ .map(([name]) => name)
64
+ await emitAiAssistantEvent('ai_assistant.moderation_flag.created', {
65
+ id: createdId,
66
+ tenantId: input.tenantId,
67
+ organizationId: input.organizationId,
68
+ agentId: input.agentId,
69
+ userId: input.userId,
70
+ categories: flaggedCategories,
71
+ })
72
+ } catch (error) {
73
+ logger.error('Failed to emit moderation_flag.created (audit row persisted)', { err: error })
74
+ }
75
+ }