@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
@@ -36,21 +36,44 @@
36
36
  "ai_assistant.agents.meta.readOnlyYes": "Tak",
37
37
  "ai_assistant.agents.meta.unlimited": "Bez limitu",
38
38
  "ai_assistant.agents.model_override.allowlistCustom": "niestandardowe",
39
+ "ai_assistant.agents.model_override.allowlistEmpty": "Brak skonfigurowanych dostawców dostępnych do nadpisań w czacie.",
40
+ "ai_assistant.agents.model_override.allowlistHelp": "Ogranicz, które nadpisania dostawcy i modelu użytkownicy mogą wybrać w stopce czatu dla tego agenta.",
39
41
  "ai_assistant.agents.model_override.allowlistInherited": "dziedziczone",
40
42
  "ai_assistant.agents.model_override.allowlistReset": "Dziedzicz",
41
43
  "ai_assistant.agents.model_override.allowlistSave": "Zapisz wybór",
44
+ "ai_assistant.agents.model_override.allowlistSaved": "Zapisano dozwolone opcje nadpisań w czacie.",
45
+ "ai_assistant.agents.model_override.allowlistTitle": "Dozwolone opcje nadpisań w czacie",
42
46
  "ai_assistant.agents.model_override.anyProvider": "pierwszy skonfigurowany",
43
47
  "ai_assistant.agents.model_override.clear": "Wyczyść nadpisanie",
48
+ "ai_assistant.agents.model_override.cleared": "Nadpisanie modelu zostało wyczyszczone; agent korzysta ze standardowego łańcucha rozwiązywania.",
44
49
  "ai_assistant.agents.model_override.codeDefault": "Domyślne z kodu",
45
50
  "ai_assistant.agents.model_override.defaultBadge": "domyślne",
51
+ "ai_assistant.agents.model_override.envAlsoNarrows": "dodatkowo zawęża tę listę na podstawie zmiennych środowiskowych.",
52
+ "ai_assistant.agents.model_override.loadErrorTitle": "Nie udało się wczytać katalogu dostawców",
53
+ "ai_assistant.agents.model_override.loading": "Wczytywanie katalogu dostawców…",
46
54
  "ai_assistant.agents.model_override.model": "Model",
47
55
  "ai_assistant.agents.model_override.noOverride": "Brak nadpisania dla agenta",
48
56
  "ai_assistant.agents.model_override.provider": "Dostawca",
49
57
  "ai_assistant.agents.model_override.providerDefault": "domyślne dostawcy",
50
58
  "ai_assistant.agents.model_override.save": "Zapisz nadpisanie",
51
59
  "ai_assistant.agents.model_override.saved": "Nadpisanie modelu zapisane.",
60
+ "ai_assistant.agents.model_override.selectModel": "Wybierz model",
61
+ "ai_assistant.agents.model_override.selectProvider": "Wybierz dostawcę",
62
+ "ai_assistant.agents.model_override.subtitle": "Nadpisz domyślnego dostawcę i model dla tego najemcy i agenta.",
52
63
  "ai_assistant.agents.model_override.tenantOverride": "Nadpisanie tenant",
53
64
  "ai_assistant.agents.model_override.title": "Dostawca i model",
65
+ "ai_assistant.agents.moderation.controlLabel": "Moderacja",
66
+ "ai_assistant.agents.moderation.effectiveEnforced": "Wymuszona",
67
+ "ai_assistant.agents.moderation.effectiveOff": "Wyłączona",
68
+ "ai_assistant.agents.moderation.effectiveOn": "Włączona",
69
+ "ai_assistant.agents.moderation.enforcedBadge": "Wymuszona",
70
+ "ai_assistant.agents.moderation.enforcedHint": "Ten agent przyjmuje niezaufane dane wejściowe, więc moderacja jest zawsze włączona i nie można jej tutaj wyłączyć.",
71
+ "ai_assistant.agents.moderation.inheritLabel": "Dziedzicz",
72
+ "ai_assistant.agents.moderation.offLabel": "Wyłączona",
73
+ "ai_assistant.agents.moderation.onLabel": "Włączona",
74
+ "ai_assistant.agents.moderation.saved": "Zapisano ustawienie moderacji danych wejściowych.",
75
+ "ai_assistant.agents.moderation.subtitle": "Filtruj dane wejściowe użytkownika pod kątem niebezpiecznych treści przed uruchomieniem modelu. Działa tylko, gdy wybrany dostawca obsługuje moderację.",
76
+ "ai_assistant.agents.moderation.title": "Moderacja danych wejściowych",
54
77
  "ai_assistant.agents.mutation_policy.clear": "Wyczyść nadpisanie",
55
78
  "ai_assistant.agents.mutation_policy.clearedMessage": "Nadpisanie polityki mutacji usunięte; agent korzysta z polityki zadeklarowanej w kodzie.",
56
79
  "ai_assistant.agents.mutation_policy.codeDeclared": "Zadeklarowane w kodzie",
@@ -275,7 +298,9 @@
275
298
  "ai_assistant.chat.toolRunning": "w toku…",
276
299
  "ai_assistant.chat.transcriptLabel": "Transkrypcja czatu",
277
300
  "ai_assistant.chat.uiPartPending": "Oczekująca część UI:",
301
+ "ai_assistant.chat.uploadingHint": "Trwa przesyłanie załączników… Wysyłanie jest zablokowane do zakończenia.",
278
302
  "ai_assistant.chat.userRoleLabel": "Ty",
303
+ "ai_assistant.chat.welcomeDescription": "Zapytaj mnie o cokolwiek z Twoich danych. Oto kilka rzeczy, które potrafię:",
279
304
  "ai_assistant.chat.welcomeTitle": "W czym mogę pomóc?",
280
305
  "ai_assistant.chat.zoomImage": "Otwórz podgląd {name}",
281
306
  "ai_assistant.dock.bottom": "Dokuj na dole",
@@ -284,13 +309,17 @@
284
309
  "ai_assistant.dock.left": "Dokuj po lewej",
285
310
  "ai_assistant.dock.minimize": "Minimalizuj",
286
311
  "ai_assistant.dock.right": "Dokuj po prawej",
312
+ "ai_assistant.errors.moderationBlocked": "Twoja wiadomość została zablokowana przez filtr bezpieczeństwa treści. Przeformułuj ją i spróbuj ponownie.",
313
+ "ai_assistant.errors.moderationUnavailable": "Sprawdzanie bezpieczeństwa treści jest chwilowo niedostępne. Spróbuj ponownie za chwilę.",
287
314
  "ai_assistant.launcher.composerPlaceholder": "Zapytaj o cokolwiek…",
315
+ "ai_assistant.launcher.dialogDescription": "Wybierz asystenta. Nawiguj klawiszami ↑/↓, Enter uruchamia, Esc zamyka.",
288
316
  "ai_assistant.launcher.dialogTitle": "Asystenci AI",
289
317
  "ai_assistant.launcher.dock.subtitle": "Asystent AI",
290
318
  "ai_assistant.launcher.empty": "Żaden asystent nie pasuje do wyszukiwania.",
291
319
  "ai_assistant.launcher.hint.close": "Zamknij",
292
320
  "ai_assistant.launcher.hint.launch": "Uruchom",
293
321
  "ai_assistant.launcher.hint.navigate": "Nawigacja",
322
+ "ai_assistant.launcher.none": "Dla Twojego konta nie są dostępni żadni asystenci.",
294
323
  "ai_assistant.launcher.searchPlaceholder": "Szukaj asystentów...",
295
324
  "ai_assistant.launcher.setup.body": "Asystenci AI są zainstalowani, ale nie skonfigurowano klucza dostawcy. Ustaw OM_AI_PROVIDER oraz pasujący klucz API w pliku .env, a następnie uruchom aplikację ponownie.",
296
325
  "ai_assistant.launcher.setup.docs": "Dokumentacja asystenta AI",
@@ -299,6 +328,7 @@
299
328
  "ai_assistant.launcher.sheet.dock": "Przypnij z boku",
300
329
  "ai_assistant.launcher.triggerAriaLabel": "Otwórz asystenta AI",
301
330
  "ai_assistant.launcher.triggerLabel": "AI",
331
+ "ai_assistant.launcher.welcome.fallback": "W czym mogę pomóc?",
302
332
  "ai_assistant.launcher.welcome.suggestion1": "W czym możesz mi pomóc?",
303
333
  "ai_assistant.launcher.welcome.suggestion2": "Pokaż, do jakich danych masz dostęp",
304
334
  "ai_assistant.launcher.welcome.suggestion3": "Zaproponuj, co mogę wypróbować",
@@ -326,6 +356,18 @@
326
356
  "ai_assistant.modelPicker.triggerAriaLabel": "Wybierz model AI",
327
357
  "ai_assistant.modelPicker.useDefault": "Użyj domyślnego agenta",
328
358
  "ai_assistant.modelPicker.useDefaultWithModel": "Użyj domyślnego agenta: {{model}}",
359
+ "ai_assistant.moderationFlags.columns.agent": "Agent",
360
+ "ai_assistant.moderationFlags.columns.categories": "Kategorie",
361
+ "ai_assistant.moderationFlags.columns.createdAt": "Oznaczono",
362
+ "ai_assistant.moderationFlags.columns.user": "Użytkownik",
363
+ "ai_assistant.moderationFlags.empty": "Brak oznaczonych wiadomości.",
364
+ "ai_assistant.moderationFlags.filters.agentPlaceholder": "moduł.agent",
365
+ "ai_assistant.moderationFlags.filters.apply": "Zastosuj",
366
+ "ai_assistant.moderationFlags.filters.from": "Od",
367
+ "ai_assistant.moderationFlags.filters.to": "Do",
368
+ "ai_assistant.moderationFlags.navTitle": "Flagi moderacji",
369
+ "ai_assistant.moderationFlags.subtitle": "Dane wejściowe zablokowane przez filtr bezpieczeństwa treści. Tylko flagi kategorii i wyniki — treść promptu nie jest przechowywana.",
370
+ "ai_assistant.moderationFlags.title": "Flagi moderacji",
329
371
  "ai_assistant.notifications.conversation_shared.body": "Udostępniono Ci konwersację z asystentem AI.",
330
372
  "ai_assistant.notifications.conversation_shared.title": "Udostępniono Ci konwersację",
331
373
  "ai_assistant.notifications.conversation_shared.view_button": "Otwórz konwersację",
@@ -137,7 +137,7 @@ describe('hasRequiredFeatures', () => {
137
137
  ).toBe(true)
138
138
  })
139
139
 
140
- it('delegates to rbacService when provided', () => {
140
+ it('uses the shared policy even when a legacy rbacService argument is provided', () => {
141
141
  const rbacService = {
142
142
  hasAllFeatures: jest.fn().mockReturnValue(true),
143
143
  }
@@ -148,9 +148,6 @@ describe('hasRequiredFeatures', () => {
148
148
  rbacService as any
149
149
  )
150
150
  expect(result).toBe(true)
151
- expect(rbacService.hasAllFeatures).toHaveBeenCalledWith(
152
- ['customers.view'],
153
- ['customers.view']
154
- )
151
+ expect(rbacService.hasAllFeatures).not.toHaveBeenCalled()
155
152
  })
156
153
  })
@@ -0,0 +1,121 @@
1
+ import { extractLatestUserText, runInputModerationGate } from '../agent-runtime'
2
+ import {
3
+ AiModerationBlockedError,
4
+ AiModerationUnavailableError,
5
+ type ModerationResult,
6
+ type ModerationService,
7
+ } from '../moderation'
8
+
9
+ function serviceReturning(result: ModerationResult): ModerationService {
10
+ return { checkInput: jest.fn(async () => result) }
11
+ }
12
+ function serviceThrowing(error: unknown): ModerationService {
13
+ return { checkInput: jest.fn(async () => { throw error }) }
14
+ }
15
+
16
+ const CLEAN: ModerationResult = { flagged: false, categories: { hate: { flagged: false, score: 0.01 } } }
17
+ const FLAGGED: ModerationResult = { flagged: true, categories: { hate: { flagged: true, score: 0.98 } } }
18
+
19
+ const base = {
20
+ supportsInputModeration: true,
21
+ userText: 'hello there',
22
+ resolveApiKey: () => 'sk-test' as string | null,
23
+ env: {} as Record<string, string | undefined>,
24
+ }
25
+
26
+ describe('extractLatestUserText', () => {
27
+ it('returns the text parts of the most recent user message', () => {
28
+ const messages = [
29
+ { role: 'user', parts: [{ type: 'text', text: 'first' }] },
30
+ { role: 'assistant', parts: [{ type: 'text', text: 'reply' }] },
31
+ { role: 'user', parts: [{ type: 'text', text: 'second' }, { type: 'text', text: 'line' }] },
32
+ ] as never
33
+ expect(extractLatestUserText(messages)).toBe('second\nline')
34
+ })
35
+
36
+ it('falls back to string content and returns empty when no user message', () => {
37
+ expect(extractLatestUserText([{ role: 'user', content: 'plain' }] as never)).toBe('plain')
38
+ expect(extractLatestUserText([{ role: 'assistant', parts: [{ type: 'text', text: 'x' }] }] as never)).toBe('')
39
+ })
40
+ })
41
+
42
+ describe('runInputModerationGate', () => {
43
+ it('skips entirely when policy resolves to off (no untrusted, no override, no env)', async () => {
44
+ const service = serviceReturning(FLAGGED)
45
+ await expect(runInputModerationGate({ ...base, service })).resolves.toBeUndefined()
46
+ expect(service.checkInput).not.toHaveBeenCalled()
47
+ })
48
+
49
+ it('skips when the provider does not support moderation, even on an enforced surface', async () => {
50
+ const service = serviceReturning(FLAGGED)
51
+ await expect(
52
+ runInputModerationGate({ ...base, untrustedInput: true, supportsInputModeration: false, service }),
53
+ ).resolves.toBeUndefined()
54
+ expect(service.checkInput).not.toHaveBeenCalled()
55
+ })
56
+
57
+ it('skips when the user text is empty', async () => {
58
+ const service = serviceReturning(FLAGGED)
59
+ await expect(
60
+ runInputModerationGate({ ...base, untrustedInput: true, userText: ' ', service }),
61
+ ).resolves.toBeUndefined()
62
+ expect(service.checkInput).not.toHaveBeenCalled()
63
+ })
64
+
65
+ it('passes clean input through on an enabled surface', async () => {
66
+ const service = serviceReturning(CLEAN)
67
+ await expect(
68
+ runInputModerationGate({ ...base, perAgentOverride: true, service }),
69
+ ).resolves.toBeUndefined()
70
+ expect(service.checkInput).toHaveBeenCalledTimes(1)
71
+ })
72
+
73
+ it('throws AiModerationBlockedError (with categories) on flagged input', async () => {
74
+ const service = serviceReturning(FLAGGED)
75
+ const error = await runInputModerationGate({ ...base, untrustedInput: true, service }).catch((e) => e)
76
+ expect(error).toBeInstanceOf(AiModerationBlockedError)
77
+ expect((error as AiModerationBlockedError).categories.hate.flagged).toBe(true)
78
+ })
79
+
80
+ it('runs the onFlagged side effect before throwing, and the throw still applies if it fails', async () => {
81
+ const service = serviceReturning(FLAGGED)
82
+ const onFlagged = jest.fn(async () => { throw new Error('db down') })
83
+ const error = await runInputModerationGate({ ...base, untrustedInput: true, service, onFlagged }).catch((e) => e)
84
+ expect(onFlagged).toHaveBeenCalledWith(FLAGGED.categories)
85
+ expect(error).toBeInstanceOf(AiModerationBlockedError)
86
+ })
87
+
88
+ it('fails CLOSED on an enforced surface when the endpoint is unavailable', async () => {
89
+ const service = serviceThrowing(new AiModerationUnavailableError('endpoint down'))
90
+ await expect(
91
+ runInputModerationGate({ ...base, untrustedInput: true, service }),
92
+ ).rejects.toBeInstanceOf(AiModerationUnavailableError)
93
+ })
94
+
95
+ it('fails OPEN on an opt-in (on) surface when the endpoint is unavailable', async () => {
96
+ const service = serviceThrowing(new AiModerationUnavailableError('endpoint down'))
97
+ await expect(
98
+ runInputModerationGate({ ...base, perAgentOverride: true, service }),
99
+ ).resolves.toBeUndefined()
100
+ })
101
+
102
+ it('fails CLOSED on enforced when the service is missing from the container', async () => {
103
+ await expect(
104
+ runInputModerationGate({ ...base, untrustedInput: true, service: null }),
105
+ ).rejects.toBeInstanceOf(AiModerationUnavailableError)
106
+ })
107
+
108
+ it('fails OPEN on opt-in when the API key is missing', async () => {
109
+ const service = serviceReturning(CLEAN)
110
+ await expect(
111
+ runInputModerationGate({ ...base, perAgentOverride: true, resolveApiKey: () => null, service }),
112
+ ).resolves.toBeUndefined()
113
+ expect(service.checkInput).not.toHaveBeenCalled()
114
+ })
115
+
116
+ it('does not resolve the API key on the common skip path (policy off)', async () => {
117
+ const resolveApiKey = jest.fn(() => 'sk-test' as string | null)
118
+ await expect(runInputModerationGate({ ...base, resolveApiKey, service: serviceReturning(CLEAN) })).resolves.toBeUndefined()
119
+ expect(resolveApiKey).not.toHaveBeenCalled()
120
+ })
121
+ })
@@ -87,4 +87,17 @@ describe('AnthropicAdapter', () => {
87
87
  })
88
88
  expect(model).toBeDefined()
89
89
  })
90
+
91
+ it('maps an end-user identifier into the Anthropic metadata.userId providerOptions fragment', () => {
92
+ // Key MUST be the AI SDK provider-option name `userId` (camelCase); the SDK
93
+ // translates it to the `metadata.user_id` request-body field and strips
94
+ // unknown providerOptions keys, so a snake_case key would be dropped.
95
+ expect(adapter.mapEndUserIdentifier?.('hashed-id')).toEqual({
96
+ anthropic: { metadata: { userId: 'hashed-id' } },
97
+ })
98
+ })
99
+
100
+ it('does not advertise input moderation support (Anthropic has no moderation endpoint)', () => {
101
+ expect(adapter.supportsInputModeration).toBeFalsy()
102
+ })
90
103
  })
@@ -330,3 +330,44 @@ describe('LM Studio preset', () => {
330
330
  expect(model).toBeDefined()
331
331
  })
332
332
  })
333
+
334
+ describe('OpenAIAdapter — safety identifier + moderation capability', () => {
335
+ it('maps an end-user identifier into the OpenAI safetyIdentifier providerOptions fragment', () => {
336
+ // Key MUST be the AI SDK provider-option name `safetyIdentifier` (camelCase);
337
+ // the SDK translates it to the `safety_identifier` request-body field and
338
+ // strips unknown providerOptions keys, so a snake_case key would be dropped.
339
+ const openaiPreset = OPENAI_COMPATIBLE_PRESETS.find((preset) => preset.id === 'openai')!
340
+ const provider = createOpenAICompatibleProvider(openaiPreset)
341
+ expect(provider.mapEndUserIdentifier?.('hashed-id')).toEqual({
342
+ openai: { safetyIdentifier: 'hashed-id' },
343
+ })
344
+ })
345
+
346
+ it('does not map a safety identifier for OpenAI-compatible backends (OpenAI-only param)', () => {
347
+ // `safety_identifier` is an OpenAI-specific body field; compatible/self-hosted
348
+ // backends (Groq, DeepInfra, Azure, Ollama, …) may reject the unknown param,
349
+ // so they must not advertise the mapping.
350
+ const provider = createOpenAICompatibleProvider(DEEPINFRA_PRESET)
351
+ expect(provider.mapEndUserIdentifier).toBeUndefined()
352
+ for (const preset of OPENAI_COMPATIBLE_PRESETS) {
353
+ const built = createOpenAICompatibleProvider(preset)
354
+ if (preset.id === 'openai') {
355
+ expect(built.mapEndUserIdentifier).toBeDefined()
356
+ } else {
357
+ expect(built.mapEndUserIdentifier).toBeUndefined()
358
+ }
359
+ }
360
+ })
361
+
362
+ it('marks the native OpenAI preset as supporting input moderation', () => {
363
+ const openaiPreset = OPENAI_COMPATIBLE_PRESETS.find((preset) => preset.id === 'openai')
364
+ expect(openaiPreset).toBeDefined()
365
+ const provider = createOpenAICompatibleProvider(openaiPreset!)
366
+ expect(provider.supportsInputModeration).toBe(true)
367
+ })
368
+
369
+ it('leaves OpenAI-compatible backends without moderation support', () => {
370
+ const provider = createOpenAICompatibleProvider(DEEPINFRA_PRESET)
371
+ expect(provider.supportsInputModeration).toBe(false)
372
+ })
373
+ })
@@ -1216,6 +1216,66 @@ describe('Phase 4a — tenantOverride, requestOverride, allowRuntimeOverride (re
1216
1216
  })
1217
1217
  })
1218
1218
 
1219
+ describe('createModelFactory — end-user safety identifier + moderation capability', () => {
1220
+ // The shared makeProvider helper lists fields explicitly and does not carry
1221
+ // the optional mapEndUserIdentifier / supportsInputModeration members, so we
1222
+ // build providers inline here.
1223
+ type IdentifierProvider = FakeProvider & {
1224
+ mapEndUserIdentifier?: (identifier: string) => Record<string, unknown>
1225
+ supportsInputModeration?: boolean
1226
+ }
1227
+ function makeIdentifierProvider(overrides: Partial<IdentifierProvider> = {}): IdentifierProvider {
1228
+ return {
1229
+ id: 'test-provider',
1230
+ defaultModel: 'provider-default-model',
1231
+ resolveApiKey: () => 'test-api-key',
1232
+ createModel: (options) => ({ kind: 'fake-model', ...options }),
1233
+ isConfigured: () => true,
1234
+ ...overrides,
1235
+ }
1236
+ }
1237
+
1238
+ it('emits the provider-mapped providerOptions fragment when an identifier is supplied', () => {
1239
+ const mapEndUserIdentifier = jest.fn((id: string) => ({ openai: { safetyIdentifier: id } }))
1240
+ const provider = makeIdentifierProvider({ mapEndUserIdentifier, supportsInputModeration: true })
1241
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider))
1242
+ const resolution = factory.resolveModel({ endUserIdentifier: 'hashed-user' })
1243
+ expect(mapEndUserIdentifier).toHaveBeenCalledWith('hashed-user')
1244
+ expect(resolution.providerOptions).toEqual({ openai: { safetyIdentifier: 'hashed-user' } })
1245
+ expect(resolution.supportsInputModeration).toBe(true)
1246
+ })
1247
+
1248
+ it('forwards the identifier to the adapter createModel options', () => {
1249
+ const createModel = jest.fn((options: { modelId: string; apiKey: string }) => ({ ...options }))
1250
+ const provider = makeIdentifierProvider({
1251
+ createModel,
1252
+ mapEndUserIdentifier: (id: string) => ({ openai: { safetyIdentifier: id } }),
1253
+ })
1254
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider))
1255
+ factory.resolveModel({ endUserIdentifier: 'hashed-user' })
1256
+ expect(createModel).toHaveBeenCalledWith(
1257
+ expect.objectContaining({ endUserIdentifier: 'hashed-user' }),
1258
+ )
1259
+ })
1260
+
1261
+ it('omits providerOptions when no identifier is supplied', () => {
1262
+ const mapEndUserIdentifier = jest.fn((id: string) => ({ openai: { safety_identifier: id } }))
1263
+ const provider = makeIdentifierProvider({ mapEndUserIdentifier })
1264
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider))
1265
+ const resolution = factory.resolveModel({})
1266
+ expect(mapEndUserIdentifier).not.toHaveBeenCalled()
1267
+ expect(resolution.providerOptions).toBeUndefined()
1268
+ })
1269
+
1270
+ it('omits providerOptions when the provider has no mapping, and reports moderation unsupported', () => {
1271
+ const provider = makeIdentifierProvider()
1272
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider))
1273
+ const resolution = factory.resolveModel({ endUserIdentifier: 'hashed-user' })
1274
+ expect(resolution.providerOptions).toBeUndefined()
1275
+ expect(resolution.supportsInputModeration).toBe(false)
1276
+ })
1277
+ })
1278
+
1219
1279
  describe('createModelFactory — vendor-prefix gateways', () => {
1220
1280
  function gatewayProviders(
1221
1281
  overrides: { openrouterConfigured?: boolean } = {},
@@ -0,0 +1,76 @@
1
+ import type { AwilixContainer } from 'awilix'
2
+
3
+ const createMock = jest.fn()
4
+ const emitMock = jest.fn()
5
+
6
+ jest.mock('../../events', () => ({
7
+ emitAiAssistantEvent: (...args: unknown[]) => emitMock(...args),
8
+ }))
9
+ jest.mock('../../data/repositories/AiModerationFlagRepository', () => ({
10
+ AiModerationFlagRepository: jest.fn().mockImplementation(() => ({ create: createMock })),
11
+ }))
12
+
13
+ import { recordModerationFlag } from '../moderation-flag-recorder'
14
+
15
+ function fakeContainer(): AwilixContainer {
16
+ return {
17
+ resolve: () => ({ fork: () => ({}) }),
18
+ } as unknown as AwilixContainer
19
+ }
20
+
21
+ const baseInput = {
22
+ tenantId: 'tenant-1',
23
+ organizationId: 'org-1',
24
+ agentId: 'customers.account_assistant',
25
+ userId: 'user-1',
26
+ providerId: 'openai',
27
+ modelId: 'gpt-5-mini',
28
+ categories: {
29
+ hate: { flagged: true, score: 0.97 },
30
+ violence: { flagged: false, score: 0.01 },
31
+ },
32
+ }
33
+
34
+ describe('recordModerationFlag', () => {
35
+ beforeEach(() => {
36
+ createMock.mockReset()
37
+ emitMock.mockReset()
38
+ createMock.mockResolvedValue({ id: 'flag-1' })
39
+ emitMock.mockResolvedValue(undefined)
40
+ })
41
+
42
+ it('persists the audit row and emits the event with only flagged categories', async () => {
43
+ await recordModerationFlag(baseInput, fakeContainer())
44
+ expect(createMock).toHaveBeenCalledWith(
45
+ expect.objectContaining({ tenantId: 'tenant-1', agentId: 'customers.account_assistant', providerId: 'openai' }),
46
+ )
47
+ expect(emitMock).toHaveBeenCalledWith(
48
+ 'ai_assistant.moderation_flag.created',
49
+ expect.objectContaining({ id: 'flag-1', tenantId: 'tenant-1', categories: ['hate'] }),
50
+ )
51
+ })
52
+
53
+ it('skips entirely when tenantId is null (cannot tenant-scope the audit row)', async () => {
54
+ await recordModerationFlag({ ...baseInput, tenantId: null }, fakeContainer())
55
+ expect(createMock).not.toHaveBeenCalled()
56
+ expect(emitMock).not.toHaveBeenCalled()
57
+ })
58
+
59
+ it('skips when no container is available', async () => {
60
+ await recordModerationFlag(baseInput, undefined)
61
+ expect(createMock).not.toHaveBeenCalled()
62
+ expect(emitMock).not.toHaveBeenCalled()
63
+ })
64
+
65
+ it('swallows a persistence failure (never throws) and does not emit', async () => {
66
+ createMock.mockRejectedValue(new Error('db down'))
67
+ await expect(recordModerationFlag(baseInput, fakeContainer())).resolves.toBeUndefined()
68
+ expect(emitMock).not.toHaveBeenCalled()
69
+ })
70
+
71
+ it('swallows an emit failure after a successful persist (never throws)', async () => {
72
+ emitMock.mockRejectedValue(new Error('bus down'))
73
+ await expect(recordModerationFlag(baseInput, fakeContainer())).resolves.toBeUndefined()
74
+ expect(createMock).toHaveBeenCalledTimes(1)
75
+ })
76
+ })
@@ -0,0 +1,86 @@
1
+ import {
2
+ isModerationActive,
3
+ resolveModerationPolicy,
4
+ shouldFailClosed,
5
+ } from '../moderation-policy'
6
+
7
+ describe('resolveModerationPolicy — 5-step precedence', () => {
8
+ it('1. untrustedInput wins over everything → enforced', () => {
9
+ expect(
10
+ resolveModerationPolicy({
11
+ untrustedInput: true,
12
+ perAgentOverride: false,
13
+ tenantWideOverride: false,
14
+ env: { OM_AI_INPUT_MODERATION: 'false' },
15
+ }),
16
+ ).toBe('enforced')
17
+ })
18
+
19
+ it('2. per-agent override wins over tenant-wide + env', () => {
20
+ expect(
21
+ resolveModerationPolicy({
22
+ perAgentOverride: true,
23
+ tenantWideOverride: false,
24
+ env: { OM_AI_INPUT_MODERATION: 'false' },
25
+ }),
26
+ ).toBe('on')
27
+ expect(
28
+ resolveModerationPolicy({
29
+ perAgentOverride: false,
30
+ tenantWideOverride: true,
31
+ env: { OM_AI_INPUT_MODERATION: 'true' },
32
+ }),
33
+ ).toBe('off')
34
+ })
35
+
36
+ it('3. tenant-wide override wins over env when per-agent is inherit', () => {
37
+ expect(
38
+ resolveModerationPolicy({
39
+ perAgentOverride: null,
40
+ tenantWideOverride: true,
41
+ env: { OM_AI_INPUT_MODERATION: 'false' },
42
+ }),
43
+ ).toBe('on')
44
+ expect(
45
+ resolveModerationPolicy({
46
+ perAgentOverride: undefined,
47
+ tenantWideOverride: false,
48
+ env: { OM_AI_INPUT_MODERATION: 'true' },
49
+ }),
50
+ ).toBe('off')
51
+ })
52
+
53
+ it('4. env default applies when both overrides inherit', () => {
54
+ expect(
55
+ resolveModerationPolicy({ perAgentOverride: null, tenantWideOverride: null, env: { OM_AI_INPUT_MODERATION: 'true' } }),
56
+ ).toBe('on')
57
+ expect(
58
+ resolveModerationPolicy({ env: { OM_AI_INPUT_MODERATION: '1' } }),
59
+ ).toBe('on')
60
+ })
61
+
62
+ it('5. defaults to off when nothing is set', () => {
63
+ expect(resolveModerationPolicy({ env: {} })).toBe('off')
64
+ expect(resolveModerationPolicy({ perAgentOverride: null, tenantWideOverride: null, env: {} })).toBe('off')
65
+ })
66
+
67
+ it('treats untrustedInput=false the same as unset (falls through)', () => {
68
+ expect(
69
+ resolveModerationPolicy({ untrustedInput: false, tenantWideOverride: true, env: {} }),
70
+ ).toBe('on')
71
+ })
72
+ })
73
+
74
+ describe('moderation policy helpers', () => {
75
+ it('isModerationActive is true for enforced and on, false for off', () => {
76
+ expect(isModerationActive('enforced')).toBe(true)
77
+ expect(isModerationActive('on')).toBe(true)
78
+ expect(isModerationActive('off')).toBe(false)
79
+ })
80
+
81
+ it('shouldFailClosed is true only for enforced (opt-in on fails open)', () => {
82
+ expect(shouldFailClosed('enforced')).toBe(true)
83
+ expect(shouldFailClosed('on')).toBe(false)
84
+ expect(shouldFailClosed('off')).toBe(false)
85
+ })
86
+ })
@@ -0,0 +1,114 @@
1
+ import {
2
+ AiModerationUnavailableError,
3
+ createModerationService,
4
+ DEFAULT_MODERATION_MODEL,
5
+ } from '../moderation'
6
+
7
+ function jsonResponse(body: unknown, status = 200): Response {
8
+ return {
9
+ ok: status >= 200 && status < 300,
10
+ status,
11
+ json: async () => body,
12
+ } as unknown as Response
13
+ }
14
+
15
+ function moderationBody(flagged: boolean): unknown {
16
+ return {
17
+ results: [
18
+ {
19
+ flagged,
20
+ categories: { hate: flagged, violence: false },
21
+ category_scores: { hate: flagged ? 0.97 : 0.01, violence: 0.002 },
22
+ },
23
+ ],
24
+ }
25
+ }
26
+
27
+ describe('createModerationService.checkInput', () => {
28
+ const service = createModerationService()
29
+ let fetchSpy: jest.SpyInstance
30
+
31
+ beforeEach(() => {
32
+ fetchSpy = jest.spyOn(globalThis, 'fetch')
33
+ })
34
+ afterEach(() => {
35
+ fetchSpy.mockRestore()
36
+ })
37
+
38
+ it('returns flagged=false with mapped categories for clean input', async () => {
39
+ fetchSpy.mockResolvedValue(jsonResponse(moderationBody(false)))
40
+ const result = await service.checkInput({ text: 'hello', apiKey: 'sk-test' })
41
+ expect(result.flagged).toBe(false)
42
+ expect(result.categories.hate).toEqual({ flagged: false, score: 0.01 })
43
+ expect(result.categories.violence).toEqual({ flagged: false, score: 0.002 })
44
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
45
+ })
46
+
47
+ it('returns flagged=true with category flags + scores for flagged input', async () => {
48
+ fetchSpy.mockResolvedValue(jsonResponse(moderationBody(true)))
49
+ const result = await service.checkInput({ text: 'bad', apiKey: 'sk-test' })
50
+ expect(result.flagged).toBe(true)
51
+ expect(result.categories.hate).toEqual({ flagged: true, score: 0.97 })
52
+ })
53
+
54
+ it('sends the configured model + base URL and bearer auth', async () => {
55
+ fetchSpy.mockResolvedValue(jsonResponse(moderationBody(false)))
56
+ await service.checkInput({
57
+ text: 'hi',
58
+ apiKey: 'sk-secret',
59
+ baseURL: 'https://proxy.example.com/v1/',
60
+ model: 'omni-moderation-2026',
61
+ })
62
+ const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]
63
+ expect(url).toBe('https://proxy.example.com/v1/moderations')
64
+ expect((init.headers as Record<string, string>).Authorization).toBe('Bearer sk-secret')
65
+ expect(JSON.parse(init.body as string)).toEqual({ model: 'omni-moderation-2026', input: 'hi' })
66
+ })
67
+
68
+ it('defaults to omni-moderation-latest when no model is supplied', async () => {
69
+ fetchSpy.mockResolvedValue(jsonResponse(moderationBody(false)))
70
+ await service.checkInput({ text: 'hi', apiKey: 'sk-test' })
71
+ const init = fetchSpy.mock.calls[0][1] as RequestInit
72
+ expect(JSON.parse(init.body as string).model).toBe(DEFAULT_MODERATION_MODEL)
73
+ })
74
+
75
+ it('retries once on a 5xx and succeeds', async () => {
76
+ fetchSpy
77
+ .mockResolvedValueOnce(jsonResponse({ error: 'boom' }, 503))
78
+ .mockResolvedValueOnce(jsonResponse(moderationBody(false)))
79
+ const result = await service.checkInput({ text: 'hi', apiKey: 'sk-test' })
80
+ expect(result.flagged).toBe(false)
81
+ expect(fetchSpy).toHaveBeenCalledTimes(2)
82
+ })
83
+
84
+ it('throws AiModerationUnavailableError after a 5xx on both attempts', async () => {
85
+ fetchSpy.mockResolvedValue(jsonResponse({ error: 'boom' }, 500))
86
+ await expect(service.checkInput({ text: 'hi', apiKey: 'sk-test' })).rejects.toBeInstanceOf(
87
+ AiModerationUnavailableError,
88
+ )
89
+ expect(fetchSpy).toHaveBeenCalledTimes(2)
90
+ })
91
+
92
+ it('throws AiModerationUnavailableError on repeated network/timeout errors', async () => {
93
+ fetchSpy.mockRejectedValue(new Error('AbortError: timed out'))
94
+ await expect(service.checkInput({ text: 'hi', apiKey: 'sk-test' })).rejects.toBeInstanceOf(
95
+ AiModerationUnavailableError,
96
+ )
97
+ expect(fetchSpy).toHaveBeenCalledTimes(2)
98
+ })
99
+
100
+ it('does not retry a 4xx (credential/config error) and fails closed', async () => {
101
+ fetchSpy.mockResolvedValue(jsonResponse({ error: 'unauthorized' }, 401))
102
+ await expect(service.checkInput({ text: 'hi', apiKey: 'bad' })).rejects.toBeInstanceOf(
103
+ AiModerationUnavailableError,
104
+ )
105
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
106
+ })
107
+
108
+ it('throws AiModerationUnavailableError when the response schema is unexpected', async () => {
109
+ fetchSpy.mockResolvedValue(jsonResponse({ unexpected: true }))
110
+ await expect(service.checkInput({ text: 'hi', apiKey: 'sk-test' })).rejects.toBeInstanceOf(
111
+ AiModerationUnavailableError,
112
+ )
113
+ })
114
+ })